Skip to content

fix(web): keep $ skill picker usable inside question answers - #8359

Draft
NeilTheFisher wants to merge 1 commit into
pingdotgg:mainfrom
NeilTheFisher:fix/skill-picker-in-question-answers
Draft

fix(web): keep $ skill picker usable inside question answers#8359
NeilTheFisher wants to merge 1 commit into
pingdotgg:mainfrom
NeilTheFisher:fix/skill-picker-in-question-answers

Conversation

@NeilTheFisher

@NeilTheFisher NeilTheFisher commented Aug 27, 2026

Copy link
Copy Markdown

Alternative to #7818 which hides $/slash pickers in question answers. Fixes #8128 by keeping the picker usable.

  • Parks draft while question open so answer caret isn't clobbered
  • $br → Bro chip (not plain text), drawer closes on select
  • Esc and outside-click dismiss the menu, Lexical focus loop guarded

Verified via Chrome MCP in pending and normal threads, no stack overflow.

Note

Keep $ skill picker usable inside question answers by adding plainText editor mode

  • Fixes a stale-content bug in ChatView.tsx where pendingAnswerChange called focusAt during plain text inserts, causing onChange to overwrite just-inserted text from the skill picker.
  • Adds a plainText mode to ComposerPromptEditor.tsx that disables token plugins, uses simple cursor clamping instead of token-aware mapping, and remounts the editor on mode flips while restoring focus.
  • Updates ChatComposer.tsx to park draft prompt updates in draftPromptRef while a pending question is active, preventing trait updates and stash restores from clobbering the visible answer or its caret. On question resolution, the draft is restored with caret and trigger reset.
  • Adds Escape and outside-click dismissal for composer slash/skill menus.
  • Risk: plainText prop toggles cause a full editor remount via LexicalComposer key change ('-plain' vs '-rich'); verify focus restoration works across remounts in ComposerPromptEditorInner via restoreFocusOnRemountRef.
📊 Macroscope summarized efd8141. 3 files reviewed, 4 issues evaluated, 2 issues filtered, 2 comments posted

🗂️ Filtered Issues

apps/web/src/components/ChatView.tsx — 1 comment posted, 2 evaluated, 1 filtered
  • line 1817: applyPromptReplacement overwrites draftPromptRef.current with the pending question answer when plainAnswerMode is active. Selecting a $ skill (or another picker item) while answering therefore replaces the parked pre-question composer draft; when the question resolves, the handoff restores that answer as the normal draft instead of the user's original unsent message. [ Skipped comment generation ]
apps/web/src/components/chat/ChatComposer.tsx — 1 comment posted, 2 evaluated, 1 filtered
  • line 1817: applyPromptReplacement overwrites draftPromptRef.current with the pending answer whenever plainAnswerMode is active. Selecting a $ skill in a question answer follows the activePendingQuestion branch and updates only the answer, but this assignment replaces the parked pre-question draft; when the question ends, the handoff at lines 1565-1568 restores the answer text as the composer draft and loses the user's original unsent draft. [ Cross-file consolidated ]

@coderabbitai

coderabbitai Bot commented Aug 27, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0e105260-b696-4064-babb-5ed11e67f294

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Aug 27, 2026
setComposerTrigger(
cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor),
);
setComposerTrigger(detectComposerTrigger(nextPrompt, expandedCursor));

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.

🟡 Medium chat/ChatComposer.tsx:1757

Normal ArrowLeft/ArrowRight navigation immediately after an inserted $skill chip reopens the skill trigger menu, even though the user only moved across the immutable token. onPromptChange receives cursorAdjacentToMention for this case, so keep the guard and suppress trigger detection when that flag is set.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ChatComposer.tsx around line 1757:

Normal ArrowLeft/ArrowRight navigation immediately after an inserted `$skill` chip reopens the skill trigger menu, even though the user only moved across the immutable token. `onPromptChange` receives `cursorAdjacentToMention` for this case, so keep the guard and suppress trigger detection when that flag is set.

// showing old value: the controlled effect (activePendingProgress.customAnswer
// -> composer) will rewrite editor to new value and place cursor. Calling
// focusAt now would move selection in old content (clamped) and fire
// handleEditorChange with old value, overwriting the just-inserted skill.

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.

🟡 Medium components/ChatView.tsx:6149

The isPlainInsert guard only recognises appended text (value.startsWith(snapshot.value)). When a skill picker replaces the typed query (e.g. $br$Browser ), isPlainInsert is false, so focusAt(nextCursor) fires against the editor's stale $br snapshot. That synchronously re-emits onChange with the old value, overwriting the pending-answer state update that was just queued — the selected skill never appears.

The comment above the guard already states the real invariant: skip focusAt whenever the editor shows a different value than the one being written, because the controlled effect will rewrite the editor. Broaden the check to snapshot.value !== value instead of the append-only heuristic.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/ChatView.tsx around line 6149:

The `isPlainInsert` guard only recognises appended text (`value.startsWith(snapshot.value)`). When a skill picker **replaces** the typed query (e.g. `$br` → `$Browser `), `isPlainInsert` is `false`, so `focusAt(nextCursor)` fires against the editor's stale `$br` snapshot. That synchronously re-emits `onChange` with the old value, overwriting the pending-answer state update that was just queued — the selected skill never appears.

