Skip to content

Limit composer arrow-key mention jumps to adjacent mention boundaries - #169

Merged
juliusmarminge merged 2 commits into
mainfrom
t3code/fix-composer-pill-arrow-overrides
Mar 5, 2026
Merged

juliusmarminge merged 2 commits into
mainfrom
t3code/fix-composer-pill-arrow-overrides

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Mar 5, 2026

Copy link
Copy Markdown
Member

Summary

  • Restricts custom left/right arrow handling in ComposerPromptEditor so it only overrides navigation when the collapsed cursor is directly adjacent to a mention boundary.
  • Adds isCollapsedCursorAdjacentToMention to composer logic, including cursor clamping across collapsed mention/text segments.
  • Prevents default/propagation only when an actual mention-boundary jump is performed.
  • Adds unit tests covering non-mention text and precise left/right adjacency behavior.

Testing

  • apps/web/src/composer-logic.test.ts: added tests for isCollapsedCursorAdjacentToMention (no mention, left adjacency, right adjacency).
  • Not run: bun lint
  • Not run: bun typecheck
  • Not run: full test suite

Note

Medium Risk
Changes keyboard navigation and cursor-adjacency detection around mention pills, which is UX-critical and can regress selection behavior in edge cases. Scope is contained to composer editor logic with added unit coverage.

Overview
Arrow-key handling in the composer editor is now gated by mention-boundary adjacency. ComposerMentionArrowPlugin only prevents default left/right navigation and performs the custom offset jump when the collapsed cursor is directly adjacent to a mention (otherwise Lexical handles the key normally).

Adds isCollapsedCursorAdjacentToMention in composer-logic.ts (with cursor clamping over collapsed mention/text segments) and updates ComposerPromptEditor’s onChange callback to compute cursorAdjacentToMention via this shared logic. Includes new unit tests for adjacency behavior in composer-logic.test.ts.

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

Note

Limit left/right arrow handling in the Composer prompt editor to move the cursor only when adjacent to a mention to constrain mention boundary jumps

Update ComposerMentionArrowPlugin to intercept left/right keys only when isCollapsedCursorAdjacentToMention detects adjacency, adjust selection by ±1, and prevent default; add isCollapsedCursorAdjacentToMention with collapsed-segment logic and tests; update the onChange pipeline to pass adjacency based on text-derived checks. See ComposerPromptEditor.tsx, composer-logic.ts, and composer-logic.test.ts.

📍Where to Start

Start with the left/right key handlers in ComposerMentionArrowPlugin in ComposerPromptEditor.tsx, then review isCollapsedCursorAdjacentToMention in composer-logic.ts and its tests in composer-logic.test.ts.

Macroscope summarized 05dbf05.

- Only intercept left/right arrow keys when cursor is directly next to a mention
- Prevent default arrow behavior only for those mention-boundary moves
- Add `isCollapsedCursorAdjacentToMention` logic with coverage for left/right adjacency cases
@coderabbitai

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

Run ID: a8913bfa-3ff4-481a-968c-24f824e22f4b

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/fix-composer-pill-arrow-overrides

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

Comment on lines +473 to 475
(event) => {
let nextOffset: number | null = null;
editor.getEditorState().read(() => {

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:473

When the user presses Shift + ArrowLeft or Shift + ArrowRight next to a mention, the ComposerMentionArrowPlugin consumes the event and moves the cursor instead of allowing Lexical to extend the selection. This makes mention nodes impossible to select via keyboard. Consider adding a check for event?.shiftKey and returning false early when the shift modifier is held, so standard selection behavior is preserved.

      (event) => {
+        if (event?.shiftKey) return false;
        let nextOffset: number | null = null;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/components/ComposerPromptEditor.tsx around lines 473-475:

When the user presses `Shift + ArrowLeft` or `Shift + ArrowRight` next to a mention, the `ComposerMentionArrowPlugin` consumes the event and moves the cursor instead of allowing Lexical to extend the selection. This makes mention nodes impossible to select via keyboard. Consider adding a check for `event?.shiftKey` and returning `false` early when the shift modifier is held, so standard selection behavior is preserved.

Evidence trail:
apps/web/src/components/ComposerPromptEditor.tsx lines 472-519 at REVIEWED_COMMIT. The KEY_ARROW_LEFT_COMMAND handler (lines 472-495) and KEY_ARROW_RIGHT_COMMAND handler (lines 496-519) both check `!selection.isCollapsed()` but neither checks `event?.shiftKey`. When adjacent to a mention with collapsed selection, both handlers call `event?.preventDefault()`, `event?.stopPropagation()`, and return `true`, consuming the event regardless of whether Shift is held.

@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: Regex-based mention detection mismatches Lexical node tree
    • Replaced the regex-based isCollapsedCursorAdjacentToMention (which required trailing whitespace and operated in raw-text coordinates) with a Lexical node tree-based $isCursorAdjacentToMentionInDirection that inspects adjacent ComposerMentionNode siblings directly, eliminating both the regex detection gap and the coordinate system mismatch.

Create PR

Or push these changes by commenting:

@cursor push 083714b064
Preview (083714b064)
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
@@ -50,7 +50,6 @@
   type Ref,
 } from "react";
 
-import { isCollapsedCursorAdjacentToMention } from "~/composer-logic";
 import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";
 import { cn } from "~/lib/utils";
 import { basenameOfPath, getVscodeIconUrlForEntry } from "~/vscode-icons";
@@ -477,10 +476,7 @@
           if (!$isRangeSelection(selection) || !selection.isCollapsed()) return;
           const currentOffset = $readSelectionOffsetFromEditorState(0);
           if (currentOffset <= 0) return;
-          const promptValue = $getRoot().getTextContent();
-          if (!isCollapsedCursorAdjacentToMention(promptValue, currentOffset, "left")) {
-            return;
-          }
+          if (!$isCursorAdjacentToMentionInDirection("left")) return;
           nextOffset = currentOffset - 1;
         });
         if (nextOffset === null) return false;
@@ -504,10 +500,7 @@
           const currentOffset = $readSelectionOffsetFromEditorState(0);
           const composerLength = $getComposerRootLength();
           if (currentOffset >= composerLength) return;
-          const promptValue = $getRoot().getTextContent();
-          if (!isCollapsedCursorAdjacentToMention(promptValue, currentOffset, "right")) {
-            return;
-          }
+          if (!$isCursorAdjacentToMentionInDirection("right")) return;
           nextOffset = currentOffset + 1;
         });
         if (nextOffset === null) return false;
@@ -559,25 +552,40 @@
 }
 
 function $isCursorAdjacentToMention(): boolean {
+  return $isCursorAdjacentToMentionInDirection("left");
+}
+
+function $isCursorAdjacentToMentionInDirection(direction: "left" | "right"): boolean {
   const selection = $getSelection();
   if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false;
   const anchorNode = selection.anchor.getNode();
+  const anchorOffset = selection.anchor.offset;
 
   if (anchorNode instanceof ComposerMentionNode) return true;
 
-  if ($isElementNode(anchorNode)) {
-    const childIndex = selection.anchor.offset - 1;
-    if (childIndex >= 0) {
-      const child = anchorNode.getChildAtIndex(childIndex);
+  if (direction === "left") {
+    if ($isElementNode(anchorNode)) {
+      const childIndex = anchorOffset - 1;
+      if (childIndex >= 0) {
+        const child = anchorNode.getChildAtIndex(childIndex);
+        if (child instanceof ComposerMentionNode) return true;
+      }
+    }
+    if ($isTextNode(anchorNode) && anchorOffset === 0) {
+      const prev = anchorNode.getPreviousSibling();
+      if (prev instanceof ComposerMentionNode) return true;
+    }
+  } else {
+    if ($isElementNode(anchorNode)) {
+      const child = anchorNode.getChildAtIndex(anchorOffset);
       if (child instanceof ComposerMentionNode) return true;
     }
+    if ($isTextNode(anchorNode) && anchorOffset === anchorNode.getTextContentSize()) {
+      const next = anchorNode.getNextSibling();
+      if (next instanceof ComposerMentionNode) return true;
+    }
   }
 
-  if ($isTextNode(anchorNode) && selection.anchor.offset === 0) {
-    const prev = anchorNode.getPreviousSibling();
-    if (prev instanceof ComposerMentionNode) return true;
-  }
-
   return false;
 }

}

return false;
}

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.

Regex-based mention detection mismatches Lexical node tree

Medium Severity

isCollapsedCursorAdjacentToMention re-detects mentions from raw text using splitPromptIntoComposerSegments, whose regex requires trailing whitespace ((?=\s)). But the Lexical editor identifies mentions by node type (ComposerMentionNode). When a mention lacks trailing whitespace (e.g., at end of text, or user deleted the space), the regex won't find it, so the collapsed cursor offset from Lexical (where mention = 1 unit) gets interpreted in a mismatched coordinate system (where the full @path text counts at its actual length). The arrow key override silently fails to activate. The old code avoided this by always jumping in Lexical's collapsed space without relying on regex re-detection.

Additional Locations (2)

Fix in Cursor Fix in Web

- replace Lexical-node cursor adjacency check with text/cursor helper
- prevent `@query` typing from being treated as mention-pill adjacent
- add regression test for non-pill `@pac` input
@juliusmarminge
juliusmarminge merged commit a2682de into main Mar 5, 2026
4 checks passed
@juliusmarminge
juliusmarminge deleted the t3code/fix-composer-pill-arrow-overrides branch March 5, 2026 19:04
aorwall added a commit to aorwall/t3code that referenced this pull request Sep 15, 2026
Merges `pingdotgg/t3code` into the fork: 60 upstream commits,
`1bbca0e78` → `5623089ae`.

388 files landed against 386 upstream changed in the range; the gap
reconciles (three fork doc files landed that are not in the range;
`apps/server/src/cli/pair.ts` is in the range but not landed — upstream
modified a file the fork deliberately deletes, and the modify/delete
conflict was resolved by keeping the deletion). Fork delta against
upstream is 777 files. 14 conflicts, each resolved with the verdict
`preflight.mjs` printed for it.

The dominant theme was `d81278aa6 revert(web): remove the compact
sidebar (pingdotgg#11685)`, which deleted the base-level anchors five fork gates
sat beside. The fork's side of each conflict looked richer, but most of
that was upstream's own code inherited from the merge base. Each was
resolved by checking ownership per line against `git show
<merge-base>:<path>`, taking upstream wholesale, and re-applying only
genuinely fork-authored deltas. Details in
`docs/fork/upstream-merge-log.md`.

## Usable as-is

Features the fork can expose without Moatless backend or deployment
work.

- **Compact sidebar reverted** (pingdotgg#11685) — upstream removed the compact
variant. The five fork gates that sat beside its anchors were re-applied
to the restored layout.
- **Custom snooze dates and durations** (pingdotgg#11800) — verified client-side
only; no new RPC.
- **Composer and PR-number shortcuts** (pingdotgg#11615), and **copy-PR-link
keybinding discoverability** (pingdotgg#11826).
- **Project monogram icons** (pingdotgg#11572, pingdotgg#11806).
- **Video attachment thumbnails** (pingdotgg#11734), and **large image previews
no longer stalling the composer** (pingdotgg#11324).
- **Per-thread panel width** (pingdotgg#11310).
- **Consistent PR section toggles** (pingdotgg#11763).
- **Android agent activity card** (pingdotgg#11645).
- **OTLP protocol and header environment variables** (pingdotgg#11224, pingdotgg#11218).

## Unsupported in Moatless / needs implementation

Client, contract, RPC, auth, or deployment assumptions Moatless does not
serve. Each is an entry in `docs/fork/gaps.md` with the check that
retires it.

- **Background repository cloning** (pingdotgg#11762 web, pingdotgg#11774 mobile) —
`projectClone.start`, `.cancel`, `.retry` and the
`subscribeProjectClones` push stream turn "add a project from a remote"
into a tracked job: the row appears immediately and progress streams
into a toast (`ProjectCloneToastCoordinator.tsx`,
`apps/web/src/state/projectClones.ts`) rather than blocking the palette.
Nothing new is lost — the whole flow hangs off `action:add-project`,
which `FEATURES.projectManagement` already drops, and the stream is
additionally gated on `capabilities.projectCloneTracking`, which a
Moatless handshake omits. The four union entries are the only stand-in
and close with the rest of that bullet.
- **Worktree setup behind a progress stream** (pingdotgg#11372, grown by pingdotgg#11832)
— `subscribeWorktreeSetup`, `worktreeSetup.cancel`, surfaced by
`WorktreeSetupCard`. A Moatless thread gets a sandbox, not a worktree;
`FEATURES.worktreeSelection` keeps the composer out of `worktree` send
mode, so `baseBranchForWorktree` stays null in `ChatView.tsx`.
- **Clerk device-authorization-grant headless connect login** (pingdotgg#11794).
- **T3 Connect Clerk profile page** (pingdotgg#11765) and **Clerk stack bump**
(pingdotgg#11764).
- **Disabling the local environment** (pingdotgg#9194).
- **Self-contained CLI installs and release archives** (pingdotgg#11607, pingdotgg#11659,
pingdotgg#11451, pingdotgg#11510, pingdotgg#11318, pingdotgg#11317, pingdotgg#11316).
- **Device-host testing across environments** (pingdotgg#11699, pingdotgg#11698) — behind
`FEATURES.deviceHub`.

Contract effect: **6 `UnsupportedMethodError` union entries added, 0
dropped** (`projectClone.start`/`.cancel`/`.retry`,
`subscribeProjectClones`, `subscribeWorktreeSetup`,
`worktreeSetup.cancel`). 99 of 157 methods now declare the error.

## Backend behavior to consider reproducing in Moatless

Upstream server behavior worth having even though the fork cannot use
the implementation directly. All seven are now entries under _Runtime
fixes upstream made to its own server_ in `docs/fork/gaps.md`.

- **Thread titles generated from user intent** (pingdotgg#10720) — ships with an
evaluation harness at `apps/server/scripts/evaluate-thread-titles.ts`.
- **Title-link resolution via `SourceControlProvider`** (pingdotgg#11844, tidied
by pingdotgg#11847).
- **`async: false` setup scripts** (pingdotgg#11832) — a setup script can be
marked to finish before the agent's first turn. The client side was
carried: it rides as `waitForSetup` in the editor's form and maps to
`async: false` (`apps/web/src/projectScripts.ts`), so the flag is
written through `project.meta.update` today and does nothing until the
backend honours it.
- **Setup-script color probe suppressed** (pingdotgg#11843) — `NO_COLOR=1` /
`FORCE_COLOR=0` in `ProjectSetupScriptRunner.ts`, because setup may run
before a terminal client attaches to answer the probe.
- **Provider refresh on `subscribeConfig`** (pingdotgg#11811,
`apps/server/src/ws.ts`).
- **Terminal output send window** (pingdotgg#11407,
`apps/server/src/terminal/OutputProtocol.ts`) — 8 chunks / 64 KiB,
rather than acking per chunk.
- **Tight-list streaming one item at a time** (pingdotgg#11833,
`ProviderRuntimeIngestion.ts`).

## Owned-surface sweep

74 new upstream files, 14 of them inside a fork-owned concern. All 14
accepted unmodified — **no new `FEATURES` flag was needed**. The
reachability was traced rather than assumed: `projectCloneTracking`,
`action:add-project`, and `sendEnvMode === "worktree"` each already drop
their surface.

## Verification

`verify.mjs`'s 7 non-test checks pass — duplicated adds, tripwires,
resolutions against both parents, the unsupported-method derivation,
format, lint, types. All 13 workspace packages' test suites run, 12
fully green.

Two caveats, both environmental:

- **`@t3tools/desktop` — `scripts/browser-secret-native.test.mjs` fails
on missing `libsecret-1`.** 106 of 108 test files pass, 1363 tests pass,
and the failing file's own 10 tests are all skipped: the failure is
suite-level setup calling `pkg-config --cflags --libs libsecret-1`,
which the sandbox cannot satisfy. Pre-existing, reproduces on `HEAD^1`,
and already a standing entry in `docs/fork/gaps.md`.
- **The full `verify.mjs` pass could not be run as one command.** Two
consecutive attempts were killed by sandbox evictions during the
parallel test phase, losing their logs. Verification was completed
instead as `--fast` plus the 13 packages run sequentially via `--only
test --package`, which the script documents as the supported route and
which bounds peak memory. Every check ran; none were skipped.

## Post-merge action

`desktop-macos-preview-publish.yml` and `release-desktop.yml` are new
upstream workflows that need disabling in this fork. `moat gh workflow
disable` returns HTTP 404 for both — GitHub only registers a workflow
once it lands on the default branch — so this cannot be done until after
this PR merges. Recorded in the tracker entry.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---
Moatless task:
https://moatless.soaplabstest.com/tasks/b4c32213-a797-4017-9713-eacb4faa3ee7
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