Skip to content

feat: wiki-style sub-note links and a #tag system - #79

Merged
chasehuh merged 1 commit into
mainfrom
task/wiki-links-tags
Aug 5, 2026
Merged

feat: wiki-style sub-note links and a #tag system#79
chasehuh merged 1 commit into
mainfrom
task/wiki-links-tags

Conversation

@chasehuh

@chasehuh chasehuh commented Aug 5, 2026

Copy link
Copy Markdown
Member

PR-B of #77. The authoring layer for a note graph, plus tags.

/n/{id} deep links have been clickable since #64 — but there was no way to produce one without leaving the note, copying a URL out of the address bar, and hand-writing the Markdown. That friction is why nobody builds a note graph in agentnote. This adds create → link → open as one gesture.

Wire format: plain Markdown. [[ is a trigger, never stored.

Everything written to a body is [Title](/n/{id}) — already clickable, already resolved through note_aliases, already survives publish / note_revisions untouched.

The rejected alternative was storing raw [[Title]] and resolving at render. It needs a title→id index over titles that are derived, mutable, and non-unique, it breaks /p/… rendering, and it makes every rename a rewrite. Chase's lean was the cheapest correct answer.

The payoff: since a link is ordinary text, it rides the CRDT through a normal view.dispatch. No CRDT-aware link code, no migration, no second syntax to reconcile.

Sub-notes

Type Result
[[ Note picker, filtered as you type. Enter inserts [Title](/n/{id}).
[[ + a name matching nothing Create "…" — creates the row and inserts its link
/ (line start / after a space) New note, Link to note
# Completes from tags already in use

Creating does not navigate away. That is the whole point — you are mid-sentence in the parent. createNoteRow is split out of createNote so ⌘N keeps navigating while the editor flow does not. It still registers the note in state and broadcasts upsert, so the new note is in the sidebar (and in peer tabs) and the link you just inserted actually opens.

The async splice. Create deletes the trigger text synchronously, then awaits the round trip, then inserts at the caret read after it. Positions captured before an await go stale; a user who keeps typing during the fetch gets the link at their caret rather than a corrupted splice. Covered by a test.

The / query is an argument, not a filter. My first cut filtered commands by the typed text, which meant /dep (reaching for "Link to note" on "Deploy checklist") hid the command being reached for. It now ranks a name-matching command first and keeps both. The palette closes once the query passes a space, so it never sits open over prose.

Tags

Inline #tag#idea, #work/agentnote. Must follow whitespace and contain at least one non-digit, so # Heading stays a heading and #1 stays an issue reference. Fenced code blocks, inline code spans, and link destinations are skipped (including [jump]( #anchor)).

Client-derived, no table. GET /api/notes already ships every note's full body, so the sidebar can derive the complete tag set from state it already holds. A note_tags table would mean a schema migration plus a projection write on both the legacy PUT path and the CRDT projection path — real complexity for zero user-visible gain at this size. This is an explicit v1 decision with a clean v2 escape hatch, not an oversight.

Editor tag clicks and sidebar chips drive one filter (agentnote:select-tag, mirroring the existing agentnote:open-note contract). Tag scanning is memoized on the notes array.

Tests

pnpm vitest run292 passed (was 239; +53). pnpm exec tsc --noEmit clean. pnpm build green.

  • lib/tags.test.ts (18) — headings, #1/#42, nested tags, fences (including ~~~ not closed by ```), inline code, link destinations, trailing-separator trimming, multi-line offsets.
  • lib/editor/note-links.test.ts (23) — trigger matching for [[ and /, exact-title dedup, Create ordering, the exact inserted Markdown, bracket stripping in labels, the create-then-insert sequence, and a failed create leaving a clean buffer.
  • lib/editor/tags.dom.test.ts (12) — what is and is not painted, repaint after edit, click → agentnote:select-tag, and # completion.

pnpm lint reports 9 errors / 6 warnings — unchanged from main, verified against a clean checkout. My first version added 2 (react-hooks/refs on render-time ref writes); switched to the effect-based ref sync the repo already uses in lib/crdt/use-note-doc.ts, so this PR is back at baseline.

Dependency

@codemirror/autocomplete@6.20.3 was already in the tree as a transitive dep of @codemirror/lang-markdown; this only promotes it to a direct dependency. No version moved — verified on the lockfile diff. The lockfile also picks up supports-color peer-suffix churn from pnpm 11 rewriting paths; lockfileVersion stays 9.0, so Railway's pnpm 9 build reads it fine.

Manual QA

With NEXT_PUBLIC_AGENTNOTE_CRDT=1:

  1. Type [[ + letters of an existing note → pick → link inserted → click it → that note opens.
  2. Type [[Deploy checklist (no match) → Create "…" → new note created, link inserted, parent stays open. Click through → child opens with that text as its body.
  3. Second tab: the note from step 2 is in its sidebar.
  4. / at line start → both commands. /groceries → New note creates "groceries". /dep → Link to note still offered.
  5. #work/agentnote and #idea in two notes → both paint. # → completion lists both. Click #idea → sidebar filters; click the chip again → cleared.
  6. # Heading, #1, and a fenced block with #nope → none paint.
  7. Korean IME: compose, then [[, then compose in the query — no dropped characters.
  8. Flag off (=0) → same flows work on the legacy string path.
  9. Publish a note containing a sub-note link → /p/… renders it; see the limitation below.

Known limitations (documented, non-goals per #77)

  • A sub-note link in a published note is a no-op for anonymous readers. PublicNoteView mounts the same agentnoteLinks(), which dispatches agentnote:open-note into a page with no listener. This is pre-existing, not introduced here, but this feature makes it more common. Publishing a linked subgraph is a non-goal.
  • Renaming a note does not rewrite links to it — the label is a snapshot at insertion (Notion's behavior, not Obsidian's rename-refactor).
  • Indented (4-space) code blocks are not skipped by the tag parser; fenced ones are.
  • [[ inside a code fence still offers completion — it reacts to what you are typing, not to existing text.

Deferred to v2 per the issue: note_tags table / server-side tag search, tag rename, nested-tag hierarchy rollup, frontmatter tags:, backlinks panel, graph view.

Refs #77. Independent of #78 (PR-A) — no shared files beyond globals.css/agentnote-app.tsx, which touch different regions.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
memo Ready Ready Preview Aug 5, 2026 4:43am

Request Review

Closes the authoring gap from #77: `/n/{id}` deep links have been clickable
since #64, but there was no way to PRODUCE one without leaving the note,
copying a URL out of the address bar, and hand-writing the Markdown.

Typing `[[` opens a note picker; picking one inserts `[Title](/n/{id})`. A
query that matches nothing offers `Create "…"`, which creates a real note row
and inserts its link WITHOUT navigating away from the parent — that
non-navigating create is the whole point, so `createNoteRow` is split out of
`createNote` (⌘N keeps navigating). A `/` palette at line start offers the
same two moves.

Wire format is plain Markdown. `[[` is a trigger, never stored. Storing raw
wikilinks would need a title->id index over derived, mutable, non-unique
titles, and would break `/p/…` rendering. Since the link is ordinary text, it
rides the CRDT through a normal `view.dispatch` — no CRDT-aware link code, no
migration, no second syntax to reconcile.

The create flow deletes the trigger synchronously and inserts at the caret
read AFTER the round trip: positions captured before an await go stale, and a
user who keeps typing should get the link at their caret, not a corrupt splice.

Tags are inline `#tag`, parsed in `lib/tags.ts` — must follow whitespace and
hold a non-digit, so `# Heading` stays a heading and `#1` stays an issue ref.
Fenced blocks, inline code, and link destinations are skipped. Derived on the
client: `GET /api/notes` already ships every body, so a `note_tags` table
would mean a migration plus a projection write on both the legacy PUT and CRDT
paths for no user-visible gain at this size. Sidebar chips and editor clicks
drive one filter; `#` completes from tags already in use.

The `/` palette treats its query as an ARGUMENT, not a filter — `/dep` still
offers "Link to note". Filtering there hid the command being reached for.

`@codemirror/autocomplete` was already in the tree via `@codemirror/lang-markdown`;
this only promotes it to a direct dependency. No version moved.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant