From 025e50a44331667154bc9b59e248098ec720305a Mon Sep 17 00:00:00 2001
From: Nick Gomez <122398915+nick-inkeep@users.noreply.github.com>
Date: Wed, 8 Jul 2026 02:29:01 -0700
Subject: [PATCH] =?UTF-8?q?feat(open-knowledge):=20WYSIWYG=20link=20author?=
=?UTF-8?q?ing=20=E2=80=94=20typed=20autolink,=20paste=20linkify,=20Cmd+K?=
=?UTF-8?q?=20dual-role=20(#2478)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* [US-001] Add pure GFM-shape link token detector
New packages/app/src/editor/gfm-link-detector.ts exposes
detectGfmLinkToken(token), a string-in / {href,text}|null-out recognizer
for the three GFM autolink-literal shapes (https?://, www., email). It is
editor-independent (no ProseMirror imports) so the typed-autolink plugin
and the clipboard dispatcher can both reuse it.
The recognizer is ported from mdast-util-gfm-autolink-literal (the markdown
pipeline's own transform) so it agrees with MarkdownManager.parse by
construction: a token converts only when GFM itself would, so bare domains,
filenames, and localhost:port stay plain text with no TLD table. www.
tokens resolve to an http:// href and emails to mailto:, matching the
pipeline exactly.
The co-located test pins the acceptance matrix, a parity check against the
real pipeline, structural rejection of bare domains, and the scheme allowlist.
* [US-002] Add origin-guarded typed-URL autolink plugin
Typed URL + boundary key (space or Enter) converts to a link mark with
linkStyle gfm-autolink so it serializes as a bare literal. Detection reuses
stock TipTap's changed-range last-word scan but swaps linkify's tokenizer for
the GFM-parity detector, so only what the markdown pipeline itself would
linkify converts.
Safe in the multi-writer CRDT by construction: a fail-closed origin guard
skips any transaction batch carrying ySyncPluginKey meta (remote peers,
agents, disk loads, observer echoes), and an active/focused-editor predicate
keeps a stray local write into a pooled hidden editor from converting. The
mark is added by its own deferred dispatch (never returned from
appendTransaction), which also lets the flush honor IME composition and
re-validate the target range before mutating.
Registered only in the app editor extension list, so linkification stays a
client-side behavior; the core and persistence extension sets are untouched.
* [US-003] Give typed autolink conversions their own single-undo step
The deferred mark-add dispatch in the autolink flush is now bracketed by
yUndoPluginKey undoManager stopCapturing() calls. The call before the
dispatch keeps the mark out of the typing's capture window so one undo
removes just the link and leaves the typed text and trailing space
intact; the call after keeps subsequent keystrokes from merging into the
mark's stack item. Covered by three real-Collaboration-binding tests
(undo keeps text, redo reapplies the mark, follow-up typing undoes
independently), each proven red without its half of the mechanism.
* [US-004] Add lone-URL paste/drop linkification in the dispatcher
A single-URL payload now linkifies at a dedicated dispatcher step after the plain-paste and codeBlock gates. At a cursor, GFM autolink shapes route through MarkdownManager.parse for bare-literal bytes; over a one-block selection the selected text is kept and link-marked (allowlisted schemes verbatim, emails as mailto, dotted hosts https-prepended). Drop shares the branch tree. Every dispatcher-minted transaction now carries preventAutolink meta so the typed-autolink plugin never re-scans paste output as typing. Exports isAllowedLinkUri from core link-fidelity as the shared scheme gate.
Also re-keys shift-tracker listener attachment from process lifetime to window identity: the new real-editor tests were the first callers with a live DOM window, and the old attach-once latch orphaned the tracker's own fake-window tests.
* [US-005] Add byte-pinning e2e for linkification paths and per-path undo
Pins exact Y.Text bytes for typed autolink, paste at cursor, and paste
over selection against the real app and CRDT server, plus one-undo
semantics per path and the clean copy round-trip. Adds a reusable
selectText helper to the e2e helper barrel. The typed-path oracles pin
the observed byte lifecycle: the mark-only conversion is within the
bridge's escape-collapse tolerance, so bytes settle to the bare literal
on the next content-bearing edit. Also appends the spec to the test:e2e
list and removes the stale file-tree-compact-folders entry.
* [US-006] Add Cmd+K dual-role: add-link claim + palette narrowing
* [US-007] Add clipboard URL pre-fill to the link popover
* [US-008] Add typed [text](url) input rule
Typing the closing paren of a well-formed inline-link literal collapses
[text](url) to its display text carrying an inline link mark (serializes
back to [text](url)). The input rule is only the local-focused trigger,
so it never fires on y-sync/remote/agent/programmatic transactions and the
FR1 no-cross-writer invariant holds without an explicit origin guard. The
collapse lands as a deferred separate dispatch with stopCapturing before
and after, so one undo restores the full literal and the trigger char is
kept — matching the gfm-autolink typed path.
Href policy via isAllowedLinkUri; code/inline-code contexts refused by
TipTap's own input-rule runner; existing-link overlap and wikilink
shorthand excluded. Shared headless-editor rigs extracted to
editor-rig.test-helper.ts (gfm-autolink test rewired to them).
* [US-009] Add apex e2e: FR1 cross-writer + hidden editor, ⌘K matrix, clipboard denial
Real-app release gate for the P0 hazard and the shortcut contract:
- FR1 two live clients: a boundary-less URL typed by a peer syncs to the
receiver and is never linkified there; only a client's own boundary-typed
URL converts (typed at doc start so its boundary never lands adjacent to
the peer's URL). Converges across both clients.
- FR1 backgrounded editor: a peer's boundary-less URL reaches a hidden
Activity's pooled editor and stays plain.
- FR7 ⌘K routing matrix: selection -> link popover; collapsed caret ->
palette; caret in a link -> chip edit dialog; source pane -> palette;
Cmd+Shift+K -> not the palette (exact-⌘K narrowing regression guard).
- FR8 clipboard pre-fill under a real withheld permission: popover opens
empty and stays functional.
Injection is via live typing, not agent/markdown writes: the pipeline
autolinks bare URLs on parse, so only live-typed URLs stay plain — which is
also what exercises the client plugin's origin discipline. selectText helper
already existed in _helpers/editor-state.ts. Spec appended to the test:e2e list.
* [US-010] Pin gfm-autolink serializer edges; reconcile stale link docs
- FR-17 serializer block gains two programmatic gfm-autolink pins: a mark
whose text != href emits the text (href dropped — the documented
single-text-child contract), and a mark spanning a non-text child falls
through to the explicit [text](url) form. Parse never produces these; the
WYSIWYG linkify paths could, so their emit contract is fixed.
- InternalLink.addProseMirrorPlugins gains a comment: it intentionally does
not spread this.parent?.(), dropping the stock TipTap autolink/linkOnPaste/
clickHandler plugins in favour of OK's own linkify/paste/click surfaces.
LinkFidelity's header is reconciled to say the same (it inherits the stock
plugins; the app subclass replaces them).
- Docs: CI test:e2e subset count 30 -> 43 in AGENTS.md (CLAUDE.md is a
symlink to it); playwright.config.ts clipboard comment updated to note the
FR8 denial case that exercises the real permission gate.
Fidelity link-edge + I1-I7 unchanged (41/0); FR-17 235/0; typecheck clean.
* docs(link-authoring-ux): add feature spec + research evidence
Internal-only (copybara-excluded): SPEC.md with D1-D19 decision log,
FR1-FR9, §16 agent constraints, and the evidence/ + meta/ grounding used
to author and audit the feature.
* fix(link-authoring): satisfy knip — declare starter-kit dep, drop dead exports
The full `bun run check` gate (knip) surfaced two issues the per-story
`bun run test` gate does not run:
- @tiptap/starter-kit is imported by three test files (the shared editor
rig + two bubble-menu/clipboard tests) but was never declared; add it as a
devDependency (^3.22.3, matching the other @tiptap packages) and refresh
bun.lock.
- gfmAutolinkPlugin / gfmAutolinkPluginKey / GfmAutolinkPluginOptions were
exported only as a test seam; the gfm test now drives the plugin through
the GfmAutolink extension, so those exports are dead — make them
module-private (internal references unchanged).
* fix(link-authoring): strip FR/D process markers from comments (md-audit)
md-audit's comment-discipline rule (run only by `bun run check`, not the
per-story `bun run test`) flagged FR1/FR7/FR8/D18 tokens in the input-rule
docstring and the apex e2e comments + describe titles. Per OK convention,
functional-requirement and spec-decision IDs live in the PR/commit, not
source. Reworded to describe the behaviors directly; no test or code change.
* fix(link-authoring): land popover focus reliably; stop Escape-swallow after panel dismissal
Two real bugs found by browser QA of the ⌘K keyboard flow, both pre-existing
and newly exposed now that ⌘K makes the popover keyboard-first:
- LinkEditPopover auto-focus was a single rAF, but the input lives in the
floating bubble menu, which is unfocusable (visibility:hidden) until
floating-ui finishes positioning — so focus() was a silent no-op and the
URL input never took focus on open (instrumented: 13 focus() calls, zero
focusin events). Now retries each frame until focus lands once, then stops
permanently so it can never fight the user; cleanup cancels on close.
- link-path-suggestions gated its keydown handling on suggestions EXISTING
(showSuggestionOptions) rather than the panel being VISIBLE. Suggestions
still exist for the value after an Escape dismissal, so every subsequent
Escape/Enter was swallowed against an invisible panel and the popover
could not be closed from the keyboard — contradicting the handler's own
'the parent gets the next Escape' contract. Gate on visible options.
Verified end-to-end: ⌘K focuses the input; Escape #1 dismisses the
suggestion panel; Escape #2 closes the popover and returns focus to the
editor. Pinned in the apex e2e (focus + two-stage Escape) and a dom test
(after dismissal, keys reach the parent onKeyDown).
* refactor(link-authoring): apply local-review findings
Nine accepted findings from the local review gate (0 critical / 0 major):
- ⌘K claim reads the non-throwing editorView field structurally instead of
the editor.view throwing proxy — a ⌘K during an Activity recycle now falls
through to the palette instead of crashing the nearest ErrorBoundary.
- linkifySelection is try/caught like every sibling dispatch branch, with
logConversionFail telemetry (new linkifySelection stage label), so a throw
falls through to the normal paste tree instead of silently dropping.
- Extract dispatchAsOwnUndoStep (undo-isolation.ts): the correctness-critical
stopCapturing-dispatch-stopCapturing sequence now lives in one place, used
by both mark-producing surfaces; the input rule's microtask-deferred second
stopCapturing is aligned to the proven synchronous variant.
- AddLinkShortcutAction consumer is an exhaustive switch with an
assertNeverAddLinkAction backstop (assertNeverLinkTarget convention).
- Drop a no-op 'as any' + misleading biome-ignore on schema.nodeFromJSON
(ProseMirror types the parameter as any).
- IME guard test shadows the PUBLIC view.composing getter instead of mutating
the private input slot, so a PM restructure can't false-green it.
- Strengthened assertions: cross-block URL paste also pins the delivered
plain-text content; the Branch-A malformed-JSON test pins the dispatcher's
handled=true fall-through; new empty-URL [text]() stays-literal case.
- Apex hidden-editor test replaces its fixed 500ms sleep with a deterministic
poll of the HIDDEN pooled doc via ProviderPool.peek — now proving delivery
during the backgrounded window instead of hoping for it.
- Clipboard pre-fill catch logs a console.debug breadcrumb for unexpected
non-DOMException failures (routine denials stay silent).
Declined with evidence: exact-⌘K palette narrowing (deliberate, spec decision
log D12), the ⌘K popover-open pub/sub module (established *-events.ts idiom;
per-instance gating), and the pre-fill vs paste-over-selection trust asymmetry
(deliberate, D14).
* fix(link-authoring): exception-safe undo capture; document link authoring
Second local-review round (1 Major, 2 Minor accepted; plus three small
Consider items):
- dispatchAsOwnUndoStep closes the capture in a finally: view.dispatch runs
third-party plugin hooks and can throw; without the closing stopCapturing
the capture is left split-open and the user's next keystrokes merge into
the failed item (one undo would then strip them together). Both microtask
call sites (gfm flush, input-rule collapse) also catch+warn instead of
letting the throw escape as an uncaught async error, matching the paste
dispatcher's degradation contract.
- Clipboard pre-fill's unexpected-error breadcrumb moves from console.debug
(suppressed by default filters) to console.warn.
- User docs: editor.mdx gains a Links section covering the four authoring
paths and a Cmd+K row in the shortcut table describing the dual-role.
- Palette registry description now mentions the selection carve-out so the
two Cmd+K rows in settings read as scoped, not conflicting.
- attrsEqual only recurses into matching plain-object/array shapes (an array
vs an index-keyed object, or two Dates, no longer compare equal).
- Comment typo .len -> .length in the input rule's position math.
Declined with evidence: stopImmediatePropagation claim (spec decision D12,
byte-for-byte Edit-with-AI precedent); permissions.query gating for pre-fill
(D14 accepted the one-time web prompt; granted-only would make first-use
pre-fill silently never work); trailing placeholder ellipsis (the repo's own
microcopy lint rule forbids it — tooling beats generic style guidance).
* polish(link-authoring): third review round — logs, tests, comments, docs
Accepted from the third local-review pass:
- Deferred-linkify failure logs carry operational context (candidate ranges
+ hrefs); the input rule guards only the dispatch (trust boundary), so its
internal guards fail loud instead of logging as benign.
- Two test gaps closed: typing after an input-rule conversion stays its own
undo step (the closing-stopCapturing invariant, mirroring the gfm suite),
and a throwing mdManager.parse falls through for Branch B and the lone-URL
cursor path (Branch A was the only pinned peer).
- Shared LinkStyle union exported from link-fidelity (type-only) and the
producer constant typed against it, so a drifted linkStyle literal is a
compile error instead of a mis-serialized link.
- Comment-accuracy corrections, each verified against the actual source:
detector consumer is lone-url.ts; the whitespace class is TipTap's
UNICODE_WHITESPACE_PATTERN; PREVENT_AUTOLINK_META is defined here (TipTap
only shares the string value); captureTimeout 500ms is Y.js's default; the
AGENTS.md prepend example is flagged as a deliberate edge. Detector header
now names its upstream coupling and the parity tests that guard it.
- Docs: paste bullet covers drop, typed-trigger wording tightened,
frontmatter description mentions link authoring.
- ⌘K exact-match narrowing rationale + the capture/bubble phase-ordering
contract documented on the palette registry entry (the narrowing itself is
the spec's deliberate decision and stays).
- Popover focus retry capped at ~1s with a warn on exhaustion.
* test(link-authoring): pin the wikilink atom exclusion on the typed path
The typed-autolink plugin's wikilink safety rested on a structural argument
(the wikiLink node is an atom, so textBetween's word scan can never see
inside it) with no test on this path. Pin it: a typed URL next to a wikilink
converts alone and the atom stays intact — if WikiLink ever stops being an
atom, this fails instead of the plugin silently linkifying wikilink internals.
* changeset(link-authoring): minor release notes for link authoring
* fix(link-authoring): regen ok-marketing vendored core dist; close LinkStyle loop
The LinkStyle type export changed core's built index.mjs, and ok-marketing
vendors core's dist — regenerate it via the prescribed
'pnpm --filter @inkeep/ok-marketing regen-core-dist' (fixes the
verify-core-dist CI check).
Also adopts the local-review repair pass's completion of the LinkStyle
coupling work, verified green before adoption: the PM->mdast serializer's
linkStyle variable is typed against the shared union (a drifted literal is
now a compile error on BOTH producer and serializer sides; no behavior
change), the detector parity matrix gains eight edge rows (trailing
punctuation, unbalanced paren, underscore-domain and email bad-tail rejects,
and their keep-side complements) checked against the real parse, the
remark-gfm registration carries the reciprocal hand-port coupling note, and
the md-audit anchors catalog is regenerated accordingly.
* fix(link-authoring): notify on linkify-selection degradation; use meta constant in test
Cloud review round 1: linkifySelection's catch now calls notifyPasteDegraded
(Branch D's established degradation contract) so a dispatch failure is no
longer silent to the user — while still returning false so the branch tree
delivers the clipboard content as a standard paste (claiming the failed
paste would drop the content entirely, the outcome the dispatcher contract
forbids). The origin-guard suppression test uses the exported
PREVENT_AUTOLINK_META constant instead of a raw string literal, so a key
rename can't silently defang it.
* test(link-authoring): condition-based negative wait in the exact-cmdk apex test
Main's new e2e STOP rule forbids page.waitForTimeout in stress/visual/a11y
specs; the Cmd+Shift+K test used a 300ms sleep before asserting the palette
stayed shut. Replace with a condition-based sandwich: assert palette absent,
then press exact Cmd+K and wait for the palette to APPEAR — the positive
signal proves the keystroke pipeline processed events after the
Cmd+Shift+K press, and would fail if that press had opened or toggled the
palette. Verified in a real browser.
* fix(link-authoring): eager view capture; throw-path + events tests; notify siblings
Cloud review round 2 (APPROVE WITH SUGGESTIONS):
- The input rule captures editor.view eagerly at handler time instead of
re-reading it inside the microtask, where it is a throwing proxy if an
Activity recycle starts in the gap (the sibling gfm plugin's boundView
discipline; input rules always fire from a mounted view).
- New undo-isolation.test.ts pins the exception-safety property that IS the
helper's reason to exist: a throwing dispatch still closes the capture
(stopCapturing runs exactly twice) — a try/finally-to-try/catch refactor
now fails a test instead of silently corrupting undo.
- link-edit-popover-events gains its co-located test per the *-events.ts
peer convention (emit/unsubscribe/target-scoping).
- tryBranchA / tryBranchMarkdown / applyJsonSlice catch sites now call
notifyPasteDegraded like Branch D and linkifySelection — same-shape
sibling completion of the degradation contract this PR standardized.
* chore(link-authoring): regen catalogs + vendored core dist after rebase onto main
* fix(link-authoring): accept explicit-scheme dotless hosts in typed/cursor autolink
The dotted-domain check is a schemeless-fuzz defense: micromark's
http_autolink production skips it when an explicit scheme is present, so
http://localhost:5174 linkifies and round-trips as a bare literal. The
detector was stricter than the parser it mirrors, leaving typed/pasted
localhost URLs plain while http://127.0.0.1:8080 converted. Split the
domain check: label-tail rules apply to both arms, the dotted-host
requirement to the www arm only. Schemeless tokens (localhost:5173) and
underscore labels stay rejected; parity rows pin every new shape against
MarkdownManager.parse.
* chore(link-authoring): regen catalogs + vendored core dist after rebase onto main
* test(link-authoring): reset shift-tracker singleton around its test file
The attach guard is a module singleton; when a sibling test file in the
same bun process wires the tracker to its own window first (handle-paste
does, transitively), installShiftTracker() early-returns and the fake
window's dispatches reach nobody. Reproduced deterministically by running
handle-paste.test.ts before shift-tracker.test.ts — the exact merge_group
failure that dequeued this PR. Detach in beforeAll/afterAll via the
__resetShiftTrackerForTests hook the implementation ships for this.
GitOrigin-RevId: 25b1b0d1bc6a68dc65e9723434a4fa219bd0a49a
---
.changeset/link-authoring-ux.md | 19 +
bun.lock | 1 +
docs/content/features/editor.mdx | 12 +-
packages/app/package.json | 3 +-
packages/app/playwright.config.ts | 17 +-
.../src/editor/bubble-menu/BubbleMenuBar.tsx | 24 +-
.../bubble-menu/LinkEditPopover.dom.test.tsx | 334 ++++++++++++-
.../editor/bubble-menu/LinkEditPopover.tsx | 191 +++++++-
.../bubble-menu/bubble-menu-state.test.ts | 120 +++++
.../editor/bubble-menu/bubble-menu-state.ts | 66 +++
.../link-edit-popover-events.test.ts | 53 ++
.../bubble-menu/link-edit-popover-events.ts | 35 ++
.../src/editor/clipboard/handle-drop.test.ts | 36 +-
.../src/editor/clipboard/handle-paste.test.ts | 335 ++++++++++++-
.../app/src/editor/clipboard/handle-paste.ts | 107 +++-
.../app/src/editor/clipboard/instrument.ts | 13 +-
.../app/src/editor/clipboard/lone-url.test.ts | 162 +++++++
packages/app/src/editor/clipboard/lone-url.ts | 113 +++++
.../editor/clipboard/shift-tracker.test.ts | 8 +
.../app/src/editor/editor-rig.test-helper.ts | 99 ++++
.../src/editor/extensions/internal-link.ts | 7 +
.../src/editor/extensions/mark-identity.ts | 40 +-
packages/app/src/editor/extensions/shared.ts | 10 +
.../src/editor/gfm-autolink-plugin.test.ts | 459 ++++++++++++++++++
.../app/src/editor/gfm-autolink-plugin.ts | 290 +++++++++++
.../app/src/editor/gfm-link-detector.test.ts | 190 ++++++++
packages/app/src/editor/gfm-link-detector.ts | 168 +++++++
.../src/editor/inline-link-input-rule.test.ts | 209 ++++++++
.../app/src/editor/inline-link-input-rule.ts | 120 +++++
.../editor/link-path-suggestions.dom.test.tsx | 30 ++
.../app/src/editor/link-path-suggestions.tsx | 7 +-
.../editor/slash-command/component-items.tsx | 24 +-
.../app/src/editor/undo-isolation.test.ts | 62 +++
packages/app/src/editor/undo-isolation.ts | 35 ++
.../app/src/lib/keyboard-shortcuts.test.ts | 74 ++-
packages/app/src/lib/keyboard-shortcuts.ts | 34 +-
packages/app/src/locales/en/messages.json | 7 +-
packages/app/src/locales/en/messages.po | 16 +-
packages/app/src/locales/pseudo/messages.json | 7 +-
packages/app/src/locales/pseudo/messages.po | 14 +-
.../app/tests/stress/_helpers/editor-state.ts | 56 +++
packages/app/tests/stress/_helpers/index.ts | 1 +
.../tests/stress/link-authoring-apex.e2e.ts | 370 ++++++++++++++
.../tests/stress/link-authoring-bytes.e2e.ts | 242 +++++++++
packages/core/src/extensions/link-fidelity.ts | 35 +-
packages/core/src/index.ts | 2 +-
packages/core/src/markdown/index.ts | 3 +-
47 files changed, 4149 insertions(+), 111 deletions(-)
create mode 100644 .changeset/link-authoring-ux.md
create mode 100644 packages/app/src/editor/bubble-menu/bubble-menu-state.test.ts
create mode 100644 packages/app/src/editor/bubble-menu/bubble-menu-state.ts
create mode 100644 packages/app/src/editor/bubble-menu/link-edit-popover-events.test.ts
create mode 100644 packages/app/src/editor/bubble-menu/link-edit-popover-events.ts
create mode 100644 packages/app/src/editor/clipboard/lone-url.test.ts
create mode 100644 packages/app/src/editor/clipboard/lone-url.ts
create mode 100644 packages/app/src/editor/editor-rig.test-helper.ts
create mode 100644 packages/app/src/editor/gfm-autolink-plugin.test.ts
create mode 100644 packages/app/src/editor/gfm-autolink-plugin.ts
create mode 100644 packages/app/src/editor/gfm-link-detector.test.ts
create mode 100644 packages/app/src/editor/gfm-link-detector.ts
create mode 100644 packages/app/src/editor/inline-link-input-rule.test.ts
create mode 100644 packages/app/src/editor/inline-link-input-rule.ts
create mode 100644 packages/app/src/editor/undo-isolation.test.ts
create mode 100644 packages/app/src/editor/undo-isolation.ts
create mode 100644 packages/app/tests/stress/link-authoring-apex.e2e.ts
create mode 100644 packages/app/tests/stress/link-authoring-bytes.e2e.ts
diff --git a/.changeset/link-authoring-ux.md b/.changeset/link-authoring-ux.md
new file mode 100644
index 00000000..b94cd130
--- /dev/null
+++ b/.changeset/link-authoring-ux.md
@@ -0,0 +1,19 @@
+---
+"@inkeep/open-knowledge": minor
+---
+
+Link authoring in the visual editor now matches the conventions of the editors
+you already know. Typing a full URL (`https://…`, `www.…`, or an email) and
+pressing space or enter converts it to a link — including local-development
+URLs like `http://localhost:5174` — while plain words and filenames like
+`AGENTS.md` are never touched, and one undo restores plain text. Pasting or
+dropping a lone URL links it; pasting a URL over selected text links that text
+instead; `Cmd+Shift+V` still pastes plain. Typing markdown's `[text](url)`
+shorthand converts on the closing parenthesis. `Cmd+K` is now dual-role: with
+text selected it opens the link popover (focused, pre-filled from your
+clipboard when it holds a URL), with the cursor inside a link it opens that
+link for editing, and everywhere else it keeps opening the command palette.
+Linkification never fires on content written by other collaborators or agents
+— only on your own local typing and paste gestures. Also fixes two
+long-standing popover bugs: the URL input now reliably takes focus on open,
+and Escape correctly closes the popover after dismissing path suggestions.
diff --git a/bun.lock b/bun.lock
index 11c625db..2f7495f1 100644
--- a/bun.lock
+++ b/bun.lock
@@ -180,6 +180,7 @@
"@testing-library/jest-dom": "^6",
"@testing-library/react": "^16",
"@testing-library/user-event": "^14",
+ "@tiptap/starter-kit": "^3.22.3",
"@types/jsdom": "^28",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
diff --git a/docs/content/features/editor.mdx b/docs/content/features/editor.mdx
index d980feaf..606bbe18 100644
--- a/docs/content/features/editor.mdx
+++ b/docs/content/features/editor.mdx
@@ -1,7 +1,7 @@
---
title: Editor
icon: LuPenLine
-description: The OpenKnowledge editor. WYSIWYG markdown, source toggle, content blocks, inline frontmatter, and the file sidebar.
+description: The OpenKnowledge editor. WYSIWYG markdown, source toggle, content blocks, link authoring, inline frontmatter, and the file sidebar.
---
The editor is where you read, write, and collaborate on your docs, and where AI agents land when they edit. WYSIWYG by default with a source-mode toggle, a frontmatter properties pane, real-time CRDT collaboration, and a file sidebar that organizes the project.
@@ -40,6 +40,15 @@ An `html preview` code fence renders its contents live in a sandboxed iframe ins
`Cmd+F` opens find in the visual editor; `Cmd+Option+F` (`Ctrl+H` on Windows / Linux) adds the replace controls. `Cmd+G` and `Shift+Cmd+G` step through matches.
+## Links
+
+Links work the way they do in the editors you already know:
+
+- **Type a URL, then press space or enter.** A full URL (`https://…`, `www.…`, or an email address) becomes a link when you press space or enter after it. Plain words and filenames like `AGENTS.md` are left alone, and one `Cmd+Z` turns a conversion back into plain text.
+- **Paste or drag in a URL.** Pasted (or dropped) on its own, it becomes a link. Pasted over selected text, it links that text instead — the text stays, the URL becomes its destination. `Cmd+Shift+V` pastes plain, with no linking.
+- **Type `[text](url)`.** Closing the parenthesis converts the markdown shorthand into a real link; `Cmd+Z` restores the literal text.
+- **Press `Cmd+K` with text selected.** The link popover opens with the URL field focused, pre-filled from your clipboard when it holds a URL. With the cursor inside an existing link, `Cmd+K` opens that link for editing. With no selection, `Cmd+K` keeps opening the command palette, as everywhere else.
+
## Right-click menu and spell check
In the desktop app, right-clicking editable text opens a native context menu. Any editable field gets **Cut / Copy / Paste / Select All**; when there's a selection or a flagged word under the cursor, **Look Up** (the macOS dictionary panel) and **Search with Google** join it.
@@ -185,6 +194,7 @@ Keyboard shortcuts (use `Ctrl` on Windows / Linux in place of `Cmd`, unless the
| `Cmd+1` ... `Cmd+8` | Jump to one of the first eight editor tabs. |
| `Cmd+9` | Jump to the last editor tab. |
| `Cmd+Shift+T` | Reopen the most recently closed editor tab. |
+| `Cmd+K` | Command palette — or, with text selected in the visual editor, add a link (cursor inside a link edits it). |
| `Cmd+Option+S` | Show / Hide the file sidebar (left). Matches Apple's standard sidebar accelerator. |
| `Cmd+Option+B` | Show / Hide the document panel (right). Matches VS Code's Secondary Side Bar accelerator. |
| `Cmd+J` | Show / Hide the docked terminal (desktop app). |
diff --git a/packages/app/package.json b/packages/app/package.json
index 5b9b286a..69510af9 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -26,7 +26,7 @@
"measure:fuzz": "bash scripts/measure-fuzz.sh",
"measure:stress": "bash scripts/measure-stress.sh",
"perf:compare": "bash scripts/perf-compare.sh",
- "test:e2e": "playwright test tests/stress/ux-interactions.e2e.ts tests/stress/file-tree-create.e2e.ts tests/stress/file-tree-drag-to-root.e2e.ts tests/stress/file-tree-deselect-to-root.e2e.ts tests/stress/file-tree-compact-folders.e2e.ts tests/stress/create-then-rename-editable.e2e.ts tests/stress/find-replace.e2e.ts tests/stress/editor-tabs.e2e.ts tests/stress/crdt-stress.e2e.ts tests/stress/slash-command.e2e.ts tests/stress/paste-fidelity.e2e.ts tests/stress/fr-7a-disconnect-source-mode.e2e.ts tests/stress/docs-open.e2e.ts tests/stress/frozen-table-headers.e2e.ts tests/stress/asset-embed.e2e.ts tests/stress/asset-embed-advanced.e2e.ts tests/stress/asset-click-dispatch.e2e.ts tests/stress/handoff.e2e.ts tests/stress/multi-agent-presence.e2e.ts tests/stress/editor-mode-persistence.e2e.ts tests/stress/sidebar-search-pill.e2e.ts tests/stress/command-palette-semantic.e2e.ts tests/stress/agent-activity-panel.e2e.ts tests/stress/drop-pipeline-auto-open.e2e.ts tests/stress/command-palette-flicker.e2e.ts tests/stress/cm6-list-hanging-indent.e2e.ts tests/stress/ng7-rapid-nav-coherence.e2e.ts tests/stress/reveal-on-activate.e2e.ts tests/stress/selection-indicator.e2e.ts tests/stress/new-file-cross-doc-bleed.e2e.ts tests/stress/editor-mode-flip-cross-doc-bleed.e2e.ts tests/stress/editor-area-viewport-resize.e2e.ts tests/stress/qa-sidebar-responsive.e2e.ts tests/stress/prd-6955-reassertion-repro.e2e.ts tests/stress/prd-6955-reassertion-wedge.e2e.ts tests/stress/prd-6914-repro.e2e.ts tests/stress/showall-lazy-tree.e2e.ts tests/stress/tabs-component-strip.e2e.ts tests/stress/source-find-scroll.e2e.ts tests/stress/harness-app-warmth.e2e.ts tests/stress/qa-canary-authoring-both-modes.e2e.ts tests/stress/qa-canary-live-typing.e2e.ts tests/stress/keystroke-cadence-danger-space.e2e.ts tests/stress/jsx-unregistered-backspace-delete.e2e.ts tests/stress/jsx-unregistered-ime-concurrent.e2e.ts tests/stress/jsx-backspace-delete.e2e.ts",
+ "test:e2e": "playwright test tests/stress/ux-interactions.e2e.ts tests/stress/file-tree-create.e2e.ts tests/stress/file-tree-drag-to-root.e2e.ts tests/stress/file-tree-deselect-to-root.e2e.ts tests/stress/file-tree-compact-folders.e2e.ts tests/stress/create-then-rename-editable.e2e.ts tests/stress/find-replace.e2e.ts tests/stress/editor-tabs.e2e.ts tests/stress/crdt-stress.e2e.ts tests/stress/slash-command.e2e.ts tests/stress/paste-fidelity.e2e.ts tests/stress/fr-7a-disconnect-source-mode.e2e.ts tests/stress/docs-open.e2e.ts tests/stress/frozen-table-headers.e2e.ts tests/stress/asset-embed.e2e.ts tests/stress/asset-embed-advanced.e2e.ts tests/stress/asset-click-dispatch.e2e.ts tests/stress/handoff.e2e.ts tests/stress/multi-agent-presence.e2e.ts tests/stress/editor-mode-persistence.e2e.ts tests/stress/sidebar-search-pill.e2e.ts tests/stress/command-palette-semantic.e2e.ts tests/stress/agent-activity-panel.e2e.ts tests/stress/drop-pipeline-auto-open.e2e.ts tests/stress/command-palette-flicker.e2e.ts tests/stress/cm6-list-hanging-indent.e2e.ts tests/stress/ng7-rapid-nav-coherence.e2e.ts tests/stress/reveal-on-activate.e2e.ts tests/stress/selection-indicator.e2e.ts tests/stress/new-file-cross-doc-bleed.e2e.ts tests/stress/editor-mode-flip-cross-doc-bleed.e2e.ts tests/stress/editor-area-viewport-resize.e2e.ts tests/stress/qa-sidebar-responsive.e2e.ts tests/stress/prd-6955-reassertion-repro.e2e.ts tests/stress/prd-6955-reassertion-wedge.e2e.ts tests/stress/prd-6914-repro.e2e.ts tests/stress/showall-lazy-tree.e2e.ts tests/stress/tabs-component-strip.e2e.ts tests/stress/source-find-scroll.e2e.ts tests/stress/harness-app-warmth.e2e.ts tests/stress/qa-canary-authoring-both-modes.e2e.ts tests/stress/qa-canary-live-typing.e2e.ts tests/stress/keystroke-cadence-danger-space.e2e.ts tests/stress/jsx-unregistered-backspace-delete.e2e.ts tests/stress/jsx-unregistered-ime-concurrent.e2e.ts tests/stress/jsx-backspace-delete.e2e.ts tests/stress/link-authoring-bytes.e2e.ts tests/stress/link-authoring-apex.e2e.ts",
"test:e2e:install-browsers": "playwright install chromium webkit firefox",
"test:visual": "playwright test --config playwright.visual.config.ts",
"test:visual:update": "playwright test --config playwright.visual.config.ts --update-snapshots",
@@ -180,6 +180,7 @@
"@testing-library/jest-dom": "^6",
"@testing-library/react": "^16",
"@testing-library/user-event": "^14",
+ "@tiptap/starter-kit": "^3.22.3",
"@types/jsdom": "^28",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts
index 0111e4b7..3386df49 100644
--- a/packages/app/playwright.config.ts
+++ b/packages/app/playwright.config.ts
@@ -14,16 +14,19 @@ import { defineConfig } from '@playwright/test';
*/
/**
- * Single-browser (Chromium) — all E2E tests use programmatic clipboard
+ * Single-browser (Chromium). Most E2E tests use programmatic clipboard
* injection via `dispatchEvent(new ClipboardEvent(...))`, not real browser
- * clipboard APIs. Cross-browser clipboard differences (Safari user-activation
- * rules, Firefox async clipboard restrictions) are not exercised because the
- * tests bypass the native clipboard permission model entirely. Running 3×
+ * clipboard APIs, so cross-browser clipboard differences (Safari
+ * user-activation rules, Firefox async clipboard restrictions) aren't
+ * exercised. The one exception is the FR8 clipboard-denial case in
+ * link-authoring-apex.e2e.ts, which runs a fresh context with clipboard-read
+ * withheld to assert the real permission gate degrades silently — it needs no
+ * cross-browser matrix either (the denial path is host-agnostic). Running 3×
* browsers adds ~10 minutes of CI time with zero additional coverage.
*
- * If future tests exercise REAL browser clipboard (e.g., `page.keyboard.press
- * ('Meta+V')` with system clipboard content), add per-file project scoping
- * for those tests only — not a global 3× multiplier.
+ * If future tests exercise REAL browser clipboard reads/writes that DO vary by
+ * engine, add per-file project scoping for those tests only — not a global 3×
+ * multiplier.
*/
const isCI = !!process.env.CI;
diff --git a/packages/app/src/editor/bubble-menu/BubbleMenuBar.tsx b/packages/app/src/editor/bubble-menu/BubbleMenuBar.tsx
index a75ed58f..2bc96080 100644
--- a/packages/app/src/editor/bubble-menu/BubbleMenuBar.tsx
+++ b/packages/app/src/editor/bubble-menu/BubbleMenuBar.tsx
@@ -5,8 +5,8 @@ import { useEditorState } from '@tiptap/react';
import { BubbleMenu } from '@tiptap/react/menus';
import { useRef, useState } from 'react';
import { Separator } from '@/components/ui/separator';
-import { getFindReplaceState } from '../find-replace/tiptap-find-replace-extension';
import { BlockTypeSelector } from './BlockTypeSelector';
+import { shouldShowBubbleMenu } from './bubble-menu-state';
import { EditWithAiBubbleButton } from './EditWithAiBubbleButton';
import { FileBubbleButtons, isFileNodeSelected } from './FileBubbleButtons';
import { FootnoteBubbleButton } from './FootnoteBubbleButton';
@@ -14,22 +14,6 @@ import { ImageAlignButtons, isImageNodeSelected } from './ImageAlignButtons';
import { InlineFormatButtons } from './InlineFormatButtons';
import { LinkEditPopover } from './LinkEditPopover';
-function shouldShowBubbleMenu({ editor }: { editor: Editor }): boolean {
- if (getFindReplaceState(editor.state).query) return false;
- if (editor.isActive('codeBlock')) return false;
- // Image / File NodeSelection — show the menu so the per-type buttons
- // (`ImageAlignButtons` / `FileBubbleButtons`) are reachable even though
- // `textBetween` is empty across a leaf atom. Bypasses the text-bearing-
- // selection guards below.
- if (isImageNodeSelected(editor)) return true;
- if (isFileNodeSelected(editor)) return true;
- if (editor.state.selection.empty) return false;
- const { from, to } = editor.state.selection;
- const text = editor.state.doc.textBetween(from, to, ' ');
- if (!text.trim()) return false;
- return true;
-}
-
export function BubbleMenuBar({
editor,
shortcutEnabled = true,
@@ -124,7 +108,11 @@ export function BubbleMenuBar({
-
+ ({
usePageList: () => ({
@@ -27,12 +35,18 @@ function makeEditor({
onSetLink,
onUnsetLink,
selectionEmpty = true,
+ selectionFrom = 1,
+ selectionTo = 1,
+ viewFocused = true,
}: {
active?: boolean;
href?: string;
onSetLink?: (attrs: { href: string }) => void;
onUnsetLink?: () => void;
selectionEmpty?: boolean;
+ selectionFrom?: number;
+ selectionTo?: number;
+ viewFocused?: boolean;
} = {}): Editor {
const chain = {
focus: () => chain,
@@ -48,7 +62,14 @@ function makeEditor({
};
return {
- state: { selection: { empty: selectionEmpty } },
+ state: {
+ selection: { empty: selectionEmpty, from: selectionFrom, to: selectionTo },
+ doc: { textBetween: () => (selectionEmpty ? '' : 'docs') },
+ },
+ view: { hasFocus: () => viewFocused },
+ // The claim reads the non-throwing `editorView` field (never the `view`
+ // throwing proxy); mirror the real Editor by exposing both.
+ editorView: { hasFocus: () => viewFocused },
getAttributes: mock((name: string) => (name === 'link' ? { href } : {})),
isActive: mock((name: string) => name === 'link' && active),
on: mock(() => {}),
@@ -57,14 +78,43 @@ function makeEditor({
} as unknown as Editor;
}
-function renderPopover(editor: Editor) {
+function renderPopover(editor: Editor, opts: { shortcutEnabled?: boolean } = {}) {
return render(
-
+ ,
);
}
+function cmdKEvent(init: KeyboardEventInit = {}): KeyboardEvent {
+ return new KeyboardEvent('keydown', {
+ key: 'k',
+ bubbles: true,
+ cancelable: true,
+ ...(isMacOS() ? { metaKey: true } : { ctrlKey: true }),
+ ...init,
+ });
+}
+
+function stubClipboardRead(impl: () => Promise) {
+ const readText = mock(impl);
+ const original = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: { readText },
+ });
+ return {
+ readText,
+ restore: () => {
+ if (original) {
+ Object.defineProperty(navigator, 'clipboard', original);
+ } else {
+ Reflect.deleteProperty(navigator, 'clipboard');
+ }
+ },
+ };
+}
+
afterEach(() => {
cleanup();
if (nativeRequestAnimationFrame) {
@@ -144,3 +194,279 @@ describe('LinkEditPopover', () => {
});
});
});
+
+describe('LinkEditPopover ⌘K dual-role claim', () => {
+ test('claims ⌘K and opens the URL input when the focused editor has a text selection', async () => {
+ renderPopover(makeEditor({ selectionEmpty: false, selectionFrom: 1, selectionTo: 5 }), {
+ shortcutEnabled: true,
+ });
+
+ const event = cmdKEvent();
+ fireEvent(window, event);
+
+ expect(event.defaultPrevented).toBe(true);
+ await waitFor(() => {
+ expect(screen.getByRole('combobox', { name: 'Link URL' })).toBeTruthy();
+ });
+ });
+
+ test('claims in the capture phase, starving window-bubble consumers like the palette', () => {
+ // The command palette's opener is a window-bubble keydown listener. A
+ // claimed ⌘K must never reach one — even one registered before this
+ // component mounted. Dispatch below window so the phases separate.
+ let bubbleFired = false;
+ const bubbleListener = () => {
+ bubbleFired = true;
+ };
+ window.addEventListener('keydown', bubbleListener);
+ try {
+ renderPopover(makeEditor({ selectionEmpty: false, selectionFrom: 1, selectionTo: 5 }), {
+ shortcutEnabled: true,
+ });
+
+ const event = cmdKEvent();
+ fireEvent(document.body, event);
+
+ expect(event.defaultPrevented).toBe(true);
+ expect(bubbleFired).toBe(false);
+ } finally {
+ window.removeEventListener('keydown', bubbleListener);
+ }
+ });
+
+ test('leaves ⌘K for the palette when the editor is not focused', () => {
+ renderPopover(
+ makeEditor({ selectionEmpty: false, selectionFrom: 1, selectionTo: 5, viewFocused: false }),
+ { shortcutEnabled: true },
+ );
+
+ const event = cmdKEvent();
+ fireEvent(window, event);
+
+ expect(event.defaultPrevented).toBe(false);
+ expect(screen.queryByRole('combobox', { name: 'Link URL' })).toBeNull();
+ });
+
+ test('stays inert for pooled editors without shortcutEnabled', () => {
+ renderPopover(makeEditor({ selectionEmpty: false, selectionFrom: 1, selectionTo: 5 }));
+
+ const event = cmdKEvent();
+ fireEvent(window, event);
+
+ expect(event.defaultPrevented).toBe(false);
+ expect(screen.queryByRole('combobox', { name: 'Link URL' })).toBeNull();
+ });
+
+ test('does not claim ⌘⇧K', () => {
+ renderPopover(makeEditor({ selectionEmpty: false, selectionFrom: 1, selectionTo: 5 }), {
+ shortcutEnabled: true,
+ });
+
+ const event = cmdKEvent({ shiftKey: true });
+ fireEvent(window, event);
+
+ expect(event.defaultPrevented).toBe(false);
+ expect(screen.queryByRole('combobox', { name: 'Link URL' })).toBeNull();
+ });
+
+ test('routes a caret inside a tracked link to the chip edit spine, not the popover', () => {
+ // Real mark-identity state needs a live PM view; stub the plugin state so
+ // the caret resolves to a stable id (same idiom as component-items tests).
+ const getStateSpy = spyOn(markIdentityKey, 'getState').mockReturnValue({
+ byId: new Map([
+ [
+ 'm7',
+ { id: 'm7', markType: 'link', from: 1, to: 5, attrs: { href: 'https://example.com' } },
+ ],
+ ]),
+ counter: 7,
+ } as never);
+
+ // Capture rAF without invoking it: the deferred getInteractionLayer →
+ // setActiveNode needs a live editor view, out of scope for this unit.
+ let rafScheduled = false;
+ const originalRaf = globalThis.requestAnimationFrame;
+ globalThis.requestAnimationFrame = (() => {
+ rafScheduled = true;
+ return 0;
+ }) as typeof globalThis.requestAnimationFrame;
+
+ try {
+ renderPopover(makeEditor({ selectionEmpty: true, selectionFrom: 3 }), {
+ shortcutEnabled: true,
+ });
+
+ const event = cmdKEvent();
+ fireEvent(window, event);
+
+ expect(event.defaultPrevented).toBe(true);
+ expect(consumePendingLinkEdit('m7')).toBe(true);
+ expect(rafScheduled).toBe(true);
+ expect(screen.queryByRole('combobox', { name: 'Link URL' })).toBeNull();
+ } finally {
+ globalThis.requestAnimationFrame = originalRaf;
+ getStateSpy.mockRestore();
+ _resetPendingLinkEditForTest();
+ }
+ });
+
+ test('opens the URL input when the programmatic-open seam fires for the active editor', async () => {
+ renderPopover(makeEditor(), { shortcutEnabled: true });
+
+ act(() => {
+ emitOpenLinkEditPopover();
+ });
+
+ await waitFor(() => {
+ expect(screen.getByRole('combobox', { name: 'Link URL' })).toBeTruthy();
+ });
+ });
+
+ test('ignores the programmatic-open seam when not the active editor instance', () => {
+ renderPopover(makeEditor());
+
+ act(() => {
+ emitOpenLinkEditPopover();
+ });
+
+ expect(screen.queryByRole('combobox', { name: 'Link URL' })).toBeNull();
+ });
+});
+
+describe('LinkEditPopover clipboard pre-fill', () => {
+ test('pre-fills an allowlisted-scheme clipboard URL, selected, when the Link button opens an empty input', async () => {
+ const clip = stubClipboardRead(() => Promise.resolve('https://inkeep.com/docs'));
+ try {
+ renderPopover(makeEditor({ active: false }));
+
+ fireEvent.mouseDown(screen.getByRole('button', { name: 'Insert link' }));
+
+ const input = screen.getByRole('combobox', { name: 'Link URL' }) as HTMLInputElement;
+ await waitFor(() => {
+ expect(input.value).toBe('https://inkeep.com/docs');
+ });
+ await waitFor(() => {
+ expect(input.selectionStart).toBe(0);
+ expect(input.selectionEnd).toBe('https://inkeep.com/docs'.length);
+ });
+ } finally {
+ clip.restore();
+ }
+ });
+
+ test('pre-fills when the ⌘K claim opens the popover for a text selection', async () => {
+ const clip = stubClipboardRead(() => Promise.resolve('https://inkeep.com/'));
+ try {
+ renderPopover(makeEditor({ selectionEmpty: false, selectionFrom: 1, selectionTo: 5 }), {
+ shortcutEnabled: true,
+ });
+
+ fireEvent(window, cmdKEvent());
+
+ await waitFor(() => {
+ expect((screen.getByRole('combobox', { name: 'Link URL' }) as HTMLInputElement).value).toBe(
+ 'https://inkeep.com/',
+ );
+ });
+ } finally {
+ clip.restore();
+ }
+ });
+
+ test('leaves the input empty when the clipboard holds prose, with no error surfaced', async () => {
+ let resolveRead!: (value: string) => void;
+ const clip = stubClipboardRead(
+ () =>
+ new Promise((resolve) => {
+ resolveRead = resolve;
+ }),
+ );
+ try {
+ renderPopover(makeEditor({ active: false }));
+ fireEvent.mouseDown(screen.getByRole('button', { name: 'Insert link' }));
+ const input = screen.getByRole('combobox', { name: 'Link URL' }) as HTMLInputElement;
+
+ await act(async () => {
+ resolveRead('meeting notes for tuesday');
+ await Promise.resolve();
+ });
+
+ expect(input.value).toBe('');
+ } finally {
+ clip.restore();
+ }
+ });
+
+ test('degrades silently to an empty, functional input when the clipboard read is denied', async () => {
+ let rejectRead!: (reason: unknown) => void;
+ const clip = stubClipboardRead(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectRead = reject;
+ }),
+ );
+ try {
+ renderPopover(makeEditor({ active: false }));
+ fireEvent.mouseDown(screen.getByRole('button', { name: 'Insert link' }));
+ const input = screen.getByRole('combobox', { name: 'Link URL' }) as HTMLInputElement;
+
+ await act(async () => {
+ rejectRead(new DOMException('Read permission denied.', 'NotAllowedError'));
+ await Promise.resolve();
+ });
+
+ expect(input.value).toBe('');
+ fireEvent.change(input, { target: { value: 'https://typed.example/' } });
+ expect(input.value).toBe('https://typed.example/');
+ } finally {
+ clip.restore();
+ }
+ });
+
+ test('does not read the clipboard when opening on an existing link', async () => {
+ const clip = stubClipboardRead(() => Promise.resolve('https://clipboard.example/'));
+ try {
+ renderPopover(makeEditor({ href: 'https://example.com/docs' }));
+ fireEvent.mouseDown(screen.getByRole('button', { name: 'Insert link' }));
+ const input = screen.getByRole('combobox', { name: 'Link URL' }) as HTMLInputElement;
+ expect(input.value).toBe('https://example.com/docs');
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ // The read itself is user-visible on the web (a clipboard permission
+ // prompt), so not-reading on an edit-open is the observable contract —
+ // alongside the existing href surviving untouched.
+ expect(clip.readText).not.toHaveBeenCalled();
+ expect(input.value).toBe('https://example.com/docs');
+ } finally {
+ clip.restore();
+ }
+ });
+
+ test('a value the user typed before the clipboard resolved wins', async () => {
+ let resolveRead!: (value: string) => void;
+ const clip = stubClipboardRead(
+ () =>
+ new Promise((resolve) => {
+ resolveRead = resolve;
+ }),
+ );
+ try {
+ renderPopover(makeEditor({ active: false }));
+ fireEvent.mouseDown(screen.getByRole('button', { name: 'Insert link' }));
+ const input = screen.getByRole('combobox', { name: 'Link URL' }) as HTMLInputElement;
+ fireEvent.change(input, { target: { value: 'guides/install' } });
+
+ await act(async () => {
+ resolveRead('https://late.example/');
+ await Promise.resolve();
+ });
+
+ expect(input.value).toBe('guides/install');
+ } finally {
+ clip.restore();
+ }
+ });
+});
diff --git a/packages/app/src/editor/bubble-menu/LinkEditPopover.tsx b/packages/app/src/editor/bubble-menu/LinkEditPopover.tsx
index b40c33a6..d399548b 100644
--- a/packages/app/src/editor/bubble-menu/LinkEditPopover.tsx
+++ b/packages/app/src/editor/bubble-menu/LinkEditPopover.tsx
@@ -1,16 +1,99 @@
import { Trans, useLingui } from '@lingui/react/macro';
import type { Editor } from '@tiptap/react';
import { ArrowUpRight, CornerDownLeft, Link, Trash2 } from 'lucide-react';
-import { type KeyboardEvent, useEffect, useRef, useState } from 'react';
+import {
+ type Dispatch,
+ type KeyboardEvent as ReactKeyboardEvent,
+ type RefObject,
+ type SetStateAction,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
import { usePageList } from '@/components/PageListContext';
import { Button } from '@/components/ui/button';
import { Kbd } from '@/components/ui/kbd';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
-import { formatShortcut } from '@/lib/keyboard-shortcuts';
+import { formatShortcut, matchesKeyboardShortcut } from '@/lib/keyboard-shortcuts';
+import { detectClipboardPrefillUrl } from '../clipboard/lone-url';
+import { setPendingLinkEdit } from '../extensions/link-edit-autoopen';
+import { getInteractionLayer } from '../interaction-layer-host';
import { buildCurrentRelativeMarkdownHref, openHashHrefInNewTab } from '../internal-link-helpers';
import { type LinkPathSuggestion, LinkPathSuggestionInput } from '../link-path-suggestions';
+import { assertNeverAddLinkAction, resolveAddLinkShortcutAction } from './bubble-menu-state';
+import {
+ emitOpenLinkEditPopover,
+ subscribeToOpenLinkEditPopover,
+} from './link-edit-popover-events';
-export function LinkEditPopover({ editor }: { editor: Editor }) {
+function initialLinkInputUrl(editor: Editor): string {
+ return editor.state.selection.empty && editor.isActive('link')
+ ? (editor.getAttributes('link').href ?? '')
+ : '';
+}
+
+/**
+ * Best-effort clipboard pre-fill for a just-opened, still-empty URL input.
+ * Denied/unavailable clipboard and non-URL content degrade the same way —
+ * the input stays empty, no error surfaced. The functional guard applies
+ * the pre-fill only while the input is still untouched, so a value the
+ * user has begun typing always wins over a late clipboard resolve; the
+ * select() makes the guess replaceable in one gesture when it does land.
+ */
+async function prefillUrlFromClipboard(
+ inputRef: RefObject,
+ setUrl: Dispatch>,
+): Promise {
+ let text: string;
+ try {
+ text = await navigator.clipboard.readText();
+ } catch (error) {
+ // Expected denials (permission, insecure context, empty clipboard) arrive
+ // as DOMExceptions and degrade silently by design. Anything else — a
+ // polyfill or CSP failure that would make pre-fill always-empty — warns
+ // (debug is suppressed by default console filters) so it is
+ // field-diagnosable.
+ if (!(error instanceof DOMException)) {
+ console.warn('[link-popover] clipboard pre-fill read failed unexpectedly', error);
+ }
+ return;
+ }
+ const href = detectClipboardPrefillUrl(text);
+ if (href === null) return;
+ setUrl((prev) => (prev === '' ? href : prev));
+ requestAnimationFrame(() => {
+ const input = inputRef.current;
+ if (input && input.value === href) {
+ input.select();
+ }
+ });
+}
+
+// Shared open path for the Link button and the programmatic-open seam. The
+// clipboard read fires only when the input opens empty (the add case): an
+// edit-open already carries the existing href, and reading a value we would
+// discard could still cost the user a browser permission prompt.
+function openLinkInput(
+ editor: Editor,
+ inputRef: RefObject,
+ setUrl: Dispatch>,
+ setShowInput: Dispatch>,
+): void {
+ const initial = initialLinkInputUrl(editor);
+ setUrl(initial);
+ setShowInput(true);
+ if (initial === '') {
+ void prefillUrlFromClipboard(inputRef, setUrl);
+ }
+}
+
+export function LinkEditPopover({
+ editor,
+ shortcutEnabled = false,
+}: {
+ editor: Editor;
+ shortcutEnabled?: boolean;
+}) {
const { t } = useLingui();
const [showInput, setShowInput] = useState(false);
const [url, setUrl] = useState('');
@@ -20,11 +103,65 @@ export function LinkEditPopover({ editor }: { editor: Editor }) {
const isLinkActive = editor.state.selection.empty && editor.isActive('link');
const currentUrl = editor.getAttributes('link').href ?? '';
- function getInitialUrlForLinkInput() {
- return editor.state.selection.empty && editor.isActive('link')
- ? (editor.getAttributes('link').href ?? '')
- : '';
- }
+ // Programmatic-open seam — same open path as the Link button below (focus
+ // rides the showInput effect's rAF). Gated on `shortcutEnabled` because the
+ // event is window-scoped and every pooled editor mounts its own popover —
+ // only the active document's instance may react.
+ useEffect(() => {
+ return subscribeToOpenLinkEditPopover(() => {
+ if (!shortcutEnabled) return;
+ openLinkInput(editor, inputRef, setUrl, setShowInput);
+ });
+ }, [shortcutEnabled, editor]);
+
+ // ⌘K dual-role claim. Capture phase deterministically beats the command
+ // palette's window-bubble listener; the claim stands only while this doc's
+ // WYSIWYG owns focus AND a link affordance applies (non-empty text
+ // selection → popover; caret inside a link → chip edit surface). Any other
+ // ⌘K — source mode, no selection, palette/input focus — falls through and
+ // stays the palette. Mirrors the Edit-with-AI capture-listener pattern.
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent): void => {
+ if (!shortcutEnabled) return;
+ if (!matchesKeyboardShortcut(event, 'add-link')) return;
+ // `editor.view` is a throwing proxy while `editor.editorView` is unset
+ // (PM construction, Activity recycle/remount) — and this window-capture
+ // listener is registered whenever the shortcut is enabled, so a ⌘K can
+ // land in exactly that window. Read the non-throwing field structurally
+ // (TipTap types it private; same duck-typed access as the
+ // InteractionLayer's getEditorDom): unset reads as "not focused" and
+ // falls through cleanly to the palette.
+ const liveView = (editor as unknown as { editorView?: { hasFocus(): boolean } | null })
+ .editorView;
+ if (!liveView?.hasFocus()) return;
+ const action = resolveAddLinkShortcutAction(editor);
+ if (action === null) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+ event.stopImmediatePropagation();
+ switch (action.kind) {
+ case 'open-popover':
+ emitOpenLinkEditPopover();
+ return;
+ case 'edit-link': {
+ // Chip edit spine shared with the slash-command Link insert: flag
+ // the mark id for auto-edit, then activate its prop panel next frame.
+ const { markId } = action;
+ setPendingLinkEdit(markId);
+ requestAnimationFrame(() => {
+ getInteractionLayer(editor).setActiveNode(markId);
+ });
+ return;
+ }
+ default:
+ assertNeverAddLinkAction(action);
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown, { capture: true });
+ return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
+ }, [shortcutEnabled, editor]);
// Reset link input when selection collapses (bubble menu hides)
useEffect(() => {
@@ -40,9 +177,34 @@ export function LinkEditPopover({ editor }: { editor: Editor }) {
}, [editor]);
useEffect(() => {
- if (showInput) {
- requestAnimationFrame(() => inputRef.current?.focus());
- }
+ if (!showInput) return;
+ // The input lives inside the floating bubble menu, which stays
+ // visibility:hidden (unfocusable — focus() is a silent no-op) until
+ // floating-ui finishes positioning, an unbounded number of frames after
+ // mount. So a single rAF focus never lands. Retry each frame until focus
+ // actually LANDS once, then stop permanently — never re-grab afterwards,
+ // so the loop can't fight the user. Cleanup cancels on close/unmount; the
+ // attempt cap is a backstop only.
+ let cancelled = false;
+ let frameId = 0;
+ const focusInput = (attempts: number): void => {
+ if (cancelled) return;
+ const el = inputRef.current;
+ if (el) {
+ el.focus();
+ if (document.activeElement === el) return;
+ }
+ if (attempts < 60) {
+ frameId = requestAnimationFrame(() => focusInput(attempts + 1));
+ } else {
+ console.warn('[link-popover] URL input never became focusable');
+ }
+ };
+ frameId = requestAnimationFrame(() => focusInput(0));
+ return () => {
+ cancelled = true;
+ cancelAnimationFrame(frameId);
+ };
}, [showInput]);
function applyLink() {
@@ -63,7 +225,7 @@ export function LinkEditPopover({ editor }: { editor: Editor }) {
setUrl(buildCurrentRelativeMarkdownHref(suggestion.path, null));
}
- function handleKeyDown(e: KeyboardEvent) {
+ function handleKeyDown(e: ReactKeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
applyLink();
@@ -158,8 +320,7 @@ export function LinkEditPopover({ editor }: { editor: Editor }) {
className={isLinkActive ? 'bg-accent text-primary' : 'text-accent-foreground'}
onMouseDown={(e) => {
e.preventDefault();
- setUrl(getInitialUrlForLinkInput());
- setShowInput(true);
+ openLinkInput(editor, inputRef, setUrl, setShowInput);
}}
>
@@ -167,7 +328,7 @@ export function LinkEditPopover({ editor }: { editor: Editor }) {
Link
- {formatShortcut('command-palette')}
+ {formatShortcut('add-link')}
);
diff --git a/packages/app/src/editor/bubble-menu/bubble-menu-state.test.ts b/packages/app/src/editor/bubble-menu/bubble-menu-state.test.ts
new file mode 100644
index 00000000..a855d978
--- /dev/null
+++ b/packages/app/src/editor/bubble-menu/bubble-menu-state.test.ts
@@ -0,0 +1,120 @@
+/**
+ * resolveAddLinkShortcutAction — the ⌘K dual-role routing decision — against
+ * real headless editors (StarterKit + the fidelity link mark, plus the real
+ * mark-identity plugin where the caret branch needs it). The claim contract:
+ * a non-null action is returned exactly when the matching link affordance is
+ * reachable; everything else must return null so the keystroke falls through
+ * to the command palette.
+ */
+
+import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
+import { LinkFidelity } from '@inkeep/open-knowledge-core';
+import { Editor, Extension } from '@tiptap/core';
+import { TextSelection } from '@tiptap/pm/state';
+import StarterKit from '@tiptap/starter-kit';
+import { markIdentityKey, markIdentityPlugin } from '../extensions/mark-identity';
+import { installDomGlobals } from '../walk-currency-test-harness';
+import { resolveAddLinkShortcutAction } from './bubble-menu-state';
+
+let restoreDomGlobals: (() => void) | null = null;
+
+beforeAll(() => {
+ restoreDomGlobals = installDomGlobals();
+});
+
+afterAll(() => {
+ restoreDomGlobals?.();
+ restoreDomGlobals = null;
+});
+
+const MarkIdentityForTest = Extension.create({
+ name: 'markIdentityForTest',
+ addProseMirrorPlugins() {
+ return [markIdentityPlugin({ markTypes: ['link'] })];
+ },
+});
+
+const editors: Editor[] = [];
+
+afterEach(() => {
+ for (const editor of editors.splice(0)) editor.destroy();
+});
+
+function makeEditor(content: string, opts: { withIdentity?: boolean } = {}): Editor {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const editor = new Editor({
+ element: host,
+ content,
+ extensions: [
+ // StarterKit v3 bundles its own Link; drop it so the fidelity mark is
+ // the only `link` in the schema (and keep its stock autolink off).
+ StarterKit.configure({ link: false }),
+ LinkFidelity.configure({ autolink: false }),
+ ...(opts.withIdentity ? [MarkIdentityForTest] : []),
+ ],
+ });
+ editors.push(editor);
+ return editor;
+}
+
+function select(editor: Editor, from: number, to: number = from): void {
+ editor.view.dispatch(
+ editor.state.tr.setSelection(TextSelection.create(editor.state.doc, from, to)),
+ );
+}
+
+describe('resolveAddLinkShortcutAction', () => {
+ test('routes a non-empty text selection to the popover', () => {
+ const editor = makeEditor('
hello world
');
+ select(editor, 1, 6);
+ expect(resolveAddLinkShortcutAction(editor)).toEqual({ kind: 'open-popover' });
+ });
+
+ test('routes a cross-block text selection to the popover', () => {
+ const editor = makeEditor('
one
two
');
+ select(editor, 2, 7);
+ expect(resolveAddLinkShortcutAction(editor)).toEqual({ kind: 'open-popover' });
+ });
+
+ test('falls through on a collapsed caret outside any link', () => {
+ const editor = makeEditor('
hello world
');
+ select(editor, 3);
+ expect(resolveAddLinkShortcutAction(editor)).toBeNull();
+ });
+
+ test("routes a caret inside a tracked link to that link's edit surface", () => {
+ const editor = makeEditor('
');
+ try {
+ selectText(editor, 'docs');
+ expect(pasteInto(editor, { 'text/plain': payload })).toBe(true);
+ expect(editor.state.doc.textContent).toBe('read the docs today');
+ expect(linkMarks(editor)[0]?.attrs.href).toBe(expectedHref);
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ test('paste of a non-allowlisted scheme over a selection never links — the payload lands as inert plain text', () => {
+ const editor = makeRealEditor('
read the docs today
');
+ try {
+ selectText(editor, 'docs');
+ pasteInto(editor, { 'text/plain': 'javascript:alert(1)' });
+ expect(linkMarks(editor)).toHaveLength(0);
+ // Fall-through = ordinary replace: the string is text, never an href.
+ expect(editor.state.doc.textContent).toBe('read the javascript:alert(1) today');
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ test('paste of a URL over a code-marked selection falls through to a plain replace', () => {
+ const editor = makeRealEditor('
run bun install now
');
+ try {
+ selectText(editor, 'install');
+ pasteInto(editor, { 'text/plain': 'https://inkeep.com' });
+ expect(linkMarks(editor)).toHaveLength(0);
+ expect(editor.state.doc.textContent).toBe('run bun https://inkeep.com now');
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ test('paste of a URL over a cross-block selection falls through (no link mark)', () => {
+ const editor = makeRealEditor('
one
two
');
+ try {
+ editor.commands.setTextSelection({ from: 2, to: 8 });
+ pasteInto(editor, { 'text/plain': 'https://inkeep.com' });
+ expect(linkMarks(editor)).toHaveLength(0);
+ // The fall-through is the normal paste path: the cross-block selection
+ // is replaced by the URL as plain text — content delivered, not dropped.
+ expect(editor.state.doc.textContent).toContain('https://inkeep.com');
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ test('paste of a URL over already-linked text re-points the link, keeping the text', () => {
+ const editor = makeRealEditor('
',
+ });
+ try {
+ // Seeded links default to linkStyle 'inline'; a re-conversion would flip
+ // it to 'gfm-autolink'.
+ expect(firstLinkAttrs(editor)?.linkStyle).toBe('inline');
+ insertLocal(editor, ' ', 20);
+ await flushMicrotasksAndTimers();
+ expect(firstLinkAttrs(editor)?.linkStyle).toBe('inline');
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ test('a wikilink atom next to a typed URL never converts and stays intact', async () => {
+ // The wikiLink node is an atom: `textBetween` renders it as leaf text, so
+ // the word scan can never see inside it. This pins that structural
+ // property — if WikiLink ever stops being an atom, this fails instead of
+ // the plugin silently starting to linkify wikilink internals.
+ const { WikiLink } = await import('@inkeep/open-knowledge-core');
+ const editor = mountLightEditor({
+ extensions: [WikiLink, GfmAutolink.configure({ isActiveEditor: () => true })],
+ });
+ try {
+ editor.commands.insertContent({ type: 'wikiLink', attrs: { target: 'Some Page' } });
+ // Type a URL + boundary right after the atom: only the URL converts.
+ const end = editor.state.doc.content.size - 1;
+ insertLocal(editor, ' https://after-atom.com ', end);
+ await flushMicrotasksAndTimers();
+ expect(linkHrefs(editor)).toEqual(['https://after-atom.com']);
+ // The atom's target never became link-marked text.
+ let wikiTargets = 0;
+ editor.state.doc.descendants((node) => {
+ if (node.type.name === 'wikiLink') wikiTargets++;
+ return true;
+ });
+ expect(wikiTargets).toBe(1);
+ } finally {
+ editor.destroy();
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Deferred separate dispatch + position re-validation
+// ---------------------------------------------------------------------------
+
+describe('typed autolink — deferred dispatch', () => {
+ test('the conversion aborts silently when the range changed before the flush', async () => {
+ const editor = makeLightEditor();
+ try {
+ insertLocal(editor, 'https://example.com ', 1);
+ // Before the queued microtask runs, delete the text the candidate targeted.
+ editor.view.dispatch(editor.state.tr.delete(1, editor.state.doc.content.size));
+ await flushMicrotasksAndTimers();
+
+ expect(firstLinkAttrs(editor)).toBeNull();
+ expect(editor.state.doc.textContent).toBe('');
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ test('the mark is added by a later dispatch, not merged into the typing tr', async () => {
+ const editor = makeLightEditor();
+ try {
+ insertLocal(editor, 'https://example.com ', 1);
+ // Synchronously — before the microtask — the mark is NOT present yet.
+ expect(firstLinkAttrs(editor)).toBeNull();
+ await flushMicrotasksAndTimers();
+ // It arrives on the deferred dispatch.
+ expect(firstLinkAttrs(editor)?.linkStyle).toBe('gfm-autolink');
+ } finally {
+ editor.destroy();
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Undo isolation: the conversion is its own single-Cmd+Z step
+// ---------------------------------------------------------------------------
+
+describe('typed autolink — undo isolation (real y-undo binding)', () => {
+ test('one undo removes only the link mark, keeping the text and trailing space', async () => {
+ const ydoc = new Y.Doc();
+ seedFragmentParagraph(ydoc, 'seed');
+ const editor = makeCollabEditor(ydoc);
+ try {
+ await flushMicrotasksAndTimers();
+ // Close any binding-init capture so the typing below deterministically
+ // starts its own stack item (real typing happens long after doc open).
+ readUndoManager(editor)?.stopCapturing();
+
+ insertLocal(editor, ' https://example.com ', editor.state.doc.content.size - 1);
+ await flushMicrotasksAndTimers();
+ expect(firstLinkHref(editor)).toBe('https://example.com');
+
+ editor.commands.undo();
+ // The mark is gone but the typed text — trailing space included — stays.
+ expect(firstLinkAttrs(editor)).toBeNull();
+ expect(editor.state.doc.textContent).toBe('seed https://example.com ');
+
+ // The typing itself is the next distinct undo step.
+ editor.commands.undo();
+ expect(editor.state.doc.textContent).toBe('seed');
+ } finally {
+ editor.destroy();
+ ydoc.destroy();
+ }
+ });
+
+ test('redo after undoing a conversion re-applies the mark cleanly', async () => {
+ const ydoc = new Y.Doc();
+ seedFragmentParagraph(ydoc, 'seed');
+ const editor = makeCollabEditor(ydoc);
+ try {
+ await flushMicrotasksAndTimers();
+ readUndoManager(editor)?.stopCapturing();
+
+ insertLocal(editor, ' https://example.com ', editor.state.doc.content.size - 1);
+ await flushMicrotasksAndTimers();
+ expect(firstLinkHref(editor)).toBe('https://example.com');
+
+ editor.commands.undo();
+ expect(firstLinkAttrs(editor)).toBeNull();
+
+ editor.commands.redo();
+ const attrs = firstLinkAttrs(editor);
+ expect(attrs?.href).toBe('https://example.com');
+ expect(attrs?.linkStyle).toBe('gfm-autolink');
+ expect(editor.state.doc.textContent).toBe('seed https://example.com ');
+ } finally {
+ editor.destroy();
+ ydoc.destroy();
+ }
+ });
+
+ test("typing right after a conversion never merges into the mark's undo step", async () => {
+ const ydoc = new Y.Doc();
+ seedFragmentParagraph(ydoc, 'seed');
+ const editor = makeCollabEditor(ydoc);
+ try {
+ await flushMicrotasksAndTimers();
+ readUndoManager(editor)?.stopCapturing();
+
+ insertLocal(editor, ' https://example.com ', editor.state.doc.content.size - 1);
+ await flushMicrotasksAndTimers();
+ expect(firstLinkHref(editor)).toBe('https://example.com');
+
+ // Keep typing within the capture window of the mark dispatch.
+ insertLocal(editor, 'abc', editor.state.doc.content.size - 1);
+ await flushMicrotasksAndTimers();
+
+ editor.commands.undo();
+ // Only the new typing reverts; the conversion survives.
+ expect(editor.state.doc.textContent).toBe('seed https://example.com ');
+ expect(firstLinkHref(editor)).toBe('https://example.com');
+ } finally {
+ editor.destroy();
+ ydoc.destroy();
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Real y-sync binding: origin guard against a genuine remote transaction
+// ---------------------------------------------------------------------------
+
+describe('typed autolink — real CRDT binding', () => {
+ test('a remote y-sync edit is not linkified, but a local edit in the same binding is', async () => {
+ const ydoc = new Y.Doc();
+ // Seed a paragraph with text so the remote append has an XmlText to grow.
+ seedFragmentParagraph(ydoc, 'seed');
+ const editor = makeCollabEditor(ydoc);
+
+ try {
+ await flushMicrotasksAndTimers();
+
+ // A peer appends a URL + space through the real y-sync path. The applied
+ // transaction carries ySyncPluginKey meta → origin guard skips it.
+ const remote = new Y.Doc();
+ Y.applyUpdate(remote, Y.encodeStateAsUpdate(ydoc));
+ remote.transact(() => {
+ appendToFirstParagraph(remote.getXmlFragment('default'), ' https://remote.example ');
+ });
+ Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(remote, Y.encodeStateVector(ydoc)), remote);
+ remote.destroy();
+ await flushMicrotasksAndTimers();
+
+ expect(editor.state.doc.textContent).toContain('https://remote.example');
+ expect(linkHrefs(editor)).not.toContain('https://remote.example');
+
+ // A local edit in the SAME binding still converts.
+ const end = editor.state.doc.content.size - 1;
+ insertLocal(editor, ' https://local.example ', end);
+ await flushMicrotasksAndTimers();
+
+ expect(linkHrefs(editor)).toContain('https://local.example');
+ // And the remote URL is still plain text.
+ expect(linkHrefs(editor)).not.toContain('https://remote.example');
+ } finally {
+ editor.destroy();
+ ydoc.destroy();
+ }
+ });
+});
diff --git a/packages/app/src/editor/gfm-autolink-plugin.ts b/packages/app/src/editor/gfm-autolink-plugin.ts
new file mode 100644
index 00000000..49eeb596
--- /dev/null
+++ b/packages/app/src/editor/gfm-autolink-plugin.ts
@@ -0,0 +1,290 @@
+/**
+ * Typed-URL autolink for the WYSIWYG editor.
+ *
+ * When a local user finishes a GFM-shape URL token and types a word boundary
+ * (space or Enter), the token becomes a link mark carrying
+ * `linkStyle: 'gfm-autolink'` so it serializes as a bare literal rather than
+ * `[url](url)`. Detection reuses stock TipTap's changed-range / last-word scan
+ * but swaps linkify's tokenizer for the GFM-parity recognizer, so only what the
+ * markdown pipeline itself would linkify ever converts.
+ *
+ * Two properties keep this safe in OK's multi-writer CRDT, and both are
+ * structural rather than conventional:
+ *
+ * - Origin guard. Peer, agent, disk-load, and observer-echo edits re-enter the
+ * editor as ProseMirror transactions tagged with `ySyncPluginKey` meta.
+ * Linkifying any of those would rewrite another writer's text across every
+ * connected client. The plugin skips the whole batch the moment any member
+ * carries that meta (fail-closed). A pooled, backgrounded editor also
+ * receives those updates and its provider is live even while hidden, so
+ * detection additionally requires the bound view to be the active/focused
+ * one — a stray local write into a hidden editor can never convert. This is
+ * the same `ySyncPluginKey` origin discipline as source-dirty-observer.ts.
+ *
+ * - Undo isolation. The mark is added as its OWN ProseMirror dispatch a
+ * microtask after the typing flush, never returned from `appendTransaction`.
+ * Returning it would weld the mark and the keystroke into a single Yjs
+ * transaction — inseparable by any undo-manager setting — and Y.UndoManager
+ * would still merge separate transactions landing within its default
+ * `captureTimeout` (500 ms — Y.js's default, not OK config) into one
+ * stack item. The separate dispatch undoes the first
+ * weld; `stopCapturing()` immediately before AND after the dispatch undoes
+ * the second, so one Cmd+Z removes just the link (typed text intact) and
+ * the user's next keystrokes never merge into the mark's step. The deferral
+ * also lets the flush read `view.composing` (unreachable inside
+ * appendTransaction) and re-validate the target range, which may have moved
+ * or vanished in the gap.
+ */
+
+import type { LinkStyle } from '@inkeep/open-knowledge-core';
+import {
+ combineTransactionSteps,
+ Extension,
+ findChildrenInRange,
+ getChangedRanges,
+ getMarksBetween,
+ type NodeWithPos,
+} from '@tiptap/core';
+import { type EditorState, Plugin, PluginKey, type Transaction } from '@tiptap/pm/state';
+import type { EditorView } from '@tiptap/pm/view';
+import { ySyncPluginKey } from '@tiptap/y-tiptap';
+import { detectGfmLinkToken } from './gfm-link-detector';
+import { dispatchAsOwnUndoStep } from './undo-isolation';
+
+// TipTap's UNICODE_WHITESPACE_PATTERN (@tiptap/extension-link), the class
+// stock autolink word-splits on, so the boundary notion here agrees with the
+// tokenizer this plugin descends from rather than JS `\s`, which omits some
+// of these code points.
+const WHITESPACE_CLASS = '\\u0000-\\u0020\\u00A0\\u1680\\u180E\\u2000-\\u2029\\u205F\\u3000';
+const WHITESPACE_SPLIT = new RegExp(`[${WHITESPACE_CLASS}]`);
+const TRAILING_WHITESPACE = new RegExp(`[${WHITESPACE_CLASS}]$`);
+
+/** The mark this plugin creates and the fidelity attr that makes it serialize
+ * to a bare literal. Kept as constants so the flush and its tests agree. */
+const LINK_MARK = 'link';
+const GFM_AUTOLINK_STYLE: LinkStyle = 'gfm-autolink';
+
+/** Meta key for suppressing autolink on a transaction (same string value
+ * TipTap's Link extension uses internally, but defined and exported HERE) —
+ * set by the Link mark's setLink/unsetLink commands and by the clipboard
+ * dispatcher, both of which manage their own link bytes. */
+export const PREVENT_AUTOLINK_META = 'preventAutolink';
+
+const gfmAutolinkPluginKey = new PluginKey('gfmAutolink');
+
+interface LinkifyCandidate {
+ from: number;
+ to: number;
+ href: string;
+ /** The exact literal expected at [from, to] at dispatch time; a mismatch
+ * means the doc changed under us and the conversion is abandoned. */
+ text: string;
+}
+
+interface GfmAutolinkPluginOptions {
+ /**
+ * Whether the bound editor is the one that should linkify. Defaults to DOM
+ * focus, which is false for every pooled/background editor and true for the
+ * foreground editor a user is typing into. Overridable so headless tests can
+ * exercise the local-write path without driving real focus.
+ */
+ isActiveEditor?: (view: EditorView) => boolean;
+}
+
+function rangeHasCodeMark(view: EditorView, from: number, to: number): boolean {
+ const codeMark = view.state.schema.marks.code;
+ if (!codeMark) return false;
+ return view.state.doc.rangeHasMark(from, to, codeMark);
+}
+
+/**
+ * Scan the batch's changed ranges for a GFM-shape token that a just-typed word
+ * boundary completed. Pure with respect to the document — it only reads state
+ * and returns ranges; the caller performs the (deferred) mutation.
+ */
+function detectCandidates(
+ oldState: EditorState,
+ newState: EditorState,
+ transactions: readonly Transaction[],
+): LinkifyCandidate[] {
+ const results: LinkifyCandidate[] = [];
+ const transform = combineTransactionSteps(oldState.doc, [...transactions]);
+ const changes = getChangedRanges(transform);
+
+ for (const { newRange } of changes) {
+ const nodesInChangedRanges = findChildrenInRange(
+ newState.doc,
+ newRange,
+ (node) => node.isTextblock,
+ );
+
+ let textBlock: NodeWithPos | undefined;
+ let textBeforeWhitespace: string | undefined;
+
+ if (nodesInChangedRanges.length > 1) {
+ // Enter split a block: scan the first block's whole text (the trailing
+ // token now sits at its end). Stock takes this same ungated path.
+ textBlock = nodesInChangedRanges[0];
+ textBeforeWhitespace = newState.doc.textBetween(
+ textBlock.pos,
+ textBlock.pos + textBlock.node.nodeSize,
+ undefined,
+ ' ',
+ );
+ } else if (nodesInChangedRanges.length === 1) {
+ // Single block: only fire when the change ends in whitespace — the "a
+ // boundary key was just typed" signal.
+ const endText = newState.doc.textBetween(newRange.from, newRange.to, ' ', ' ');
+ if (!TRAILING_WHITESPACE.test(endText)) continue;
+ textBlock = nodesInChangedRanges[0];
+ textBeforeWhitespace = newState.doc.textBetween(textBlock.pos, newRange.to, undefined, ' ');
+ }
+
+ if (!textBlock || !textBeforeWhitespace) continue;
+ // Code blocks are plain-text-only; never linkify inside one.
+ if (textBlock.node.type.spec.code) continue;
+
+ const words = textBeforeWhitespace.split(WHITESPACE_SPLIT).filter(Boolean);
+ const lastWord = words[words.length - 1];
+ if (!lastWord) continue;
+
+ const detected = detectGfmLinkToken(lastWord);
+ if (!detected) continue;
+
+ // The recognizer head-anchors the linkified literal to the start of the
+ // word, so the span is [wordStart, wordStart + text.length]. +1 crosses the
+ // textblock's opening boundary into its content (stock's `link.start + 1`).
+ const from = textBlock.pos + textBeforeWhitespace.lastIndexOf(lastWord) + 1;
+ const to = from + detected.text.length;
+ results.push({ from, to, href: detected.href, text: detected.text });
+ }
+
+ return results;
+}
+
+/**
+ * The ProseMirror plugin, as a factory so `isActiveEditor` can be injected.
+ * Tests drive it through the `GfmAutolink` extension (which threads
+ * `isActiveEditor` via its options), so the factory stays module-private.
+ */
+function gfmAutolinkPlugin(options: GfmAutolinkPluginOptions = {}): Plugin {
+ const isActiveEditor = options.isActiveEditor ?? ((view: EditorView) => view.hasFocus());
+
+ let boundView: EditorView | null = null;
+ let scheduled = false;
+ const pending: LinkifyCandidate[] = [];
+
+ const flush = (): void => {
+ scheduled = false;
+ const view = boundView;
+ const candidates = pending.splice(0, pending.length);
+ if (!view || view.isDestroyed) return;
+ // No conversion mid-IME-composition, and never in a view that lost focus
+ // between detection and this microtask.
+ if (view.composing) return;
+ if (!isActiveEditor(view)) return;
+
+ const markType = view.state.schema.marks[LINK_MARK];
+ if (!markType) return;
+
+ let tr = view.state.tr;
+ let changed = false;
+ const docSize = view.state.doc.content.size;
+
+ for (const candidate of candidates) {
+ const { from, to, href, text } = candidate;
+ // Re-validate: the doc may have shifted, shrunk, or been rewritten in the
+ // gap. A range that no longer holds the exact detected literal is stale.
+ if (from < 0 || to > docSize || from >= to) continue;
+ if (view.state.doc.textBetween(from, to) !== text) continue;
+ // Skip if the span is already linked or lives inside inline code.
+ if (getMarksBetween(from, to, view.state.doc).some((m) => m.mark.type === markType)) continue;
+ if (rangeHasCodeMark(view, from, to)) continue;
+
+ tr = tr.addMark(from, to, markType.create({ href, linkStyle: GFM_AUTOLINK_STYLE }));
+ changed = true;
+ }
+
+ if (!changed) return;
+ // Tag our own mark-add so re-entering appendTransaction skips it.
+ tr = tr.setMeta(PREVENT_AUTOLINK_META, true);
+
+ // The typing that triggered detection is < captureTimeout old, so the
+ // mark-add must land as its own undo stack item (see undo-isolation.ts
+ // for the split-before/close-after contract). flush() runs as a bare
+ // microtask — an escaping throw would be an uncaught async error and the
+ // conversion would just vanish, so degrade like the clipboard dispatcher:
+ // log and drop this batch (the typed text itself is untouched).
+ try {
+ dispatchAsOwnUndoStep(view, tr);
+ } catch (err) {
+ console.warn(
+ '[gfm-autolink] linkify dispatch failed',
+ { candidates: candidates.map((c) => ({ from: c.from, to: c.to, href: c.href })) },
+ err,
+ );
+ }
+ };
+
+ return new Plugin({
+ key: gfmAutolinkPluginKey,
+ view(editorView) {
+ boundView = editorView;
+ return {
+ destroy() {
+ boundView = null;
+ pending.length = 0;
+ scheduled = false;
+ },
+ };
+ },
+ appendTransaction(transactions, oldState, newState) {
+ // Fail-closed origin guard: any CRDT-sync-tagged transaction in the batch
+ // (remote peer, agent, disk load, observer echo) disqualifies the whole
+ // batch. Also honour explicit autolink suppression.
+ if (transactions.some((tr) => tr.getMeta(ySyncPluginKey))) return null;
+ if (transactions.some((tr) => tr.getMeta(PREVENT_AUTOLINK_META))) return null;
+
+ const docChanged = transactions.some((tr) => tr.docChanged) && !oldState.doc.eq(newState.doc);
+ if (!docChanged) return null;
+
+ // Only the active/focused editor linkifies — closes the pooled-hidden-
+ // editor local-write vector the origin guard alone can't see.
+ if (!boundView || !isActiveEditor(boundView)) return null;
+
+ const candidates = detectCandidates(oldState, newState, transactions);
+ if (candidates.length === 0) return null;
+
+ pending.push(...candidates);
+ if (!scheduled) {
+ scheduled = true;
+ queueMicrotask(flush);
+ }
+ // Never return the mark-add here — it must land as its own dispatch.
+ return null;
+ },
+ });
+}
+
+export interface GfmAutolinkOptions {
+ isActiveEditor?: (view: EditorView) => boolean;
+}
+
+/**
+ * App editor extension wrapper. Registered only in the app's editor extension
+ * list, never in the core/persistence set, so linkification stays a client-side
+ * behavior of the interactive editor.
+ */
+export const GfmAutolink = Extension.create({
+ name: 'gfmAutolink',
+
+ addOptions() {
+ return {
+ isActiveEditor: undefined,
+ };
+ },
+
+ addProseMirrorPlugins() {
+ return [gfmAutolinkPlugin({ isActiveEditor: this.options.isActiveEditor })];
+ },
+});
diff --git a/packages/app/src/editor/gfm-link-detector.test.ts b/packages/app/src/editor/gfm-link-detector.test.ts
new file mode 100644
index 00000000..f7da332e
--- /dev/null
+++ b/packages/app/src/editor/gfm-link-detector.test.ts
@@ -0,0 +1,190 @@
+import { describe, expect, test } from 'bun:test';
+import { MarkdownManager, SAFE_URL_SCHEMES, sharedExtensions } from '@inkeep/open-knowledge-core';
+import type { JSONContent } from '@tiptap/core';
+import { detectGfmLinkToken, type GfmLinkToken } from './gfm-link-detector';
+
+// Real pipeline, shared with production — the parity oracle. Constructing one
+// manager pulls the same remark-gfm autolink transform the editor uses.
+const mdManager = new MarkdownManager({ extensions: sharedExtensions });
+
+/** Walk a parsed doc for the first text run carrying a link mark and return
+ * the exact {href, text} the pipeline produced (or null if nothing linked). */
+function parseFirstLink(token: string): GfmLinkToken | null {
+ function walk(node: JSONContent): GfmLinkToken | null {
+ const mark = node.marks?.find((m) => m.type === 'link');
+ if (mark) {
+ return { href: String(mark.attrs?.href ?? ''), text: node.text ?? '' };
+ }
+ for (const child of node.content ?? []) {
+ const found = walk(child);
+ if (found) return found;
+ }
+ return null;
+ }
+ return walk(mdManager.parse(token));
+}
+
+// The locked acceptance matrix. `null` = stays plain text.
+const MATRIX: Array<{ token: string; expected: GfmLinkToken | null }> = [
+ {
+ token: 'https://example.com',
+ expected: { href: 'https://example.com', text: 'https://example.com' },
+ },
+ {
+ token: 'www.example.com',
+ expected: { href: 'http://www.example.com', text: 'www.example.com' },
+ },
+ { token: 'a@b.com', expected: { href: 'mailto:a@b.com', text: 'a@b.com' } },
+ { token: 'example.com', expected: null },
+ { token: 'AGENTS.md', expected: null },
+ { token: 'package.json', expected: null },
+ { token: 'localhost:5173', expected: null },
+ { token: 'foo.bar', expected: null },
+ { token: 'v1.2.3', expected: null },
+ { token: '192.168.1.1', expected: null },
+ // Explicit-scheme dotless hosts: micromark's http_autolink skips the
+ // dotted-domain requirement, so these linkify and round-trip as bare
+ // literals (the schemeless rows above stay rejected).
+ {
+ token: 'http://localhost:5174',
+ expected: { href: 'http://localhost:5174', text: 'http://localhost:5174' },
+ },
+ {
+ token: 'http://localhost:5174/#/doc',
+ expected: { href: 'http://localhost:5174/#/doc', text: 'http://localhost:5174/#/doc' },
+ },
+ { token: 'http://localhost', expected: { href: 'http://localhost', text: 'http://localhost' } },
+ {
+ token: 'http://127.0.0.1:8080/x',
+ expected: { href: 'http://127.0.0.1:8080/x', text: 'http://127.0.0.1:8080/x' },
+ },
+ { token: 'localhost:5174', expected: null },
+ { token: 'http://foo_bar', expected: null },
+ // Ported edge branches — splitUrl's trailing-punctuation / unbalanced-paren
+ // strips, isCorrectDomain's underscore reject, the email bad-tail reject,
+ // and their keep-side complements. In this array they get parity coverage
+ // against the real parse like every other row, which is what guards the
+ // hand-port against upstream drift.
+ {
+ token: 'https://example.com.',
+ expected: { href: 'https://example.com', text: 'https://example.com' },
+ },
+ {
+ token: 'https://example.com)',
+ expected: { href: 'https://example.com', text: 'https://example.com' },
+ },
+ { token: 'https://foo_bar.com', expected: null },
+ { token: 'a@b.c_', expected: null },
+ { token: 'user@host.com-', expected: null },
+ { token: 'a@b.co1', expected: null },
+ { token: 'a@b_c.com', expected: { href: 'mailto:a@b_c.com', text: 'a@b_c.com' } },
+ {
+ token: 'www.a_b.example.com',
+ expected: { href: 'http://www.a_b.example.com', text: 'www.a_b.example.com' },
+ },
+];
+
+describe('detectGfmLinkToken — acceptance matrix', () => {
+ for (const { token, expected } of MATRIX) {
+ test(`${token} → ${expected ? `link ${expected.href}` : 'plain text'}`, () => {
+ expect(detectGfmLinkToken(token)).toEqual(expected);
+ });
+ }
+});
+
+describe('detectGfmLinkToken — parity with MarkdownManager.parse', () => {
+ // The single-source-of-truth contract: for every matrix token the detector
+ // agrees with what the real pipeline linkifies (verdict AND href).
+ const parityTokens = [
+ ...MATRIX.map((row) => row.token),
+ 'http://example.com',
+ 'HTTPS://Example.com',
+ 'WWW.Example.com',
+ 'a.b@c.co.uk',
+ 'ftp://files.example.com',
+ 'https://en.wikipedia.org/wiki/Foo_(bar)',
+ ];
+ for (const token of parityTokens) {
+ test(`agrees with the pipeline for ${token}`, () => {
+ expect(detectGfmLinkToken(token)).toEqual(parseFirstLink(token));
+ });
+ }
+});
+
+describe('detectGfmLinkToken — bare domains are rejected structurally, not by TLD', () => {
+ // No TLD table ships. A bare domain never linkifies regardless of how
+ // real its suffix looks; only a scheme, `www.`, or `@` promotes it.
+ for (const token of [
+ 'example.com',
+ 'foo.bar',
+ 'a.io',
+ 'my-site.dev',
+ 'stripe.com',
+ '192.168.1.1',
+ ]) {
+ test(`${token} stays plain text`, () => {
+ expect(detectGfmLinkToken(token)).toBeNull();
+ });
+ }
+});
+
+describe('detectGfmLinkToken — href scheme stays inside the allowlist', () => {
+ test('every produced href uses a SAFE_URL_SCHEMES scheme', () => {
+ const allowed = SAFE_URL_SCHEMES.map((s) => `${s}:`);
+ for (const token of [
+ 'https://example.com',
+ 'http://example.com',
+ 'www.example.com',
+ 'a@b.com',
+ ]) {
+ const result = detectGfmLinkToken(token);
+ expect(result).not.toBeNull();
+ const href = result?.href.toLowerCase() ?? '';
+ expect(allowed.some((prefix) => href.startsWith(prefix))).toBe(true);
+ }
+ });
+
+ test('ftp:// is a safe scheme but not a GFM shape, so it does not convert', () => {
+ // Guards against widening the recognizer to the full scheme allowlist:
+ // the shape rules are GFM's (http/https/www/email), not SAFE_URL_SCHEMES.
+ expect(detectGfmLinkToken('ftp://files.example.com')).toBeNull();
+ });
+});
+
+describe('detectGfmLinkToken — href shape details', () => {
+ test('www. tokens get an http:// href, not https://', () => {
+ expect(detectGfmLinkToken('www.example.com')?.href).toBe('http://www.example.com');
+ });
+
+ test('scheme match is case-insensitive and preserves original casing', () => {
+ expect(detectGfmLinkToken('HTTPS://Example.com')).toEqual({
+ href: 'HTTPS://Example.com',
+ text: 'HTTPS://Example.com',
+ });
+ expect(detectGfmLinkToken('WWW.Example.com')?.href).toBe('http://WWW.Example.com');
+ });
+
+ test('email tokens become mailto: hrefs with the address as text', () => {
+ expect(detectGfmLinkToken('a.b@c.co.uk')).toEqual({
+ href: 'mailto:a.b@c.co.uk',
+ text: 'a.b@c.co.uk',
+ });
+ });
+
+ test('trailing sentence punctuation is split off the linkified span', () => {
+ expect(detectGfmLinkToken('https://example.com.')).toEqual({
+ href: 'https://example.com',
+ text: 'https://example.com',
+ });
+ });
+
+ test('a closing paren that balances an opening paren stays in the link', () => {
+ expect(detectGfmLinkToken('https://en.wikipedia.org/wiki/Foo_(bar)')?.text).toBe(
+ 'https://en.wikipedia.org/wiki/Foo_(bar)',
+ );
+ });
+
+ test('empty string is not a link', () => {
+ expect(detectGfmLinkToken('')).toBeNull();
+ });
+});
diff --git a/packages/app/src/editor/gfm-link-detector.ts b/packages/app/src/editor/gfm-link-detector.ts
new file mode 100644
index 00000000..b9f3d283
--- /dev/null
+++ b/packages/app/src/editor/gfm-link-detector.ts
@@ -0,0 +1,168 @@
+/**
+ * Pure, editor-independent recognizer for the three GFM autolink-literal
+ * token shapes — protocol URLs (`https?://…`), `www.` domains, and bare
+ * emails (`local@domain`). Given a single whitespace-free token it answers:
+ * would the markdown pipeline's own parse turn this into a link, and to
+ * what href?
+ *
+ * Linkification only fires when GFM itself would, so a converted token
+ * serializes to a bare literal that re-parses to the identical link. That
+ * is why bare domains (`example.com`), filenames (`AGENTS.md`), and
+ * `localhost:5173` must NOT match: GFM never linkifies them, so the
+ * recognizer must not either. The dotted-domain requirement applies to the
+ * schemeless (`www.`) arm only — micromark's `http_autolink` production
+ * skips it when an explicit scheme is present (a documented divergence from
+ * the GFM spec prose), so `http://localhost:5174` linkifies and round-trips
+ * as a bare literal. The logic is ported from
+ * `mdast-util-gfm-autolink-literal` (the pipeline's own transform) so the
+ * two agree by construction; the co-located parity test pins the agreement
+ * against the real `MarkdownManager.parse`.
+ *
+ * Href transforms mirror GFM exactly: `www.` prepends `http://` (NOT
+ * https — the pipeline's own default), email prepends `mailto:`, and
+ * protocol URLs keep their scheme verbatim with original casing. Trailing
+ * sentence punctuation and unbalanced closing parens are excluded from the
+ * linkified span, matching how GFM splits them off.
+ *
+ * No ProseMirror / editor imports: `clipboard/lone-url.ts` (a plain string
+ * module) and unit tests both consume this as a pure string function.
+ *
+ * Coupled artifact: this is a hand-port of `mdast-util-gfm-autolink-literal`
+ * (which exposes no synchronous string classifier) and must track it on every
+ * markdown dependency bump. The drift guards are the parity tests in
+ * `gfm-link-detector.test.ts` (token matrix incl. trailing punctuation,
+ * balanced parens, structural bad-domain rejection, all checked against the
+ * real `MarkdownManager.parse`) — they run in `bun run check`.
+ */
+
+import { SAFE_URL_SCHEMES } from '@inkeep/open-knowledge-core';
+
+export type GfmLinkToken = {
+ /** Resolved link target: scheme-prepended for www/email, verbatim otherwise. */
+ href: string;
+ /** The linkified literal — the display text and the bytes that serialize back. */
+ text: string;
+};
+
+// Head-anchored ports of the two `transformGfmAutolinkLiterals` regexes. The
+// token is already whitespace-delimited, so the upstream "previous character"
+// guard (start / whitespace / punctuation) is always satisfied at index 0 and
+// is omitted here.
+const URL_HEAD = /^(https?:\/\/|www(?=\.))([-.\w]+)([^ \t\r\n]*)/i;
+const EMAIL_HEAD = /^([-.\w+]+)@([-\w]+(?:\.[-\w]+)+)/;
+// GFM rejects an email whose domain's final label ends in `-`, a digit, or `_`.
+const EMAIL_LABEL_BAD_TAIL = /[-\d_]$/;
+
+const ALLOWED_SCHEME_PREFIXES = SAFE_URL_SCHEMES.map((scheme) => `${scheme}:`);
+
+function schemeAllowed(href: string): boolean {
+ const lower = href.toLowerCase();
+ return ALLOWED_SCHEME_PREFIXES.some((prefix) => lower.startsWith(prefix));
+}
+
+function countChar(haystack: string, char: string): number {
+ let n = 0;
+ for (const c of haystack) {
+ if (c === char) n++;
+ }
+ return n;
+}
+
+/** GFM's per-label check on the final two dot-separated labels: neither may
+ * contain `_` or lack an alphanumeric character. Applies to BOTH url arms —
+ * micromark keeps the underscore restriction even for scheme'd URLs. */
+function hasCorrectDomainLabels(domain: string): boolean {
+ const parts = domain.split('.');
+ const last = parts[parts.length - 1];
+ const penultimate = parts[parts.length - 2];
+ if (last && (/_/.test(last) || !/[a-zA-Z\d]/.test(last))) return false;
+ if (penultimate && (/_/.test(penultimate) || !/[a-zA-Z\d]/.test(penultimate))) return false;
+ return true;
+}
+
+/** The schemeless (`www.`) arm additionally requires a dotted host. An
+ * explicit `http(s)://` scheme skips this — micromark's `http_autolink`
+ * accepts dotless hosts (`http://localhost:5174`), and the detector must
+ * match the parse or typed conversions would diverge from round-trip. */
+function isCorrectSchemelessDomain(domain: string): boolean {
+ return domain.split('.').length >= 2 && hasCorrectDomainLabels(domain);
+}
+
+/** GFM's trailing-punctuation split: peel a run of sentence punctuation off
+ * the end, but keep closing parens that balance an opening paren inside the
+ * URL (Wikipedia-style `…/Foo_(disambiguation)`). Returns [linkified, trailing]. */
+function splitUrl(url: string): [string, string] {
+ const trailMatch = /[!"&'),.:;<>?\]}]+$/.exec(url);
+ if (!trailMatch) return [url, ''];
+
+ let head = url.slice(0, trailMatch.index);
+ let trail = trailMatch[0];
+ let closingParenIndex = trail.indexOf(')');
+ const openingParens = countChar(head, '(');
+ let closingParens = countChar(head, ')');
+
+ while (closingParenIndex !== -1 && openingParens > closingParens) {
+ head += trail.slice(0, closingParenIndex + 1);
+ trail = trail.slice(closingParenIndex + 1);
+ closingParenIndex = trail.indexOf(')');
+ closingParens++;
+ }
+
+ return [head, trail];
+}
+
+function detectUrl(token: string): GfmLinkToken | null {
+ const match = URL_HEAD.exec(token);
+ if (!match) return null;
+
+ let protocol = match[1];
+ let domain = match[2];
+ const path = match[3];
+ let prefix = '';
+
+ // A `www.` match carries no scheme in the source; fold it into the domain
+ // and let the pipeline's `http://` prefix supply one.
+ const schemeless = /^w/i.test(protocol);
+ if (schemeless) {
+ domain = protocol + domain;
+ protocol = '';
+ prefix = 'http://';
+ }
+
+ if (schemeless ? !isCorrectSchemelessDomain(domain) : !hasCorrectDomainLabels(domain)) {
+ return null;
+ }
+
+ const [core] = splitUrl(domain + path);
+ if (!core) return null;
+
+ const text = protocol + core;
+ const href = prefix + protocol + core;
+ if (!schemeAllowed(href)) return null;
+ return { href, text };
+}
+
+function detectEmail(token: string): GfmLinkToken | null {
+ const match = EMAIL_HEAD.exec(token);
+ if (!match) return null;
+
+ const local = match[1];
+ const label = match[2];
+ if (EMAIL_LABEL_BAD_TAIL.test(label)) return null;
+
+ const text = `${local}@${label}`;
+ const href = `mailto:${text}`;
+ if (!schemeAllowed(href)) return null;
+ return { href, text };
+}
+
+/**
+ * Classify a completed token. Returns the `{ href, text }` to link, or null
+ * when GFM would leave the token as plain text. `text` is the exact literal
+ * to keep in the document (which may be shorter than the input token when
+ * trailing punctuation is split off); `href` is its resolved target.
+ */
+export function detectGfmLinkToken(token: string): GfmLinkToken | null {
+ if (!token) return null;
+ return detectUrl(token) ?? detectEmail(token);
+}
diff --git a/packages/app/src/editor/inline-link-input-rule.test.ts b/packages/app/src/editor/inline-link-input-rule.test.ts
new file mode 100644
index 00000000..82006df3
--- /dev/null
+++ b/packages/app/src/editor/inline-link-input-rule.test.ts
@@ -0,0 +1,209 @@
+/**
+ * Typed `[text](url)` input rule — conversion + href policy, exclusion
+ * contexts, and the one-undo-restores-the-literal contract against a real
+ * y-undo binding.
+ *
+ * Input rules fire only from `handleTextInput`, so these rigs type through
+ * `view.someProp('handleTextInput', …)` with the same unhandled-fallback
+ * insertion the real DOM input path performs — a bare `insertText` dispatch
+ * never triggers a rule.
+ */
+
+import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
+import type { Editor } from '@tiptap/core';
+import * as Y from 'yjs';
+import {
+ firstLinkAttrs,
+ linkHrefs,
+ mountCollabEditor,
+ mountLightEditor,
+ readUndoManager,
+} from './editor-rig.test-helper';
+import { InlineLinkInputRule } from './inline-link-input-rule';
+import { flushMicrotasksAndTimers, installDomGlobals } from './walk-currency-test-harness';
+
+let restoreDomGlobals: (() => void) | null = null;
+
+beforeAll(() => {
+ restoreDomGlobals = installDomGlobals();
+});
+
+afterAll(() => {
+ restoreDomGlobals?.();
+ restoreDomGlobals = null;
+});
+
+function makeEditor(opts: { content?: string } = {}): Editor {
+ return mountLightEditor({ content: opts.content, extensions: [InlineLinkInputRule] });
+}
+
+/**
+ * Type text the way the DOM input path does: each character goes through
+ * `handleTextInput` (where input rules run) and falls back to a plain
+ * insertion when no rule claims it.
+ */
+function typeText(editor: Editor, text: string): void {
+ for (const char of text) {
+ const { from, to } = editor.state.selection;
+ const deflt = () => editor.state.tr.insertText(char, from, to);
+ const handled = editor.view.someProp('handleTextInput', (handleTextInput) =>
+ handleTextInput(editor.view, from, to, char, deflt),
+ );
+ if (!handled) {
+ editor.view.dispatch(deflt());
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Conversion
+// ---------------------------------------------------------------------------
+
+describe('inline-link input rule — conversion', () => {
+ test('typing [text](url) converts to linked display text on the closing paren', async () => {
+ const editor = makeEditor();
+ try {
+ typeText(editor, '[docs](https://example.com)');
+ await flushMicrotasksAndTimers();
+
+ expect(editor.state.doc.textContent).toBe('docs');
+ const attrs = firstLinkAttrs(editor);
+ expect(attrs?.href).toBe('https://example.com');
+ expect(attrs?.linkStyle).toBe('inline');
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ test('a relative target is allowed (internal-link contract)', async () => {
+ const editor = makeEditor();
+ try {
+ typeText(editor, '[guide](/docs/start)');
+ await flushMicrotasksAndTimers();
+
+ expect(editor.state.doc.textContent).toBe('guide');
+ expect(firstLinkAttrs(editor)?.href).toBe('/docs/start');
+ } finally {
+ editor.destroy();
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Href policy
+// ---------------------------------------------------------------------------
+
+describe('inline-link input rule — href policy', () => {
+ test('an empty URL [text]() stays literal', async () => {
+ const editor = makeEditor();
+ try {
+ typeText(editor, '[x]()');
+ await flushMicrotasksAndTimers();
+ expect(editor.state.doc.textContent).toBe('[x]()');
+ expect(linkHrefs(editor)).toEqual([]);
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ test('a disallowed scheme leaves the literal untouched', async () => {
+ const editor = makeEditor();
+ try {
+ typeText(editor, '[x](javascript:alert)');
+ await flushMicrotasksAndTimers();
+
+ expect(editor.state.doc.textContent).toBe('[x](javascript:alert)');
+ expect(linkHrefs(editor)).toEqual([]);
+ } finally {
+ editor.destroy();
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Exclusion contexts
+// ---------------------------------------------------------------------------
+
+describe('inline-link input rule — exclusions', () => {
+ test('does not fire inside a code block', async () => {
+ const editor = makeEditor({ content: '
x
' });
+ try {
+ // Caret at the end of the code block content.
+ editor.commands.setTextSelection(editor.state.doc.content.size - 1);
+ typeText(editor, '[a](https://b.com)');
+ await flushMicrotasksAndTimers();
+
+ expect(editor.state.doc.textContent).toBe('x[a](https://b.com)');
+ expect(linkHrefs(editor)).toEqual([]);
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ test('wikilink shorthand is excluded structurally', async () => {
+ const editor = makeEditor();
+ try {
+ typeText(editor, '[[Page]]');
+ await flushMicrotasksAndTimers();
+
+ expect(linkHrefs(editor)).toEqual([]);
+ } finally {
+ editor.destroy();
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Undo isolation (real y-undo binding)
+// ---------------------------------------------------------------------------
+
+describe('inline-link input rule — one undo restores the literal', () => {
+ test('a single undo brings back [text](url) with the text intact', async () => {
+ const ydoc = new Y.Doc();
+ const editor = mountCollabEditor(ydoc, [InlineLinkInputRule]);
+ try {
+ typeText(editor, '[docs](https://example.com)');
+ await flushMicrotasksAndTimers();
+ expect(editor.state.doc.textContent).toBe('docs');
+ expect(linkHrefs(editor)).toEqual(['https://example.com']);
+
+ const undoManager = readUndoManager(editor);
+ expect(undoManager).not.toBeNull();
+ undoManager?.undo();
+ await flushMicrotasksAndTimers();
+
+ expect(editor.state.doc.textContent).toBe('[docs](https://example.com)');
+ expect(linkHrefs(editor)).toEqual([]);
+ } finally {
+ editor.destroy();
+ ydoc.destroy();
+ }
+ });
+
+ test('typing after a conversion stays its own undo step (no merge into the collapse)', async () => {
+ const ydoc = new Y.Doc();
+ const editor = mountCollabEditor(ydoc, [InlineLinkInputRule]);
+ try {
+ typeText(editor, '[docs](https://example.com)');
+ await flushMicrotasksAndTimers();
+ expect(editor.state.doc.textContent).toBe('docs');
+
+ // Keystrokes after the collapse land within captureTimeout of it; the
+ // closing stopCapturing must keep them OUT of the collapse's undo item.
+ typeText(editor, ' more');
+ await flushMicrotasksAndTimers();
+ expect(editor.state.doc.textContent).toBe('docs more');
+
+ const undoManager = readUndoManager(editor);
+ undoManager?.undo();
+ await flushMicrotasksAndTimers();
+
+ // Only the trailing typing is removed; the converted link survives.
+ expect(editor.state.doc.textContent).toBe('docs');
+ expect(linkHrefs(editor)).toEqual(['https://example.com']);
+ } finally {
+ editor.destroy();
+ ydoc.destroy();
+ }
+ });
+});
diff --git a/packages/app/src/editor/inline-link-input-rule.ts b/packages/app/src/editor/inline-link-input-rule.ts
new file mode 100644
index 00000000..62ffce6d
--- /dev/null
+++ b/packages/app/src/editor/inline-link-input-rule.ts
@@ -0,0 +1,120 @@
+/**
+ * Typed `[text](url)` input rule for the WYSIWYG editor.
+ *
+ * When a local user closes a well-formed inline-link literal by typing the
+ * final `)`, the `[text](url)` span collapses to its display `text` carrying a
+ * `link` mark. The mark keeps the schema-default `linkStyle: 'inline'`, so it
+ * serializes straight back to `[text](url)` (unlike the bare-literal
+ * `gfm-autolink` style the typed-URL plugin uses). Scope is deliberately narrow
+ * — the one universal Markdown link shorthand editors share (Outline, Plate).
+ *
+ * Safety and undo, both structural:
+ *
+ * - Origin safety by construction. Input rules fire only from
+ * `handleTextInput` — a local, focused DOM keystroke — never from a y-sync
+ * transaction (remote peer, agent, disk load, observer echo) or a
+ * programmatic write into a pooled/background editor. So the "never convert
+ * another writer's content" invariant holds without an explicit origin
+ * guard, unlike the appendTransaction-based typed-URL plugin.
+ * - Href policy. The URL must pass `isAllowedLinkUri` (the shared scheme
+ * allowlist); a `javascript:`-style payload leaves the literal untouched.
+ * Relative targets resolve against the placeholder base and are allowed,
+ * matching the internal-link contract.
+ * - Undo isolation, mark kept in step with the trigger. The rule does NOT
+ * consume the `)`; it lets the paren land through normal typing (so the
+ * literal is complete in the doc) and collapses in a deferred separate
+ * dispatch a microtask later, split from the typing by `stopCapturing()`
+ * before and after. One Cmd+Z then reverts just the collapse, restoring the
+ * full `[text](url)` literal — the same trigger-char-kept, one-undo contract
+ * the gfm-autolink path has. Absent a y-undo binding there is no capture
+ * stack and nothing to split.
+ */
+
+import { isAllowedLinkUri } from '@inkeep/open-knowledge-core';
+import { Extension, InputRule } from '@tiptap/core';
+import type { EditorView } from '@tiptap/pm/view';
+import { dispatchAsOwnUndoStep } from './undo-isolation';
+
+/** `[text](url)` ending at the caret. `text` = one+ non-`]`; `url` = one+
+ * non-`)`/non-whitespace (inline links whose URL needs spaces use `<>`, out
+ * of scope). The trailing `)` anchors the rule to the closing keystroke.
+ * `[[Page]]` wikilinks never match — they carry no `](` after the first `]`. */
+const INLINE_LINK_RE = /\[([^\]]+)\]\(([^)\s]+)\)$/;
+
+const LINK_MARK = 'link';
+
+/**
+ * Collapse `[text](url)` (already complete in the doc, including the just-typed
+ * `)`) at `[from, from+len]` down to `text` carrying a link mark. Runs a
+ * microtask after the paren landed, so it re-validates the span first — the doc
+ * may have shifted or been rewritten in the gap.
+ */
+function collapseToLink(
+ view: EditorView,
+ from: number,
+ fullMatch: string,
+ text: string,
+ href: string,
+): void {
+ if (view.isDestroyed || view.composing) return;
+ const { state } = view;
+ const markType = state.schema.marks[LINK_MARK];
+ if (!markType) return;
+
+ const to = from + fullMatch.length;
+ if (from < 0 || to > state.doc.content.size) return;
+ if (state.doc.textBetween(from, to) !== fullMatch) return;
+ // Don't nest inside an existing link.
+ if (state.doc.rangeHasMark(from, to, markType)) return;
+
+ // The collapse must be its own undo stack item so one undo restores the
+ // literal (see undo-isolation.ts for the split-before/close-after contract).
+ // Only the dispatch is guarded — it crosses into third-party plugin hooks;
+ // the guards above are internal-trusted and should fail loud.
+ const linked = state.schema.text(text, [markType.create({ href })]);
+ try {
+ dispatchAsOwnUndoStep(view, state.tr.replaceRangeWith(from, to, linked));
+ } catch (err) {
+ console.warn('[inline-link-rule] collapse dispatch failed', { from, text, href }, err);
+ }
+}
+
+export const InlineLinkInputRule = Extension.create({
+ name: 'inlineLinkInputRule',
+
+ addInputRules() {
+ const editor = this.editor;
+ return [
+ new InputRule({
+ find: INLINE_LINK_RE,
+ // Returning null (no steps) means TipTap does not consume the match:
+ // the `)` inserts through the normal path and the deferred collapse
+ // below does the linkification. Code blocks and inline-code contexts
+ // are already refused upstream by TipTap's input-rule runner.
+ handler: ({ state, range, match }) => {
+ const text = match[1];
+ const url = match[2];
+ if (!text || !url) return null;
+ if (!isAllowedLinkUri(url)) return null;
+
+ const markType = state.schema.marks[LINK_MARK];
+ if (!markType) return null;
+ if (state.doc.rangeHasMark(range.from, range.to, markType)) return null;
+
+ // range.from is the start of `[` in the pre-paren doc; positions
+ // before the caret are stable across the paren insertion, so the
+ // completed literal will span [range.from, range.from + match[0].length].
+ const from = range.from;
+ const fullMatch = match[0];
+ // Capture the view eagerly: input rules fire from handleTextInput
+ // (the view is mounted here), while `editor.view` re-read inside the
+ // microtask is a throwing proxy if a recycle starts in the gap.
+ // collapseToLink's isDestroyed guard covers teardown after capture.
+ const view = editor.view;
+ queueMicrotask(() => collapseToLink(view, from, fullMatch, text, url));
+ return null;
+ },
+ }),
+ ];
+ },
+});
diff --git a/packages/app/src/editor/link-path-suggestions.dom.test.tsx b/packages/app/src/editor/link-path-suggestions.dom.test.tsx
index be03b02a..e23ee6fb 100644
--- a/packages/app/src/editor/link-path-suggestions.dom.test.tsx
+++ b/packages/app/src/editor/link-path-suggestions.dom.test.tsx
@@ -220,6 +220,36 @@ describe('LinkPathSuggestionInput', () => {
expect(screen.getByRole('listbox', { name: 'Path suggestions' })).toBeDefined();
});
+ test('after Escape dismisses the panel, keys reach the parent onKeyDown', () => {
+ // Suggestions still EXIST for the value after dismissal — only the panel is
+ // hidden. Keys must not be swallowed against the invisible panel: the next
+ // Escape has to reach the parent popover/dialog (which closes on it) and
+ // Enter has to reach the parent's apply handler.
+ const seen: string[] = [];
+ render(
+ {}}
+ onKeyDown={(event) => seen.push(event.key)}
+ />,
+ );
+
+ const input = screen.getByRole('combobox', { name: 'Link target' });
+ fireEvent.focus(input);
+ expect(screen.getByRole('listbox', { name: 'Path suggestions' })).toBeDefined();
+
+ fireEvent.keyDown(input, { key: 'Escape' });
+ expect(screen.queryByRole('listbox', { name: 'Path suggestions' })).toBeNull();
+ expect(seen).toEqual([]);
+
+ fireEvent.keyDown(input, { key: 'Escape' });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(seen).toEqual(['Escape', 'Enter']);
+ });
+
test('ArrowDown and ArrowUp update the active option', () => {
render();
diff --git a/packages/app/src/editor/link-path-suggestions.tsx b/packages/app/src/editor/link-path-suggestions.tsx
index 3084ab01..b749281d 100644
--- a/packages/app/src/editor/link-path-suggestions.tsx
+++ b/packages/app/src/editor/link-path-suggestions.tsx
@@ -210,7 +210,12 @@ export function LinkPathSuggestionInput({
}
function handleKeyDown(event: KeyboardEvent) {
- if (showSuggestionOptions) {
+ // Gate on the options being VISIBLE (panel open), not merely existing:
+ // suggestions still exist for the current value after an Escape dismissal,
+ // and swallowing keys against an invisible panel strands the user — the
+ // next Escape must reach the parent popover/dialog (as the branch below
+ // documents) and Enter must reach the parent's apply handler.
+ if (showSuggestions && showSuggestionOptions) {
if (event.key === 'ArrowDown') {
event.preventDefault();
setHighlightedIndex((current) => Math.min(current + 1, suggestions.length - 1));
diff --git a/packages/app/src/editor/slash-command/component-items.tsx b/packages/app/src/editor/slash-command/component-items.tsx
index 52a2b069..e7dbb97d 100644
--- a/packages/app/src/editor/slash-command/component-items.tsx
+++ b/packages/app/src/editor/slash-command/component-items.tsx
@@ -23,7 +23,7 @@ import type { Editor } from '@tiptap/react';
import { CopyPlus, ExternalLink, FileUp, Hash, Link2 } from 'lucide-react';
import type { ReactNode } from 'react';
import { setPendingLinkEdit } from '../extensions/link-edit-autoopen';
-import { markIdentityKey } from '../extensions/mark-identity';
+import { findMarkIdAt } from '../extensions/mark-identity';
import { uploadAndInsert } from '../image-upload/index.ts';
import { getInteractionLayer } from '../interaction-layer-host';
import { resolveIcon } from '../registry/icons.ts';
@@ -656,26 +656,6 @@ function openFilePickerAndUpload(editor: Editor): void {
input.click();
}
-/**
- * Resolve the stable mark id of a `link` mark covering `pos` from the
- * mark-identity plugin state. IDs are assigned synchronously during the
- * dispatch that inserts the mark, so this reads them off `editor.state`
- * immediately after the insert chain runs. Returns null when the plugin
- * isn't installed (non-app editors / tests) or no link mark covers `pos` —
- * callers degrade gracefully (the link is still inserted; only the
- * auto-open is skipped).
- */
-function findLinkMarkIdAt(editor: Editor, pos: number): string | null {
- const state = markIdentityKey.getState(editor.state);
- if (!state) return null;
- for (const info of state.byId.values()) {
- if (info.markType === 'link' && info.from <= pos && pos < info.to) {
- return info.id;
- }
- }
- return null;
-}
-
/**
* Slash-menu items for inline-only PM atoms. Block components flow
* through the descriptor registry (`getComponentItems()`); inline atoms
@@ -744,7 +724,7 @@ export function getInlineComponentItems(): SlashCommandItem[] {
})
.run();
- const markId = findLinkMarkIdAt(editor, insertPos);
+ const markId = findMarkIdAt(editor.state, insertPos, 'link');
if (!markId) return;
setPendingLinkEdit(markId);
requestAnimationFrame(() => {
diff --git a/packages/app/src/editor/undo-isolation.test.ts b/packages/app/src/editor/undo-isolation.test.ts
new file mode 100644
index 00000000..376fccf5
--- /dev/null
+++ b/packages/app/src/editor/undo-isolation.test.ts
@@ -0,0 +1,62 @@
+/**
+ * The exception-safety property of `dispatchAsOwnUndoStep`: the CLOSING
+ * `stopCapturing()` must run even when `view.dispatch` throws — a
+ * split-open-but-never-closed capture merges the user's next keystrokes into
+ * the failed item, and one undo then strips them together. The happy path is
+ * covered by the collab-rig undo suites in gfm-autolink-plugin.test.ts and
+ * inline-link-input-rule.test.ts; this file pins the throw path (the only
+ * path where the `finally` matters — a try/catch refactor would break it).
+ */
+
+import { afterAll, beforeAll, expect, test } from 'bun:test';
+import type { EditorView } from '@tiptap/pm/view';
+import * as Y from 'yjs';
+import { mountCollabEditor, readUndoManager } from './editor-rig.test-helper';
+import { dispatchAsOwnUndoStep } from './undo-isolation';
+import { installDomGlobals } from './walk-currency-test-harness';
+
+let restoreDomGlobals: (() => void) | null = null;
+
+beforeAll(() => {
+ restoreDomGlobals = installDomGlobals();
+});
+
+afterAll(() => {
+ restoreDomGlobals?.();
+ restoreDomGlobals = null;
+});
+
+test('a throwing dispatch still closes the capture (stopCapturing runs twice)', () => {
+ const ydoc = new Y.Doc();
+ const editor = mountCollabEditor(ydoc, []);
+ try {
+ const undoManager = readUndoManager(editor);
+ expect(undoManager).not.toBeNull();
+ if (!undoManager) return;
+
+ let stops = 0;
+ const originalStop = undoManager.stopCapturing.bind(undoManager);
+ undoManager.stopCapturing = () => {
+ stops++;
+ originalStop();
+ };
+
+ // dispatchAsOwnUndoStep reads only `state` and `dispatch` from the view;
+ // hand it the real editor state with a dispatch that throws mid-step.
+ const throwingView = {
+ state: editor.state,
+ dispatch: () => {
+ throw new Error('plugin hook exploded');
+ },
+ } as unknown as EditorView;
+
+ expect(() => dispatchAsOwnUndoStep(throwingView, editor.state.tr)).toThrow(
+ 'plugin hook exploded',
+ );
+ // Split before + close after — BOTH ran despite the throw.
+ expect(stops).toBe(2);
+ } finally {
+ editor.destroy();
+ ydoc.destroy();
+ }
+});
diff --git a/packages/app/src/editor/undo-isolation.ts b/packages/app/src/editor/undo-isolation.ts
new file mode 100644
index 00000000..4c6e5b6d
--- /dev/null
+++ b/packages/app/src/editor/undo-isolation.ts
@@ -0,0 +1,35 @@
+/**
+ * Dispatch a transaction as its OWN y-undo stack item.
+ *
+ * The WYSIWYG linkify surfaces add their mark within `captureTimeout` of the
+ * typing that triggered them (500 ms — Y.js's default, not OK config), so
+ * without a capture split Y.UndoManager
+ * merges the mark into the typing's stack item — one undo then deletes the
+ * typed text too. `stopCapturing()` before the dispatch splits the item off
+ * the preceding typing; `stopCapturing()` after closes it so the user's NEXT
+ * keystrokes don't merge in either (undoing them would strip the mark as a
+ * side effect). Both calls are synchronous — the correctness-critical sequence
+ * lives here, in one place, so every mark-producing surface shares the exact
+ * same contract. Absent a y-undo binding (non-collaborative editors) there is
+ * no capture stack and the dispatch happens plain.
+ */
+
+import type { Transaction } from '@tiptap/pm/state';
+import type { EditorView } from '@tiptap/pm/view';
+import { yUndoPluginKey } from '@tiptap/y-tiptap';
+import type { UndoManager } from 'yjs';
+
+export function dispatchAsOwnUndoStep(view: EditorView, tr: Transaction): void {
+ const undoState: { undoManager?: UndoManager } | undefined = yUndoPluginKey.getState(view.state);
+ const undoManager = undoState?.undoManager;
+ undoManager?.stopCapturing();
+ try {
+ view.dispatch(tr);
+ } finally {
+ // `dispatch` runs third-party plugin hooks and can throw. The closing
+ // stopCapturing must run regardless — a split-open-but-never-closed
+ // capture would merge the user's NEXT keystrokes into the failed item,
+ // and a later undo would strip them together (silent undo corruption).
+ undoManager?.stopCapturing();
+ }
+}
diff --git a/packages/app/src/lib/keyboard-shortcuts.test.ts b/packages/app/src/lib/keyboard-shortcuts.test.ts
index 75593fbd..2387dfc2 100644
--- a/packages/app/src/lib/keyboard-shortcuts.test.ts
+++ b/packages/app/src/lib/keyboard-shortcuts.test.ts
@@ -309,14 +309,37 @@ describe('keyboard shortcut registry', () => {
).toBe(true);
});
- test('matches command palette with allowed extra modifiers and rejects missing mod', () => {
+ test('matches command palette on exact Cmd/Ctrl+K only', () => {
expect(
matchesKeyboardShortcut(
- { metaKey: true, ctrlKey: false, altKey: false, shiftKey: true, key: 'k' },
+ { metaKey: true, ctrlKey: false, altKey: false, key: 'k' },
'command-palette',
'mac',
),
).toBe(true);
+ expect(
+ matchesKeyboardShortcut(
+ { metaKey: false, ctrlKey: true, altKey: false, key: 'k' },
+ 'command-palette',
+ 'windowsLinux',
+ ),
+ ).toBe(true);
+ // ⇧⌘K must NOT open the palette — that chord belongs to CodeMirror's
+ // delete-line in source mode.
+ expect(
+ matchesKeyboardShortcut(
+ { metaKey: true, ctrlKey: false, altKey: false, shiftKey: true, key: 'k' },
+ 'command-palette',
+ 'mac',
+ ),
+ ).toBe(false);
+ expect(
+ matchesKeyboardShortcut(
+ { metaKey: true, ctrlKey: false, altKey: true, key: 'k' },
+ 'command-palette',
+ 'mac',
+ ),
+ ).toBe(false);
expect(
matchesKeyboardShortcut(
{ metaKey: false, ctrlKey: false, altKey: false, key: 'k' },
@@ -326,6 +349,53 @@ describe('keyboard shortcut registry', () => {
).toBe(false);
});
+ test('add-link shares the exact ⌘K chord with the palette and excludes extra modifiers', () => {
+ expect(formatShortcut('add-link', 'mac')).toBe('⌘ K');
+ expect(formatShortcut('add-link', 'windowsLinux')).toBe('Ctrl K');
+ expect(
+ matchesKeyboardShortcut(
+ { metaKey: true, ctrlKey: false, altKey: false, key: 'k' },
+ 'add-link',
+ 'mac',
+ ),
+ ).toBe(true);
+ expect(
+ matchesKeyboardShortcut(
+ { metaKey: false, ctrlKey: true, altKey: false, key: 'k' },
+ 'add-link',
+ 'windowsLinux',
+ ),
+ ).toBe(true);
+ // Wrong platform modifier: Ctrl+K on macOS must NOT match (mod is exact).
+ expect(
+ matchesKeyboardShortcut(
+ { metaKey: false, ctrlKey: true, altKey: false, key: 'k' },
+ 'add-link',
+ 'mac',
+ ),
+ ).toBe(false);
+ expect(
+ matchesKeyboardShortcut(
+ { metaKey: true, ctrlKey: false, altKey: false, shiftKey: true, key: 'k' },
+ 'add-link',
+ 'mac',
+ ),
+ ).toBe(false);
+ expect(
+ matchesKeyboardShortcut(
+ { metaKey: true, ctrlKey: false, altKey: true, key: 'k' },
+ 'add-link',
+ 'mac',
+ ),
+ ).toBe(false);
+ // One exact chord, two consumers: matching is identical for both ids;
+ // routing between them is contextual (capture-phase claim in the editor
+ // vs. the palette's window-bubble fallthrough).
+ const exactCmdK = { metaKey: true, ctrlKey: false, altKey: false, key: 'k' };
+ expect(matchesKeyboardShortcut(exactCmdK, 'add-link', 'mac')).toBe(true);
+ expect(matchesKeyboardShortcut(exactCmdK, 'command-palette', 'mac')).toBe(true);
+ });
+
test('matches source-aware replace shortcuts per platform', () => {
expect(
matchesKeyboardShortcut(
diff --git a/packages/app/src/lib/keyboard-shortcuts.ts b/packages/app/src/lib/keyboard-shortcuts.ts
index 6f5881a1..d4e2682e 100644
--- a/packages/app/src/lib/keyboard-shortcuts.ts
+++ b/packages/app/src/lib/keyboard-shortcuts.ts
@@ -86,16 +86,27 @@ export const SHORTCUT_CATEGORY_ORDER = Object.keys(SHORTCUT_CATEGORY_LABELS) as
const KEYBOARD_SHORTCUT_DEFINITIONS = [
{
+ // Exact ⌘K only (no extra-modifier tolerance) — a deliberate narrowing
+ // that ships with the dual-role ⌘K: it keeps the add-link claim
+ // unambiguous and frees ⌘⇧K (which would otherwise shadow CodeMirror's
+ // delete-line in source mode). ⌘⇧K intentionally does NOT open the
+ // palette; a regression test pins that.
+ //
+ // Phase-ordering contract for the shared ⌘K chord: this palette listener
+ // is window BUBBLE phase; the add-link claim (LinkEditPopover) is window
+ // CAPTURE phase and stops propagation when it applies. Any future
+ // capture-phase ⌘K handler must slot into that ordering consciously —
+ // the registry itself has no phase concept.
id: 'command-palette',
category: 'general',
title: msg`Command palette`,
- description: msg`Search files, commands, projects, and AI handoff actions.`,
+ description: msg`Search files, commands, projects, and AI handoff actions. With text selected in the visual editor, this chord adds a link instead.`,
scope: msg`Global`,
bindings: [
{
mac: '⌘ K',
windowsLinux: 'Ctrl K',
- match: { key: 'k', mod: true, allowExtraModifiers: true },
+ match: { key: 'k', mod: true },
},
],
},
@@ -431,6 +442,25 @@ const KEYBOARD_SHORTCUT_DEFINITIONS = [
scope: msg`Visual editor`,
bindings: [{ mac: '⇧⌘ H', windowsLinux: 'Ctrl Shift H' }],
},
+ {
+ // Same chord as `command-palette` by design: a capture-phase window
+ // listener in the WYSIWYG bubble menu claims exact ⌘K first when a link
+ // affordance applies (focused editor + text selection, or caret inside a
+ // link); everywhere else the event falls through to the palette's
+ // window-bubble listener.
+ id: 'add-link',
+ category: 'wysiwyg',
+ title: msg`Add link`,
+ description: msg`Link the selected text, or edit the link under the caret.`,
+ scope: msg`Visual editor selection`,
+ bindings: [
+ {
+ mac: '⌘ K',
+ windowsLinux: 'Ctrl K',
+ match: { key: 'k', mod: true },
+ },
+ ],
+ },
{
id: 'edit-with-ai',
category: 'general',
diff --git a/packages/app/src/locales/en/messages.json b/packages/app/src/locales/en/messages.json
index fd97de36..e8123301 100644
--- a/packages/app/src/locales/en/messages.json
+++ b/packages/app/src/locales/en/messages.json
@@ -1001,6 +1001,9 @@
"ODGGlj": ["Folder name: ", ["detail"]],
"OETysD": ["No hub pages yet. Hubs appear once pages accumulate inbound graph links."],
"OEu_0l": ["Failed to load skills: ", ["0"]],
+ "OFBmkg": [
+ "Search files, commands, projects, and AI handoff actions. With text selected in the visual editor, this chord adds a link instead."
+ ],
"OJTOUj": ["Requires ", ["brand"], "."],
"OJeiSH": ["Failed to add property"],
"OLBz-_": ["Mermaid diagram failed to render."],
@@ -1053,7 +1056,6 @@
"PR9FSv": ["Move to the next visual-editor find result."],
"PRnH8G": ["of ", ["0"]],
"PVUn_c": ["Open folder"],
- "PZt1uN": ["Search files, commands, projects, and AI handoff actions."],
"P_bX4O": ["How to set up"],
"PdA9NZ": [["ahead"], " ahead"],
"PdJZ5p": ["No skills yet."],
@@ -1262,6 +1264,7 @@
"VuxVSP": ["Start source completion"],
"VvS_k_": ["Config sharing is now local-only."],
"VxO6wR": ["Delete selected files or folders"],
+ "VzGJRl": ["Add link"],
"W-ntoW": ["heading-slug"],
"W0i24j": ["Object"],
"W1qRuM": ["This document is already active in the editor. Use Open to collapse the graph."],
@@ -2228,6 +2231,7 @@
"u3wRF-": ["Published"],
"u6Hp4N": ["Markdown"],
"u7H-Wn": ["Show panel (", ["panelShortcutLabel"], ")"],
+ "u7U_Ij": ["Link the selected text, or edit the link under the caret."],
"uA_jXp": ["Switch and update branch"],
"uCaIdy": ["GitHub sign-in is missing or expired. Reconnect to resume syncing."],
"uDlyqd": [
@@ -2278,6 +2282,7 @@
{ "one": ["Uploaded one file"], "other": ["Uploaded ", ["uploadedCount"], " files"] }
]
],
+ "v77jrg": ["Visual editor selection"],
"v7hK0U": ["Use lowercase letters, digits, and hyphens only."],
"v7lbnC": ["Find next match"],
"vAYoCZ": ["Couldn't open the worktree. Try again."],
diff --git a/packages/app/src/locales/en/messages.po b/packages/app/src/locales/en/messages.po
index bb3a1883..3d833f41 100644
--- a/packages/app/src/locales/en/messages.po
+++ b/packages/app/src/locales/en/messages.po
@@ -682,6 +682,10 @@ msgstr "Add item"
msgid "Add item to {keyName}"
msgstr "Add item to {keyName}"
+#: src/lib/keyboard-shortcuts.ts
+msgid "Add link"
+msgstr "Add link"
+
#: src/components/settings/SearchSection.tsx
msgid "Add meaning-based ranking to search so conceptually-related pages surface even when they share no keywords. This setting applies only to this computer."
msgstr "Add meaning-based ranking to search so conceptually-related pages surface even when they share no keywords. This setting applies only to this computer."
@@ -3867,6 +3871,10 @@ msgstr "Link copied."
msgid "Link target"
msgstr "Link target"
+#: src/lib/keyboard-shortcuts.ts
+msgid "Link the selected text, or edit the link under the caret."
+msgstr "Link the selected text, or edit the link under the caret."
+
#: src/editor/slash-command/component-items.tsx
msgid "Link to a page or external URL."
msgstr "Link to a page or external URL."
@@ -5959,8 +5967,8 @@ msgid "Search failed."
msgstr "Search failed."
#: src/lib/keyboard-shortcuts.ts
-msgid "Search files, commands, projects, and AI handoff actions."
-msgstr "Search files, commands, projects, and AI handoff actions."
+msgid "Search files, commands, projects, and AI handoff actions. With text selected in the visual editor, this chord adds a link instead."
+msgstr "Search files, commands, projects, and AI handoff actions. With text selected in the visual editor, this chord adds a link instead."
#: src/components/CommandPalette.tsx
msgid "Search files, folders, and commands for the current workspace."
@@ -7583,6 +7591,10 @@ msgstr "Visual editor list item"
msgid "Visual editor replace input focused"
msgstr "Visual editor replace input focused"
+#: src/lib/keyboard-shortcuts.ts
+msgid "Visual editor selection"
+msgstr "Visual editor selection"
+
#: src/lib/keyboard-shortcuts.ts
msgid "Visual editor table"
msgstr "Visual editor table"
diff --git a/packages/app/src/locales/pseudo/messages.json b/packages/app/src/locales/pseudo/messages.json
index af02b67c..027f0dc2 100644
--- a/packages/app/src/locales/pseudo/messages.json
+++ b/packages/app/src/locales/pseudo/messages.json
@@ -1001,6 +1001,9 @@
"ODGGlj": ["Ƒōĺďēŕ ńàḿē: ", ["detail"]],
"OETysD": ["Ńō ĥũƀ ƥàĝēś ŷēţ. Ĥũƀś àƥƥēàŕ ōńćē ƥàĝēś àććũḿũĺàţē ĩńƀōũńď ĝŕàƥĥ ĺĩńķś."],
"OEu_0l": ["Ƒàĩĺēď ţō ĺōàď śķĩĺĺś: ", ["0"]],
+ "OFBmkg": [
+ "Śēàŕćĥ ƒĩĺēś, ćōḿḿàńďś, ƥŕōĴēćţś, àńď ÀĨ ĥàńďōƒƒ àćţĩōńś. Ŵĩţĥ ţēxţ śēĺēćţēď ĩń ţĥē vĩśũàĺ ēďĩţōŕ, ţĥĩś ćĥōŕď àďďś à ĺĩńķ ĩńśţēàď."
+ ],
"OJTOUj": ["Ŕēǫũĩŕēś ", ["brand"], "."],
"OJeiSH": ["Ƒàĩĺēď ţō àďď ƥŕōƥēŕţŷ"],
"OLBz-_": ["Ḿēŕḿàĩď ďĩàĝŕàḿ ƒàĩĺēď ţō ŕēńďēŕ."],
@@ -1053,7 +1056,6 @@
"PR9FSv": ["Ḿōvē ţō ţĥē ńēxţ vĩśũàĺ-ēďĩţōŕ ƒĩńď ŕēśũĺţ."],
"PRnH8G": ["ōƒ ", ["0"]],
"PVUn_c": ["Ōƥēń ƒōĺďēŕ"],
- "PZt1uN": ["Śēàŕćĥ ƒĩĺēś, ćōḿḿàńďś, ƥŕōĴēćţś, àńď ÀĨ ĥàńďōƒƒ àćţĩōńś."],
"P_bX4O": ["Ĥōŵ ţō śēţ ũƥ"],
"PdA9NZ": [["ahead"], " àĥēàď"],
"PdJZ5p": ["Ńō śķĩĺĺś ŷēţ."],
@@ -1262,6 +1264,7 @@
"VuxVSP": ["Śţàŕţ śōũŕćē ćōḿƥĺēţĩōń"],
"VvS_k_": ["Ćōńƒĩĝ śĥàŕĩńĝ ĩś ńōŵ ĺōćàĺ-ōńĺŷ."],
"VxO6wR": ["Ďēĺēţē śēĺēćţēď ƒĩĺēś ōŕ ƒōĺďēŕś"],
+ "VzGJRl": ["Àďď ĺĩńķ"],
"W-ntoW": ["ĥēàďĩńĝ-śĺũĝ"],
"W0i24j": ["ŌƀĴēćţ"],
"W1qRuM": ["Ţĥĩś ďōćũḿēńţ ĩś àĺŕēàďŷ àćţĩvē ĩń ţĥē ēďĩţōŕ. Ũśē Ōƥēń ţō ćōĺĺàƥśē ţĥē ĝŕàƥĥ."],
@@ -2228,6 +2231,7 @@
"u3wRF-": ["Ƥũƀĺĩśĥēď"],
"u6Hp4N": ["Ḿàŕķďōŵń"],
"u7H-Wn": ["Śĥōŵ ƥàńēĺ (", ["panelShortcutLabel"], ")"],
+ "u7U_Ij": ["Ĺĩńķ ţĥē śēĺēćţēď ţēxţ, ōŕ ēďĩţ ţĥē ĺĩńķ ũńďēŕ ţĥē ćàŕēţ."],
"uA_jXp": ["Śŵĩţćĥ àńď ũƥďàţē ƀŕàńćĥ"],
"uCaIdy": ["ĜĩţĤũƀ śĩĝń-ĩń ĩś ḿĩśśĩńĝ ōŕ ēxƥĩŕēď. Ŕēćōńńēćţ ţō ŕēśũḿē śŷńćĩńĝ."],
"uDlyqd": [
@@ -2278,6 +2282,7 @@
{ "one": ["Ũƥĺōàďēď ōńē ƒĩĺē"], "other": ["Ũƥĺōàďēď ", ["uploadedCount"], " ƒĩĺēś"] }
]
],
+ "v77jrg": ["Vĩśũàĺ ēďĩţōŕ śēĺēćţĩōń"],
"v7hK0U": ["Ũśē ĺōŵēŕćàśē ĺēţţēŕś, ďĩĝĩţś, àńď ĥŷƥĥēńś ōńĺŷ."],
"v7lbnC": ["Ƒĩńď ńēxţ ḿàţćĥ"],
"vAYoCZ": ["Ćōũĺďń'ţ ōƥēń ţĥē ŵōŕķţŕēē. Ţŕŷ àĝàĩń."],
diff --git a/packages/app/src/locales/pseudo/messages.po b/packages/app/src/locales/pseudo/messages.po
index eb8558d8..1330e8b3 100644
--- a/packages/app/src/locales/pseudo/messages.po
+++ b/packages/app/src/locales/pseudo/messages.po
@@ -677,6 +677,10 @@ msgstr ""
msgid "Add item to {keyName}"
msgstr ""
+#: src/lib/keyboard-shortcuts.ts
+msgid "Add link"
+msgstr ""
+
#: src/components/settings/SearchSection.tsx
msgid "Add meaning-based ranking to search so conceptually-related pages surface even when they share no keywords. This setting applies only to this computer."
msgstr ""
@@ -3862,6 +3866,10 @@ msgstr ""
msgid "Link target"
msgstr ""
+#: src/lib/keyboard-shortcuts.ts
+msgid "Link the selected text, or edit the link under the caret."
+msgstr ""
+
#: src/editor/slash-command/component-items.tsx
msgid "Link to a page or external URL."
msgstr ""
@@ -5954,7 +5962,7 @@ msgid "Search failed."
msgstr ""
#: src/lib/keyboard-shortcuts.ts
-msgid "Search files, commands, projects, and AI handoff actions."
+msgid "Search files, commands, projects, and AI handoff actions. With text selected in the visual editor, this chord adds a link instead."
msgstr ""
#: src/components/CommandPalette.tsx
@@ -7578,6 +7586,10 @@ msgstr ""
msgid "Visual editor replace input focused"
msgstr ""
+#: src/lib/keyboard-shortcuts.ts
+msgid "Visual editor selection"
+msgstr ""
+
#: src/lib/keyboard-shortcuts.ts
msgid "Visual editor table"
msgstr ""
diff --git a/packages/app/tests/stress/_helpers/editor-state.ts b/packages/app/tests/stress/_helpers/editor-state.ts
index 8a6084f3..11a8e6cd 100644
--- a/packages/app/tests/stress/_helpers/editor-state.ts
+++ b/packages/app/tests/stress/_helpers/editor-state.ts
@@ -132,6 +132,62 @@ export async function focusEditor(page: Page, timeoutMs = 5_000): Promise
);
}
+/**
+ * Select the first occurrence of `text` in the active PM editor and wait
+ * until `editor.state.selection` actually covers it.
+ *
+ * Routed through TipTap's `setTextSelection` command on
+ * `window.__activeEditor` rather than synthesized mouse drags or
+ * platform-specific word-selection chords — deterministic under CI worker
+ * contention. The search walks text nodes and matches within a single one,
+ * so `text` must not span a mark/node boundary (fine for word-level
+ * selections; extend when a caller needs a cross-node range — the helper
+ * throws loudly rather than selecting a partial match).
+ *
+ * The trailing `waitForFunction` is the "selection took" signal callers
+ * need before dispatching selection-dependent events (paste, keyboard
+ * chords): `setTextSelection` updates PM state synchronously, but polling
+ * the state from the test context is what proves the evaluate round-trip
+ * completed and the range maps to the expected characters.
+ */
+export async function selectText(page: Page, text: string): Promise {
+ await page.evaluate((target) => {
+ const editor = window.__activeEditor;
+ if (!editor) throw new Error('selectText: window.__activeEditor not set');
+ let from = -1;
+ editor.state.doc.descendants((node, pos) => {
+ if (from !== -1) return false;
+ const nodeText = node.isText ? node.text : undefined;
+ if (nodeText) {
+ const idx = nodeText.indexOf(target);
+ if (idx !== -1) {
+ from = pos + idx;
+ return false;
+ }
+ }
+ return true;
+ });
+ if (from === -1) {
+ throw new Error(`selectText: "${target}" not found within a single text node`);
+ }
+ editor
+ .chain()
+ .focus()
+ .setTextSelection({ from, to: from + target.length })
+ .run();
+ }, text);
+ await page.waitForFunction(
+ (target) => {
+ const editor = window.__activeEditor;
+ if (!editor) return false;
+ const { from, to } = editor.state.selection;
+ return to > from && editor.state.doc.textBetween(from, to) === target;
+ },
+ text,
+ { timeout: 5_000 },
+ );
+}
+
/**
* Wait until ProseMirror's `editor.state.selection` has an ancestor of the
* given `nodeType` name — i.e. the cursor is INSIDE that node type per PM's
diff --git a/packages/app/tests/stress/_helpers/index.ts b/packages/app/tests/stress/_helpers/index.ts
index f1be6b50..6826ec67 100644
--- a/packages/app/tests/stress/_helpers/index.ts
+++ b/packages/app/tests/stress/_helpers/index.ts
@@ -14,6 +14,7 @@ export { resetContentToFixtureBaseline } from './content-reset.ts';
export {
focusEditor,
selectAllAndWaitForSelection,
+ selectText,
waitForPmSelectionInNode,
} from './editor-state.ts';
export { filterCriticalErrors, type LogEntry } from './error-filters.ts';
diff --git a/packages/app/tests/stress/link-authoring-apex.e2e.ts b/packages/app/tests/stress/link-authoring-apex.e2e.ts
new file mode 100644
index 00000000..a892dd16
--- /dev/null
+++ b/packages/app/tests/stress/link-authoring-apex.e2e.ts
@@ -0,0 +1,370 @@
+/**
+ * Apex E2E for the link-authoring feature — the release gate for the
+ * cross-writer hazard and the ⌘K dual-role contract.
+ *
+ * Four concerns, each proven in the real app rather than at a unit rung:
+ *
+ * 1. Cross-writer safety across two live clients. A boundary-terminated URL
+ * that reaches a client via CRDT sync (another writer's content) is NEVER
+ * linkified; only a client's OWN locally-typed URL + boundary converts.
+ * This is the origin guard (ySyncPluginKey) observed end-to-end.
+ * 2. Cross-writer safety for a pooled/backgrounded editor. A hidden Activity's
+ * editor still has a live provider and receives remote writes; it must
+ * never linkify them (origin guard + active-editor gate).
+ * 3. ⌘K routing matrix: non-empty selection → link popover; collapsed caret →
+ * palette; caret inside a link → chip edit surface; source pane → palette;
+ * ⌘⇧K → NOT the palette (the exact-⌘K narrowing).
+ * 4. Clipboard pre-fill degrades silently: with clipboard-read withheld
+ * (real permission gate), the popover opens empty and stays functional.
+ *
+ * Byte-shape oracles live in link-authoring-bytes.e2e.ts; this file asserts
+ * link-mark presence/absence and which UI surface a shortcut routes to.
+ *
+ * Run:
+ * cd packages/app && bunx playwright test tests/stress/link-authoring-apex.e2e.ts
+ */
+
+import { randomUUID } from 'node:crypto';
+import type { Page } from '@playwright/test';
+import type { Node as PMNode } from '@tiptap/pm/model';
+import { expect, focusEditor, selectText, test, waitForActiveProviderSynced } from './_helpers';
+
+const EDITOR = '.ProseMirror:not(.composer-prosemirror)';
+const LINK_CHIP = `${EDITOR} span[data-link]`;
+const PALETTE = '[cmdk-root]';
+
+/** Does the active editor's doc carry any link mark? */
+async function pmHasLink(page: Page): Promise {
+ return page.evaluate(() =>
+ JSON.stringify(window.__activeEditor?.state.doc.toJSON() ?? {}).includes('"type":"link"'),
+ );
+}
+
+async function waitForYTextToContain(page: Page, needle: string): Promise {
+ await page.waitForFunction(
+ (n: string) =>
+ (window.__activeProvider?.document?.getText('source')?.toString() ?? '').includes(n),
+ needle,
+ { timeout: 10_000 },
+ );
+}
+
+// ───────────────────────────────────────────────────────────────────────────
+// Cross-writer: two live clients — a synced URL is never linkified by the receiver
+// ───────────────────────────────────────────────────────────────────────────
+
+test.describe('apex — cross-writer linkification never fires', () => {
+ test('a boundary-less URL typed by a peer stays plain on the receiver; only a client’s own boundary-typed URL converts', async ({
+ browser,
+ api,
+ baseURL,
+ }) => {
+ const docName = `test-link-apex-fr1-${randomUUID().slice(0, 8)}`;
+ await api.createPage(`${docName}.md`);
+
+ const ctxA = await browser.newContext({ baseURL });
+ const ctxB = await browser.newContext({ baseURL });
+ const pageA = await ctxA.newPage();
+ const pageB = await ctxB.newPage();
+
+ try {
+ await Promise.all([pageA.goto(`/#/${docName}`), pageB.goto(`/#/${docName}`)]);
+ await Promise.all([
+ pageA.waitForFunction(() => Boolean(window.__activeProvider), null, { timeout: 15_000 }),
+ pageB.waitForFunction(() => Boolean(window.__activeProvider), null, { timeout: 15_000 }),
+ ]);
+ await Promise.all([pageA.waitForSelector(EDITOR), pageB.waitForSelector(EDITOR)]);
+
+ // Client A LIVE-TYPES a GFM-shaped URL with NO trailing boundary key.
+ // Live typing (not a markdown/agent write, which would parse the bare URL
+ // into a link) leaves it as plain text: A's own plugin needs a boundary
+ // to fire, and none was typed. The plain text syncs to B as another
+ // writer's content.
+ await pageA.locator(EDITOR).click();
+ await pageA.keyboard.type('https://a-side.com');
+ await waitForYTextToContain(pageB, 'a-side.com');
+
+ // Neither client linkified A's boundary-less URL — B must not convert
+ // content that arrived via CRDT sync, and A never typed a boundary.
+ expect(await pmHasLink(pageA)).toBe(false);
+ expect(await pmHasLink(pageB)).toBe(false);
+ await expect(pageA.locator(LINK_CHIP)).toHaveCount(0);
+ await expect(pageB.locator(LINK_CHIP)).toHaveCount(0);
+
+ // B types its OWN URL + space at the START of the doc — its boundary
+ // (the trailing space) lands after B's token, never adjacent to A's URL,
+ // so only B's token converts. (A boundary typed right after A's URL would
+ // legitimately convert it too: that is B's own local edit completing the
+ // token, not a cross-writer linkification.)
+ await pageB.locator(EDITOR).click();
+ await pageB.evaluate(() => window.__activeEditor?.commands.focus('start'));
+ await pageB.keyboard.type('https://b-own.com ');
+
+ await pageB.waitForFunction(
+ () =>
+ JSON.stringify(window.__activeEditor?.state.doc.toJSON() ?? {}).includes('"type":"link"'),
+ null,
+ { timeout: 5_000 },
+ );
+
+ // Exactly one link on B: its own URL. A's synced URL is still plain.
+ await expect(pageB.locator(`${LINK_CHIP}[aria-label="Link: https://b-own.com"]`)).toHaveCount(
+ 1,
+ );
+ await expect(pageB.locator(LINK_CHIP)).toHaveCount(1);
+
+ // The mark syncs back to A; A shows exactly the same one link (B's), and
+ // still nothing on its own boundary-less URL.
+ await waitForYTextToContain(pageA, 'b-own.com');
+ await expect(pageA.locator(`${LINK_CHIP}[aria-label="Link: https://b-own.com"]`)).toHaveCount(
+ 1,
+ );
+ await expect(pageA.locator(LINK_CHIP)).toHaveCount(1);
+ } finally {
+ await ctxA.close();
+ await ctxB.close();
+ }
+ });
+});
+
+// ───────────────────────────────────────────────────────────────────────────
+// A pooled/backgrounded editor never linkifies remote writes
+// ───────────────────────────────────────────────────────────────────────────
+
+test.describe('apex — backgrounded editor never linkifies', () => {
+ test('a peer’s boundary-less URL reaches a hidden Activity’s editor and stays plain', async ({
+ browser,
+ api,
+ baseURL,
+ }) => {
+ const docX = `test-link-apex-hidx-${randomUUID().slice(0, 8)}`;
+ const docY = `test-link-apex-hidy-${randomUUID().slice(0, 8)}`;
+ await api.createPage(`${docX}.md`);
+ await api.createPage(`${docY}.md`);
+
+ // Context H holds X, then navigates to Y — X's editor flips to
+ // but keeps a live provider in the pool.
+ const ctxH = await browser.newContext({ baseURL });
+ const ctxM = await browser.newContext({ baseURL });
+ const pageH = await ctxH.newPage();
+ const pageM = await ctxM.newPage();
+
+ try {
+ await pageH.goto(`/#/${docX}`);
+ await pageH.waitForFunction(() => Boolean(window.__activeProvider), null, {
+ timeout: 15_000,
+ });
+ await pageH.waitForSelector(EDITOR);
+ await pageH.goto(`/#/${docY}`);
+ await pageH.waitForFunction(() => Boolean(window.__activeProvider), null, {
+ timeout: 15_000,
+ });
+ await pageH.waitForSelector(EDITOR);
+
+ // Context M opens X (foreground) and live-types a boundary-less URL. It
+ // stays plain (no boundary → M's own plugin doesn't fire) and syncs into
+ // X's Y.Doc, reaching H's HIDDEN X editor while it is backgrounded.
+ await pageM.goto(`/#/${docX}`);
+ await pageM.waitForFunction(() => Boolean(window.__activeProvider), null, {
+ timeout: 15_000,
+ });
+ await pageM.waitForSelector(EDITOR);
+ await pageM.locator(EDITOR).click();
+ await pageM.keyboard.type('https://while-hidden.com');
+ await waitForYTextToContain(pageM, 'while-hidden.com');
+
+ // Deterministic delivery signal: poll H's HIDDEN pooled doc (via the
+ // provider pool's read-only peek) until the peer's URL reaches it while
+ // X is still backgrounded — the exact window the active-editor gate
+ // must hold through. Only then return to X.
+ await pageH.waitForFunction(
+ (doc: string) =>
+ (
+ window.__providerPool?.peek(doc)?.provider?.document?.getText('source')?.toString() ??
+ ''
+ ).includes('while-hidden.com'),
+ docX,
+ { timeout: 10_000 },
+ );
+ await pageH.goto(`/#/${docX}`);
+ await pageH.waitForFunction(() => Boolean(window.__activeProvider), null, {
+ timeout: 15_000,
+ });
+ await waitForYTextToContain(pageH, 'while-hidden.com');
+
+ // The URL is present but the hidden editor never linkified it.
+ expect(await pmHasLink(pageH)).toBe(false);
+ await expect(pageH.locator(LINK_CHIP)).toHaveCount(0);
+ } finally {
+ await ctxH.close();
+ await ctxM.close();
+ }
+ });
+});
+
+// ───────────────────────────────────────────────────────────────────────────
+// ⌘K routing matrix
+// ───────────────────────────────────────────────────────────────────────────
+
+test.describe('apex — ⌘K dual-role routing', () => {
+ let docName: string;
+
+ test.beforeEach(async ({ page, api }) => {
+ docName = `test-link-apex-cmdk-${randomUUID().slice(0, 8)}`;
+ await api.createPage(`${docName}.md`);
+ await page.goto(`/#/${docName}`);
+ await waitForActiveProviderSynced(page);
+ await page.waitForSelector(EDITOR);
+ // Seed plain text (for selection / collapsed-caret cases) plus an existing
+ // link (for the caret-inside-link case).
+ await api.replaceDoc(
+ docName,
+ 'edit this text and visit [the docs](https://example.com) often\n',
+ );
+ await waitForYTextToContain(page, 'the docs');
+ await expect(page.locator(LINK_CHIP)).toHaveCount(1);
+ });
+
+ test('non-empty selection routes ⌘K to the link popover, not the palette', async ({ page }) => {
+ await selectText(page, 'this text');
+ await focusEditor(page);
+ await page.keyboard.press('ControlOrMeta+k');
+
+ const input = page.getByLabel('Link URL');
+ await expect(input).toBeVisible({ timeout: 2_000 });
+ await expect(page.locator(PALETTE)).toHaveCount(0);
+
+ // Keyboard contract: the input takes focus on open (the popover lives in
+ // the floating bubble menu, which is unfocusable until positioned — the
+ // app retries until focus lands). Then two-stage Escape per the combobox
+ // convention: the first dismisses the path-suggestion panel, the second
+ // closes the popover and returns focus to the editor.
+ await expect
+ .poll(() => page.evaluate(() => document.activeElement?.getAttribute('aria-label')), {
+ timeout: 2_000,
+ })
+ .toBe('Link URL');
+ await page.keyboard.press('Escape');
+ await expect(input).toBeVisible();
+ await page.keyboard.press('Escape');
+ await expect(input).toBeHidden({ timeout: 2_000 });
+ await expect
+ .poll(() => page.evaluate(() => window.__activeEditor?.view.hasFocus() ?? false), {
+ timeout: 2_000,
+ })
+ .toBe(true);
+ });
+
+ test('collapsed caret in plain text routes ⌘K to the palette', async ({ page }) => {
+ // Caret inside "often" — plain text, not a link.
+ await page.evaluate(() => {
+ const ed = window.__activeEditor;
+ if (!ed) throw new Error('no active editor');
+ const idx = ed.state.doc.textContent.indexOf('often');
+ ed.chain()
+ .focus()
+ .setTextSelection(idx + 2)
+ .run();
+ });
+ await focusEditor(page);
+ await page.keyboard.press('ControlOrMeta+k');
+
+ await expect(page.locator(PALETTE)).toBeVisible({ timeout: 2_000 });
+ });
+
+ test('caret inside a link routes ⌘K to the chip edit surface, not the palette', async ({
+ page,
+ }) => {
+ // Collapse the caret inside the link mark's range.
+ await page.evaluate(() => {
+ const ed = window.__activeEditor;
+ if (!ed) throw new Error('no active editor');
+ let pos = -1;
+ ed.state.doc.descendants((node: PMNode, at: number) => {
+ if (pos !== -1) return false;
+ if (node.isText && node.marks.some((m) => m.type.name === 'link')) {
+ pos = at + 1;
+ return false;
+ }
+ return true;
+ });
+ if (pos === -1) throw new Error('no link mark found to place caret in');
+ ed.chain().focus().setTextSelection(pos).run();
+ });
+ await focusEditor(page);
+ await page.keyboard.press('ControlOrMeta+k');
+
+ await expect(page.getByRole('combobox', { name: 'Link target' })).toBeVisible({
+ timeout: 2_000,
+ });
+ await expect(page.locator(PALETTE)).toHaveCount(0);
+ });
+
+ test('⌘⇧K does NOT open the palette (exact-⌘K narrowing)', async ({ page }) => {
+ await page.locator(EDITOR).click();
+ await focusEditor(page);
+ await page.keyboard.press('ControlOrMeta+Shift+k');
+ await expect(page.locator(PALETTE)).toHaveCount(0);
+
+ // Condition-based negative proof (no wall-clock wait): exact ⌘K on the
+ // same keystroke pipeline DOES open the palette. That positive signal
+ // confirms key handling processed events after the ⌘⇧K press — if ⌘⇧K
+ // had opened (or toggled) the palette, this visibility wait would fail.
+ await page.keyboard.press('ControlOrMeta+k');
+ await expect(page.locator(PALETTE)).toBeVisible({ timeout: 2_000 });
+ });
+
+ test('⌘K in the source pane routes to the palette (WYSIWYG lacks focus)', async ({ page }) => {
+ await page.getByRole('radio', { name: 'Markdown source' }).click();
+ const cm = page.locator('.cm-content');
+ await expect(cm).toBeVisible({ timeout: 5_000 });
+ await cm.click();
+ await page.keyboard.press('ControlOrMeta+k');
+
+ await expect(page.locator(PALETTE)).toBeVisible({ timeout: 2_000 });
+ });
+});
+
+// ───────────────────────────────────────────────────────────────────────────
+// Clipboard pre-fill degrades silently under permission denial
+// ───────────────────────────────────────────────────────────────────────────
+
+test.describe('apex — clipboard pre-fill under real permission denial', () => {
+ test('with clipboard-read withheld, the popover opens empty and stays functional', async ({
+ browser,
+ api,
+ baseURL,
+ }) => {
+ // A fresh context with NO permissions granted — navigator.clipboard.readText
+ // rejects, exercising the real gate rather than a stub.
+ const ctx = await browser.newContext({ baseURL });
+ const page = await ctx.newPage();
+ try {
+ const docName = `test-link-apex-clip-${randomUUID().slice(0, 8)}`;
+ await api.createPage(`${docName}.md`);
+ await page.goto(`/#/${docName}`);
+ await waitForActiveProviderSynced(page);
+ await page.waitForSelector(EDITOR);
+ await api.replaceDoc(docName, 'select me and link\n');
+ await waitForYTextToContain(page, 'select me');
+
+ await selectText(page, 'select me');
+ await focusEditor(page);
+ await page.keyboard.press('ControlOrMeta+k');
+
+ const input = page.getByLabel('Link URL');
+ await expect(input).toBeVisible({ timeout: 2_000 });
+ // Denial degraded to an empty input — no error surfaced, no pre-fill.
+ await expect(input).toHaveValue('');
+
+ // Still functional: typing a URL and applying it creates the link.
+ await input.fill('https://typed-by-hand.com');
+ await page.keyboard.press('Enter');
+ await expect(
+ page.locator(`${LINK_CHIP}[aria-label="Link: https://typed-by-hand.com"]`),
+ ).toHaveCount(1, { timeout: 5_000 });
+ } finally {
+ await ctx.close();
+ }
+ });
+});
diff --git a/packages/app/tests/stress/link-authoring-bytes.e2e.ts b/packages/app/tests/stress/link-authoring-bytes.e2e.ts
new file mode 100644
index 00000000..9b9c0e0a
--- /dev/null
+++ b/packages/app/tests/stress/link-authoring-bytes.e2e.ts
@@ -0,0 +1,242 @@
+/**
+ * Byte-pinning E2E for WYSIWYG linkification: exact Y.Text oracles (never
+ * substring matches) for the three link-creation paths — typed URL + space,
+ * lone-URL paste at cursor, lone-URL paste over a selection — plus the
+ * per-path single-undo contract.
+ *
+ * Exact bytes are the point. A plain-text URL serializes GFM-escaped
+ * (`https\://…`) so it stays prose on re-parse, while a linkified URL
+ * serializes as the bare literal; a `toContain('inkeep.com')` cannot tell
+ * those apart, whole-string equality on Y.Text can.
+ *
+ * Two serialization behaviors of the bridge shape the oracles below — both
+ * pre-existing, neither introduced by linkification:
+ *
+ * - Paragraph-trailing whitespace does not round-trip: `'AGENTS.md '`
+ * serializes as `'AGENTS.md\n'`. Expected bytes never carry a trailing
+ * space before the newline.
+ *
+ * - A mark-only change can be byte-invisible. Server Observer A settles
+ * without rewriting Y.Text when the new fragment serialization is within
+ * `normalizeBridge` tolerance of the settled bytes, and the CommonMark
+ * escape-collapse class makes `https\://x` and `https://x` compare equal
+ * (they are parse-equivalent: escaped autolink-shaped bytes re-parse to
+ * the same link). So a typed conversion — which only ADDS a mark over
+ * text that is already in Y.Text — leaves the escaped bytes at rest
+ * until the next content-bearing edit re-serializes the paragraph, at
+ * which point the bare literal lands. The typed-path tests pin exactly
+ * that lifecycle: mark first (fragment truth), bare bytes on the next
+ * edit (Y.Text truth). Paste paths insert or restructure content, so
+ * their bytes settle immediately.
+ *
+ * Link marks render as `` chips (InternalLink
+ * deliberately emits no `` — anchor navigation would race the
+ * InteractionLayer), so DOM assertions target the chip.
+ *
+ * Clipboard injection: DataTransfer + dispatchEvent (same pattern as
+ * paste-fidelity.e2e.ts) — bypasses the navigator.clipboard permission
+ * gate on headless Chromium.
+ *
+ * Run:
+ * cd packages/app && bunx playwright test tests/stress/link-authoring-bytes.e2e.ts
+ */
+
+import { randomUUID } from 'node:crypto';
+import type { Page } from '@playwright/test';
+import {
+ expect,
+ focusEditor,
+ selectText,
+ simulateCopyAndRead,
+ test,
+ waitForActiveProviderSynced as waitForProvider,
+} from './_helpers';
+
+const EDITOR = '.ProseMirror:not(.composer-prosemirror)';
+const LINK_CHIP = `${EDITOR} span[data-link]`;
+const URL_LITERAL = 'https://inkeep.com';
+
+async function getYText(page: Page): Promise {
+ return page.evaluate(() => {
+ const provider = window.__activeProvider;
+ return provider?.document?.getText('source')?.toString() ?? '';
+ });
+}
+
+/** Paste a text/plain-only payload into the WYSIWYG editor. */
+async function pasteText(page: Page, text: string) {
+ await page.evaluate((content) => {
+ const editor = document.querySelector('.ProseMirror:not(.composer-prosemirror)');
+ if (!editor) throw new Error('ProseMirror editor not found');
+ const dt = new DataTransfer();
+ dt.setData('text/plain', content);
+ const event = new ClipboardEvent('paste', {
+ clipboardData: dt,
+ bubbles: true,
+ cancelable: true,
+ });
+ editor.dispatchEvent(event);
+ }, text);
+}
+
+/** PM-layer truth: does the active editor's doc carry a link mark, and what
+ * is its plain text? Byte oracles cannot see a mark whose serialization is
+ * within bridge tolerance (file header), so mark-level assertions read the
+ * fragment side directly. */
+async function pmLinkSnapshot(page: Page): Promise<{ hasLink: boolean; text: string }> {
+ return page.evaluate(() => {
+ const ed = window.__activeEditor;
+ return {
+ hasLink: JSON.stringify(ed?.state.doc.toJSON() ?? {}).includes('"type":"link"'),
+ text: ed?.state.doc.textContent ?? '',
+ };
+ });
+}
+
+/** Wait until the active editor's doc gains (or is confirmed to hold) a link
+ * mark — the typed path lands it a microtask after the boundary keystroke. */
+async function waitForPmLink(page: Page): Promise {
+ await page.waitForFunction(
+ () => JSON.stringify(window.__activeEditor?.state.doc.toJSON() ?? {}).includes('"type":"link"'),
+ null,
+ { timeout: 5_000 },
+ );
+}
+
+// ─── lone-URL paste at cursor ───
+
+test.describe('lone-URL paste at cursor — bare-literal bytes', () => {
+ let docName: string;
+
+ test.beforeEach(async ({ page, api }) => {
+ docName = `test-linkbytes-cursor-${randomUUID().slice(0, 8)}`;
+ await api.createPage(`${docName}.md`);
+ await page.goto(`/#/${docName}`);
+ await waitForProvider(page);
+ await page.waitForSelector(EDITOR);
+ await page.click(EDITOR);
+ });
+
+ test('pasted lone URL lands as a link with exactly the bare-literal bytes', async ({ page }) => {
+ await pasteText(page, URL_LITERAL);
+ await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe('https://inkeep.com\n');
+ await expect(page.locator(`${LINK_CHIP}[aria-label="Link: ${URL_LITERAL}"]`)).toHaveCount(1);
+ });
+
+ test('WYSIWYG copy of the pasted link round-trips clean text/plain', async ({ page }) => {
+ await pasteText(page, URL_LITERAL);
+ await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe('https://inkeep.com\n');
+ const out = await simulateCopyAndRead(page, 'wysiwyg');
+ expect(out.plain).toBe('https://inkeep.com\n');
+ });
+
+ test('one undo removes the pasted link entirely', async ({ page }) => {
+ await pasteText(page, URL_LITERAL);
+ await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe('https://inkeep.com\n');
+ await focusEditor(page);
+ await page.keyboard.press('ControlOrMeta+z');
+ await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe('');
+ await expect(page.locator(LINK_CHIP)).toHaveCount(0);
+ });
+
+ test('explicit-scheme dotless host (localhost) pastes as a link with bare-literal bytes', async ({
+ page,
+ }) => {
+ // The dotted-domain rule is schemeless-only: http://localhost is a GFM
+ // autolink literal, so it converts and its bytes stay the bare URL.
+ const localUrl = 'http://localhost:5174/#/some-doc';
+ await pasteText(page, localUrl);
+ await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe(`${localUrl}\n`);
+ await expect(page.locator(`${LINK_CHIP}[aria-label="Link: ${localUrl}"]`)).toHaveCount(1);
+ });
+});
+
+// ─── lone-URL paste over a selection ───
+
+test.describe('lone-URL paste over a selection — [text](url) bytes', () => {
+ let docName: string;
+
+ test.beforeEach(async ({ page, api }) => {
+ docName = `test-linkbytes-sel-${randomUUID().slice(0, 8)}`;
+ await api.createPage(`${docName}.md`);
+ await page.goto(`/#/${docName}`);
+ await waitForProvider(page);
+ await page.waitForSelector(EDITOR);
+ // Seed through the agent-write path (a remote, undo-untracked origin) so
+ // the linkify mark is the first locally-tracked undo item — mirrors real
+ // usage, where the selected text long predates the paste.
+ await api.replaceDoc(docName, 'inkeep docs\n');
+ await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe('inkeep docs\n');
+ await expect(page.locator(`${EDITOR} p`).first()).toContainText('inkeep docs');
+ });
+
+ test('pasting a URL over a selected word keeps the text and links it', async ({ page }) => {
+ await selectText(page, 'docs');
+ await pasteText(page, URL_LITERAL);
+ await expect
+ .poll(() => getYText(page), { timeout: 5_000 })
+ .toBe('inkeep [docs](https://inkeep.com)\n');
+ await expect(page.locator(`${LINK_CHIP}[aria-label="Link: ${URL_LITERAL}"]`)).toHaveCount(1);
+ });
+
+ test('one undo restores the pre-paste unlinked text', async ({ page }) => {
+ await selectText(page, 'docs');
+ await pasteText(page, URL_LITERAL);
+ await expect
+ .poll(() => getYText(page), { timeout: 5_000 })
+ .toBe('inkeep [docs](https://inkeep.com)\n');
+ await focusEditor(page);
+ await page.keyboard.press('ControlOrMeta+z');
+ await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe('inkeep docs\n');
+ await expect(page.locator(LINK_CHIP)).toHaveCount(0);
+ });
+});
+
+// ─── typed URL + space ───
+
+test.describe('typed URL + space — GFM autolink byte contract', () => {
+ let docName: string;
+
+ test.beforeEach(async ({ page, api }) => {
+ docName = `test-linkbytes-typed-${randomUUID().slice(0, 8)}`;
+ await api.createPage(`${docName}.md`);
+ await page.goto(`/#/${docName}`);
+ await waitForProvider(page);
+ await page.waitForSelector(EDITOR);
+ await page.click(EDITOR);
+ });
+
+ test('typed GFM URL converts on space; bytes settle bare on the next edit', async ({ page }) => {
+ await page.keyboard.type('https://inkeep.com ');
+ // Conversion truth lives in the fragment: the mark lands immediately, but
+ // the mark-only change is within bridge tolerance of the escaped bytes
+ // already at rest (file header), so Y.Text is asserted after the next
+ // content-bearing edit forces a re-serialization.
+ await waitForPmLink(page);
+ await expect(page.locator(`${LINK_CHIP}[aria-label="Link: ${URL_LITERAL}"]`)).toHaveCount(1);
+ await page.keyboard.type('done');
+ await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe('https://inkeep.com done\n');
+ });
+
+ test('typed filename-shaped token stays plain and serializes unlinked', async ({ page }) => {
+ await page.keyboard.type('AGENTS.md ');
+ await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe('AGENTS.md\n');
+ await expect(page.locator(LINK_CHIP)).toHaveCount(0);
+ });
+
+ test('one undo removes only the mark — text intact, bytes re-escape', async ({ page }) => {
+ await page.keyboard.type('https://inkeep.com ');
+ await waitForPmLink(page);
+ await page.keyboard.press('ControlOrMeta+z');
+ // Mark gone, typed text and trailing space intact at the fragment layer.
+ await expect
+ .poll(() => pmLinkSnapshot(page), { timeout: 5_000 })
+ .toEqual({ hasLink: false, text: 'https://inkeep.com ' });
+ await expect(page.locator(LINK_CHIP)).toHaveCount(0);
+ // The next edit re-serializes the now-unmarked paragraph: the URL escapes
+ // back to prose form — the sharp proof that the undone doc's canonical
+ // bytes are the escaped ones (not merely that they never changed).
+ await page.keyboard.type('x');
+ await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe('https\\://inkeep.com x\n');
+ });
+});
diff --git a/packages/core/src/extensions/link-fidelity.ts b/packages/core/src/extensions/link-fidelity.ts
index 0bfa8964..b93cd344 100644
--- a/packages/core/src/extensions/link-fidelity.ts
+++ b/packages/core/src/extensions/link-fidelity.ts
@@ -1,9 +1,12 @@
/**
* Link mark override for source-text fidelity.
*
- * Extends @tiptap/extension-link (preserving autolink, linkOnPaste,
- * and click handling plugins) and adds fidelity attributes for link
- * style (inline, full, collapsed, shortcut) and reference label.
+ * Extends @tiptap/extension-link and adds fidelity attributes for link
+ * style (inline, full, collapsed, shortcut) and reference label. LinkFidelity
+ * itself inherits the stock link plugins (autolink, linkOnPaste, click
+ * handling), but the app-side `InternalLink` subclass overrides
+ * `addProseMirrorPlugins` to drop them in favour of OK's own linkify/paste/
+ * click surfaces — so in the app those stock plugins are not active.
*
* Markdown parsing/serialization is handled by the unified pipeline (packages/core/src/markdown/).
*/
@@ -16,15 +19,25 @@ const ALLOWED_LINK_SCHEMES: ReadonlySet = new Set(SAFE_URL_SCHEMES.map((
const PLACEHOLDER_BASE = 'https://placeholder.invalid';
/**
- * Allowlist gate for TipTap's autolinker / linkOnPaste / setLink input
- * paths. Bare relative URLs (e.g. `/foo`, `./bar`, `#hash`) parse against
- * `PLACEHOLDER_BASE` and inherit `https:`, so they pass without a special
- * case. Storage-layer mdast→PM (`MarkdownManager`) intentionally bypasses
- * this hook — see AGENTS.md "Storage never sanitizes; render-time layers
- * do." Render-time defenses (`rehypeSanitizeUrls`, `isSafeNavigationUrl`,
- * `sanitizeComponentProps`) cover egress.
+ * Known `linkStyle` values. The producer side (e.g. the app's typed-autolink
+ * plugin) and the PM→mdast serializer branch on these string literals; typing
+ * both against this union turns a drifted value (rename/typo) into a compile
+ * error instead of a silently mis-serialized link.
*/
-function isAllowedLinkUri(url: string): boolean {
+export type LinkStyle = 'inline' | 'full' | 'collapsed' | 'shortcut' | 'autolink' | 'gfm-autolink';
+
+/**
+ * Allowlist gate for link-mark creation paths: TipTap's autolinker /
+ * linkOnPaste / setLink inputs and the app clipboard dispatcher's lone-URL
+ * paste classifier. Bare relative URLs (e.g. `/foo`, `./bar`, `#hash`)
+ * parse against `PLACEHOLDER_BASE` and inherit `https:`, so they pass
+ * without a special case. Storage-layer mdast→PM (`MarkdownManager`)
+ * intentionally bypasses this hook — see AGENTS.md "Storage never
+ * sanitizes; render-time layers do." Render-time defenses
+ * (`rehypeSanitizeUrls`, `isSafeNavigationUrl`, `sanitizeComponentProps`)
+ * cover egress.
+ */
+export function isAllowedLinkUri(url: string): boolean {
try {
const parsed = new URL(url, PLACEHOLDER_BASE);
return ALLOWED_LINK_SCHEMES.has(parsed.protocol.toLowerCase());
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 9dee6588..f0753aa3 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -282,7 +282,7 @@ export { HtmlBlockFidelity } from './extensions/html-block-fidelity.ts';
export { ImageSrcFidelity } from './extensions/image-src-fidelity.ts';
export { JsxComponent } from './extensions/jsx-component.ts';
export { JsxInline } from './extensions/jsx-inline.ts';
-export { LinkFidelity } from './extensions/link-fidelity.ts';
+export { isAllowedLinkUri, LinkFidelity, type LinkStyle } from './extensions/link-fidelity.ts';
export { LinkRefDefFidelity } from './extensions/link-ref-def-fidelity.ts';
export { List, ListItem, ListItemNode, ListNode } from './extensions/list.ts';
export { MathInline } from './extensions/math-inline.ts';
diff --git a/packages/core/src/markdown/index.ts b/packages/core/src/markdown/index.ts
index cdbc4c87..58be3530 100644
--- a/packages/core/src/markdown/index.ts
+++ b/packages/core/src/markdown/index.ts
@@ -53,6 +53,7 @@ import {
createStructuralFreshnessChecker,
type StructuralFreshnessChecker,
} from '../bridge/structural-freshness.ts';
+import type { LinkStyle } from '../extensions/link-fidelity.ts';
import { isValidSourceLiteralRaw } from '../extensions/source-literal-mark.ts';
import { createRegistry } from '../registry/index.ts';
import type { PropDef } from '../registry/types.ts';
@@ -1538,7 +1539,7 @@ function buildPmToMdastHandlers(
children: [{ type: 'text' as const, value: label }],
} as unknown as MdastNodes;
}
- const style = mark.attrs.linkStyle;
+ const style: LinkStyle | undefined = mark.attrs.linkStyle;
if (style === 'autolink') {
return {
type: 'link' as const,