Skip to content

Wiki-style note links + tags (Obsidian/Notion), muted Offline chrome, and ⌘B bold #77

Description

@chasehuh

Summary

Make agentnote feel like Obsidian/Notion for linked thinking: create a sub-note and drop a working hyperlink to it without leaving the keyboard ([[ completion or / command), give it a real tag system (#tag with editor highlight, autocomplete, and a sidebar filter), and stop the CRDT Offline indicator from screaming like a hard error.

Riding in the same train (small, related chrome/keymap work): ⌘B becomes Markdown bold and the sidebar toggle moves to ⌘⇧B.

"Sub-notes" here are not a nested DB hierarchy. They are ordinary notes rows created through the existing POST /api/notes, referenced from any body by an ordinary Markdown link. The graph lives in the text, exactly like Obsidian.

Why This Matters

Today a note is an island. /n/{id} deep links already work and are already clickable in the editor (#64, #29), but there is no way to produce one from inside the editor — you have to create a note with ⌘N, navigate away, copy the URL out of the address bar, navigate back, and hand-write the Markdown. That friction is the entire reason nobody builds a note graph in agentnote.

Everything needed already exists. agentnoteLinks() resolves and opens /n/{id}, createNote mints Meet-style ids, /n/[id] routes, and CRDT edits merge. This issue is the missing authoring layer on top: create → link → open in one gesture.

Separately, CRDT-on-in-prod (#75) made a latent chrome bug user-visible: a transient WebSocket reconnect paints a red bordered alert box saying "Offline" in the titlebar, for a state where nothing is actually wrong (the edit is durable in IndexedDB and will flush). That is alarm fatigue for a non-event.

Conversation Context

Product decisions from Chase, locked before implementation:

Sub-notes / links

  • Sub-notes are actually separate notes rows (same table, same create API) — not nested DB children, not folders.
  • In the parent body they appear as easy hyperlinks, so navigation feels like an Obsidian/Notion graph.
  • Creation must be frictionless: a slash command like /new_note, and/or a / palette that creates + inserts a link, and/or Obsidian-style [[Title]] completion. Any of these, as long as the flow is one gesture.
  • Targets to emulate: Obsidian (wikilinks, [[ ]], tags) and Notion (easy page mentions / linked pages). Match the feel, not the pixels.
  • Chase's lean on storage: "links that already work as /n/{id} are fine; wiki syntax can be editor sugar that resolves/creates notes." Prefer a Markdown-compatible wire format so publish//p/… and plain sync stay sane.
  • CRDT is ON in prod. Any insert-link / create-note path must work on the Yjs path (Y.Text), not only the legacy onChange string path.
  • Creating a note in one tab must appear in the other tab's sidebar (existing BroadcastChannel upsert).

Tags

  • Design a coherent tag system, not a half-baked hashtag. It must fit Markdown-first agentnote and scale later (sidebar filter / autocomplete). Pick a concrete v1 with explicit v2 non-goals.

Offline chrome

  • Keep the Offline concept and copy ("Offline", tooltip about being saved on device).
  • Move the Offline mark to the left of Publish in the titlebar.
  • Render it muted gray (secondary/tertiary text) — no red alert chrome for the CRDT offline case. It must not steal attention.
  • Real legacy conflict / auth failures may keep stronger chrome. Prefer a new class (zed-titlebar__sync) over reusing zed-save-error's red styles.
  • Residual from Enable prod CRDT + Railway realtime after audit (Notion-like multi-tab sync) #75: WS-loading notes can misleadingly read "Saved". Fix the loading-vs-offline labeling here if cheap.

Bold vs sidebar (added mid-flight)

  • ⌘B / Ctrl+B should be Markdown bold wrap (Obsidian/Notion-style emphasis), working on both the CRDT and legacy editor paths.
  • Sidebar toggle moves to ⌘⇧B / Ctrl+Shift+B. Update every tooltip/copy that still says ⌘B.
  • Leave ⌘\ (sidebar alternate) and ⌘N (new note) alone.
  • Italic ⌘I is an optional nicety, explicitly not required.

Current Behavior

Links

lib/editor/links.ts (agentnoteLinks()) already:

  • hides [label](url) chrome and paints the label with .cm-md-link,
  • resolves /n/{id} and legacy ?n= via resolveHref() / noteIdFromInAppHref(),
  • on click dispatches window CustomEvent("agentnote:open-note", { detail: { id } }).

components/agentnote-app.tsx:917-930 listens for that event and calls selectNote(target). Unknown ids are a silent no-op (deliberate — do not 404 the whole app).

There is no way to author such a link from the editor. createNote() (components/agentnote-app.tsx:1155) is reachable only from ⌘N and the sidebar + button, and it navigates away from the current note.

Tags

Nothing. #foo is plain text.

Offline chrome

components/agentnote-app.tsx:334-343 collapses CRDT status into the legacy SaveState:

const displaySaveState: SaveState = CRDT_ENABLED
  ? docSession.status === "offline" ? "error" : "saved"
  : saveState;

Note "loading" and "syncing" both fall into "saved" — this is the #75 residual: a note whose WebSocket has not connected reads "Saved" while it is in fact read-only and unconfirmed.

displaySaveState === "error" then renders one shared block (agentnote-app.tsx:1357-1393) with className="zed-save-error" and role="alert", positioned after the Publish button. app/globals.css:263-299:

.zed-save-error {
  border: 1px solid color-mix(in srgb, var(--c-error) 55%, var(--c-border));
  background: color-mix(in srgb, var(--c-error) 12%, transparent);
  color: var(--c-error);
  /* … */
}

So a 2-second CRDT reconnect blip paints full red-alert chrome.

Shortcuts

components/agentnote-app.tsx:1298-1316 — a window-level keydown handler:

const meta = event.metaKey || event.ctrlKey;
if (meta && event.key.toLowerCase() === "n") {  createNote() }
if (meta && event.key.toLowerCase() === "b") {  setSidebarOpen(v => !v) }
if (meta && event.key.toLowerCase() === "\\") {  setSidebarOpen(v => !v) }

Because it is on window and does not check shiftKey, it fires for ⌘B regardless of editor focus, and preventDefault() alone would not stop it from also running after a CodeMirror keymap handled the key. It must lose the plain-meta+b binding, not merely be out-prioritized.

Copy that references ⌘B for the sidebar: agentnote-app.tsx:1320 (comment), :1327 (dock button title), :1570 (empty-state hint), plus README's editor bullet.

Desired Behavior

A. Wiki-style note links ([[) and a / command palette

Wire format — decided: Markdown [Title](/n/{id}). [[ ]] is a trigger, never a storage format. Nothing new is ever persisted.

Rationale:

  • Chase's lean, and it is the cheapest correct answer. These links are already clickable, already resolve through note_aliases, and already survive publish//p/… and note_revisions untouched.
  • The CRDT carries plain text — a link inserted via a normal CodeMirror dispatch flows through yCollab into Y.Text for free. No CRDT-aware link code.
  • No new parser in the persistence path, no migration, no reconciliation between two link syntaxes.
  • Rejected alternative: storing raw [[Title]] and resolving at render. It would need a title→id index (titles are derived, mutable, and non-unique), would break /p/… rendering, and would make every rename a rewrite.

Typing [[ opens a note-picker completion:

  • lists live notes by derived title (deriveNoteTitle), most-recent first, filtered by what is typed after [[;
  • selecting one replaces the whole [[query trigger with [Title](/n/{id});
  • when the query is non-empty, a Create "query" option is offered (top of the list when nothing matches exactly). It POST /api/notes with body: query, adds the note to the list + broadcasts upsert to peer tabs, and inserts the link — without navigating away from the parent note;
  • Esc dismisses and leaves the literal [[ in the buffer.

Typing / at the start of a line (or after whitespace) opens a small command palette:

  • New note — same create-and-link flow as [[ + Create, using text typed after / as the title;
  • Link to note — opens the same note picker.

Both triggers converge on one code path.

B. Tags — v1

Format: inline #tag in the note body. Obsidian's primary form; zero new syntax to learn; Markdown-safe.

Parsing rules (lib/tags.ts):

  • A tag is # followed by one or more of [A-Za-z0-9_/-], and must contain at least one non-digit — so #1, #42 (issue refs) are not tags, matching Obsidian.
  • # must be at the start of the document or preceded by whitespace or (.
  • Not a tag: # Heading (ATX headings require a space after #, which our rule already excludes), anything inside a fenced code block or an inline code span, and URL fragments inside a link destination (](…#frag)).
  • / is a legal tag character so #work/agentnote parses as one tag. Hierarchy rollup is v2 — v1 treats it as an opaque string.

v1 is client-derived — no database table. GET /api/notes already returns every note's full body to the client (listNotes), 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, for zero user-visible gain at this size. Explicitly a v1 decision with a v2 escape hatch, not an oversight.

Surfaces:

  • Editor#tag painted with .cm-tag (muted accent, no underline). Clicking sets the sidebar filter.
  • # autocomplete — completes from tags used anywhere in the user's notes.
  • Sidebar — a tag filter row above the list. Clicking a tag filters the notes list to notes carrying it; clicking again clears.

C. Offline chrome

Split the one red block into two elements:

Path State Element Look Position
CRDT loading / syncing .zed-titlebar__sync muted gray, "Syncing…" left of Publish
CRDT offline .zed-titlebar__sync muted gray, "Offline" + Retry left of Publish
CRDT synced nothing
Legacy auth / conflict / generic .zed-save-error unchanged red unchanged

.zed-titlebar__sync gets no border, no background, color: var(--c-text-muted), and role="status" (not role="alert"). The tooltip keeps the existing copy: "Saved on this device — syncs when the connection returns."

Adding the loading/syncing → "Syncing…" row closes the #75 residual: a note waiting on the WebSocket no longer claims to be "Saved".

D. ⌘B bold / ⌘⇧B sidebar

  • ⌘B / Ctrl+B in the editor toggles **bold**: wrap a selection, unwrap if already wrapped (markers inside or just outside the selection), and on an empty caret insert **** with the caret in the middle.
  • One input history event, so ⌘Z undoes the wrap in a single step.
  • Works on the CRDT path for free — it is a normal view.dispatch, which yCollab syncs into Y.Text.
  • The window handler drops plain meta+b and gains meta+shift+b for the sidebar. ⌘\ and ⌘N unchanged.
  • All ⌘B sidebar copy updated to ⌘⇧B.

On macOS defaultKeymap binds Ctrl-b to cursorCharLeft (emacs-style). Our binding is Mod-b = Cmd on macOS, so there is no conflict there; on Linux/Windows Mod-b = Ctrl-b, which defaultKeymap does not bind. Bind at Prec.high regardless, matching agentnoteStrikethroughKeymap().

Source Of Truth

Internal repo

  • lib/editor/links.tsagentnoteLinks(), resolveHref(), noteIdFromInAppHref(), openHref(). The agentnote:open-note event contract. Do not regress (Editor: Obsidian-style clickable Markdown links (port chasehuh_com agentnoteLinks) #64, fix(editor): treat scheme-less hosts as https links #71, fix(editor): click links only on visible label text #72).
  • components/agentnote-app.tsxcreateNote() (:1155), the agentnote:open-note listener (:917), displaySaveState (:334), titlebar JSX (:1321-1399), window keydown (:1298).
  • components/codemirror-editor.tsxeditorExtensions(); where every editor extension is composed and where CRDT (ytext + yCollab) vs legacy (onChange) diverge.
  • lib/editor/strikethrough.tstoggleStrikethrough() is the exact shape bold should take (changeByRange, wrap/unwrap/caret cases, single input event) and agentnoteStrikethroughKeymap() the exact binding shape.
  • lib/notes.tscreateNote(), resolveCanonicalNoteId() (canonical / hyphenless / UUID / legacy-short / alias resolution).
  • lib/note-id.ts — Meet-style xxx-xxxx-xxx ids. Note the alphabet excludes l.
  • lib/note-title.tsderiveNoteTitle(): title = first non-empty body line, capped at 120 chars. Client and server must agree.
  • app/api/notes/route.tsPOST accepts { title?, body? }, returns 201 { note }.
  • lib/tab-sync.tsSyncMessage; upsert is how a new note reaches peer tabs.
  • app/globals.css:263-299.zed-save-error red chrome; :671-680.cm-md-link.
  • README.md:119-190 — CRDT + realtime transport behavior, including the documented "header reads Offline — saved on this device".

External

  • Obsidian wikilinks / tags — the UX being emulated ([[Note]], [[Note|alias]], inline #tag, #parent/child). We emulate the gesture, not the storage format.
  • Notion page mentions — the "type, pick, it becomes a link" feel.
  • @codemirror/autocomplete 6.20.3 is already in pnpm-lock.yaml as a transitive dep of @codemirror/lang-markdown. Promote it to an explicit dependencies entry; no new version enters the tree.

Proposed API / Schema

No API or schema changes. Everything reuses existing endpoints.

Creating a sub-note from [[ / /:

POST /api/notes
Content-Type: application/json

{ "body": "Deploy checklist" }
{ "note": { "id": "abc-mnop-xyz", "title": "Deploy checklist", "body": "Deploy checklist", "updated_at": "", "…": "" } }

Inserted into the parent body verbatim:

[Deploy checklist](/n/abc-mnop-xyz)

Tag shape (in-memory only, lib/tags.ts):

export type TagHit = { from: number; to: number; tag: string };
export function parseTags(body: string): TagHit[];
export function tagsInBody(body: string): string[];       // deduped, lowercased
export function notesWithTag(notes: Note[], tag: string): Note[];

Validation rules

  • A completion may only insert a link for a note the current user owns — guaranteed structurally: candidates come from the client's own notes list, which is user_id-scoped server-side.
  • Create "query" sends the query as body; the server derives title. Empty/whitespace-only query offers no Create option.
  • Backward compatibility: existing [label](/n/{id}) links, bare URLs, ?n= legacy links, and images/videos are untouched — this only adds an authoring path and a tag decoration.

Implementation Notes

Likely files to modify

  • components/codemirror-editor.tsx — accept noteLinkSource / onCreateNote props; register the completion extension, the tag highlight, and the bold keymap.
  • components/agentnote-app.tsx — pass note list + a non-navigating create callback to the editor; split the titlebar sync/error chrome; sidebar tag filter; window keydown meta+bmeta+shift+b; ⌘B copy.
  • app/globals.css — add .zed-titlebar__sync, .cm-tag, completion popup theming, sidebar tag-filter styles. Leave .zed-save-error alone.
  • package.json — add @codemirror/autocomplete (already in the lock).
  • README.md — document [[, /, #tag, and the new ⌘B/⌘⇧B bindings.

New files

  • lib/editor/note-links.ts[[ and / completion sources; the create-and-insert flow.
  • lib/editor/tags.ts#tag decoration + # completion source.
  • lib/tags.ts — pure tag parser (code-fence / inline-code / URL-fragment aware).
  • lib/editor/toggle-mark.ts — generic wrap/unwrap toggle extracted from toggleStrikethrough, now with two real callers (**, ~~). Not speculative: strikethrough is rewritten to call it, and the extracted helper gets the unit tests strikethrough never had.
  • lib/editor/bold.tstoggleBold + agentnoteBoldKeymap().

Flow — create a sub-note from [[

  1. User types [[dep in the editor (CRDT or legacy).
  2. Completion source matches the [[ trigger, returns matching notes + a Create "dep" option.
  3. User picks Create "dep".
  4. Synchronously delete the [[dep trigger text (a plain dispatch; safe on both paths).
  5. await onCreateNote("dep")POST /api/notes → note added to notes state → upsert broadcast to peer tabs.
  6. Insert [dep](/n/abc-mnop-xyz) at the current caret, read fresh from view.state at that moment.
  7. Clicking the label fires agentnote:open-note; the app finds the note (it is in state from step 5) and opens it.

Step 4/6 split is deliberate: the create is a network round trip, so document positions captured before the await may be stale. Deleting the trigger synchronously and re-reading the caret after the await means a user who keeps typing gets the link at their caret rather than a corrupted splice.

Tests

  • lib/tags.test.ts — tags parsed; #1 rejected; # Heading rejected; fenced-code and inline-code contents rejected; ](…#frag) rejected; #work/agentnote is one tag.
  • lib/editor/toggle-mark.test.ts — wrap, unwrap-from-inside, unwrap-from-outside, empty-caret insert, single undo step; for both ** and ~~.
  • lib/editor/note-links.test.ts — trigger matching for [[ / /; candidate filtering; Create option presence; the exact inserted Markdown.
  • lib/editor/tags.dom.test.ts#tag decorated, #1 and code spans not decorated.
  • Offline chrome: assert the CRDT offline element does not carry the error class / --c-error, and that loading renders "Syncing…" rather than "Saved".

Edge Cases And Risks

  • Published notes containing sub-note links. A /p/… reader clicking [Child](/n/abc-…) fires agentnote:open-note into a page with no listener → silent no-op. This is pre-existing behavior (PublicNoteView mounts the same agentnoteLinks()), not introduced here, but this feature will make it far more common. Documented limitation; publishing a whole linked subgraph is a non-goal.
  • Link to an archived or deleted note. Already a soft no-op via the unknown-id guard. The link text stays in the body; it is plain Markdown.
  • Title drift. The link label is a snapshot of the title at insertion time. Renaming the child does not rewrite parents (same as Notion's behavior for text links, unlike Obsidian's rename-refactor). Link-refactor-on-rename is a v2 non-goal.
  • Autocomplete vs IME. The CRDT path already holds remote updates during composition (createCompositionGate). The completion popup must not hijack keys mid-composition — @codemirror/autocomplete is composition-aware by default; verify with Korean input in QA.
  • [[ inside a code fence. Low-stakes: it would offer a completion in a code block. Acceptable for v1; the tag parser does respect fences because a tag renders decoration on existing text, whereas the completion only reacts to what the user is actively typing.
  • ⌘B regression risk. The window handler must lose the plain meta+b case entirely — preventDefault() in the CodeMirror keymap does not stop propagation, so leaving it would toggle the sidebar and bold on every press.
  • Bold marker vs existing decorations. ** is parsed by @codemirror/lang-markdown already; we add no decoration for it (unlike strikethrough, which hides its own ~~). Bold markers stay visible — consistent with how the editor renders other emphasis today.
  • Tag scan cost. Deriving tags across all notes on every render would be wasteful; memoize on the notes array.

Non-Goals

  • Full Obsidian graph view or a backlinks panel (may stub the data model, must not ship UI).
  • Nested note DB hierarchy / folders as the primary model.
  • Storing raw [[wikilink]] syntax in the body.
  • Link-refactor on rename.
  • A note_tags table, server-side tag search/filter API, tag rename, nested-tag hierarchy rollup, frontmatter tags: key, tag colors — all v2.
  • Phase-3 remote cursors / presence.
  • Changing the publish URL scheme or Meet-style ids.
  • Turning CRDT off.
  • Italic ⌘I and other formatting shortcuts.
  • Publishing a linked subgraph to /p/….

Acceptance Criteria

Links / sub-notes

  • Typing [[ in the editor opens a note picker filtered by the text after [[.
  • Selecting an existing note replaces the trigger with [Title](/n/{id}).
  • A non-empty query offers Create "query", which creates a real notes row via POST /api/notes and inserts its link without navigating away from the parent.
  • Typing / at line start (or after whitespace) opens a command palette with New note and Link to note.
  • Both work with NEXT_PUBLIC_AGENTNOTE_CRDT=1 (link text lands in Y.Text) and with the flag off.
  • Clicking an inserted link opens the target note in-app (existing agentnote:open-note path).
  • A note created this way appears in a second tab's sidebar via the existing upsert broadcast.
  • Nothing but standard Markdown is ever written to the body — no [[ ]] persists.

Tags

  • #tag is highlighted in the editor with .cm-tag; #1 and # Heading are not.
  • Tags inside fenced code blocks, inline code spans, and URL fragments are not treated as tags.
  • #work/agentnote parses as one tag.
  • Typing # completes from tags used across the user's notes.
  • The sidebar shows a tag filter; selecting a tag filters the note list; selecting again clears it.

Offline chrome

  • With CRDT on and the server unreachable, the titlebar shows a muted gray "Offline" to the left of Publish, with no red border/background and role="status".
  • .zed-save-error red chrome still renders for legacy auth / conflict / generic failures.
  • A CRDT note still loading/syncing reads "Syncing…", not "Saved" (Enable prod CRDT + Railway realtime after audit (Notion-like multi-tab sync) #75 residual).

Shortcuts

  • ⌘B / Ctrl+B in the editor toggles **bold** (wrap / unwrap / empty-caret ****), as one undo step.
  • ⌘B works on the CRDT path.
  • ⌘B no longer toggles the sidebar; ⌘⇧B / Ctrl+Shift+B does.
  • ⌘\ and ⌘N are unchanged.
  • Every ⌘B sidebar tooltip / hint / README mention now reads ⌘⇧B.

Repo

  • pnpm vitest run, pnpm lint, pnpm build, and tsc --noEmit are green.
  • README documents [[, /, #tag, and the new key bindings.

QA Plan

pnpm vitest run
pnpm lint
pnpm exec tsc --noEmit
pnpm build

Manual, with NEXT_PUBLIC_AGENTNOTE_CRDT=1:

  1. In a note, type [[ → picker appears. Type a few letters of an existing note → select → link inserted. Click it → that note opens.
  2. Type [[Deploy checklistCreate "Deploy checklist" → a new note is created, the link is inserted, and the parent note stays open. Click through → the child opens with Deploy checklist as its body.
  3. Open a second tab: the note created in step 2 is in its sidebar.
  4. Type / at line start → New note / Link to note both work.
  5. Type #work/agentnote and #idea in two notes → both highlight. Type # → completion lists both. Filter the sidebar by #idea → only that note remains; clear it.
  6. Type # Heading, #1, and a fenced block containing #nope → none highlight.
  7. Korean IME: compose text, then [[, then compose in the query — no dropped characters, no popup hijack.
  8. Kill the collab WebSocket (or go offline): titlebar shows muted gray Offline left of Publish, no red box. Keep typing — edits still apply. Reconnect → indicator clears.
  9. Hard-reload a note while offline before the socket connects → reads "Syncing…", not "Saved".
  10. Force a legacy conflict (CRDT off, two tabs) → red zed-save-error chrome still appears with Use server / Overwrite.
  11. Select a word, ⌘B**word**. ⌘B again → unwrapped. ⌘Z once → back to the prior state in one step. Empty caret + ⌘B**** with the caret centered.
  12. ⌘B with the sidebar open → sidebar does not toggle. ⌘⇧B toggles it. ⌘\ still toggles it. ⌘N still creates a note.

Suggested PR Scope

M. Split into two PRs, in this order:

  • PR-A — titlebar sync chrome + bold/sidebar shortcuts. .zed-titlebar__sync, loading-vs-offline labeling, lib/editor/toggle-mark.ts + bold.ts, window keydown change, copy updates. Small, independently reviewable, ships the two most visible annoyances first.
  • PR-B — wiki-style note links + tags. @codemirror/autocomplete, lib/editor/note-links.ts, lib/tags.ts + lib/editor/tags.ts, sidebar tag filter, README.

PR-B builds on nothing in PR-A, so they can be reviewed in parallel, but landing A first keeps B's diff purely about the feature.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions