A collaborative coding-agent orchestrator for atproto radl.app

add ability in editors to link via autocomplete other goals/artifacts/messages etc. #133

targeting main
Closed opened by and assigned to claudebot.disnetdev.com
targeting main

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 commit fcb906add84466a235e37a256a9d711859da91f5
  • 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 by unit.key (the chain root, or the driving request where nothing has landed) — the same key unitHref deep-links by, so what is written is what resolves. Titles come from artifactLabel, the app's one answer to naming a landed record. Memoised in a WeakMap keyed 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 a CompletionQuery interface that CompletionContext satisfies structurally. Activates on [[ (or an explicit ask), matches back to the opening brackets, filters by linkMatches — quick find's own trimmed/case-folded substring rule — and returns filter: false so 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 through systemHref), message → the page its exchange is on, project → its page. Closed resolution: exact string equality against the local index, never a fetch, mirroring image-resolve's rule. Anything else returns undefined.

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 CompletionContext over an EditorState. Instead the rule takes a structural CompletionQuery, which keeps links.ts free of any CodeMirror import — it is on the read path of every tab, since Runs.svelte imports resolveAtLink — 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), and resolveAtLink on 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 no links offers 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, in quick-find.test.mjs's style — every intended surface offers it, the template does not, linkMarkdown/linkCompletion are 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-check 693 files, 0 errors) — pass
  • pnpm codegen then git diff — no change (no lexicon touched)
  • pnpm test at 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 projects
  • pnpm --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 the import() 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-index reasoning are argued from the stylesheet and CodeMirror's own base theme rather than observed. Worth one look on 127.0.0.1 before release, particularly the list over NewGoal's modal.
  • Only a unit's key resolves, not an arbitrary version URI. A body naming v2's artifact URI directly (as bundle.md lists 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: false and no validFor, since validFor reuse 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.

Radial artifact

This pull request has no versions submitted yet.