Skip to content

Add Lexical-based composer editor with file mention pills - #142

Merged
juliusmarminge merged 3 commits into
mainfrom
t3code/file-mention-pills-textarea
Mar 2, 2026
Merged

Add Lexical-based composer editor with file mention pills#142
juliusmarminge merged 3 commits into
mainfrom
t3code/file-mention-pills-textarea

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Mar 2, 2026

Copy link
Copy Markdown
Member

Summary

  • Introduce ComposerPromptEditor component using Lexical for rich text editing
  • Add @-mention support for files with autocomplete dropdown
  • Include mention detection utilities with comprehensive tests
  • Integrate new editor into ChatView replacing plain textarea

Test plan

  • Type @ in composer to trigger file mention autocomplete
  • Select a file from the dropdown to insert a mention pill
  • Verify pills render correctly and are preserved on submission
  • Test keyboard navigation in autocomplete menu
  • Verify paste and undo/redo behavior works correctly

🤖 Generated with Claude Code


Note

Medium Risk
Replaces the core chat composer input with a custom Lexical editor and new cursor/trigger handling, which can affect typing, selection, and send behavior. Also changes draft persistence by removing stored cursor state, which could surface edge cases when restoring drafts.

Overview
Replaces the chat composer <textarea> with a new Lexical-based ComposerPromptEditor that renders @path tokens as non-editable mention chips (with VS Code-style icons) while keeping the underlying prompt as plain text.

Updates ChatView to manage cursor/trigger state locally (instead of persisting cursor in the draft store), hardens autocomplete selection via snapshot/expected-text checks, and routes arrow/tab/enter handling through the editor so the command menu highlight/active styling stays in sync.

Adds splitPromptIntoComposerSegments + tests for mention parsing, extends composer logic with expandCollapsedComposerCursor (and tests), and introduces Lexical dependencies (lexical, @lexical/react) in the web app.

Written by Cursor Bugbot for commit 4dbd631. This will update automatically on new commits. Configure here.

Note

Replace textarea composer with Lexical-based ComposerPromptEditor in ChatView to render file mention pills and route command keys

Introduce a Lexical editor with a custom ComposerMentionNode for non-editable @path chips, switch ChatView to local cursor/trigger state with expandCollapsedComposerCursor, and add command key handling via ComposerCommandKeyPlugin. Update command menu to track an active item. Remove cursor persistence from composerDraftStore. Add parsing utility and tests. See ComposerPromptEditor.tsx and ChatView.tsx.

📍Where to Start

Start with ComposerPromptEditor and its custom node/plugins in ComposerPromptEditor.tsx, then review the integration in ChatView in ChatView.tsx.

Macroscope summarized 4dbd631.

@coderabbitai

coderabbitai Bot commented Mar 2, 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.

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
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch t3code/file-mention-pills-textarea

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

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Enter key swallowed when autocomplete menu has no items
    • Added currentItems.length > 0 guard to the Tab/Enter handler so that when no autocomplete items match, Enter falls through to onSend() instead of being silently consumed.
  • ✅ Fixed: Mention pill icons hardcode dark theme variant
    • Replaced hardcoded "dark" with runtime detection via document.documentElement.classList.contains("dark") to match the active theme.

Create PR

Or push these changes by commenting:

@cursor push 16d96f5d92
Preview (16d96f5d92)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -2117,7 +2117,7 @@
         nudgeComposerMenuHighlight("ArrowUp");
         return true;
       }
