Skip to content

fix(composer): Up/Down and Shift+Up/Down no longer stall at wrapped link chips - #1886

Merged
matt2e merged 8 commits into
mainfrom
shift-up-not-working-with-links-in-composer
Oct 29, 2026
Merged

matt2e merged 8 commits into
mainfrom
shift-up-not-working-with-links-in-composer

Conversation

@matt2e

@matt2e matt2e commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Problem

In the message composer, Up and Down (with or without Shift) stop working around a link chip that wraps. Extending a selection upwards with Shift+Up stalls as soon as the next line up holds only part of a wrapped GitHub link, and further presses do nothing. Plain Up/Down and Shift+Down break at the same places. WebKit (the macOS app) leaves the caret where it is; Chromium jumps to the start or end of the draft.

Root cause

Links and mentions in the composer are ProseMirror inline atoms rendered as contenteditable="false" spans whose text wraps like ordinary text. Vertical caret movement was left to the browser. When the visual line above or below holds only (part of) a token — the link's first line when it starts a paragraph, or a middle line when it wraps three or more times — that line has no editable position other than the token's edges, so the browser finds nowhere to put the caret: WebKit gives up, Chromium falls through to the document boundary.

Fix

  • crossTokenLine (EditableInput.tsx) runs before a native Up/Down, plain or with Shift. It reads each token's client rects to find the lines a token holds alone and moves in document terms instead. It stops at the token edge when entering such a line, crosses a middle line to the far edge, and steps to the neighbouring line when leaving. Shift keeps the anchor. It runs from the React keydown handler after the host, so an open completion list still claims Up and Down, and from WebKit's control-character caret-key path.

  • Leaving such a line keeps the caret's column (992b66df6): the caret lands at the position on the neighbouring line drawn nearest its column, as a browser does between lines of text. Previously it landed at the near end of that line, so Up from a paragraph-leading link stopped just before the line break and Down from a paragraph-ending link just after it. A Shift selection then grew by the break alone and the press looked like it did nothing. Positions are measured with coordsAtPos rather than hit-tested, so a line scrolled out of the composer still counts.

  • Stepping off a link lands on character boundaries only (6b4495b5d). The nearest-column walk visits grapheme boundaries, segmenting the textblock's text with Intl.Segmenter; each link, mention or line break reads as one character, so the text's offsets are the textblock's positions. Previously it measured every UTF-16 position, and a position inside a surrogate pair or grapheme cluster is drawn at its glyph, so it could tie with or beat a real boundary. Up from a paragraph-leading link below 😀😀😀 words above put the caret between the first emoji's surrogates in both engines, and Shift+Up then Backspace left a lone \ud83d in the draft. In WebKit, Down onto a line of 👍🏽, ZWJ families or decomposed é landed inside a cluster too.

  • Once the geometry has picked a head, a Shift+Up or Shift+Down press is settled by the selection it leaves (46b0cc63f). This replaces two rules keyed on where a press started: 124c7006f took a whole link when Shift entered its line from just past the line break beside it, and fff8e8786 sent the reverse press back to the anchor. Together they let a press and its reverse carry the head past the anchor. With a link between two empty lines, Shift+Down from its end then Shift+Up selected the whole link backwards instead of collapsing. Shift+Down then Shift+Up from the end of the line above a link that fits on its own line selected review backwards, so typing would replace it. With Please review / link / empty line / Thanks, three Shift+Down presses then three Shift+Up visited 135, 13 and 0 on the way back, so one press undid two and the next overshot. Three rules now apply:

    • (a) A press toward the anchor can reach it but never pass it.
    • (b) Growing the selection never adds only a line break onto a link: it takes the whole link instead, since the break alone looks like no move at all. With a link on its own line between Please review and Thanks, Shift+Up from the start of Thanks selects the whole link in one press. This also covers links on consecutive lines: each Shift press across them takes a whole link, not the break between them.
    • (c) Shrinking the selection never leaves only a line break selected: it collapses to the anchor. A lone break looks collapsed, and typing would replace it and join two lines.

    (a) and (c) apply only toward the anchor, and (b) only away from it, so they never apply to the same press. From a collapsed caret, a lone break onto an empty line is the whole move, since that line holds nothing else. Plain Up and Down are unchanged: they still stop at a link's near edge, which is a real caret position.

  • The grapheme walk reads each inline leaf as \n rather than U+FFFC (124c7006f). A combining mark, VS16, skin-tone modifier or ZWJ typed after a link joined U+FFFC in one grapheme, which dropped the position after the link from the walk; nothing after \n joins it. Only a bare carriage return directly before a link would still join it, and typed text never holds one, since inserted text normalises CR LF.

  • A position directly after a newline character or a hard break is measured on its own line (fff8e8786). Shift+Enter and a text/plain paste put the lines in one paragraph, separated by newline characters, where fill() makes a paragraph per line. crossTokenLine measured the Up caret and the line above a link with side -1, and just past a newline that measures the newline itself, drawn at the end of the line above, so a caret at the start of a line was taken to be one line higher. In a Please review / link / X line / Thanks draft built that way, Up from the start of Thanks skipped X line onto the link's end, and plain Up from the start of X line, just past the break below the link, skipped the link's end for its start. With an empty line above the link, Down from the line above skipped the empty line onto the link, and Down from the empty line hit the original bug: Chromium jumped to the end and WebKit stalled. Two helpers, broken (is the character before a position a newline) and at (measure with side 1 after one), now measure the caret and the lines above and below a link, in Chromium and WebKit alike.

  • A textblock start is measured with side 1, like a position just past a line break (46b0cc63f). Chromium draws no rect at the start of a textblock whose text begins with a newline, so in a message that begins with a blank line, measuring there with side -1 put the line above a link at top 0. Down from that empty first line then jumped to the end of the draft.

  • Rects that bidi reordering splits onto one line are folded into a single line. A line the link is split across is entered at the link's edge from a neighbouring line and left natively.

  • WebKit only, Down only: once WebKit has drawn the caret just after a wrapped token, native Down stalls. The DOM caret is moved to the start of the following text first, which is the same document position.

  • mac-webkit.ts exports webKit() (vendor check on any platform); macWebKit() builds on it.

Every other line move stays native. Mac Ctrl+N/Ctrl+P are unchanged.

Demo

WebKit at 480 px, synthetic draft. The caret starts at the end and Shift+↑ is pressed 8 times, 400 ms apart. Left: main at the branch point (afac5b5dc). Right: this branch (992b66df6). On the left the selection stops after one line, at the end of the last link. On the right each press adds one line, reaching the top of the draft on the 7th press; the 8th has nothing left to select. The video predates 6b4495b5d, 124c7006f, fff8e8786 and 46b0cc63f, and none of them changes this sequence. 6b4495b5d affects only lines with multi-unit characters, which this draft has none of. The video's draft is built with fill(), a paragraph per line. At this head (46b0cc63f) the same draft gives the heads the video shows, whether built by fill() or by a text/plain paste: 465, 335, 309, 175, 153, 29, 0, 0. At 124c7006f the pasted draft gave 465, 335, 175, 29, 0, 0, 0, 0, skipping the words before each link.

before-after.mp4

Tests

  • New browser journey in tests/browser/composer-links.spec.mjs, "Up and Down move one line at a time through links that wrap" (Chromium + WebKit). It covers Up/Down with and without Shift through two wrapped links (a paragraph-leading link and one that wraps twice), paragraphs that start or end with a wrapped link, Shift from just past the line break beside a link-only line, a link on its own line between two lines of text, drafts built by a text/plain paste (newline characters in one paragraph, as Shift+Enter makes), including a link that fits on its own line, two links on consecutive lines and a draft that starts and ends with an empty line, a Shift press and its reverse from either end of every line and link, a Shift+Enter line break after a link, the control-character key path, a bidi-split link, and emoji next to a paragraph-leading link. It checks the anchor after every press and fails a press that crosses only a line break, except onto or off an empty line, which holds nothing else. Browser-only justification: where a link wraps, and which lines it holds alone, depend on real layout and the engine's native caret movement, which jsdom cannot reproduce. No browser cases were removed.

  • Emoji case (6b4495b5d): 😀😀😀 words above on the line above a paragraph-leading link that wraps, and a line of 👍🏽 below it. Up and Shift+Up from the link's start land at 0; Backspace after that Shift+Up deletes the whole line above and leaves no lone surrogate; Down and Shift+Down land on 👍🏽 boundaries. The same commit also asserts that the third and fourth links wrap, which the expected moves depend on.

  • Shift into a link-only line (124c7006f): from just past the line break beside a wrapped paragraph-leading link (Down from the end of the line above) and beside a paragraph-ending link (Up from the start of the line below), plain Up/Down stop at the link's near edge, while Shift+Up/Down take the whole link and then keep moving one line per press. This replaces 6b4495b5d's pin on the old Shift+Down. A new Please review / link / Thanks draft puts a link on its own line: Shift+Up from the start of Thanks visits the link's start, then 0, and Shift+Down from the end of Please review lands at the link's end, each press crossing more than a line break. The \n placeholder has no browser case, as the two candidate positions sit on either side of a zero-width mark; it was checked in Node only, and no committed test covers it.

  • Pasted drafts (fff8e8786): a new paste helper dispatches a text/plain paste and asserts that the draft is one paragraph and that the link wraps. It builds Please review / link / X line / Thanks, and Please review / empty line / link / empty line / Thanks. From either end of every line, plain Up and plain Down must each move exactly one visual line, and Shift+Up and Shift+Down walks must land on exact heads. A new undo check, a whole-link Shift press followed by the reverse Shift press, had to end collapsed where it started, on the filled own-line draft and on both pasted drafts; 46b0cc63f replaces it with moves() (below). The filled own-line draft also now asserts that its link wraps.

  • Press and back (46b0cc63f): a moves() loop runs from every line end and link edge (0, the draft's end, either side of each newline, and each link's start and end) of the filled own-line draft and of every pasted draft, in both directions. A plain press must move exactly one visual line. A Shift press must keep the anchor and select more than a line break, unless it starts or ends on an empty line. The opposite Shift press straight after must keep the anchor, never carry the head past it, and never leave a lone line break selected. New pasted drafts:

    • Please review / a link short enough not to wrap / Thanks: a link that fits on its own line.
    • PRs: / link / link / Thanks: two wrapped links on consecutive lines. Shift+Down from 0 visits the first link's start, its end, then the second link's end; Shift+Up from the end visits the second link's end, its start, the first link's start, then 0. Each press takes a whole link, never the break between them.
    • Please review / link / empty line / Thanks: three Shift+Down presses from the end of Please review visit the link's end, the empty line, then the start of Thanks; three Shift+Up presses retrace them, collapsing at the start.
    • empty line / link / empty line: Chromium draws no rect before the leading newline.
  • Fail-then-pass for 46b0cc63f, with every assertion in the journey made soft and the count of failed assertions per engine:

    EditableInput.tsx at Chromium WebKit
    fff8e8786 13 11
    46b0cc63f 0 0
    46b0cc63f without (a), the anchor clamp (fails on the link that fits on its own line) 1 1
    46b0cc63f without (b), taking the link 17 17
    46b0cc63f without (c), collapsing a lone break 12 12
    46b0cc63f without the block-start side 3 0
  • Fail-then-pass: the journey fails on the merge base in both engines. Removing each part of the fix makes it fail: without the Down caret step WebKit fails (Chromium passes, hence the WebKit gate); the bidi case fails in macOS and Linux WebKit without the fold, and with the fold alone; without 992b66df6's column step both engines fail on the Up walk to the start of the draft and, run alone, on the new case's Down walk; without 6b4495b5d's grapheme walk the emoji case fails in both engines, in Chromium on Up (it visits offset 1, mid-😀) and in WebKit on Down (mid-👍🏽); without 124c7006f's Shift step each new walk fails in Chromium and WebKit on a break-only move. With every assertion made soft, the journey fails 18 of them at 124c7006f in both engines, covering every new fff8e8786 check that the old code breaks, and none at fff8e8786. Dropping only the Up caret measurement fails the 7 Up checks, only the line-above measurement the 6 Down checks, and only the undo rule the 6 reverse presses.

  • At 46b0cc63f: composer-links.spec.mjs 16/16 with --repeat-each 4 on macOS (Chromium + WebKit); Vitest EditableInput.test.tsx 253/253; typecheck, lint and Biome clean. The demo draft gives 465, 335, 309, 175, 153, 29, 0, 0 on Shift+Up from the end, built by fill() or by paste, in both engines.

  • At fff8e8786: composer-links.spec.mjs 16/16 with --repeat-each 4 on macOS (Chromium + WebKit); Vitest EditableInput.test.tsx 253/253; typecheck, lint and Biome clean.

  • At 124c7006f: composer-links.spec.mjs 16/16 with --repeat-each 4 on macOS (Chromium + WebKit); Vitest EditableInput.test.tsx 253/253; typecheck, lint and Biome clean.

  • At 6b4495b5d: composer-links.spec.mjs 16/16 with --repeat-each 4 on macOS (Chromium + WebKit); Vitest EditableInput.test.tsx 253/253; typecheck, lint and Biome clean.

  • At 992b66df6: composer-links.spec.mjs 20/20 with --repeat-each 5 on macOS (Chromium + WebKit), 12/12 in the Linux CI Playwright container; EditableInput.test.tsx, typecheck, Biome and the pre-commit/pre-push hooks clean.

  • At 2bcd59e86: Vitest src/features/messages 86 files / 2071 tests pass; lint clean.

  • Pre-existing and unrelated: composer-attachments.spec.mjs (3) and Vitest project-git / claude-sh / codex-sh fail the same way on the base.

  • Follow-up: no draft in moves() has text sharing a wrapped link's line, so (a) and (c) never run in the demo draft's shape. A pasted draft such as ${pasted} is ready would cover it; worth adding later.

Known gaps

  • A caret exactly at a soft wrap is ambiguous. The move is measured on the line it leaves, so at worst it goes one line too far rather than sticking.
  • WebKit only: Up/Down onto a bidi-split link, where RTL text adjoins the link on the side the caret comes from, can stall for one press before recovering. This is pre-existing and smaller than before (the base stalled twice).
  • There is no goal column, so a Shift walk followed by the same number of reverse presses does not always return exactly to the start. Every press keeps the anchor and never carries the head past it, and no press leaves only a line break selected except a press onto an empty line, which holds nothing else. A selection can remain in these shapes:
    • A wrapped link at the start of a line with text after it. Shift+Up from the end of that text selects back to the link's start, and Shift+Down then stops at the link's end, leaving the trailing text selected: entering the link's first line has no column to keep, and crossing back lands on the link's far edge. In the demo draft, from 174, the end of … is ready for review., the heads are 29 then 153. This is the demo draft's shape, and it is no worse than on main.
    • A link that fits on its own line. Shift+Down from its start then Shift+Up, or Shift+Up from its end then Shift+Down, leaves the link selected: entering that line stops at the edge chosen by direction, not by the caret's column.
    • A link that fits on its own line beside an empty line. Three presses out and three back can collapse one press early, and the last press then selects the line beyond the start. With Please review / empty line / link / Thanks, three Shift+Down presses from the end of Please review visit 14, 59 and 66, and three Shift+Up presses then visit 59, 13 (collapsed) and 0, leaving Please review selected. The first press down selects only the break onto the empty line, and on the way back (c) collapses that same selection, so the press from the link's end undoes two. With the empty line below the link instead, the walk up and back does the same.

