You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Rolled-up Markdown links ([label](/n/{id}), painted by agentnoteLinks() since #64) are read-only chips today: there is no keyboard path to edit the label or the URL, and pressing Enter with the caret at the visual end of a label splits the link markup in half. This issue adds Obsidian Live-Preview-grade link editing convenience — a link temporarily unwraps to its raw Markdown source while the caret is inside it, and Enter can never land inside link markup.
Why This Matters
[[-created sub-note links (#77/#80/#82) are now the primary way notes reference each other, so every note has rolled-up links in it. Once rolled up, those links are a dead end:
You cannot rename a link label without deleting the whole chip and retyping it.
You cannot repoint a link at a different note.
Enter at the end of a link — the single most common thing to do after inserting one in a list — corrupts the note, producing two lines of literal text where a link used to be.
That last one is a silent data-shape bug: the user sees a blue chip, presses Enter, and the chip becomes - [mobidoo-reply / - ](/n/abc). Nothing warns them.
Conversation Context
Reported by Chase against agentnote.dev with a screenshot of a rolled-up - mobidoo-reply sub-note link inside a Markdown list:
"Enter at the end of the label splits in the middle." The caret sits at the visual end of the blue rolled-up link; Enter does not cleanly break the list item and the link markup gets spliced.
"Arrowing into the link should temporarily break the rolled-up format" so the raw Markdown ([label](/n/id) chrome + URL) becomes editable. Today there is no "source expand while editing" affordance at all.
Explicit direction: build this as a best-practice link-embed editing model in the direction of Obsidian Live Preview / Notion mention editing — not a one-off Enter hack.
Current Behavior
Verified reproduction
Document (the screenshot case), with document offsets:
The Lezer tree is Link[2,25) containing LinkMark[2,3) LinkMark[16,17) LinkMark[17,18) URL[18,24) LinkMark[24,25).
Bug 1 — Enter splits the link. Running agentnoteInsertNewlineContinueMarkup (bound at Prec.highest in components/codemirror-editor.tsx:131-135) at each caret offset produces:
Enter@15 -> "- [mobidoo-repl\n- y](/n/abc)" <- splits the label
Enter@16 -> "- [mobidoo-reply\n- ](/n/abc)" <- REPORTED CASE (visual end of label)
Enter@17 -> "- [mobidoo-reply]\n- (/n/abc)"
Enter@24 -> "- [mobidoo-reply](/n/abc\n- )" <- splits the URL
Enter@25 -> "- [mobidoo-reply](/n/abc)\n- " <- the only correct one
A CommonMark inline link cannot survive a list-item break: [ and ](url) land in different list items, so the link is gone and the raw chrome becomes visible literal text.
Bug 2 — invisible caret dead zone.lib/editor/links.ts:286 declares the hidden ranges atomic:
but buildHideDecorations emits four separate adjacent ranges — [16,17), [17,18), [18,24), [24,25) — and CodeMirror's skip logic (node_modules/@codemirror/view/dist/index.js:3729) only skips a position that is strictly inside a single range:
Offsets 16, 17, 18, 24 and 25 all paint at the same x coordinate (everything between them is Decoration.replaced to zero width). So walking off the end of a rolled-up link takes five indistinguishable Right presses, and four of the five resting spots put Enter/Backspace/typing inside link markup.
Bug 3 — no edit affordance.hiddenLinkMarks / visibleLinkMarks rebuild only on tr.docChanged or a syntax-tree change (needsRebuild, lib/editor/links.ts:274-276). Selection is not an input at all, so a link never unwraps. The only way to edit a label or URL today is to delete the chip and retype the whole thing.
What already works and must not regress
Editor: Obsidian-style clickable Markdown links (port chasehuh_com agentnoteLinks) #64 — agentnoteLinks() hides [ / ](url) chrome, paints the label .cm-md-link (accent + underline, app/globals.css:765), and plain left-click dispatches agentnote:open-note for /n/{id} or opens external URLs in a new tab (tryOpenLinkAtPointer, lib/editor/links.ts:390-426). Cmd/Ctrl/Shift/Alt-click deliberately fall through to native CM behavior.
Bare/scheme-less links (docs.sume.com/x) and <autolinks> keep their current painting and click behavior.
Desired Behavior
A. Enter never splits link markup
When the caret is strictly inside a link's Markdown span (from < pos < to) and Enter is pressed, the break is taken after the closing ) instead of at the caret:
- [mobidoo-reply](/n/abc) caret at 16 (visual end of label)
Enter
- [mobidoo-reply](/n/abc)
- ▌
This holds for every interior offset (label, ], (, URL, )), inside list items and plain paragraphs, and produces one transaction so it is a single undo step and one CRDT update.
Rationale for "always break after the link" rather than "refuse to break": a Markdown inline link cannot span a list-item break, so no interior offset has a meaningful split. Jumping to the end is the only outcome that is both non-destructive and matches what a user pressing Enter at the end of a chip wants. Caret exactly at from (before [) or at to (after )) is not interior, so breaking before/after a link keeps working unchanged.
B. Temporary source unwrap while editing
A link unwraps — its [, ](, URL and ) chrome becomes visible, non-atomic, editable text — when the caret/selection is inside it, and re-rolls the moment the caret leaves.
rolled up: - mobidoo-reply▌ (caret outside the span)
unwrapped: - [mobidoo-reply▌](/n/abc) (caret at 16, inside the span)
Because the chrome is no longer replaced or atomic while unwrapped, Right/Left step through ], (, /n/abc, ) as real visible characters — Bug 2's invisible dead zone disappears as a consequence, without loosening the atomic policy for rolled-up links.
C. Concrete edit-vs-open rule (the part to lock down)
This is Obsidian Live Preview's rule verbatim: click navigates, caret placement edits, and the two are never the same gesture. Consequences that are intentional, not oversights:
You cannot open a link by clicking it while it is unwrapped. Arrow out (or click elsewhere), it re-rolls, then click.
Re-roll is driven purely by selection position — no idle timer. A timer would make the editor's painted state a function of wall-clock time, which is untestable, fights IME, and is not what Obsidian does.
A drag-selection that spans a link with both endpoints outside it leaves the link rolled up. Only an endpoint (anchor or head) strictly inside the span unwraps it. This keeps ⌘A / sweep-selects from reflowing the whole document into source.
D. Scope
Every agentnoteLinks() Markdown link (Link nodes), especially /n/{id} sub-note links.
Autolink (<https://…>) rides the same code path for free — its </> chrome unwraps identically.
Bare/scheme-less URLs have no hidden chrome and are unaffected.
Must work under yCollab (y-codemirror.next) — the CRDT path is the only path in production.
components/codemirror-editor.tsx:131-135 — the Prec.highest Enter binding; :172 — where agentnoteLinks() is mounted (edit and read-only); :180 — yCollab.
node_modules/@codemirror/view/dist/index.js:3729skipAtomicRanges — the strict pos > from && pos < to test that creates the between-adjacent-ranges dead zone.
Obsidian Live Preview reference behavior: source-mode reveal follows the selection; click on a rendered link navigates.
Proposed Design
New state field: which links are being edited
exporttypeLinkSpan={from: number;to: number};/** * The `[label](url)` / `<url>` span containing `pos`, when `pos` is *strictly* * inside it. Boundary offsets (before `[`, after `)`) return null so that * breaking before/after a link is untouched. */exportfunctionlinkSpanAt(state: EditorState,pos: number): LinkSpan|null;
Implementation: syntaxTree(state).resolveInner(pos, -1), walk .parent until a Link / Autolink node, then apply the strict node.from < pos < node.to test. Verified against the tree above — resolveInner(2, -1) stops at ListItem (no Link ancestor), resolveInner(25, -1) reaches Link[2,25) but fails the strict test. This is O(log n), unlike the existing full-document iterate().
Recomputed only when tr.docChanged || tr.selection || syntaxTree advanced; returns the previous array by identity when the span set is unchanged, so the expensive decoration fields can compare by reference:
activeLinks must be listed beforehiddenLinkMarks / visibleLinkMarks in the extension array so it is already computed when they read tr.state. This is the same ordering constraint the existing needsRebuild comment documents for the language field.
Active spans are collected from selection endpoints only (range.head, plus range.anchor when non-empty) — see §C for why.
Decorations
collectMarkdownLinks(state, active) gains an active argument. For a link whose span is active:
skip its hide ranges → chrome becomes visible and non-atomic (the atomic facet is derived from hiddenLinkMarks, so one change buys both);
emit a source mark over the whole span, plus a second mark over the label, using new class names that do not contain the cm-md-link token:
.cm-md-link-src over [from, to) — dim accent, no underline, default cursor;
.cm-md-link-src-label over the label — full accent, so the label still reads as the link text.
keep pushing to hits so hrefAtPos() stays a pure "what does this position link to" helper and bare-link occupancy is unaffected.
The class-name choice is load-bearing: tryOpenLinkAtPointer matches el.closest(".cm-md-link"), and cm-md-link-src is a single class token, so an unwrapped link cannot match it. .cm-md-link--bare keeps working because its spec is two tokens ("cm-md-link cm-md-link--bare"). A redundant state-level guard (isLinkActive(view.state, pos)) is still added to tryOpenLinkAtPointer, because posAtCoords has a documented fallback path through view.posAtDOM and an accidental navigation while editing loses the user's place.
RangeSetBuilder ordering is safe: a span mark always starts one offset before its label mark ([ vs the first label char), so there are no equal-from ties to order.
Enter
agentnoteInsertNewlineContinueMarkup derives a link-escaped state, runs the existing commands against it, then re-issues the resulting transaction onto the real state so the caret move and the break are one transaction:
constsource=stateBrokenOutOfLink(state);letran=continueMarkup({state: source,dispatch: capture});if(!ran&&source!==state){// Plain paragraph: continueMarkup declines, and defaultKeymap's Enter would// still split the link. Break after it ourselves.ran=insertNewlineAndIndent({state: source,dispatch: capture});}if(!ran||!tr)returnfalse;dispatch(rebaseOnto(state,tightenListContinue(source,tr)));
rebaseOnto returns the transaction unchanged when tr.startState === state, so the no-link path is byte-identical to today (including returning false so defaultKeymap still gets its turn). The changes are valid against state because a selection-only update() leaves doc identical.
Implementation Notes
Likely files to modify
lib/editor/links.ts — linkSpanAt(), isLinkActive(), activeLinks field, active-aware collectMarkdownLinks() / builders, needsRebuild(), guard in tryOpenLinkAtPointer(), extension order in agentnoteLinks().
lib/editor/list-continue.ts — stateBrokenOutOfLink(), rebaseOnto(), rework of agentnoteInsertNewlineContinueMarkup.
app/globals.css — .cm-md-link-src / .cm-md-link-src-label next to the existing .cm-md-link block.
New files
lib/editor/list-continue.test.ts — Enter behavior around links (there is no test file for this module today).
Enter at 16 in - [mobidoo-reply](/n/abc) → - [mobidoo-reply](/n/abc)\n- , caret at 28.
Enter at 20 (inside the URL) → same result.
Enter at 15 (inside the label) → same result.
Enter at 25 → unchanged from today.
Enter at 2 (before [) → splits before the link, link intact.
Enter inside a link in a plain (non-list) paragraph → newline after the link.
Multi-cursor → no link escape.
Existing tightenListContinueInsert non-tight-list collapsing still holds.
Edge Cases And Risks
IME / composition (CRDT path). Unwrapping adds and removes Decoration.replace ranges, which forces DOM reconstruction; doing that mid-composition can drop a Korean/Japanese composition. Mitigated by construction: the transition fires when the caret enters or leaves the span, and during composition the caret stays inside, so no decoration change happens mid-compose. Worth a lib/crdt smoke check that yCollab + unwrap coexist.
Undo granularity. The Enter fix must emit exactly one transaction; two (select then input) would make ⌘Z under Y.UndoManager leave the caret parked inside the link.
Reflow on unwrap. Revealing ](/n/8f2c…) widens the line and can change soft-wrap. Accepted — it is what Obsidian does — but avoid scrollIntoView on the re-roll transaction so the viewport does not jump.
Read-only / published views.agentnoteLinks() is mounted for readOnly too (components/codemirror-editor.tsx:172). EditorState.readOnly still allows selection, so a published note could unwrap on click-drag. Either accept it or gate the unwrap on !state.readOnly — pick one and state it in the PR.
Perf.activeLinks runs on every selection change; it must use resolveInner (O(log n)), never the full-document documentTree().iterate() — otherwise every arrow keypress becomes an O(doc) scan on long notes.
An idle timer for re-roll (explicitly rejected in §C).
Acceptance Criteria
Enter with the caret at the visual end of - [label](/n/id) inserts the newline after the full link; the link markup is intact and the new line continues the list.
Same for Enter at any interior offset — inside the label, on ], on (, inside the URL, on ) — in both list items and plain paragraphs.
Enter before [ or after ) behaves exactly as it does today.
The Enter fix produces a single transaction (one undo step, one CRDT update).
A link unwraps to full [label](url) source when a selection endpoint is strictly inside its span, and re-rolls when the caret leaves.
While unwrapped, the caret can rest on every chrome character (chrome is not atomic), so Left/Right step through it one character at a time.
pnpm vitest run, tsc --noEmit, and pnpm build are green; no new lint findings.
QA Plan
pnpm vitest run — new links.test.ts and list-continue.test.ts cases plus the full existing suite.
npx tsc --noEmit and pnpm build.
Manual on a dev server, in a note with - [mobidoo-reply](/n/{real-id}):
Arrow Right from inside the label → the link expands to source; keep arrowing → caret walks ], (, /n/…, ) one char at a time; one more Right → re-rolls.
With the caret at the end of the label, press Enter → new list item after the intact chip.
Edit the label text and the URL while unwrapped, arrow out → re-rolls with the new label; click it → opens the (new) target.
Click the rolled-up chip → still opens the note.
Type Korean into a label while unwrapped → no dropped composition.
Open the same note in two tabs (CRDT) and repeat → both converge.
Suggested PR Scope
M — one PR. The Enter fix and the unwrap share linkSpanAt() and the same span semantics; splitting them would mean landing a half-policy where Enter jumps out of a link the user cannot see the boundaries of. Land together.
Summary
Rolled-up Markdown links (
[label](/n/{id}), painted byagentnoteLinks()since #64) are read-only chips today: there is no keyboard path to edit the label or the URL, and pressing Enter with the caret at the visual end of a label splits the link markup in half. This issue adds Obsidian Live-Preview-grade link editing convenience — a link temporarily unwraps to its raw Markdown source while the caret is inside it, and Enter can never land inside link markup.Why This Matters
[[-created sub-note links (#77/#80/#82) are now the primary way notes reference each other, so every note has rolled-up links in it. Once rolled up, those links are a dead end:That last one is a silent data-shape bug: the user sees a blue chip, presses Enter, and the chip becomes
- [mobidoo-reply/- ](/n/abc). Nothing warns them.Conversation Context
Reported by Chase against agentnote.dev with a screenshot of a rolled-up
- mobidoo-replysub-note link inside a Markdown list:[label](/n/id)chrome + URL) becomes editable. Today there is no "source expand while editing" affordance at all.Explicit direction: build this as a best-practice link-embed editing model in the direction of Obsidian Live Preview / Notion mention editing — not a one-off Enter hack.
Current Behavior
Verified reproduction
Document (the screenshot case), with document offsets:
-ListMark[LinkMark(hidden)mobidoo-reply.cm-md-link)]LinkMark(hidden)(LinkMark(hidden)/n/abcURL(hidden))LinkMark(hidden)The Lezer tree is
Link[2,25)containingLinkMark[2,3) LinkMark[16,17) LinkMark[17,18) URL[18,24) LinkMark[24,25).Bug 1 — Enter splits the link. Running
agentnoteInsertNewlineContinueMarkup(bound atPrec.highestincomponents/codemirror-editor.tsx:131-135) at each caret offset produces:A CommonMark inline link cannot survive a list-item break:
[and](url)land in different list items, so the link is gone and the raw chrome becomes visible literal text.Bug 2 — invisible caret dead zone.
lib/editor/links.ts:286declares the hidden ranges atomic:but
buildHideDecorationsemits four separate adjacent ranges —[16,17),[17,18),[18,24),[24,25)— and CodeMirror's skip logic (node_modules/@codemirror/view/dist/index.js:3729) only skips a position that is strictly inside a single range:The boundaries between two adjacent atomic ranges are therefore legal caret positions. Measured with
view.moveByChar:Offsets 16, 17, 18, 24 and 25 all paint at the same x coordinate (everything between them is
Decoration.replaced to zero width). So walking off the end of a rolled-up link takes five indistinguishable Right presses, and four of the five resting spots put Enter/Backspace/typing inside link markup.Bug 3 — no edit affordance.
hiddenLinkMarks/visibleLinkMarksrebuild only ontr.docChangedor a syntax-tree change (needsRebuild,lib/editor/links.ts:274-276). Selection is not an input at all, so a link never unwraps. The only way to edit a label or URL today is to delete the chip and retype the whole thing.What already works and must not regress
agentnoteLinks()hides[/](url)chrome, paints the label.cm-md-link(accent + underline,app/globals.css:765), and plain left-click dispatchesagentnote:open-notefor/n/{id}or opens external URLs in a new tab (tryOpenLinkAtPointer,lib/editor/links.ts:390-426). Cmd/Ctrl/Shift/Alt-click deliberately fall through to native CM behavior.ensureSyntaxTree's return value (not thesyntaxTree(state)snapshot) and rebuilds when the background parser reports progress.documentTree()andneedsRebuild()carry the RCA in comments; keep both.[[wiki create/link,parent_idsub-note tree, and the Link-to-note picker.[Title](/n/{id}).docs.sume.com/x) and<autolinks>keep their current painting and click behavior.Desired Behavior
A. Enter never splits link markup
When the caret is strictly inside a link's Markdown span (
from < pos < to) and Enter is pressed, the break is taken after the closing)instead of at the caret:This holds for every interior offset (label,
],(, URL,)), inside list items and plain paragraphs, and produces one transaction so it is a single undo step and one CRDT update.Rationale for "always break after the link" rather than "refuse to break": a Markdown inline link cannot span a list-item break, so no interior offset has a meaningful split. Jumping to the end is the only outcome that is both non-destructive and matches what a user pressing Enter at the end of a chip wants. Caret exactly at
from(before[) or atto(after)) is not interior, so breaking before/after a link keeps working unchanged.B. Temporary source unwrap while editing
A link unwraps — its
[,](, URL and)chrome becomes visible, non-atomic, editable text — when the caret/selection is inside it, and re-rolls the moment the caret leaves.Because the chrome is no longer replaced or atomic while unwrapped, Right/Left step through
],(,/n/abc,)as real visible characters — Bug 2's invisible dead zone disappears as a consequence, without loosening the atomic policy for rolled-up links.C. Concrete edit-vs-open rule (the part to lock down)
This is Obsidian Live Preview's rule verbatim: click navigates, caret placement edits, and the two are never the same gesture. Consequences that are intentional, not oversights:
D. Scope
agentnoteLinks()Markdown link (Linknodes), especially/n/{id}sub-note links.Autolink(<https://…>) rides the same code path for free — its</>chrome unwraps identically.yCollab(y-codemirror.next) — the CRDT path is the only path in production.Source Of Truth
Internal repo/source
lib/editor/links.ts—agentnoteLinks(),collectMarkdownLinks(),hiddenLinkMarks(+atomicRanges),visibleLinkMarks,needsRebuild(),documentTree(),tryOpenLinkAtPointer(),hrefAtPos().lib/editor/list-continue.ts—agentnoteInsertNewlineContinueMarkup,tightenListContinue(),tightenListContinueInsert().components/codemirror-editor.tsx:131-135— thePrec.highestEnter binding;:172— whereagentnoteLinks()is mounted (edit and read-only);:180—yCollab.app/globals.css:765-774—.cm-md-linkstyling.lib/editor/links.test.ts— existing coverage forresolveHref,hrefAtPos, decorations, and fix: roll up [label](/n/…) chrome on first editor paint #84/fix: roll up [label](/n/…) chrome on first editor paint #85 first paint. Thedecorations()helper there reads decorations out of state (jsdom has no layout) and is the pattern new tests should reuse.External source
node_modules/@codemirror/view/dist/index.js:3729skipAtomicRanges— the strictpos > from && pos < totest that creates the between-adjacent-ranges dead zone.node_modules/@codemirror/view/dist/index.js:1519, 8893—EditorView.atomicRangesfacet.node_modules/@codemirror/commands/dist/index.d.ts:521—insertNewlineAndIndent: StateCommand, the fallback for Enter in a non-list paragraph.@lezer/markdownnode names used:Link,Autolink,LinkMark,URL.Proposed Design
New state field: which links are being edited
Implementation:
syntaxTree(state).resolveInner(pos, -1), walk.parentuntil aLink/Autolinknode, then apply the strictnode.from < pos < node.totest. Verified against the tree above —resolveInner(2, -1)stops atListItem(no Link ancestor),resolveInner(25, -1)reachesLink[2,25)but fails the strict test. This is O(log n), unlike the existing full-documentiterate().Recomputed only when
tr.docChanged || tr.selection || syntaxTreeadvanced; returns the previous array by identity when the span set is unchanged, so the expensive decoration fields can compare by reference:activeLinksmust be listed beforehiddenLinkMarks/visibleLinkMarksin the extension array so it is already computed when they readtr.state. This is the same ordering constraint the existingneedsRebuildcomment documents for the language field.Active spans are collected from selection endpoints only (
range.head, plusrange.anchorwhen non-empty) — see §C for why.Decorations
collectMarkdownLinks(state, active)gains anactiveargument. For a link whose span is active:hideranges → chrome becomes visible and non-atomic (the atomic facet is derived fromhiddenLinkMarks, so one change buys both);cm-md-linktoken:.cm-md-link-srcover[from, to)— dim accent, no underline, default cursor;.cm-md-link-src-labelover the label — full accent, so the label still reads as the link text.hitssohrefAtPos()stays a pure "what does this position link to" helper and bare-link occupancy is unaffected.The class-name choice is load-bearing:
tryOpenLinkAtPointermatchesel.closest(".cm-md-link"), andcm-md-link-srcis a single class token, so an unwrapped link cannot match it..cm-md-link--barekeeps working because its spec is two tokens ("cm-md-link cm-md-link--bare"). A redundant state-level guard (isLinkActive(view.state, pos)) is still added totryOpenLinkAtPointer, becauseposAtCoordshas a documented fallback path throughview.posAtDOMand an accidental navigation while editing loses the user's place.RangeSetBuilderordering is safe: a span mark always starts one offset before its label mark ([vs the first label char), so there are no equal-fromties to order.Enter
agentnoteInsertNewlineContinueMarkupderives a link-escaped state, runs the existing commands against it, then re-issues the resulting transaction onto the real state so the caret move and the break are one transaction:rebaseOntoreturns the transaction unchanged whentr.startState === state, so the no-link path is byte-identical to today (including returningfalsesodefaultKeymapstill gets its turn). The changes are valid againststatebecause a selection-onlyupdate()leavesdocidentical.Implementation Notes
Likely files to modify
lib/editor/links.ts—linkSpanAt(),isLinkActive(),activeLinksfield,active-awarecollectMarkdownLinks()/ builders,needsRebuild(), guard intryOpenLinkAtPointer(), extension order inagentnoteLinks().lib/editor/list-continue.ts—stateBrokenOutOfLink(),rebaseOnto(), rework ofagentnoteInsertNewlineContinueMarkup.app/globals.css—.cm-md-link-src/.cm-md-link-src-labelnext to the existing.cm-md-linkblock.New files
lib/editor/list-continue.test.ts— Enter behavior around links (there is no test file for this module today).Tests
lib/editor/links.test.ts:linkSpanAtboundaries:2 → null,3/16/17/24 → {from:2,to:25},25 → null,26 → null.[16,25)present,.cm-md-linklabel present, no.cm-md-link-src..cm-md-link-srcpresent, no.cm-md-link.view.moveByCharfrom 16 steps 17, 18, 19… one char at a time while unwrapped, and the rolled-up case keeps its current skipping.agentnote:open-note.lib/editor/list-continue.test.ts:- [mobidoo-reply](/n/abc)→- [mobidoo-reply](/n/abc)\n-, caret at 28.[) → splits before the link, link intact.tightenListContinueInsertnon-tight-list collapsing still holds.Edge Cases And Risks
Decoration.replaceranges, which forces DOM reconstruction; doing that mid-composition can drop a Korean/Japanese composition. Mitigated by construction: the transition fires when the caret enters or leaves the span, and during composition the caret stays inside, so no decoration change happens mid-compose. Worth alib/crdtsmoke check thatyCollab+ unwrap coexist.selecttheninput) would make ⌘Z underY.UndoManagerleave the caret parked inside the link.](/n/8f2c…)widens the line and can change soft-wrap. Accepted — it is what Obsidian does — but avoidscrollIntoViewon the re-roll transaction so the viewport does not jump.agentnoteLinks()is mounted forreadOnlytoo (components/codemirror-editor.tsx:172).EditorState.readOnlystill allows selection, so a published note could unwrap on click-drag. Either accept it or gate the unwrap on!state.readOnly— pick one and state it in the PR.activeLinksruns on every selection change; it must useresolveInner(O(log n)), never the full-documentdocumentTree().iterate()— otherwise every arrow keypress becomes an O(doc) scan on long notes.resolveInnerfinds noLinkand the link stays rolled up. Self-correcting:needsRebuildalready fires on parse progress andactiveLinksrecomputes on the same condition.Non-Goals
[Title](/n/{id}).Decoration.widget/WidgetType) — stay on plain mark/replace decorations plus an atomic/selection policy.mousedown-to-open prevents drag-selecting from inside a rolled-up label. Pre-existing from Editor: Obsidian-style clickable Markdown links (port chasehuh_com agentnoteLinks) #64; call it out if it bites.Acceptance Criteria
- [label](/n/id)inserts the newline after the full link; the link markup is intact and the new line continues the list.], on(, inside the URL, on)— in both list items and plain paragraphs.[or after)behaves exactly as it does today.[label](url)source when a selection endpoint is strictly inside its span, and re-rolls when the caret leaves.lib/editor/links.ts.pnpm vitest run,tsc --noEmit, andpnpm buildare green; no new lint findings.QA Plan
pnpm vitest run— newlinks.test.tsandlist-continue.test.tscases plus the full existing suite.npx tsc --noEmitandpnpm build.- [mobidoo-reply](/n/{real-id}):],(,/n/…,)one char at a time; one more Right → re-rolls.Suggested PR Scope
M — one PR. The Enter fix and the unwrap share
linkSpanAt()and the same span semantics; splitting them would mean landing a half-policy where Enter jumps out of a link the user cannot see the boundaries of. Land together.