The comment above the guard already states the real invariant: skip `focusAt` whenever the editor shows a different value than the one being written, because the controlled effect will rewrite the editor. Broaden the check to `snapshot.value !== value` instead of the append-only heuristic.

@macroscopeapp macroscopeapp 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.

UI Consistency: composer answer-mode review

Four findings, all in changed lines. The main one is that the new plainText mode added to the shared ComposerPromptEditor primitive is never enabled by any call site, while several behavioral guards around it were relaxed in the live (rich) path.

  • apps/web/src/components/chat/ChatComposer.tsx:3495plainText={false} makes the entire new editor mode unreachable, and the state comment at 1028-1031 now describes behavior that does not happen.
  • apps/web/src/components/chat/ChatComposer.tsx:1757 — dropping the cursorAdjacentToMention suppression only in the pending-answer branch makes chip-adjacent caret behavior diverge from the draft branch in the same rich editor.
  • apps/web/src/components/chat/ChatComposer.tsx:1527-1533 — capture-phase window Escape handler with stopPropagation() swallows Escape app-wide whenever a composer trigger is active.
  • apps/web/src/components/ChatView.tsx:6150-6154 — the isPlainInsert heuristic only recognizes appends, so mid-text insertion still hits the ping-pong it is meant to prevent.

Posted via Macroscope — UI Consistency

: []
}
skills={selectedProviderStatus?.skills ?? []}
plainText={false} // chip even in custom-answer (user wants chip not $text)

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.

plainText is hardcoded to false here, and the only other consumer (SettingsFontPreviews) takes the default false — so the whole new mode in ComposerPromptEditor (the plain/rich LexicalComposer key, the token-plugin gating, the three cursor-mapping swaps, restoreFocusOnRemountRef and the render-phase initial*Ref mutations) is unreachable in the shipped app. That leaves a fairly large unexercised branch on a shared primitive, plus behavior that now runs unconditionally in the rich path (the remount focus-restore layout effect, refs written during render) with no consumer that needs it.

Suggest either wiring a real consumer for plainText or dropping the mode and keeping only the parts this fix actually needs (plainAnswerMode draft parking). Related: the state comment at lines 1028-1031 ("the editor renders raw text (no inline tokens) and the trigger menus stay closed") contradicts this call site — chips render and the trigger menus are explicitly kept working — so it should be corrected either way.

Posted via Macroscope — UI Consistency

Comment on lines +1527 to +1533
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopPropagation();
setComposerTrigger(null);
setComposerHighlightedItemId(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.

This Escape handler is a capture-phase window listener that unconditionally preventDefault() + stopPropagation() while composerMenuOpen. Since the trigger is derived from composer text, it can stay open while focus moves elsewhere (model picker Select, a popover, a dialog), and in that state the composer eats Escape before any Base UI popup or global handler sees it — a keyboard-dismissal regression outside the composer.

Suggest scoping the handler to the composer (and its floating layer) so other surfaces keep their Escape:

     const onKeyDown = (event: KeyboardEvent) => {
       if (event.key !== "Escape") return;
+      const target = event.target as Element | null;
+      if (
+        target &&
+        !target.closest('[data-chat-composer-form="true"]') &&
+        !isInsideComposerFloatingLayer(target)
+      ) {
+        return;
+      }
       event.preventDefault();

Worth noting ComposerStashMenu already owns its own dismissal via an onClose prop; owning dismissal in the menu component would keep this behavior with the popup rather than in a global listener.

Posted via Macroscope — UI Consistency

Comment on lines +6150 to +6154
const isPlainInsert =
snapshot !== undefined &&
typeof snapshot.value === "string" &&
value.length > snapshot.value.length &&
value.startsWith(snapshot.value);

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.

isPlainInsert only matches insertions appended to the end (value.startsWith(snapshot.value)). A skill inserted with the caret mid-answer produces a value that is not a prefix-extension, so focusAt still runs against the stale editor content and the ping-pong this guard describes can still overwrite the insertion. (typeof snapshot.value === "string" is also dead — value is typed string on the snapshot.)

Suggest keying off an explicit signal that the change came from a programmatic replacement (e.g. a flag threaded from applyPromptReplacement, or having the editor handle acknowledged controlled writes) rather than inferring it from string shape, so mid-text inserts are covered too.

Posted via Macroscope — UI Consistency

setComposerTrigger(
cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor),
);
setComposerTrigger(detectComposerTrigger(nextPrompt, expandedCursor));

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.

Because the editor still renders chips in answer mode (plainText={false}), cursorAdjacentToMention is still meaningful here. Removing the suppression means parking the caret directly beside an already-committed $skill/@file chip in the custom-answer field re-opens the picker with the committed token as the query, while the draft branch below (line 1776) still suppresses it — the same editor now behaves differently depending on whether a question is open.

Suggest keeping the guard in both branches:

Suggested change
setComposerTrigger(detectComposerTrigger(nextPrompt, expandedCursor));
setComposerTrigger(
cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor),
);

Posted via Macroscope — UI Consistency

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

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Skill/slash picker inside a question's custom-answer field cannot be dismissed and wedges input in every thread

2 participants