Repository navigation
UI Consistency: 2 issues found
Two findings on the new reasoning-cycle keyboard path, both in apps/web/src/components/chat/ChatComposer.tsx:
- Wrong prompt source while a provider question is pending (line 2570):
currentPrompt = promptRef.currentholds the pending user-input custom answer (not the draft prompt) wheneveractivePendingProgress !== null, becauseonPromptChangeroutes typing toonChangeActivePendingUserInputCustomAnswerand the sync effect assigns the answer topromptRef. The shortcut then evaluatesresolveReasoningTransitionagainst different text than the traits menu (which gets the draftprompt), so a draft carrying anUltrathink:prefix can have a conflictingeffortselection persisted while the prefix stays, and an answer that mentions "ultrathink" makes the action a silent no-op (the blocked toast is suppressed in that state). Suggested fix: read the draft prompt viagetComposerDraft(composerDraftTarget)?.promptwhen the editor is not showing the draft. - No key-repeat guard (line 2568): holding
alt+,/alt+.queues one identical info toast per repeat; the repo's other shortcut listeners (Sidebar.tsx,ModelPickerContent.tsx) guardevent.defaultPrevented || event.repeat. Suggested fix: ignore repeats inside the reasoning branch only.
The TraitsPicker.tsx refactor to resolveTraitsSelectChange looks behavior-preserving: the blocked ("ultrathink in body") case is still surfaced by the existing inline notice and disabled radio items, and the non-reasoning path retains the descriptor-rebuild semantics.
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.
Methodology:
- Read the full new shortcut branch at head (
apps/web/src/components/chat/ChatComposer.tsx2550-2656) plus the prompt/cursor plumbing it reuses (setPrompt,setPromptFromTraits,onPromptChange, the pending-answer sync effect at 1505-1543, and thepromptRefsync at 1433-1436). - Verified
ComposerPromptEditor's controlledvalue/cursorlayout effect does apply an externally supplied cursor after a value rewrite, somapComposerCursorAcrossLeadingPromptChange+collapseExpandedComposerCursoris the right pairing anddetectComposerTriggeris correctly given the expanded cursor (matches lines 1685 and 1757). - Compared the keyboard path's persistence call with
TraitsMenuContent.updateModelOptions(samesetProviderModelOptions(target, provider, options, { instanceId, model, persistSticky: true })contract). - Checked
resolveShortcutCommandplatform/alias handling:EVENT_CODE_KEY_ALIASESnow includesComma/Period, andplatformdefaults tonavigator.platform, so the composer listener resolves macOS Option punctuation without passingplatformexplicitly. - Confirmed the settings call sites (
ProjectSettingsPanel.tsx,SettingsPanels.tsx) that passprompt="", a no-oponPromptChange, andallowPromptInjectedEffort={false}still cannot persist a prompt-injected value throughresolveTraitsSelectChange(the transition changes onlyprompt, which those call sites discard, andremovePromptInjectedReasoningSelectionreturns the same array reference). - Noted but did not flag: the keyboard path calls
getProviderModelCapabilities(...)withoutplanModeEnabledwhile every traits call site passes it. That flag only strips the opencodeagentoption, so it cannot affect reasoning descriptors. - Toast wording/type (
info) matches the app-level toast union inapps/web/src/components/ui/toastHelpers.ts.
🔍 Agent activity — 20 tool calls
💭 I'm checking whether the current PR addresses the prior review feedback about the editor not consistently reflecting the draft prompt. It looks like the new code adds a guard to bind the editor to the draft prompt state, so I want to verify this directly in ChatComposer.tsx.
1. git_tools — reasoningDirection
2. git_tools
💭 The empty grep result is strange—maybe the ref checkout isn't available in that URL tree format. I should try grepping at head using the rev parameter instead.
3. git_tools — reasoningDirection
💭 I want to check the full block along with what happens afterward, particularly the stash-specific guards flagged in the earlier run at bab9ac2, which looks like it's resolved by the early return. Let me pull up lines 2500-2700 to confirm.
4. browse_code — apps/web/src/components/chat/ChatComposer.tsx
💭 I'm checking whether the mouse-driven prompt path also triggers a composer refocus, unlike the keyboard path, which seems intentional. More importantly, I need to verify whether ComposerPromptEditor actually applies the
cursorprop to update the DOM selection when the value changes externally, since if it doesn't, the caret could end up in the wrong position. I also want to check consistency in the toast messages — whether "info" is a valid toast type, whether the unsupported toast fires regardless of composer state while the blocked toast is gated by draft-prompt visibility, and whether other reasoning-selection paths show any success feedback at all.
5. browse_code — In apps/web/src, what toast types does toastManager.add support (list the union of type values), and how does ComposerPromptEditor handle an externally-changed value and cursor prop — does it imperatively set the DOM selection when cursor changes, or does it only read cursor on user input? Show the relevant code.
💭 The cursor mapping seems to work for value changes, placing the caret at the normalized position. I'm now checking whether setComposerTrigger's detectComposerTrigger call expects an expanded cursor, comparing against how other call sites in the composer's onChange handler use it. I also want to check the handleComposerPromptChange function in ChatComposer to see what other state it updates on prompt change - things like composerMenuOpen, cursorAdjacentToMention, or menu selection index resets - to make sure the keyboard path isn't missing any of these updates.
6. git_tools — detectComposerTrigger|setComposerTrigger|collapseExpandedComposerCursor|expandCollapsedComposerCursor
💭 I'm checking that the keyboard path uses the expanded cursor consistently, but now I'm second-guessing whether
mapComposerCursorAcrossLeadingPromptChangeactually operates on the right coordinate space—whether the expanded cursor indexes into the expanded text with serialized mentions or the raw collapsed prompt text. Working through it, expanded cursor is an index into the raw prompt string, since mentions are collapsed to single characters in the DOM representation but expanded to full text elsewhere—so the mapping function using a raw-string offset checks out. Now I want to trace through what elsehandleComposerPromptChangetriggers, like updating the draft store, cursor state, trigger detection, and any menu or persistence side effects.
7. browse_code — apps/web/src/components/chat/ChatComposer.tsx
💭 I notice
setPromptFromTraitspersists the draft usingsetComposerDraftPrompt, while the new keyboard path just callssetPrompt(transition.prompt)instead. I need to trace wheresetPromptis actually defined in this component to understand the difference.
8. git_tools — setPrompt\b|setComposerDraftPrompt|const prompt =|promptRef
💭 I'm tracing how the global keydown listener handles alt+. even when the composer editor isn't focused, and whether updating the prompt while blurred still correctly syncs the editor's state and caret position via its layout effect. The mouse-driven trait change schedules composer focus, but the keyboard path intentionally skips that per earlier feedback. I'm also weighing minor cosmetic details like toast quote styling, whether an "unsupported" toast could fire noisily during pending states, and whether the traits chip properly reflects the new reasoning value for both the modelOptions-driven case and the prompt-prefix-driven ultrathink case. Now I'm digging into the TraitsPicker select-change handler rewrite: the old version rebuilt provider option selections from all descriptors via updateDescriptors, while the new resolveTraitsSelectChange only does that for the non-reasoning path — for reasoning changes, it derives modelOptions directly from current selections rather than descriptors, which means it may no longer carry forward other descriptor defaults like contextWindow into the selection state. I'm noting an inconsistency: when a select action is blocked (like ultrathink appearing in body text), the mouse path silently returns with no feedback while the keyboard path now surfaces a toast — though checking the original code shows the mouse path was always silent, so this asymmetry predates the PR but becomes more visible now that one path has feedback and the other doesn't. A silent no-op menu click is still a moderate UX concern worth noting given the "preserve interaction semantics" angle. I want to check the current TraitsPicker file more closely though—whether the menu still shows the selected value correctly and whether updateModelOptions handles undefined properly.
9. browse_code — apps/web/src/components/chat/TraitsPicker.tsx
💭 I notice
getSelectedTraitspassesplanModeEnabledas a fourth argument togetProviderModelCapabilities, but the keyboard path in ChatComposer calls it with only three args, omitting plan-mode entirely. That's a real inconsistency — the keyboard path would compute capabilities without accounting for plan mode.
10. git_tools — planModeEnabled
💭 I realize the planModeEnabled omission only affects the agent descriptor, not reasoning transitions, so it's harmless and not worth flagging. I'm now turning to how the traits menu resolves caps through getProviderOptionDescriptors, checking how currentValue gets pulled from selections. I'm wondering about the toast spam issue — do these unsupported/blocked toasts dedupe, or would rapid repeated key presses stack multiple toasts? I should check whether the keyboard handler ignores
event.repeatwhen the key is held down, and whether other keybindings in the codebase follow that convention.
11. git_tools — .repeat
💭 I notice the composer stash handler doesn't guard against event.repeat, unlike the sidebar and ModelPickerContent which both check it — this looks like a real inconsistency worth flagging, since holding alt+. would keep cycling reasoning and could spam toasts for unsupported models. More important though is checking whether
resolveShortcutCommandneeds a platform argument. Sidebar passesnavigator.platformexplicitly, but ChatComposer's handler doesn't — if the default isn't mac-aware, then Option+, on macOS would produce a different character and fail to match, breaking the shortcut entirely in the one place it's actually wired up. I need to check keybindings.ts to see how platform defaulting works there.
12. browse_code — apps/web/src/keybindings.ts
💭 I'm confirming that platform detection defaults to navigator.platform and code-based aliases apply regardless of platform, including the global Comma/Period aliasing. Now I'm considering how this holds up on non-mac keyboard layouts, like French AZERTY where the comma key sits at a different physical position. Beyond layout quirks, I'm now questioning whether the toast messaging is consistent — the "unsupported" case shows the same generic "doesn't advertise reasoning levels" text even when the model does advertise levels but just doesn't support this particular one, which feels like a minor copy inaccuracy. I also want to check whether the mouse-driven menu path handles the "blocked" state the same way the keyboard shortcut does, since the keyboard toast is gated on a draft-prompt condition that the mouse path might silently skip. Looking at the menu's radio group logic, if a blocked transition returns early without updating state, the selected radio value stays unchanged with no feedback at all — which was true before this PR too, but now the keyboard path adds a toast for the same blocked case while the menu stays silent. That's a real inconsistency worth flagging in TraitsPicker.tsx: the fix introduced feedback on one entry point but not the other for the identical shared transition. I'm also comparing how the old and new code build the ultrathink prompt prefix for empty prompts, and both produce the same "Ultrathink:\n" result, so that's fine. Now I want to check the non-empty prompt case and the slash-command guard behavior in model.ts around lines 560-600 to see if the old prefix-stripping function behaves the same as the new hardcoded prefix logic.
13. browse_code — apps/web/src/components/chat/TraitsPicker.tsx
💭 Actually, I realize the menu already surfaces this via disabled items and inline text explaining that "ultrathink" in the prompt blocks the option, so it's not silent after all—the keyboard toast is just the equivalent feedback for that path. That resolves the inconsistency I flagged. Now I'm looking at how
allowPromptInjectedfactors in.
14. git_tools — allowPromptInjectedEffort
💭 This looks like an important finding. In settings panels without a draft prompt, TraitsPicker is called with an empty prompt and a no-op onPromptChange, plus allowPromptInjectedEffort set to false—meaning prompt-injected values like "ultrathink" shouldn't be selectable there. I'm checking the old behavior where selecting ultrathink would still check for prompt-injected values and try to compute and set a next prompt even when there's nothing to inject into. Under the new resolveTraitsSelectChange path, selecting ultrathink with an empty prompt still resolves to a prompt-injected transition: it produces "Ultrathink:\n" as the next prompt and treats the change as "changed" status even though the caller has no real prompt state. Since onPromptChange is a no-op here, that computed prompt gets silently discarded, but I need to check what happens to nextModelOptions in this same scenario — whether removePromptInjectedReasoningSelection leaves the model options list unchanged or accidentally strips something needed for the settings UI. I notice the keyboard path omits
planModeEnabledfrom the capabilities call unlike other composer sites, but that only affects the agent descriptor, not reasoning — a minor nit not worth flagging given the guidance to skip optional preferences. Now I'm checking the pending-question approval flow: when a prompt mismatch occurs, it silently returns with no toast, which matches the earlier feedback direction. Digging intopromptRef, I realize it's actually ambiguous — it gets set to the draft prompt in one effect but reassigned to the custom answer text when a pending question is active, so it doesn't consistently represent "the draft prompt." That means on the keyboard path,currentPromptderived frompromptRef.currentcould actually be holding the answer text instead of the prompt, which could throw off the comparison logic. I'm realizing the real risk is more subtle: if the pending answer text happens to contain "ultrathink," the transition silently blocks with no toast shown to the user. There's also a scenario where the draft prompt actually starts with an "Ultrathink:" prefix but the keyboard path reads the answer ref instead, potentially misdetecting the current level and persisting a redundant effort setting while leaving the draft's prefix untouched—I need to check whether promptRef truly points to the answer here.
15. browse_code — apps/web/src/components/chat/ChatComposer.tsx
16. browse_code — apps/web/src/components/chat/ChatComposer.tsx
💭 I confirm that while a pending user-input question is active, promptRef holds the custom answer rather than the draft prompt, and this only diverges during that pending-question effect—otherwise promptRef stays synced to the draft, even in approval state. This means the transition logic gets computed against the wrong text when a question is pending: if the answer contains "ultrathink" it could falsely report blocked or think ultrathink is currently selected, cycling to the wrong next level. Even when the prompt-write guard correctly prevents writes in that state, modelOptions can still be persisted using a current value derived from the answer text instead of the actual draft prompt—for example, a draft of "Ultrathink:\nfix bug" but an answer of "yes" would cause the wrong level to be computed and possibly persisted. Tracing an actual scenario: with a claudeCaps order of medium/high/ultrathink, decreasing from "high" leaves the prompt untouched since the answer text has no prefix, so modelOptions gets set to "medium" while the draft prompt still carries the "Ultrathink:" prefix. This creates a real desync between the traits menu, which reads the draft prompt and would show medium, and the actual sent turn, which stays ultrathink because of that leftover prefix — a genuine inconsistency caused by reading the ref instead of the draft. This looks like a legitimate finding for the review scope, since the handler reads
promptRef.current(which aliases the pending-answer text) rather than the actual draft prompt. I want to check whether a later effect resetspromptRefback to the draft after the answer flow, so I'm looking at the effect dependencies to see which one runs when and whether the answer-change handler is the one overwritingpromptRefin the first place.
17. browse_code — apps/web/src/components/chat/ChatComposer.tsx
💭 I'm confirming that in the pending-answer case, the promptRef isn't updated directly by onPromptChange, but a separate effect detects when the customAnswer changes externally from the store and syncs promptRef.current to match the new answer since the draft prompt no longer matches it. I'm also checking the approval state, where the editor shows an empty value but promptRef still holds the draft prompt. In that state, prompt changes are blocked while modelOptions changes remain allowed, and if the draft prompt starts with an "Ultrathink:" prefix, a decrease-cycling action would try to strip that prefix but get blocked by the guard, resulting in a silent no-op — which seems acceptable. The real issue is that when a question is pending, keyboard cycling ignores the Ultrathink prefix and persists an effort change into modelOptions while the draft keeps its prefix, so the traits chip and next send still reflect Ultrathink even though the model selection has silently changed underneath. The fix is to read the draft prompt (from the composer store) rather than promptRef.current in this pending-question scenario, only falling back to promptRef.current when the editor is actually showing the draft prompt. I'm weighing whether to just always use the store's
promptvalue, since promptRef exists mainly to capture fresh values within the same render tick for fast typing. Actually, maybe the simplest fix is to just bail early when!editorShowsDraftPrompt, treating the shortcut as inert while a question or approval is showing — consistent with the existing prompt-change guard, even though it means a pending question blocks reasoning changes. I should also check whether the draft store's getter is accessible here as an alternative source of truth for the prompt. Separately, I'm noticing that holding down the shortcut key repeats the action rapidly, spamming toasts, unlike other handlers that guard againstevent.repeat. That's a real, concrete issue worth flagging as a second, smaller comment alongside the main finding. I'll include it briefly, but first let me verify the getComposerDraft signature to make sure my suggestion is accurate.
18. git_tools — getComposerDraft
💭 Falling back to the composer draft's prompt with an empty string default works. I want to verify my claim about promptRef during pending questions is solid, so I'm checking
activePendingProgressand the condition in effect 1505 requiringcustomAnswerto be a string, alongside howeditorShowsDraftPromptis computed. This confirms that wheneveractivePendingProgressisn't null, the editor shows the answer and promptRef holds it—unlesscustomAnsweris undefined, in which case promptRef stays on the draft. I'm tracing through a concrete desync scenario: during a pending Claude question with answer text "no" but a draft prompt starting with "Ultrathink:", the reasoning effort computation reads from the wrong source. Pressing alt+, to decrease effort computes the next prompt from the answer text (unchanged, no prefix) while updating the persisted model options to a lower effort—so the draft still shows "Ultrathink" in the chip even though the user believes they've lowered it to Medium, since nothing visible actually changes. If instead the shortcut always sent the ultrathink-prefixed draft, decreasing effort would strip the prefix and produce a different prompt than currentPrompt, triggering an early return with no phantom option write—so switching to using the draft prompt fixes both the silent effort change and makes the guard work as designed. I'm confident enough to write this up as a review comment on the line defining currentPrompt from promptRef.current, and I want to check that it doesn't duplicate an earlier review comment about the editor not always being bound to the draft prompt, which was addressed by the editorShowsDraftPrompt guard. My new finding is narrower: it's about promptRef aliasing the pending-answer text rather than the draft, which is different enough from the earlier comment to be worth posting since the code already changed in response to that prior note—though the underlying observation (that typing during a pending question routes to the answer handler instead of the draft) is similar, this is a legitimate follow-up on a gap that remains unaddressed, specifically that the modelOptions write and blocked-detection still read from the answer text rather than the draft. Separately, I want to check whether there's an existing repeat-guard anywhere upstream that would prevent key-repeat toast spam before deciding whether to flag that too. I confirm no repeat check exists on this listener, so I'll add a brief second comment flagging it as a real UX regression risk, similar to guards elsewhere in the codebase, though I'm weighing exactly which line to attach it to—likely near where the command resolves rather than at the toast call itself, since a top-of-handler guard could unintentionally alter existing repeat-triggered behavior. I won't dig too deep into other toast usages—Base UI's toast manager stacks by default, and even with alimitprop, queued toasts would just display sequentially rather than being suppressed, so the spam issue persists either way. Let me just verify the Provider's limit setting to describe this accurately.
19. git_tools — limit|Toast.Provider|timeout
💭 I notice there's no dedupe or limit configured in the wrapper, so holding a key down could queue many toasts since Base UI's default limit is only 3. I'll write two review comments: one primary comment about line 2570 noting that currentPrompt from promptRef.current holds the pending-answer text while a provider question is displayed, with a diff suggestion since that line is part of the added diff, and a secondary comment suggesting an event.repeat guard to prevent toast spam. For the primary fix, I'm considering reading from getComposerDraft when the editor isn't showing the draft prompt, but that requires adding getComposerDraft to the dependency array in a separate hunk, so I'll use a unified diff rather than an inline suggestion. I also considered simply bailing out of the reasoning branch when the editor isn't showing the draft prompt, but that would lose modelOptions-only changes while a question is pending, which contradicts the intended behavior that changes should apply to the next message. I'll place the second comment near the toast-related code, likely around line 2620 where the unsupported toast is added. For the second fix, I'll add an inline suggestion at the start of the reasoning branch to prevent held-key repeats from queuing duplicate toasts, checking event.repeat immediately inside the branch. Both comments target the ChatComposer file, and since the first finding represents a genuine functional desync risk, I'm leaning toward flagging this check as a failure.
20. modify_pr — Reviewed the composer/traits reasoning-cycle changes for interaction consistency with the existing traits-menu path. Two findings in apps/web/src/components/chat/ChatComposer.tsx; the TraitsPicker refactor itself looks behavior-preserving (the blocked case is still communicated in-menu by the inline notice plus disabled radio items).
Agent Credits: 305 credits