Not yet done

  • Manual check in the native macOS / Linux desktop app, and the human test pass. To try: narrow the window, paste a long GitHub PR link at the start of a line with text after it, add a line below, then press Up/Down and Shift+Up/Down through it. Each press should move exactly one line. Then put a line starting with an emoji (for example 😀😀😀 words above) directly above a link that starts its paragraph and wraps: with the caret at the link's start, Shift+Up should select the whole line above, and Backspace should delete it without leaving a broken character (no � or stray half-emoji). Next, at a narrow width, put a link on its own line between two lines of text (for example Please review, the link, Thanks): Shift+Up from the start of the line below should select the whole link in one press, and plain Up from the start of that line should put the caret just after the link. Finally, build the lines with Shift+Enter instead: type Please review, press Shift+Enter, paste the link, press Shift+Enter, type X line, press Shift+Enter, type Thanks. From the start and the end of each line, Up and Down, with or without Shift, should move exactly one line per press: no press should skip X line or an end of the link, or look like it did nothing. The one exception is that Shift+Up from the start of X line, or Shift+Down from the end of Please review, selects the whole link in one press; the reverse Shift press straight after should collapse the selection back where it started. Last, press Shift+Up a few lines and then Shift+Down the same number of times. No press should carry the selection across where you started, and no press should leave only an invisible line break selected. The walk back usually ends collapsed where you started, but in the shapes listed under Known gaps it can stop one line short, leaving a link or the text after it selected, or, beside an empty line, collapse one press early and then select the line beyond where you started.

