| author | |
| committer | |
| log | 325c0a3687d4dbd6ce536b059c7c2b7ad0c4964a |
| tree | 8ec47b0f95db2f88d8bce30a1e81ee8448aabe78 |
| parent | 7c7fcf46c2307a0fa0348d8af9012791eb9ebb2d |
- 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 cases3 files changed, 104 insertions(+), 12 deletions(-)
apps/web/src/components/ComposerPromptEditor.tsx+30-12| ... | ... | @@ -50,6 +50,7 @@ import { |
| 50 | 50 | type Ref, |
| 51 | 51 | } from "react"; |
| 52 | 52 | |
| 53 | import { isCollapsedCursorAdjacentToMention } from "~/composer-logic"; | |
| 53 | 54 | import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; |
| 54 | 55 | import { cn } from "~/lib/utils"; |
| 55 | 56 | import { basenameOfPath, getVscodeIconUrlForEntry } from "~/vscode-icons"; |
| ... | ... | @@ -469,16 +470,25 @@ function ComposerMentionArrowPlugin() { |
| 469 | 470 | useEffect(() => { |
| 470 | 471 | const unregisterLeft = editor.registerCommand( |
| 471 | 472 | KEY_ARROW_LEFT_COMMAND, |
| 472 | () => { | |
| 473 | let currentOffset = -1; | |
| 473 | (event) => { | |
| 474 | let nextOffset: number | null = null; | |
| 474 | 475 | editor.getEditorState().read(() => { |
| 475 | 476 | const selection = $getSelection(); |
| 476 | 477 | if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; |
| 477 | currentOffset = $readSelectionOffsetFromEditorState(0); | |
| 478 | const currentOffset = $readSelectionOffsetFromEditorState(0); | |
| 479 | if (currentOffset <= 0) return; | |
| 480 | const promptValue = $getRoot().getTextContent(); | |
| 481 | if (!isCollapsedCursorAdjacentToMention(promptValue, currentOffset, "left")) { | |
| 482 | return; | |
| 483 | } | |
| 484 | nextOffset = currentOffset - 1; | |
| 478 | 485 | }); |
| 479 | if (currentOffset <= 0) return false; | |
| 486 | if (nextOffset === null) return false; | |
| 487 | const selectionOffset = nextOffset; | |
| 488 | event?.preventDefault(); | |
| 489 | event?.stopPropagation(); | |
| 480 | 490 | editor.update(() => { |
| 481 | $setSelectionAtComposerOffset(currentOffset - 1); | |
| 491 | $setSelectionAtComposerOffset(selectionOffset); | |
| 482 | 492 | }); |
| 483 | 493 | return true; |
| 484 | 494 | }, |
| ... | ... | @@ -486,18 +496,26 @@ function ComposerMentionArrowPlugin() { |
| 486 | 496 | ); |
| 487 | 497 | const unregisterRight = editor.registerCommand( |
| 488 | 498 | KEY_ARROW_RIGHT_COMMAND, |
| 489 | () => { | |
| 490 | let currentOffset = -1; | |
| 491 | let composerLength = 0; | |
| 499 | (event) => { | |
| 500 | let nextOffset: number | null = null; | |
| 492 | 501 | editor.getEditorState().read(() => { |
| 493 | 502 | const selection = $getSelection(); |
| 494 | 503 | if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; |
| 495 | composerLength = $getComposerRootLength(); | |
| 496 | currentOffset = $readSelectionOffsetFromEditorState(0); | |
| 504 | const currentOffset = $readSelectionOffsetFromEditorState(0); | |
| 505 | const composerLength = $getComposerRootLength(); | |
| 506 | if (currentOffset >= composerLength) return; | |
| 507 | const promptValue = $getRoot().getTextContent(); | |
| 508 | if (!isCollapsedCursorAdjacentToMention(promptValue, currentOffset, "right")) { | |
| 509 | return; | |
| 510 | } | |
| 511 | nextOffset = currentOffset + 1; | |
| 497 | 512 | }); |
| 498 | if (currentOffset < 0 || currentOffset >= composerLength) return false; | |
| 513 | if (nextOffset === null) return false; | |
| 514 | const selectionOffset = nextOffset; | |
| 515 | event?.preventDefault(); | |
| 516 | event?.stopPropagation(); | |
| 499 | 517 | editor.update(() => { |
| 500 | $setSelectionAtComposerOffset(currentOffset + 1); | |
| 518 | $setSelectionAtComposerOffset(selectionOffset); | |
| 501 | 519 | }); |
| 502 | 520 | return true; |
| 503 | 521 | }, |
apps/web/src/composer-logic.test.ts+28| ... | ... | @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; |
| 3 | 3 | import { |
| 4 | 4 | detectComposerTrigger, |
| 5 | 5 | expandCollapsedComposerCursor, |
| 6 | isCollapsedCursorAdjacentToMention, | |
| 6 | 7 | replaceTextRange, |
| 7 | 8 | } from "./composer-logic"; |
| 8 | 9 | |
| ... | ... | @@ -77,3 +78,30 @@ describe("expandCollapsedComposerCursor", () => { |
| 77 | 78 | expect(detectComposerTrigger(text, expandedCursor)).toBeNull(); |
| 78 | 79 | }); |
| 79 | 80 | }); |
| 81 | ||
| 82 | describe("isCollapsedCursorAdjacentToMention", () => { | |
| 83 | it("returns false when no mention exists", () => { | |
| 84 | expect(isCollapsedCursorAdjacentToMention("plain text", 6, "left")).toBe(false); | |
| 85 | expect(isCollapsedCursorAdjacentToMention("plain text", 6, "right")).toBe(false); | |
| 86 | }); | |
| 87 | ||
| 88 | it("detects left adjacency only when cursor is directly after a mention", () => { | |
| 89 | const text = "open @AGENTS.md next"; | |
| 90 | const mentionStart = "open ".length; | |
| 91 | const mentionEnd = mentionStart + 1; | |
| 92 | ||
| 93 | expect(isCollapsedCursorAdjacentToMention(text, mentionEnd, "left")).toBe(true); | |
| 94 | expect(isCollapsedCursorAdjacentToMention(text, mentionStart, "left")).toBe(false); | |
| 95 | expect(isCollapsedCursorAdjacentToMention(text, mentionEnd + 1, "left")).toBe(false); | |
| 96 | }); | |
| 97 | ||
| 98 | it("detects right adjacency only when cursor is directly before a mention", () => { | |
| 99 | const text = "open @AGENTS.md next"; | |
| 100 | const mentionStart = "open ".length; | |
| 101 | const mentionEnd = mentionStart + 1; | |
| 102 | ||
| 103 | expect(isCollapsedCursorAdjacentToMention(text, mentionStart, "right")).toBe(true); | |
| 104 | expect(isCollapsedCursorAdjacentToMention(text, mentionEnd, "right")).toBe(false); | |
| 105 | expect(isCollapsedCursorAdjacentToMention(text, mentionStart - 1, "right")).toBe(false); | |
| 106 | }); | |
| 107 | }); |
apps/web/src/composer-logic.ts+46| ... | ... | @@ -58,6 +58,52 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number) |
| 58 | 58 | return expandedCursor; |
| 59 | 59 | } |
| 60 | 60 | |
| 61 | function collapsedSegmentLength(segment: { type: "text"; text: string } | { type: "mention" }): number { | |
| 62 | return segment.type === "mention" ? 1 : segment.text.length; | |
| 63 | } | |
| 64 | ||
| 65 | function clampCollapsedComposerCursor( | |
| 66 | segments: ReadonlyArray<{ type: "text"; text: string } | { type: "mention" }>, | |
| 67 | cursorInput: number, | |
| 68 | ): number { | |
| 69 | const collapsedLength = segments.reduce( | |
| 70 | (total, segment) => total + collapsedSegmentLength(segment), | |
| 71 | 0, | |
| 72 | ); | |
| 73 | if (!Number.isFinite(cursorInput)) { | |
| 74 | return collapsedLength; | |
| 75 | } | |
| 76 | return Math.max(0, Math.min(collapsedLength, Math.floor(cursorInput))); | |
| 77 | } | |
| 78 | ||
| 79 | export function isCollapsedCursorAdjacentToMention( | |
| 80 | text: string, | |
| 81 | cursorInput: number, | |
| 82 | direction: "left" | "right", | |
| 83 | ): boolean { | |
| 84 | const segments = splitPromptIntoComposerSegments(text); | |
| 85 | if (!segments.some((segment) => segment.type === "mention")) { | |
| 86 | return false; | |
| 87 | } | |
| 88 | ||
| 89 | const cursor = clampCollapsedComposerCursor(segments, cursorInput); | |
| 90 | let collapsedOffset = 0; | |
| 91 | ||
| 92 | for (const segment of segments) { | |
| 93 | if (segment.type === "mention") { | |
| 94 | if (direction === "left" && cursor === collapsedOffset + 1) { | |
| 95 | return true; | |
| 96 | } | |
| 97 | if (direction === "right" && cursor === collapsedOffset) { | |
| 98 | return true; | |
| 99 | } | |
| 100 | } | |
| 101 | collapsedOffset += collapsedSegmentLength(segment); | |
| 102 | } | |
| 103 | ||
| 104 | return false; | |
| 105 | } | |
| 106 | ||
| 61 | 107 | export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null { |
| 62 | 108 | const cursor = clampCursor(text, cursorInput); |
| 63 | 109 | const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1; |