feat: wiki-style sub-note links and a #tag system - #79
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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.
chasehuh
force-pushed
the
task/wiki-links-tags
branch
from
August 5, 2026 04:43
a56b702 to
74375ef
Compare
This was referenced Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 throughnote_aliases, already survives publish /note_revisionsuntouched.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
[[Enterinserts[Title](/n/{id}).[[+ a name matching nothingCreate "…"— creates the row and inserts its link/(line start / after a space)#Creating does not navigate away. That is the whole point — you are mid-sentence in the parent.
createNoteRowis split out ofcreateNoteso ⌘N keeps navigating while the editor flow does not. It still registers the note in state and broadcastsupsert, so the new note is in the sidebar (and in peer tabs) and the link you just inserted actually opens.The async splice.
Createdeletes the trigger text synchronously, then awaits the round trip, then inserts at the caret read after it. Positions captured before anawaitgo 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# Headingstays a heading and#1stays an issue reference. Fenced code blocks, inline code spans, and link destinations are skipped (including[jump]( #anchor)).Client-derived, no table.
GET /api/notesalready ships every note's full body, so the sidebar can derive the complete tag set from state it already holds. Anote_tagstable would mean a schema migration plus a projection write on both the legacyPUTpath 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 existingagentnote:open-notecontract). Tag scanning is memoized on the notes array.Tests
pnpm vitest run— 292 passed (was 239; +53).pnpm exec tsc --noEmitclean.pnpm buildgreen.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 lintreports 9 errors / 6 warnings — unchanged frommain, verified against a clean checkout. My first version added 2 (react-hooks/refson render-time ref writes); switched to the effect-based ref sync the repo already uses inlib/crdt/use-note-doc.ts, so this PR is back at baseline.Dependency
@codemirror/autocomplete@6.20.3was 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 upsupports-colorpeer-suffix churn from pnpm 11 rewriting paths;lockfileVersionstays9.0, so Railway's pnpm 9 build reads it fine.Manual QA
With
NEXT_PUBLIC_AGENTNOTE_CRDT=1:[[+ letters of an existing note → pick → link inserted → click it → that note opens.[[Deploy checklist(no match) →Create "…"→ new note created, link inserted, parent stays open. Click through → child opens with that text as its body./at line start → both commands./groceries→ New note creates "groceries"./dep→ Link to note still offered.#work/agentnoteand#ideain two notes → both paint.#→ completion lists both. Click#idea→ sidebar filters; click the chip again → cleared.# Heading,#1, and a fenced block with#nope→ none paint.[[, then compose in the query — no dropped characters.=0) → same flows work on the legacy string path./p/…renders it; see the limitation below.Known limitations (documented, non-goals per #77)
PublicNoteViewmounts the sameagentnoteLinks(), which dispatchesagentnote:open-noteinto 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.[[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_tagstable / server-side tag search, tag rename, nested-tag hierarchy rollup, frontmattertags:, 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