🤖 Generated with Claude Code

matt2e and others added 7 commits October 29, 2026 15:16
… holds

Links and mentions in the composer are uneditable inline tokens. When
one wraps, a line holding only part of it, or only the token, has no
caret position besides the token's edges. Native Up and Down with or
without Shift therefore stall there in WebKit and jump to the start or
end of the document in Chromium, both entering such a line and leaving
it.

Before a native line move, crossTokenLine reads each token's client
rects to find the lines it holds alone and moves in document terms
instead. Entering such a line stops at the token edge it holds. A middle
line is crossed to the far edge. Leaving from an edge reaches the
token's other edge or steps onto the neighbouring line, measured past a
hard break, a typed newline or the paragraph boundary. Shift keeps the
anchor. It runs from the React keydown handler after the host, so an
open completion list still claims Up and Down, and from the
control-character caret-key path.

Every other line move stays native, with one adjustment. ProseMirror
places a caret after a token between the token's element and the
following text. Once WebKit has drawn the caret there beside a wrapped
token, Down stalls. The DOM caret now moves to the start of that text
first, which is the same document position.

Known limitations: leaving a token-only line lands at the near end of
the neighbouring line rather than the caret's column. A caret at a soft
wrap is measured on the line it leaves, so a move there can go one line
too far.

The new Chromium and WebKit journey fails without each part of the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Bidi reordering can split a link into several client rects on one line,
as WebKit reports them when the link's Hebrew runs on into the text
after it. crossTokenLine took each rect for a line of its own, so the
first fragment looked like a line the link held alone: Down from the
line above stopped at the link's start and the next Down only stepped to
its end, and Up from that line's text did the same in reverse. A rect on
its predecessor's line is now folded into it.

Folding alone is not enough. WebKit's native Down onto a column over the
split link puts the DOM caret inside the token: the press does nothing
and the next one skips a line. A line the link is split across is
therefore entered at its edge like a line it holds alone, but only from
the neighbouring line, and it is still left natively, which works from
either edge in both engines.

The DOM caret step before a native move now runs only for Down in
WebKit. Without it Down stalls beside a wrapped link in macOS and Linux
WebKit alike, so it checks the WebKit vendor rather than macWebKit();
Chromium and Up pass without it. It uses Selection.extend for a range.

Both call sites now share crossTokenLine's own check for plain or Shift
Up and Down, so Ctrl+Up on a Mac and Meta+Up elsewhere no longer reach
it on the control-character path alone. The Mac's Ctrl+N and Ctrl+P stay
native. moveHead now serves both crossTokenLine and the sideways
inline-leaf step in moveCaret.

Known gap, unchanged from before: native Up or Down onto a split link
still stalls in WebKit when the link neither starts nor ends its line
on the side the caret comes from, as no line break shows which line is
the neighbour.

