Record link autocomplete in the markdown editors #
Typing [[ in any markdown field that has a space in scope now offers that space's goals,
artifacts, thread messages and projects; picking one writes [<its current title>](at://did/collection/rkey)
into the body. Read back, that link is an ordinary in-app link to the record's page; a URI this tab
does not hold keeps the inert <code> chip the app has always drawn.
Implements the based-on plan step for step. The one deliberate departure is noted under Deviations.
Branch and commit #
- branch
radial/impl-d10b53633123, one commitfcb906add84466a235e37a256a9d711859da91f5 - base
main@841b51d(branch did not exist; created from the checkout, no rebase needed) - pull request: opened by Radial from the pushed branch (tangled has no CLI for it)
What landed #
packages/ui/src/lib/links.ts (new, pure). Candidates, insertion and resolution in one module:
linkCandidates(space)—{kind, uri, title, detail, search}for goals, units, projects and thread messages, in that order. A unit is named byunit.key(the chain root, or the driving request where nothing has landed) — the same keyunitHrefdeep-links by, so what is written is what resolves. Titles come fromartifactLabel, the app's one answer to naming a landed record. Memoised in aWeakMapkeyed by both the index and the directory: the fold decides what exists, the directory decides what a person is called, and a handle resolves after the fold that named them.linkMarkdown()— the exact inserted string, with\,[and]escaped and whitespace collapsed to one line.linkCompletion(context, candidates)— the whole completion rule as a pure function over aCompletionQueryinterface thatCompletionContextsatisfies structurally. Activates on[[(or an explicit ask), matches back to the opening brackets, filters bylinkMatches— quick find's own trimmed/case-folded substring rule — and returnsfilter: falseso CodeMirror does not filter the same list a second, looser way. One list is bounded at 60 options; nothing is made unfindable, since the bound is applied after filtering.resolveAtLink(index, spaceUri, href)— read-time reverse lookup, memoised per (index, space). Goal → its page, unit → its drawer (project-hung units throughsystemHref), message → the page its exchange is on, project → its page. Closed resolution: exact string equality against the local index, never a fetch, mirroringimage-resolve's rule. Anything else returnsundefined.
editor.ts. @codemirror/autocomplete added as a direct dependency (already in the lockfile as
a CodeMirror-family transitive; the lock gained 3 lines and no version moved) and imported inside the
existing dynamic Promise.all, so it stays off the browser read path. autocompletion({override, icons: false}) plus tooltips({parent: document.body}) — .md-host is overflow: hidden, so an
in-editor list would be clipped to one line of the field, and CodeMirror's z-index: 500 clears the
scrim at 50. The list is themed in the app's own tokens. The Escape binding is gated on
completionStatus(state) !== null: completionKeymap is Prec.highest and already handles it, but
the ordering is another package's decision and the failure it would cause (one press closing the list
and blurring mid-pick) is silent.
MarkdownEditor.svelte. One optional prop, links?: () => LinkCandidate[] — a getter, so the
poll loop's next fold is what the next [[ offers without the editor being reconfigured.
Surfaces. Wired in Composer, GuestComposer, NewGoal, NewRequest, AskAnswer, AskReview,
Verdict (each finding) and UnitDetail's reply composer. Off for NewType's template, on the
images precedent. The textarea fallback ignores the prop entirely.
Runs.svelte. An at:// link that resolves renders as an ordinary in-app <a class="lk"> (no
target="_blank", so SvelteKit navigates it); a miss keeps today's chip byte for byte with the full
URI in its title. safeHref's allowlist is untouched — an at:// href still never reaches a browser
navigation directly. The space comes from session, the way RadialImage reaches it, rather than
threading a prop through a dozen callers.
filters.ts. goalSearchText extracted beside labelCorpus and read by both /goals and the
completion, so a word visible on a goal row cannot find the row and not its completion. Behaviour on
/goals is unchanged (same three fields, same order).
Deviations from the plan #
- The plan's step 5 suggested testing the source by constructing a real
CompletionContextover anEditorState. Instead the rule takes a structuralCompletionQuery, which keepslinks.tsfree of any CodeMirror import — it is on the read path of every tab, sinceRuns.svelteimportsresolveAtLink— and the real thing is exercised end to end in the mounted component suite instead. AskReview's brief was not in the plan's list of surfaces but is a request brief with a space in scope, so it is wired like the rest.- Message candidates are not capped; the bound moved to what one completion returns, after filtering, so an old message is still findable by its own words. Messages are still ranked last.
Tests #
src/lib/links.test.ts(pure, 29 tests): candidate building over the fixture, ordering, memoisation, the escape rules, the completion rule (activation, span, apply, explicit trigger, empty result, the bound), andresolveAtLinkon every kind plus the hostile cases — foreign DID, unknown collection, a fragment,https:,javascript:, empty. Also: every candidate it offers resolves.MarkdownEditor.svelte.test.ts(+5, jsdom with CodeMirror running):[[opens and narrows, accepting lands the exact markdown in the bound string, the first Escape closes the list and the second leaves the field, a surface with nolinksoffers nothing, and the textarea half is unaffected. DOM assertions stay at "the list is open" — jsdom has no layout.test/record-links.test.mjs(new, 5): the wiring, inquick-find.test.mjs's style — every intended surface offers it, the template does not,linkMarkdown/linkCompletionare reached from exactly one module, the renderer resolves rather than fetches, and the projections are shared.filters.test.ts(+1) for the extracted projection.
Checks run #
pnpm lint(check-no-nul + every package's typecheck,svelte-check693 files, 0 errors) — passpnpm codegenthengit diff— no change (no lexicon touched)pnpm testat the root — pass: core 410, daemon 593, sidecar 126, ingest 49, atproto 35, transport-iroh 6, transport-iroh-wasm 13, ui 98 node-tests + 895 vitest across both projectspnpm --filter @radial/ui build— pass. Verified in the emitted bundle that the CodeMirror view library (200 kB) and@codemirror/autocomplete(37 kB) are reached only through theimport()in the editor chunk, and are not statically reachable from the app entry.
Remaining risks #
- Not manually driven in a browser. The turn container has no display, so the plan's manual
walkthrough was not performed; the tooltip's placement and the
z-indexreasoning are argued from the stylesheet and CodeMirror's own base theme rather than observed. Worth one look on127.0.0.1before release, particularly the list overNewGoal's modal. - Only a unit's key resolves, not an arbitrary version URI. A body naming v2's artifact URI
directly (as
bundle.mdlists artifacts) renders as a chip, not a link. Deliberate — the plan's rule is that a link names a unit and a unit is named by its key — but it is the most likely thing somebody pastes by hand. - Stale labels are by design. A goal renamed after being linked keeps its old label in old bodies; the href follows the live index. Stated in the README so it is a decision, not a surprise.
- The completion source re-runs per keystroke (
filter: falseand novalidFor, sincevalidForreuse skips re-filtering when filtering is off). The cost is a substring test over a memoised list; the plan's alternative would have handed filtering to CodeMirror's fuzzy matcher, which subsequence-matches artifact bodies and is a second, looser rule for finding a record.