You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
⌘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 windowCustomEvent("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:
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:
Because it is on window and does not check shiftKey, it fires for ⌘Bregardless 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().
components/codemirror-editor.tsx — editorExtensions(); where every editor extension is composed and where CRDT (ytext + yCollab) vs legacy (onChange) diverge.
lib/editor/strikethrough.ts — toggleStrikethrough() is the exact shape bold should take (changeByRange, wrap/unwrap/caret cases, single input event) and agentnoteStrikethroughKeymap() the exact binding shape.
lib/tab-sync.ts — SyncMessage; 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/autocomplete6.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/notesContent-Type: application/json
{ "body": "Deploy checklist" }
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+b → meta+shift+b; ⌘B copy.
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.
Completion source matches the [[ trigger, returns matching notes + a Create "dep" option.
User picks Create "dep".
Synchronously delete the [[dep trigger text (a plain dispatch; safe on both paths).
await onCreateNote("dep") → POST /api/notes → note added to notes state → upsert broadcast to peer tabs.
Insert [dep](/n/abc-mnop-xyz)at the current caret, read fresh from view.state at that moment.
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.
In a note, type [[ → picker appears. Type a few letters of an existing note → select → link inserted. Click it → that note opens.
Type [[Deploy checklist → Create "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.
Open a second tab: the note created in step 2 is in its sidebar.
Type / at line start → New note / Link to note both work.
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.
Type # Heading, #1, and a fenced block containing #nope → none highlight.
Korean IME: compose text, then [[, then compose in the query — no dropped characters, no popup hijack.
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.
Hard-reload a note while offline before the socket connects → reads "Syncing…", not "Saved".
Force a legacy conflict (CRDT off, two tabs) → red zed-save-error chrome still appears with Use server / Overwrite.
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.
⌘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.
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 (#tagwith 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):
⌘Bbecomes Markdown bold and the sidebar toggle moves to⌘⇧B."Sub-notes" here are not a nested DB hierarchy. They are ordinary
notesrows created through the existingPOST /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},createNotemints 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
notesrows (same table, same create API) — not nested DB children, not folders./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.[[ ]], tags) and Notion (easy page mentions / linked pages). Match the feel, not the pixels./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.Y.Text), not only the legacyonChangestring path.Tags
Offline chrome
zed-titlebar__sync) over reusingzed-save-error's red styles.Bold vs sidebar (added mid-flight)
⌘B/Ctrl+Bshould be Markdown bold wrap (Obsidian/Notion-style emphasis), working on both the CRDT and legacy editor paths.⌘⇧B/Ctrl+Shift+B. Update every tooltip/copy that still says ⌘B.⌘\(sidebar alternate) and⌘N(new note) alone.⌘Iis an optional nicety, explicitly not required.Current Behavior
Links
lib/editor/links.ts(agentnoteLinks()) already:[label](url)chrome and paints the label with.cm-md-link,/n/{id}and legacy?n=viaresolveHref()/noteIdFromInAppHref(),windowCustomEvent("agentnote:open-note", { detail: { id } }).components/agentnote-app.tsx:917-930listens for that event and callsselectNote(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⌘Nand the sidebar+button, and it navigates away from the current note.Tags
Nothing.
#foois plain text.Offline chrome
components/agentnote-app.tsx:334-343collapses CRDT status into the legacySaveState: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) withclassName="zed-save-error"androle="alert", positioned after the Publish button.app/globals.css:263-299:So a 2-second CRDT reconnect blip paints full red-alert chrome.
Shortcuts
components/agentnote-app.tsx:1298-1316— awindow-level keydown handler:Because it is on
windowand does not checkshiftKey, it fires for⌘Bregardless of editor focus, andpreventDefault()alone would not stop it from also running after a CodeMirror keymap handled the key. It must lose the plain-meta+bbinding, not merely be out-prioritized.Copy that references ⌘B for the sidebar:
agentnote-app.tsx:1320(comment),:1327(dock buttontitle),:1570(empty-state hint), plus README's editor bullet.Desired Behavior
A. Wiki-style note links (
[[) and a/command paletteWire format — decided: Markdown
[Title](/n/{id}).[[ ]]is a trigger, never a storage format. Nothing new is ever persisted.Rationale:
note_aliases, and already survive publish//p/…andnote_revisionsuntouched.yCollabintoY.Textfor free. No CRDT-aware link code.[[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:deriveNoteTitle), most-recent first, filtered by what is typed after[[;[[querytrigger with[Title](/n/{id});Create "query"option is offered (top of the list when nothing matches exactly). ItPOST /api/noteswithbody: query, adds the note to the list + broadcastsupsertto peer tabs, and inserts the link — without navigating away from the parent note;Escdismisses 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
#tagin the note body. Obsidian's primary form; zero new syntax to learn; Markdown-safe.Parsing rules (
lib/tags.ts):#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(.# 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/agentnoteparses as one tag. Hierarchy rollup is v2 — v1 treats it as an opaque string.v1 is client-derived — no database table.
GET /api/notesalready returns every note's fullbodyto the client (listNotes), 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, for zero user-visible gain at this size. Explicitly a v1 decision with a v2 escape hatch, not an oversight.Surfaces:
#tagpainted with.cm-tag(muted accent, no underline). Clicking sets the sidebar filter.#autocomplete — completes from tags used anywhere in the user's notes.C. Offline chrome
Split the one red block into two elements:
loading/syncing.zed-titlebar__syncoffline.zed-titlebar__syncsyncedauth/conflict/generic.zed-save-error.zed-titlebar__syncgets no border, no background,color: var(--c-text-muted), androle="status"(notrole="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.
⌘Bbold /⌘⇧Bsidebar⌘B/Ctrl+Bin 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.inputhistory event, so⌘Zundoes the wrap in a single step.view.dispatch, whichyCollabsyncs intoY.Text.windowhandler drops plainmeta+band gainsmeta+shift+bfor the sidebar.⌘\and⌘Nunchanged.On macOS
defaultKeymapbindsCtrl-btocursorCharLeft(emacs-style). Our binding isMod-b= Cmd on macOS, so there is no conflict there; on Linux/WindowsMod-b=Ctrl-b, whichdefaultKeymapdoes not bind. Bind atPrec.highregardless, matchingagentnoteStrikethroughKeymap().Source Of Truth
Internal repo
lib/editor/links.ts—agentnoteLinks(),resolveHref(),noteIdFromInAppHref(),openHref(). Theagentnote:open-noteevent 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.tsx—createNote()(:1155), theagentnote:open-notelistener (:917),displaySaveState(:334), titlebar JSX (:1321-1399),windowkeydown (:1298).components/codemirror-editor.tsx—editorExtensions(); where every editor extension is composed and where CRDT (ytext+yCollab) vs legacy (onChange) diverge.lib/editor/strikethrough.ts—toggleStrikethrough()is the exact shape bold should take (changeByRange, wrap/unwrap/caret cases, singleinputevent) andagentnoteStrikethroughKeymap()the exact binding shape.lib/notes.ts—createNote(),resolveCanonicalNoteId()(canonical / hyphenless / UUID / legacy-short / alias resolution).lib/note-id.ts— Meet-stylexxx-xxxx-xxxids. Note the alphabet excludesl.lib/note-title.ts—deriveNoteTitle(): title = first non-empty body line, capped at 120 chars. Client and server must agree.app/api/notes/route.ts—POSTaccepts{ title?, body? }, returns201 { note }.lib/tab-sync.ts—SyncMessage;upsertis how a new note reaches peer tabs.app/globals.css:263-299—.zed-save-errorred 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
[[Note]],[[Note|alias]], inline#tag,#parent/child). We emulate the gesture, not the storage format.@codemirror/autocomplete6.20.3 is already inpnpm-lock.yamlas a transitive dep of@codemirror/lang-markdown. Promote it to an explicitdependenciesentry; no new version enters the tree.Proposed API / Schema
No API or schema changes. Everything reuses existing endpoints.
Creating a sub-note from
[[//:{ "note": { "id": "abc-mnop-xyz", "title": "Deploy checklist", "body": "Deploy checklist", "updated_at": "…", "…": "…" } }Inserted into the parent body verbatim:
Tag shape (in-memory only,
lib/tags.ts):Validation rules
noteslist, which isuser_id-scoped server-side.Create "query"sends the query asbody; the server derivestitle. Empty/whitespace-only query offers no Create option.[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— acceptnoteLinkSource/onCreateNoteprops; 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;windowkeydownmeta+b→meta+shift+b; ⌘B copy.app/globals.css— add.zed-titlebar__sync,.cm-tag, completion popup theming, sidebar tag-filter styles. Leave.zed-save-erroralone.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—#tagdecoration +#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 fromtoggleStrikethrough, 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.ts—toggleBold+agentnoteBoldKeymap().Flow — create a sub-note from
[[[[depin the editor (CRDT or legacy).[[trigger, returns matching notes + aCreate "dep"option.Create "dep".[[deptrigger text (a plain dispatch; safe on both paths).await onCreateNote("dep")→POST /api/notes→ note added tonotesstate →upsertbroadcast to peer tabs.[dep](/n/abc-mnop-xyz)at the current caret, read fresh fromview.stateat that moment.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
awaitmay 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;#1rejected;# Headingrejected; fenced-code and inline-code contents rejected;](…#frag)rejected;#work/agentnoteis 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;Createoption presence; the exact inserted Markdown.lib/editor/tags.dom.test.ts—#tagdecorated,#1and code spans not decorated.--c-error, and thatloadingrenders "Syncing…" rather than "Saved".Edge Cases And Risks
/p/…reader clicking[Child](/n/abc-…)firesagentnote:open-noteinto a page with no listener → silent no-op. This is pre-existing behavior (PublicNoteViewmounts the sameagentnoteLinks()), not introduced here, but this feature will make it far more common. Documented limitation; publishing a whole linked subgraph is a non-goal.createCompositionGate). The completion popup must not hijack keys mid-composition —@codemirror/autocompleteis 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.⌘Bregression risk. Thewindowhandler must lose the plainmeta+bcase entirely —preventDefault()in the CodeMirror keymap does not stop propagation, so leaving it would toggle the sidebar and bold on every press.**is parsed by@codemirror/lang-markdownalready; 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.Non-Goals
[[wikilink]]syntax in the body.note_tagstable, server-side tag search/filter API, tag rename, nested-tag hierarchy rollup, frontmattertags:key, tag colors — all v2.⌘Iand other formatting shortcuts./p/….Acceptance Criteria
Links / sub-notes
[[in the editor opens a note picker filtered by the text after[[.[Title](/n/{id}).Create "query", which creates a realnotesrow viaPOST /api/notesand inserts its link without navigating away from the parent./at line start (or after whitespace) opens a command palette withNew noteandLink to note.NEXT_PUBLIC_AGENTNOTE_CRDT=1(link text lands inY.Text) and with the flag off.agentnote:open-notepath).upsertbroadcast.[[ ]]persists.Tags
#tagis highlighted in the editor with.cm-tag;#1and# Headingare not.#work/agentnoteparses as one tag.#completes from tags used across the user's notes.Offline chrome
role="status"..zed-save-errorred chrome still renders for legacyauth/conflict/genericfailures.loading/syncingreads "Syncing…", not "Saved" (Enable prod CRDT + Railway realtime after audit (Notion-like multi-tab sync) #75 residual).Shortcuts
⌘B/Ctrl+Bin the editor toggles**bold**(wrap / unwrap / empty-caret****), as one undo step.⌘Bworks on the CRDT path.⌘Bno longer toggles the sidebar;⌘⇧B/Ctrl+Shift+Bdoes.⌘\and⌘Nare unchanged.Repo
pnpm vitest run,pnpm lint,pnpm build, andtsc --noEmitare green.[[,/,#tag, and the new key bindings.QA Plan
pnpm vitest run pnpm lint pnpm exec tsc --noEmit pnpm buildManual, with
NEXT_PUBLIC_AGENTNOTE_CRDT=1:[[→ picker appears. Type a few letters of an existing note → select → link inserted. Click it → that note opens.[[Deploy checklist→Create "Deploy checklist"→ a new note is created, the link is inserted, and the parent note stays open. Click through → the child opens withDeploy checklistas its body./at line start →New note/Link to noteboth work.#work/agentnoteand#ideain two notes → both highlight. Type#→ completion lists both. Filter the sidebar by#idea→ only that note remains; clear it.# Heading,#1, and a fenced block containing#nope→ none highlight.[[, then compose in the query — no dropped characters, no popup hijack.zed-save-errorchrome still appears with Use server / Overwrite.⌘B→**word**.⌘Bagain → unwrapped.⌘Zonce → back to the prior state in one step. Empty caret +⌘B→****with the caret centered.⌘Bwith the sidebar open → sidebar does not toggle.⌘⇧Btoggles it.⌘\still toggles it.⌘Nstill creates a note.Suggested PR Scope
M. Split into two PRs, in this order:
.zed-titlebar__sync, loading-vs-offline labeling,lib/editor/toggle-mark.ts+bold.ts,windowkeydown change, copy updates. Small, independently reviewable, ships the two most visible annoyances first.@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.