The journey's new bidi case fails without the fold in macOS and Linux
WebKit, with folding alone (the stall) and with the previous code (the
sideways press). Chromium reports one rect and passes throughout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Follow-ups from review of the bidi-split link fix; behaviour is
unchanged.

mac-webkit.ts now exports webKit(), the vendor check for WebKit on any
platform, and macWebKit() builds on it. The DOM caret step before a
native Down uses it instead of repeating the vendor literal.

The crossTokenLine comment on split lines now says they are left
natively, which works from either edge, and why a caret already on such
a line is not entering: text beside the token can put the previous
line's measure on that very line.

The journey's bidi loop now checks the anchor after each press, held at
the start with Shift and following the head without, as walk does for
the other cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Stepping off a link that starts its paragraph, Up landed at the end of
the line above, just before the line break. Down from a wrapped link
that ends its paragraph landed just after the break. Either way a Shift
selection grew by nothing but the break, so the press looked like it did
nothing and the next press moved the line. In the PR's demo draft,
Shift+Up from the end visited 465, 335, 334, 175, 174, 29, 28, 0.

crossTokenLine now steps off to the position on the neighbouring line
nearest the caret's column, as a browser does between lines of text. It
walks that line's positions with coordsAtPos from the near end instead of
hit-testing a point, so a line scrolled out of the composer still counts.
Each position is measured where a caret there is drawn, so a soft wrap
belongs to the line after it. From a link that starts its line, that is
the start of the line above, or the end of a link whose tail starts that
line. The demo draft now visits 465, 335, 309, 175, 153, 29, 0, one line
per press, in WebKit and Chromium alike.

The journey's walk now fails a press that crosses only a line break, the
first Up walk climbs to the start of the draft, and a new case covers
paragraphs that start or end with a wrapped link: Down lands under the
link's end, and Up from a paragraph-leading link lands at the previous
link's end. Without the fix the walk fails in both engines, first on the
existing Up walk and, run alone, on the new case's Down walk.

Unchanged: entering a line that only a link holds still stops at the
edge it holds, so Shift+Down from the end of the line above a
paragraph-leading link, or Shift+Up from the start of the line below a
paragraph-ending one, still adds only the break. That line has no caret
position at the caret's column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Stepping off a link with Up or Down measured every UTF-16 position on
the neighbouring line for the one nearest the caret's column. A position
inside a surrogate pair or a grapheme cluster is drawn at its glyph, so
it could tie with or beat a real boundary. Up from a link that starts its
paragraph, below `😀😀😀 words above`, put the head between the first
emoji's surrogates in Chromium and WebKit, and Backspace after Shift+Up
left a lone `\ud83d` in the draft. In WebKit, Down onto a line of 👍🏽,
ZWJ families or decomposed é landed inside a cluster too.

The walk now visits grapheme boundaries only, segmenting the textblock's
text with Intl.Segmenter. Each inline leaf reads as one character, so the
text's offsets are the textblock's positions.

The journey gains a case with an emoji line above a paragraph-leading
link and a line of 👍🏽 below it. Up and Shift+Up land at the start,
Backspace then leaves the link intact, and Down lands between clusters.
It fails without the fix in both engines: in Chromium on Up, which
visits offset 1, and in WebKit on Down, mid-👍🏽. The journey also
asserts that the third and fourth links wrap, which the expected moves
depend on, and pins today's Shift+Down from the end of the line above a
paragraph-leading link at the link's start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Entering a line that only a link holds stops at the edge it holds. From
just past the line break beside the link, a Shift selection therefore
grew by that break alone, which looks like no move at all. With a link
pasted on its own line between 'Please review' and 'Thanks', Shift+Up
from the start of 'Thanks' visited the link's end, then its start, then
0, so the first press looked dead. Shift+Down from the end of the line
above a paragraph-leading link did the same.

crossTokenLine now takes the whole link in that case: Shift+Up moves to
the link's start and Shift+Down to its end, and the next press goes on
from there. Plain Up and Down still stop at the near edge, a real caret
position, and Shift from anywhere else on the neighbouring line still
stops there too, since the selection then grows by more than the break.

The column walk now reads each inline leaf as a newline rather than
U+FFFC. A combining mark, variation selector, skin-tone modifier or ZWJ
typed after a link joined U+FFFC in one grapheme, dropping the position
after the link from the walk; nothing after a newline joins it. Each
leaf is still one character, so offsets map 1:1 to positions. Only a
bare carriage return directly before a leaf joins a newline, and typed
text never holds one: inserted text normalises CR LF. A synced draft
could still hold one, and the walk would then skip that one candidate.
It still never lands mid-character.

The journey replaces the pin on the old Shift+Down and checks plain and
Shift moves from just past the break in both directions. It also adds
the 'Please review' / link / 'Thanks' draft, where every Shift+Up and
Shift+Down press must cross more than a line break. Before the fix, each
new walk fails in Chromium and WebKit on a break-only move. The leaf
change has no browser case: the two candidates sit on either side of a
zero-width mark, so it is checked in Node only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Shift+Enter and a text/plain paste put newline characters in one
paragraph, where the journey's fill() made a paragraph per line.
crossTokenLine measured the Up caret and the line above a link with side
-1, and just past a newline that measures the newline itself, drawn at
the end of the line above. A caret at the start of a line was taken to
be one line higher, so in such a draft, in Chromium and WebKit alike:

- Up from the start of the line below a link-only line skipped that
  line onto the link's end, and Up from just past the break below the
  link skipped its end for its start.
- With an empty line above the link, Down from the line above skipped
  the empty line onto the link, and Down from the empty line hit the
  original bug: Chromium jumped to the end and WebKit stalled.
- The PR's demo draft, pasted rather than filled, visited 465, 335, 175,
  29, 0 on Shift+Up, skipping the words before each link.

A position directly after a typed newline or a hard break is now
measured with side 1, on its own line, for the caret and for the lines
above and below a link. Newline drafts now move as paragraph drafts do,
and the demo draft visits 465, 335, 309, 175, 153, 29, 0 either way.

Undoing the whole-link Shift press left the break alone selected. After
Shift+Up from just past the break below a wrapped link, Shift+Down
crossed the rest of the link to its end, one break short of the anchor:
the selection looked collapsed and typing joined the link to the next
line. Shift+Down then Shift+Up from the line above did the same.
Crossing a token toward an anchor just past the break beside it now goes
to the anchor. For Up that cross happens entering the link's first line
from its last, so the check follows both branches.

The journey adds drafts built by a text/plain paste: 'Please review',
the link, 'X line' and 'Thanks', and the same with an empty line either
side of the link. From either end of every line, a plain press either
way must move exactly one visual line, and Shift walks must land on
exact heads. walk still fails a press that crosses only a line break,
except onto or off an empty line, which holds nothing else. Undoing the
whole-link press is checked on the filled own-line draft and on both
pasted drafts.

With every assertion soft, the journey fails 18 of them at 124c700 in
both engines, covering every new check that the old code breaks.
Dropping only the Up caret fix fails the 7 Up checks, only the
line-above fix the 6 Down checks, and only the undo rule the 6 reverse
presses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e marked this pull request as ready for review October 29, 2026 07:56
@matt2e
matt2e requested a review from a team as a code owner October 29, 2026 07:56

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

I reviewed the PR and went through the Nexus review-tracker notes. I didn't see any P2-or-higher issues in this change.

Posted by Codex (gpt-6.1-sol) on behalf of kylez.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Star Lord’s automated source review via Wes’s account: no actionable changes requested.

Rocket, Mantis and Groot completed independent lanes; I reconciled the keyboard, selection, grapheme and lifecycle paths at head fff8e8786aca85e8a4c47f6db25af879825b96e9, base e210c8a472cc49ee7c0b49b62bfad10fd5ef4c8e (merge-base afac5b5dc287b148e54d9e6c9bd2d2878ca73660).

Source-only: no tests or app execution; native desktop, assistive technology, documented soft-wrap/bidi limitations and test-cost changes remain unverified. The CI snapshot reports required checks successful with Windows native validation skipped; public material and seven demo frames were inspected, but full video playback/audio were not.

Two Shift-only rules, keyed on where a press started, let a press and
its reverse carry the head past the anchor. 124c700 took a whole link
when Shift entered its line from just past the line break beside it,
and fff8e87 sent the reverse press back to the anchor. In Chromium and
WebKit, with pasted and filled drafts alike:

- 'Please review' / blank / link / blank / 'Thanks': Shift+Down from
  the link's end, then Shift+Up, selected the whole link backwards
  instead of collapsing. From the link's start, the mirror did the same.
- A link that fits on its own line: Shift+Down from the end of the line
  above, then Shift+Up, selected 'review' backwards, so typing would
  replace it.
- Two links on consecutive lines: Shift+Down from the end of the first
  selected only the line break between them, and Shift+Up back then
  selected the first link whole.
- 'Please review' / link / blank / 'Thanks': three Shift+Down presses
  from the end of the first line, then three Shift+Up, visited 135, 13
  and 0 on the way back. One press undid two, and the next overshot the
  start.

Both rules are gone. Once the geometry has picked a head, a Shift press
settles it by the selection it leaves:

- Toward the anchor, the head may reach the anchor but never pass it.
  It collapses there rather than leave a lone line break selected: that
  looks collapsed, and typing would replace the break and join two
  lines.
- Away from the anchor, growing by a lone break onto a token's near edge
  takes the token, since the break alone looks like no move at all. That
  also covers stepping off one link onto the next.

The two never apply to the same press. Collapsing applies only toward
the anchor: from a collapsed caret, a lone break onto an empty line is
the whole move, as that line holds nothing else. Plain presses are
unchanged.

Separately, Chromium draws no rect at a textblock start when the block's
text begins with a newline. Measured with side -1 there, the line above
a link read as top 0, so Down from an empty first line jumped to the end
of the document. A textblock start is now measured with side 1, like a
position just past a line break.

The journey now checks a Shift press and the opposite press from either
end of every line and link of each pasted draft, and of the filled
own-line draft in place of `undo`. The anchor must stay put, the head
must never pass it, and no lone break may be left selected. New pasted
drafts cover a link that fits on one line, two links on consecutive
lines, an empty line below the link only (three presses down and back),
and an empty first and last line. With every assertion soft, the
journey fails 13 assertions at fff8e87 in Chromium and 11 in WebKit,
and none now. Dropping one part at a time, in both engines unless
noted:

- the anchor clamp: 1 (the one-line link case);
- taking the token: 17;
- collapsing a lone break: 12;
- the block-start side: 3, in Chromium only.

Known limitation: press-and-back returns exactly to the start
everywhere but on a link that fits on its own line. Shift+Down from its
start then Shift+Up, or Shift+Up from its end then Shift+Down, leaves
the link selected. Entering that line stops at the edge chosen by
direction, not by the caret's column. The head still never passes the
anchor.

The PR's demo draft still gives 465, 335, 309, 175, 153, 29, 0, 0 on
Shift+Up from the end, filled or pasted, in both engines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Star Lord’s automated source review via Wes’s account: no actionable changes requested.

Rocket, Mantis and Groot completed independent lanes; I reconciled the Shift-selection settlement, leading-newline geometry, callers and regression coverage at head 46b0cc63f25cf230c2a01ca03380522a3cf2c6a9, base e210c8a472cc49ee7c0b49b62bfad10fd5ef4c8e.

Source-only: no tests or app execution; native keyboard/assistive-technology behavior, documented selection limitations and test-cost changes remain unverified. CI was still running at the snapshot. Public material and seven saved demo frames were inspected; full playback/audio were not.

@matt2e
matt2e merged commit 776a4c5 into main Oct 29, 2026
37 checks passed
@matt2e
matt2e deleted the shift-up-not-working-with-links-in-composer branch October 29, 2026 09:28
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.

3 participants