-      if (key === "Tab" || key === "Enter") {
+      if ((key === "Tab" || key === "Enter") && currentItems.length > 0) {
         const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0];
         if (selectedItem) {
           onSelectComposerItem(selectedItem);

diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx
--- a/apps/web/src/components/ComposerPromptEditor.tsx
+++ b/apps/web/src/components/ComposerPromptEditor.tsx
@@ -152,7 +152,8 @@
   icon.ariaHidden = "true";
   icon.className = "size-3.5 shrink-0 opacity-85";
   icon.loading = "lazy";
-  icon.src = getVscodeIconUrlForEntry(pathValue, inferMentionPathKind(pathValue), "dark");
+  const theme = document.documentElement.classList.contains("dark") ? "dark" : "light";
+  icon.src = getVscodeIconUrlForEntry(pathValue, inferMentionPathKind(pathValue), theme);
 
   const label = document.createElement("span");
   label.className = "truncate leading-tight";

Comment thread apps/web/src/components/ChatView.tsx Outdated
Comment thread apps/web/src/components/ComposerPromptEditor.tsx Outdated
useEffect(() => {
const unregisterLeft = editor.registerCommand(
KEY_ARROW_LEFT_COMMAND,
() => {

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/ComposerPromptEditor.tsx:472

Arrow key handling overrides native navigation (modifiers and graphemes). Suggest only intercepting when crossing ComposerMentionNode boundaries and otherwise returning false so the browser/Lexical drive navigation. This preserves Cmd/Opt shortcuts and avoids placing the cursor inside surrogate pairs.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/components/ComposerPromptEditor.tsx around line 472:

Arrow key handling overrides native navigation (modifiers and graphemes). Suggest only intercepting when crossing `ComposerMentionNode` boundaries and otherwise returning `false` so the browser/Lexical drive navigation. This preserves Cmd/Opt shortcuts and avoids placing the cursor inside surrogate pairs.

Evidence trail:
apps/web/src/components/ComposerPromptEditor.tsx lines 466-506 (ComposerMentionArrowPlugin implementation), lines 267-277 (findSelectionPointAtOffset text node handling), lines 328-342 ($setSelectionAtComposerOffset). The handlers return `true` without checking modifier keys and navigate by UTF-16 code unit offsets.

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Raw text cursor used in collapsed editor offset space
    • Added collapseRawComposerCursor to convert the raw text cursor from replaceTextRange into collapsed space before passing it to setComposerCursor and focusAt, while keeping the raw cursor for detectComposerTrigger which correctly operates in raw text space.

Create PR

Or push these changes by commenting:

@cursor push 5f4628100a
Preview (5f4628100a)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -34,6 +34,7 @@
 import {
   type ComposerTrigger,
   type ComposerTriggerKind,
+  collapseRawComposerCursor,
   detectComposerTrigger,
   expandCollapsedComposerCursor,
   replaceTextRange,
@@ -2008,10 +2009,11 @@
       const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement);
       promptRef.current = next.text;
       setPrompt(next.text);
-      setComposerCursor(next.cursor);
+      const collapsedCursor = collapseRawComposerCursor(next.text, next.cursor);
+      setComposerCursor(collapsedCursor);
       setComposerTrigger(detectComposerTrigger(next.text, next.cursor));
       window.requestAnimationFrame(() => {
-        composerEditorRef.current?.focusAt(next.cursor);
+        composerEditorRef.current?.focusAt(collapsedCursor);
       });
       return true;
     },

diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts
--- a/apps/web/src/composer-logic.ts
+++ b/apps/web/src/composer-logic.ts
@@ -26,6 +26,41 @@
   return index + 1;
 }
 
+export function collapseRawComposerCursor(text: string, rawCursorInput: number): number {
+  const rawCursor = clampCursor(text, rawCursorInput);
+  const segments = splitPromptIntoComposerSegments(text);
+  if (segments.length === 0) {
+    return rawCursor;
+  }
+
+  let remaining = rawCursor;
+  let collapsedCursor = 0;
+
+  for (const segment of segments) {
+    if (segment.type === "mention") {
+      const rawLength = segment.path.length + 1;
+      if (remaining <= 0) {
+        return collapsedCursor;
+      }
+      if (remaining < rawLength) {
+        return collapsedCursor + 1;
+      }
+      remaining -= rawLength;
+      collapsedCursor += 1;
+      continue;
+    }
+
+    const segmentLength = segment.text.length;
+    if (remaining <= segmentLength) {
+      return collapsedCursor + remaining;
+    }
+    remaining -= segmentLength;
+    collapsedCursor += segmentLength;
+  }
+
+  return collapsedCursor;
+}
+
 export function expandCollapsedComposerCursor(text: string, cursorInput: number): number {
   const collapsedCursor = clampCursor(text, cursorInput);
   const segments = splitPromptIntoComposerSegments(text);

Comment thread apps/web/src/components/ChatView.tsx
juliusmarminge and others added 2 commits March 2, 2026 13:49
Introduce ComposerPromptEditor component using Lexical for rich text
editing with @-mention support for files. Includes mention detection
utilities and tests.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Treat mention pills as atomic cursor units in Lexical selection math
- Add left/right arrow + selection normalization behavior around mention nodes
- Expand collapsed mention cursor before trigger detection so @path menu closes correctly
- Update mention pill rendering for non-editable, non-selectable chips and theme-aware icons
- Add tests for collapsed-to-expanded cursor mapping and trigger behavior
@juliusmarminge
juliusmarminge force-pushed the t3code/file-mention-pills-textarea branch from 9df6323 to e484bb1 Compare March 2, 2026 21:51
Adds visual active-item highlighting to the command menu, suppresses
trigger detection when the cursor is adjacent to a mention pill, and
resets transient UI state on thread change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@juliusmarminge
juliusmarminge merged commit 5e78386 into main Mar 2, 2026
3 of 4 checks passed
aorwall added a commit to aorwall/t3code that referenced this pull request Sep 10, 2026
Merges `pingdotgg/t3code` `2a3035353..0f602b3` (16 commits) into the
fork.

- **Landed:** 283 files (`HEAD^1..HEAD`) against 277 in the upstream
range — `merge-stats.mjs` reports an exact 277/277 file match, so
nothing in the range was dropped and nothing extra came in. The six over
are three typecheck fixes and three fork docs, both listed below. Fork
delta 733 files (`HEAD^2..HEAD`).
- **Conflicts:** 6 files, all on one upstream feature (pingdotgg#10839, linking
several pull requests to a thread). Resolutions in
`docs/fork/upstream-merge-log.md`.
- **Sweep:** 13 owned-concern hits, all `infra/relay/**`
FCM/Android-push files under the decided-out `cloud-relay-connect`
concern. Inherited in tree, adopted by nothing.
- **Unsupported methods:** 0 ADD, 0 DROP — no
`packages/contracts/src/rpc.ts` edit needed.

## What upstream shipped

### Usable as-is against Moatless

Pure client work, no backend involvement — these are live the moment
this merges.

- **pingdotgg#11020** message copy buttons show on touch devices.
- **pingdotgg#11018** middle-click pastes in the terminal on Linux.
- **pingdotgg#10869** expanded images zoom and pan.
- **pingdotgg#11002** the composer uses the available space for model names.
- **pingdotgg#10981** duplicate tool-call commands can be expanded independently.
- **pingdotgg#10947** provider settings grow a bulk model toggle.
- **pingdotgg#10609** the PR list's diff counts return to the top right.
- **pingdotgg#11022** remote projects open in Zed
(`packages/contracts/src/editor.ts` plus the desktop shell — the fork
ships both).
- **pingdotgg#10998 / pingdotgg#10983 / pingdotgg#10964** three Android glass/overlap fixes in
`apps/mobile`.

### Unsupported in Moatless — needs backend implementation

- **pingdotgg#10839 — several pull requests per thread.** This is the substantive
decision in the merge. Upstream now carries `thread.pullRequests:
ThreadPullRequestLink[]`, `packages/shared/src/threadPullRequests.ts`,
and a `ThreadPullRequestBadgeControl` pill with its own `pull-requests`
stack tab. That is exactly the equivalent the fork's
`task-bound-pull-request` convergence entry said to re-home its `+N`
menu onto — but it cannot be re-homed yet: Moatless serves no
`pullRequests` array on a thread and does not advertise the new
`threadPullRequests` capability, so upstream's badge would resolve to
nothing and paint an empty pill over a working one. Taking `theirs`
would have silently deleted live fork behaviour.

**Resolution:** upstream's implementation landed whole, and the two
presentations are switched on `useSupportsMultiplePullRequests` —
upstream's badge and stack where the server advertises the capability,
the fork's binding-derived pill and `+N` menu where it does not.
Additive, no prop threading, and it re-homes itself the day the backend
advertises. `docs/fork/inventory.json` and `docs/fork/gaps.md` are
updated with the switch and with the exact deletion list for when that
happens.

**To close it:** serve `thread.pullRequests` on
`OrchestrationThread`/`OrchestrationThreadShell` from `task_bindings`,
and report `capabilities.threadPullRequests: true`.

- **pingdotgg#10870 — find threads by linked pull request.** Search terms come
off the same `thread.pullRequests` array, so sidebar and command-palette
search by PR number/URL match nothing here until the array is served.
Closes with pingdotgg#10839.

- **pingdotgg#10875 — navigate, merge and rebase GitHub stacks.** Adds two RPC
methods, `pullRequests.stack` and `pullRequests.linkedThreads`, which
the Moatless backend does not dispatch. Both are already covered by the
shared `PullRequestRpcError` union, so the client decodes the refusal
correctly and the stack UI stays inert — no contract change needed.
Implementing the two methods is what turns it on.

- **pingdotgg#10416 — Android agent notifications and ongoing activity.** Rides
FCM through `infra/relay`, which is part of the decided-out
`cloud-relay-connect` concern (being removed with Clerk). Inherited in
tree, not adopted.

### Backend behaviour worth reproducing in Moatless

- **pingdotgg#11007 — recent PR reads survive a server restart.** Upstream added
`apps/server/src/pullRequest/PullRequestReadCache.ts`, persisting which
pull requests a user has already read so a restart does not re-mark the
whole list unread. Moatless owns this surface itself, so nothing in this
repository holds it open — recorded so whoever touches the backend's PR
read state knows the answer exists upstream.

## Verification

`verify.mjs`, seven of eight green: `duplicate-adds`, `tripwires`,
`resolution-check`, `unsupported-methods`, `fmt:check`, `lint`,
`typecheck`.

`test` is red on `@t3tools/desktop` alone —
`scripts/browser-secret-native.test.mjs > bundled libsecret helper`
fails to compile because `libsecret-1` is not installed in this sandbox.
**Pre-existing environment gap, not merge-introduced:** it is already an
entry in `docs/fork/gaps.md`, and `git diff --name-only HEAD^1 HEAD |
grep browser-secret` is empty. 100 of 102 desktop files pass. Four
packages did not finish under `vp run -r test` (`@t3tools/mobile`, `t3`,
`@t3tools/web`, `t3code-relay`) and all four pass when run alone, which
is parallel load rather than the merge.

Three typecheck failures were fixed in the merge commit, all fork-only
web code that upstream's widened shared types reached:
`sandboxControl.placement.test.tsx` needed the two new `RightPanelTabs`
props, and `useSandboxAvailability.ts` / `useSandboxDetail.ts` needed
`isSuccess` threaded through now that `EnvironmentQueryView` carries it.

Nothing is unresolved.

---
Moatless task:
https://moatless.soaplabstest.com/tasks/db1b3cbe-4401-441b-bbec-6b0c725c93ce
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant