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
Give agentnote a real note hierarchy: a durable notes.parent_id edge, set when a note is created from inside another note, rendered as an expandable/collapsible tree in the sidebar with the feel of Zed's project panel.
This revises the lock in #77, which said sub-notes are "not a nested DB hierarchy" and put "nested note DB hierarchy / folders" in Non-Goals. That call was right for #79's authoring layer — it kept the wire format plain Markdown and the CRDT untouched — but it left every note at the same level in the sidebar, so a note tree built with [[ reads as a flat recency bag. Chase has since locked the opposite direction for creation:
"link은 그냥 다른 곳으로 가는 거고, 그 안에서 생성하거나 하는 놈들은 sub note."
(A link just goes somewhere else; the ones you create from inside a note are sub-notes.)
The key distinction, and the whole point of this issue: linking ≠ nesting. Only creation-from-inside nests.
Why This Matters
#79 shipped [[ completion and a / palette, so producing a sub-note is now one gesture. But every note it creates lands as a sibling of its parent in a list sorted purely by updated_at DESC. Ten minutes of linked thinking produces ten top-level rows and no visible structure. The graph exists in the text and nowhere in the UI.
The fix is not "more link chrome" — it is a folder-shaped sidebar. Chase's words: folder-feel dependency in the UI. A note you created while inside "Q3 planning" should live under "Q3 planning", the way a file you create inside a directory in Zed lands inside that directory.
This is also the cheapest moment to do it. #79 already funnels every create-from-editor through one helper (createNoteRow), so the parent edge is a single argument threaded through a single call site. Waiting means retrofitting parentage onto notes created without it.
Conversation Context
Product locks from Chase, authoritative for this issue and superseding #77's flat-graph wording where they conflict:
Create vs link matrix
Action
Result
Link to an existing note — Link to note in the / palette, or picking an existing note in [[
Peer hyperlink only. Inserts [Title](/n/{id}). Creates no parent/child edge. Nothing about the target note changes.
Create from inside a note — [[Foo → Create "Foo", or / → New note with a title
True sub-note. Still an ordinary notes row, but owned under the active note as parent. Nests under the parent in the sidebar. Still inserts the same Markdown link in the parent body.
⌘N / sidebar +
Root-level note (parent_id = null). Current behavior, including navigating to it. Not a sub-note.
Corollary lock, stated because it is the tempting wrong turn: hierarchy is a create-time parent, not "any link". A body containing [X](/n/abc) does not make abc a child. Do not derive the tree by parsing bodies.
Sidebar is a tree — parents expandable/collapsible, children indented under the parent.
Creating a sub-note from the current note should feel like Zed's "new file inside the selected directory": the child appears under its parent, not at the top of a recency-flattened list.
Keyboard expand/collapse (arrows / disclosure chevron) is expected.
Auto-reveal the active note's ancestors when it is selected — Zed's project_panel.auto_reveal_entries spirit: "switching to a file in the editor will automatically highlight it in the project panel and scroll it into view."
Optional v1 niceties if cheap: sticky ancestor rows (project_panel.sticky_scroll, "ancestor directories pin themselves to the top of the panel as you scroll") and compact single-child chains (project_panel.auto_fold_dirs, "chains of directories that each contain a single child directory are collapsed into one row").
Do not block v1 on full Zed parity. Multi-select, drag-and-drop reparenting, and cut/paste between folders are explicit residuals.
Current Behavior
Data model — no parent edge exists
lib/db.ts:86-245ensureSchema() is the migration mechanism: a sequence of idempotent CREATE TABLE IF NOT EXISTS / ALTER TABLE … ADD COLUMN IF NOT EXISTS / CREATE INDEX IF NOT EXISTS statements behind a memoized global.__agentnoteSchemaReady promise. is_public, public_id, published_at, author_handle, and deleted_at were all added exactly this way. There is no parent_id.
lib/types.ts:1-22Note has no parent field. lib/notes.ts:274-306createNote(userId, { title, body }) takes no parent.
Listing is flat and recency-ordered
lib/notes.ts:167-176:
exportasyncfunctionlistNotes(userId: string): Promise<Note[]>{constresult=awaitquery<NoteRow>(`SELECT ${NOTE_COLUMNS} FROM notes WHERE user_id = $1 AND deleted_at IS NULL ORDER BY updated_at DESC`,[userId],);returnresult.rows.map(mapNote);}
components/agentnote-app.tsx:551-555 re-sorts client-side and applies the #tag filter:
components/agentnote-app.tsx:1531-1590 renders sortedNotes.map(...) into one flat <nav className="zed-panel__list"> of .zed-note-item rows. No indentation, no disclosure, no nesting. CSS at app/globals.css:599-694.
Create paths (post-#79, the surface this issue changes)
components/agentnote-app.tsx:1180-1228 — one shared helper, two callers:
/** * Create a note and register it locally, without touching the active note. * Shared by ⌘N (which then navigates) and the editor's `[[` / `/` sub-note * flow (which must NOT navigate — the user is mid-sentence in the parent). */constcreateNoteRow=useCallback(async(input?: {body?: string}): Promise<Note|null>=>{constresponse=awaitfetch("/api/notes",{method: "POST",headers: {"Content-Type": "application/json"},body: JSON.stringify({body: input?.body??""}),});if(!response.ok)returnnull;constdata=(awaitresponse.json())as{note: Note};setNotes((prev)=>sortNotesByRecent([data.note, ...prev]));syncPost.current({type: "upsert",sourceId: tabId.current,note: data.note});returndata.note;},[],);constcreateNote=useCallback(async()=>{// ⌘N + sidebar `+`constok=awaitensureSafeToLeaveActive();if(!ok)return;constnote=awaitcreateNoteRow();if(!note)return;awaitselectNote(note,{skipFlush: true});if(!isNarrowViewport())setSidebarOpen(true);},[createNoteRow,ensureSafeToLeaveActive,selectNote]);constnoteLinkOptions=useMemo(// `[[` and `/`()=>({candidates: ()=>notesRef.current.map((note)=>({id: note.id,title: previewTitle(note)})),createNote: async(title: string)=>{constnote=awaitcreateNoteRow({body: title});returnnote ? {id: note.id,title: previewTitle(note)} : null;},}),[createNoteRow],);
lib/editor/note-links.ts already routes exactly the two create-from-inside gestures through options.createNote — createCompletion (the [[ → Create "…" item, :115-131) and the / palette's New note (:176-198). Picking an existing note goes through linkCompletion (:95-113), which never touches createNote. The create/link split this issue needs already exists in the code; only the parent argument is missing.
app/api/notes/route.ts:22-45POST reads only { title, body } and ignores everything else.
Sync
lib/tab-sync.ts:29-33 — the upsert message carries a whole Note, so any new column rides along automatically once it is on the type. components/agentnote-app.tsx:1066-1114 merges peer upserts into notes.
CRDT (lib/crdt/*) owns the note body only. app/api/notes/[id]/route.ts:74-85 409s a whole-document PUT on a CRDT-managed note (crdt_managed_body). Nothing in that path reads or writes note metadata columns.
Desired Behavior
Creating a note from inside note P (via [[ → Create "…" or / → New note) stores parent_id = P.id and inserts [Title](/n/{child}) into P's body as it does today.
Linking an existing note leaves parent_id untouched on both notes.
⌘N and the sidebar + create root notes (parent_id = null).
The sidebar renders a tree: children indented under their parent, parents carrying a disclosure chevron.
Expanding/collapsing works by click and by keyboard (→/← on a focused row; ⌘→/⌘← for expand-all/collapse-all), and the state survives reload.
Selecting a note auto-reveals it: every ancestor expands.
A newly created sub-note is immediately visible under its parent, in this tab and in peer tabs.
Archiving a parent does not hide or archive its children — they render at root level until the parent is restored.
Source Of Truth
Internal
Path
Why it matters
lib/db.ts:86-245
ensureSchema() — the idempotent migration to extend. Follow the ADD COLUMN IF NOT EXISTS style used for deleted_at / author_handle.
lib/notes.ts:29-40, 66-67, 69-82
NoteRow, NOTE_COLUMNS, mapNote — every read path funnels through these three.
lib/notes.ts:167-176
listNotes — the tree's data source.
lib/notes.ts:194-233
resolveCanonicalNoteId — use it to validate an incoming parent_id (handles aliases, hyphenless ids, and tenant scoping in one call).
lib/notes.ts:274-306
createNote — id-collision retry loop; the insert to extend.
lib/notes.ts:456-479, 481-500, 503-518
archiveNote / restoreNote / permanentlyDeleteNote — the lifecycle events that must have defined parent semantics.
lib/types.ts:1-22
Note. Adding a field here is what makes it flow through upsert broadcasts and SSR props for free.
app/api/notes/route.ts:22-45
POST /api/notes — accepts the new optional parent_id.
components/agentnote-app.tsx:1180-1228
createNoteRow / createNote / noteLinkOptions — the single seam where create-from-inside diverges from ⌘N.
components/agentnote-app.tsx:551-555, 1531-1590
sortedNotes + the flat sidebar list to replace with a tree.
lib/editor/note-links.ts:95-131, 163-229
Confirms createNote is called by exactly the two create gestures and never by link-existing. Should not need to change.
lib/tab-sync.ts:29-33
upsert carries a whole Note.
app/globals.css:599-694
.zed-panel__list / .zed-note-item styles to extend with indentation + chevron.
app/page.tsx, app/n/[id]/page.tsx
SSR entry points; both hand listNotes() output to AgentNoteApp as initialNotes.
Add after the existing deleted_at block, before note_revisions:
-- True sub-notes: a note created from inside another note is owned by it.-- Peer hyperlinks in the body do NOT create this edge (see #77 / #79).ALTERTABLE notes
ADD COLUMN IF NOT EXISTS parent_id TEXTREFERENCES notes(id) ONUPDATE CASCADE ON DELETESETNULL;
CREATEINDEXIF NOT EXISTS notes_user_parent_idx
ON notes (user_id, parent_id);
ON DELETE SET NULL, deliberately notCASCADE: permanently deleting a parent must promote its children to root, never silently delete a subtree the user did not select. ON UPDATE CASCADE matches every other FK here and keeps migrateLegacyNoteIds (lib/db.ts:34-84) working when a PK is rewritten.
Nullable forever — root notes are the common case.
Note type (lib/types.ts)
/** * Owning note, set only when this note was CREATED from inside another note * (`[[` create / `/` New note). Null = root. Peer links in a body never set * this — hierarchy is a create-time edge, not a parse of the text. */
parent_id: string|null;
Add parent_id to NOTE_COLUMNS, NoteRow, and mapNote in lib/notes.ts. Do not add it to PublicNote — hierarchy is private and /p/… payloads stay unchanged.
parent_id absent, null, or "" → root note. No error.
Non-string parent_id → 400.
parent_id that fails isValidNoteId → 400.
parent_id that resolveCanonicalNoteId(userId, parentId) cannot resolve → 400. That single call enforces tenant isolation (another user's note is unresolvable) and liveness (archived notes are excluded unless includeArchived) at once. Store the canonical id it returns, not the raw input, so aliased ids normalize.
Fail closed, not silently-root. A create that was meant to nest must not quietly produce a root note. The client compensates: only send parent_id when the active note is present in the live notes list (see Implementation Notes), so the realistic 400 is a genuine bug or a cross-tab archive race.
No cycles are structurally reachable in v1: parent_id is set once at insert, on a row that cannot yet have children, and there is no reparent endpoint. Defence in depth is in the tree builder, which must be cycle-safe (see below) so a hand-edited or corrupted row degrades to root rows instead of hanging the render.
Backward compatibility: existing clients that POST { title, body } keep working unchanged; every existing note reads back parent_id: null.
Lifecycle semantics (locked)
Event
Parent edge
Sidebar
Archive a parent
Children keep parent_id. Unchanged rows.
Parent leaves the live list; children's parent_id no longer resolves, so the tree builder renders them at root.
Restore that parent
Unchanged
Children re-nest under it automatically.
Permanently delete a parent
ON DELETE SET NULL promotes children to root
Children stay, now at root. Irreversible, and correct — the parent row is gone.
Archive a child
Unchanged
Child moves to the Archived section (flat, as today).
Archiving a parent deliberately does not cascade-archive children. Cascading would make one × click hide an arbitrary amount of work, and restoreNote has no record of which children it archived to restore them precisely.
Implementation Notes
New files
lib/note-tree.ts — pure, dependency-free, fully unit-testable. The load-bearing module.
importtype{Note}from"./types";exporttypeNoteTreeRow={note: Note;depth: number;hasChildren: boolean;expanded: boolean;};/** * Group notes into parent/child order. * * Cycle- and orphan-safe by construction: a `parent_id` that is not present in * `notes` (archived parent, cross-tenant row) and any node reachable from * itself are both demoted to roots. Siblings keep the app's recency order. */exportfunctionbuildNoteTree(notes: Note[]): Note[/* roots */];/** Ancestor chain, nearest first. Used to auto-reveal the active note. */exportfunctionancestorIds(notes: Note[],id: string): string[];/** * Depth-first render order. `collapsed` holds ids whose children are hidden — * absent means expanded, so a brand-new sub-note is visible immediately. */exportfunctionflattenNoteTree(notes: Note[],collapsed: ReadonlySet<string>,): NoteTreeRow[];
Sibling order: updated_at DESC, i.e. today's sortNotesByRecent applied within each level. Recency still drives the eye; it just no longer flattens structure.
lib/note-tree.test.ts — see Tests.
Modified files
lib/db.ts — the ALTER TABLE + index above, inside ensureSchema().
lib/types.ts — parent_id: string | null on Note.
lib/notes.ts — parent_id in NoteRow, NOTE_COLUMNS, mapNote; createNote(userId, { title, body, parentId }) inserting the fourth column.
app/api/notes/route.ts — parse + validate parent_id, resolve to canonical, pass to createNote.
components/agentnote-app.tsx —
createNoteRow(input?: { body?: string; parentId?: string | null }), sending parent_id only when set.
noteLinkOptions.createNote passes parentId: activeIdRef.current, guarded: only when that id is present in notesRef.current (a live note). activeIdRef is already in scope (used at :1261).
createNote (⌘N / +) unchanged — no parentId, so root.
collapsed state: useState<Set<string>>, persisted to localStorage under agentnote.tree.collapsed alongside the existing WRAP_STORAGE_KEY pattern.
Auto-reveal: an effect on activeId that deletes ancestorIds(notes, activeId) from collapsed.
Replace the sortedNotes.map(...) list with flattenNoteTree(...).map(...), rendering a chevron button when hasChildren and indenting via a --depth custom property.
#tag filter stays flat. When tagFilter is set, render today's filtered flat list — a filter is a search view, not a tree. Keep this explicit in a comment so it does not read as an oversight.
app/globals.css — indentation on .zed-note-item__hit (padding-left: calc(12px + var(--depth) * 12px)), a .zed-note-item__chevron disclosure control sized to the existing 20px icon-button grammar, and a fixed-width empty slot for childless rows so titles stay aligned.
README.md — a short section stating the create-vs-link rule.
Flow
User types [[Retro in note P → CodeMirror [[ source offers Create "Retro" (lib/editor/note-links.ts:151-156).
createAndInsert (:70-93) removes the trigger text, then awaits options.createNote("Retro").
POST /api/notes { body, parent_id } → resolveCanonicalNoteId validates ownership + liveness → createNote inserts with parent_id.
Response note (carrying parent_id) is merged into notes and broadcast as upsert; peer tabs rebuild their tree from the same field.
createAndInsert inserts [Retro](/n/{child}) at the caret — through yCollab into Y.Text when CRDT is on, exactly as today. The body write is unchanged; only the row's metadata is new.
Sidebar re-renders: Retro appears indented under P, P gains a chevron.
Tests
lib/note-tree.test.ts (pure, node env):
roots and children group correctly; sibling order is updated_at DESC
flattenNoteTree emits depth-first order with correct depth / hasChildren
a note whose parent_id is absent from the input (archived parent) renders at root, not dropped
a 2-cycle (a.parent = b, b.parent = a) and a self-cycle (a.parent = a) both terminate and yield root rows — no infinite recursion, no lost notes
collapsed hides a whole subtree, not just direct children
ancestorIds returns the chain nearest-first and [] for a root / unknown id
lib/notes-parent.test.ts (mock query in the style of lib/notes-revisions.test.ts):
createNote with parentId writes it; without, writes null
mapNote surfaces parent_id
app/api/notes route validation (in the style of lib/crdt/doc-routes.test.ts):
create-from-editor payload sets the parent
link-existing performs no create at all — asserted at the note-links level: linkCompletion's apply inserts Markdown and never calls options.createNote (extends lib/editor/note-links.test.ts)
another user's note id as parent_id → 400, no row written (tenant isolation)
an archived note id as parent_id → 400
a malformed parent_id → 400
absent parent_id → 201, parent_id: null
Edge Cases And Risks
Tenant isolation is the sharpest edge: parent_id is a user-supplied foreign key into notes. Routing it through resolveCanonicalNoteId(userId, …) rather than trusting the string is mandatory — a raw insert would let a crafted POST attach a note under another tenant's row and leak its id through the tree.
Cycles. Unreachable through the API today, but the tree builder recurses over user-controlled data. It must be cycle-safe by construction (visited set), not by assuming the API is the only writer.
400 on a mid-typing archive race. If the parent is archived in another tab between the keystroke and the POST, the create fails and createAndInsert has already removed the trigger text — the user loses [[Retro. Rare, and identical to today's behavior for any create failure (offline, 500). The client-side live-note guard closes the common case. Accepted; not worth a retry path in v1.
CRDT is untouched. Hierarchy is a notes row field. No Yjs document changes, no new update types, no change to the crdt_managed_body 409 gate. Stated explicitly because the natural instinct is to sync it through the doc.
Migration on a live DB.ADD COLUMN … IF NOT EXISTS on a nullable column with no default is a catalog-only operation in Postgres — no table rewrite, safe on the production notes table. The self-referencing FK does require a validation scan; on a table this size that is negligible.
Deep nesting overflow. Long titles at depth 5+ will clip in a narrow sidebar. text-overflow: ellipsis already applies; no depth cap in v1.
Backward compatibility. Every existing note reads parent_id: null and renders exactly as today. Old bundles that POST without parent_id keep creating root notes. /p/… payloads are byte-identical.
Non-Goals
Real filesystem folders, multiple parents, or a graph DB.
Drag-and-drop reparenting (residual — implement only if it falls out trivially).
Deriving hierarchy from Markdown links. Explicitly rejected: hierarchy is the create-time parent, never a parse of /n/ links in a body.
Changing publish URLs, the Meet-style note id format, or turning CRDT off.
Exposing hierarchy in /p/… public payloads, or publishing a linked subgraph.
Full Zed parity: multi-select, cut/paste between folders, indent guides.
Cascade-archiving children with their parent.
Server-side tree assembly or a GET /api/notes?parent=… endpoint — the flat list plus parent_id is enough to build the tree client-side without an N+1.
Acceptance Criteria
Data model
notes.parent_id TEXT NULL REFERENCES notes(id) ON UPDATE CASCADE ON DELETE SET NULL exists, with notes_user_parent_idx on (user_id, parent_id).
ensureSchema() is idempotent — running it against an already-migrated DB is a no-op, and against a pre-migration DB adds the column without touching existing rows.
Note.parent_id is returned by GET /api/notes, GET /api/notes/{id}, and SSR initialNotes.
PublicNote is unchanged.
Create vs link
[[ → Create "X" inside note P creates a note with parent_id = P.id.
/ → New note with a title inside P creates a note with parent_id = P.id.
Picking an existing note in [[, or / → Link to note, changes noparent_id on either note.
⌘N and the sidebar + create notes with parent_id = null.
Body wire format is unchanged: [Title](/n/{id}), no [[ persisted.
Validation
parent_id belonging to another user → 400, no row created.
parent_id of an archived note → 400.
Malformed parent_id → 400.
A parent_id given as an alias / hyphenless id is stored canonicalized.
Sidebar (Zed feel)
Children render indented under their parent; parents show a disclosure chevron.
Clicking the chevron collapses/expands; the state persists across reload.
→ / ← on a focused row expand/collapse it; ⌘→ / ⌘← expand/collapse all.
Selecting a nested note auto-expands all its ancestors (auto_reveal_entries spirit).
A sub-note created from the editor appears under its parent immediately, without navigating away from the parent.
It appears under the same parent in a second tab via the existing upsert broadcast.
Archiving a parent leaves its children visible at root level; restoring it re-nests them.
With a #tag filter active, the list is flat (documented, not accidental).
Repo
pnpm test, pnpm lint, pnpm build, and tsc --noEmit are green.
README explains sub-notes vs links.
QA Plan
pnpm test — new lib/note-tree.test.ts and parent-validation tests pass; the existing 312 stay green.
pnpm lint && npx tsc --noEmit && pnpm build.
Migration idempotence: run ensureSchema() twice against a local Postgres; second run is a no-op. Verify with \d notes that the column, FK, and index exist.
Manual, CRDT on (NEXT_PUBLIC_AGENTNOTE_CRDT=1):
In note A, [[Child → Create "Child". Assert: link inserted at the caret, still editing A, Child appears indented under A.
In note A, [[ → pick existing note B. Assert: B does not move under A.
⌘N. Assert: new note is at root.
Collapse A, reload. Assert: still collapsed. Open /n/{Child} directly. Assert: A auto-expands and Child is selected.
Second tab open on /: create a sub-note in tab 1, assert it appears nested in tab 2 without a reload.
Archive A. Assert: Child is still listed, now at root. Restore A. Assert: Child is nested again.
Post-deploy: hard-refresh agentnote.dev (service-worker/bundle cache), then re-run step 4's first two checks against production.
Suggested PR Scope
One PR, M/L (~500 lines). Schema, API validation, and the tree renderer are one dependency chain — the column is unobservable without the sidebar, and the sidebar is untestable without the column. Splitting would ship a migration nobody can verify. lib/note-tree.ts carries the logic and is pure, so review effort concentrates in one small, heavily-tested file.
Summary
Give agentnote a real note hierarchy: a durable
notes.parent_idedge, set when a note is created from inside another note, rendered as an expandable/collapsible tree in the sidebar with the feel of Zed's project panel.This revises the lock in #77, which said sub-notes are "not a nested DB hierarchy" and put "nested note DB hierarchy / folders" in Non-Goals. That call was right for #79's authoring layer — it kept the wire format plain Markdown and the CRDT untouched — but it left every note at the same level in the sidebar, so a note tree built with
[[reads as a flat recency bag. Chase has since locked the opposite direction for creation:The key distinction, and the whole point of this issue: linking ≠ nesting. Only creation-from-inside nests.
Why This Matters
#79 shipped
[[completion and a/palette, so producing a sub-note is now one gesture. But every note it creates lands as a sibling of its parent in a list sorted purely byupdated_at DESC. Ten minutes of linked thinking produces ten top-level rows and no visible structure. The graph exists in the text and nowhere in the UI.The fix is not "more link chrome" — it is a folder-shaped sidebar. Chase's words: folder-feel dependency in the UI. A note you created while inside "Q3 planning" should live under "Q3 planning", the way a file you create inside a directory in Zed lands inside that directory.
This is also the cheapest moment to do it. #79 already funnels every create-from-editor through one helper (
createNoteRow), so the parent edge is a single argument threaded through a single call site. Waiting means retrofitting parentage onto notes created without it.Conversation Context
Product locks from Chase, authoritative for this issue and superseding #77's flat-graph wording where they conflict:
Create vs link matrix
Link to notein the/palette, or picking an existing note in[[[Title](/n/{id}). Creates no parent/child edge. Nothing about the target note changes.[[Foo→Create "Foo", or/→New notewith a titlenotesrow, but owned under the active note as parent. Nests under the parent in the sidebar. Still inserts the same Markdown link in the parent body.+parent_id = null). Current behavior, including navigating to it. Not a sub-note.Corollary lock, stated because it is the tempting wrong turn: hierarchy is a create-time parent, not "any link". A body containing
[X](/n/abc)does not makeabca child. Do not derive the tree by parsing bodies.UI — actively reference Zed
Primary reference: the Zed project panel (zed-industries/zed, docs
docs/src/project-panel.md). Match the feel, not a pixel clone:project_panel.auto_reveal_entriesspirit: "switching to a file in the editor will automatically highlight it in the project panel and scroll it into view."project_panel.sticky_scroll, "ancestor directories pin themselves to the top of the panel as you scroll") and compact single-child chains (project_panel.auto_fold_dirs, "chains of directories that each contain a single child directory are collapsed into one row").Current Behavior
Data model — no parent edge exists
lib/db.ts:86-245ensureSchema()is the migration mechanism: a sequence of idempotentCREATE TABLE IF NOT EXISTS/ALTER TABLE … ADD COLUMN IF NOT EXISTS/CREATE INDEX IF NOT EXISTSstatements behind a memoizedglobal.__agentnoteSchemaReadypromise.is_public,public_id,published_at,author_handle, anddeleted_atwere all added exactly this way. There is noparent_id.lib/notes.ts:66-67:lib/types.ts:1-22Notehas no parent field.lib/notes.ts:274-306createNote(userId, { title, body })takes no parent.Listing is flat and recency-ordered
lib/notes.ts:167-176:components/agentnote-app.tsx:551-555re-sorts client-side and applies the#tagfilter:components/agentnote-app.tsx:1531-1590renderssortedNotes.map(...)into one flat<nav className="zed-panel__list">of.zed-note-itemrows. No indentation, no disclosure, no nesting. CSS atapp/globals.css:599-694.Create paths (post-#79, the surface this issue changes)
components/agentnote-app.tsx:1180-1228— one shared helper, two callers:lib/editor/note-links.tsalready routes exactly the two create-from-inside gestures throughoptions.createNote—createCompletion(the[[→Create "…"item,:115-131) and the/palette'sNew note(:176-198). Picking an existing note goes throughlinkCompletion(:95-113), which never touchescreateNote. The create/link split this issue needs already exists in the code; only the parent argument is missing.app/api/notes/route.ts:22-45POSTreads only{ title, body }and ignores everything else.Sync
lib/tab-sync.ts:29-33— theupsertmessage carries a wholeNote, so any new column rides along automatically once it is on the type.components/agentnote-app.tsx:1066-1114merges peer upserts intonotes.CRDT (
lib/crdt/*) owns the note body only.app/api/notes/[id]/route.ts:74-85409s a whole-documentPUTon a CRDT-managed note (crdt_managed_body). Nothing in that path reads or writes note metadata columns.Desired Behavior
P(via[[→Create "…"or/→New note) storesparent_id = P.idand inserts[Title](/n/{child})intoP's body as it does today.parent_iduntouched on both notes.+create root notes (parent_id = null).→/←on a focused row;⌘→/⌘←for expand-all/collapse-all), and the state survives reload.Source Of Truth
Internal
lib/db.ts:86-245ensureSchema()— the idempotent migration to extend. Follow theADD COLUMN IF NOT EXISTSstyle used fordeleted_at/author_handle.lib/notes.ts:29-40, 66-67, 69-82NoteRow,NOTE_COLUMNS,mapNote— every read path funnels through these three.lib/notes.ts:167-176listNotes— the tree's data source.lib/notes.ts:194-233resolveCanonicalNoteId— use it to validate an incomingparent_id(handles aliases, hyphenless ids, and tenant scoping in one call).lib/notes.ts:274-306createNote— id-collision retry loop; the insert to extend.lib/notes.ts:456-479, 481-500, 503-518archiveNote/restoreNote/permanentlyDeleteNote— the lifecycle events that must have defined parent semantics.lib/types.ts:1-22Note. Adding a field here is what makes it flow throughupsertbroadcasts and SSR props for free.app/api/notes/route.ts:22-45POST /api/notes— accepts the new optionalparent_id.components/agentnote-app.tsx:1180-1228createNoteRow/createNote/noteLinkOptions— the single seam where create-from-inside diverges from ⌘N.components/agentnote-app.tsx:551-555, 1531-1590sortedNotes+ the flat sidebar list to replace with a tree.lib/editor/note-links.ts:95-131, 163-229createNoteis called by exactly the two create gestures and never by link-existing. Should not need to change.lib/tab-sync.ts:29-33upsertcarries a wholeNote.app/globals.css:599-694.zed-panel__list/.zed-note-itemstyles to extend with indentation + chevron.app/page.tsx,app/n/[id]/page.tsxlistNotes()output toAgentNoteAppasinitialNotes.External
auto_reveal_entries,auto_fold_dirs(default on),sticky_scroll(default on); arrow-key expand/collapse,cmd-left/cmd-rightcollapse-all/expand-all.crates/project_panel/for the visual grammar (indent, chevron, selected-row treatment).4d76c6athat shipped[[///#tag).Proposed API / Schema
Migration (
lib/db.ts, insideensureSchema())Add after the existing
deleted_atblock, beforenote_revisions:ON DELETE SET NULL, deliberately notCASCADE: permanently deleting a parent must promote its children to root, never silently delete a subtree the user did not select.ON UPDATE CASCADEmatches every other FK here and keepsmigrateLegacyNoteIds(lib/db.ts:34-84) working when a PK is rewritten.Nullable forever — root notes are the common case.
Notetype (lib/types.ts)Add
parent_idtoNOTE_COLUMNS,NoteRow, andmapNoteinlib/notes.ts. Do not add it toPublicNote— hierarchy is private and/p/…payloads stay unchanged.POST /api/notesRequest:
{ "body": "Sprint retro", "parent_id": "abc-defg-hij" }parent_idis optional; omit ornullfor a root note.Response
201(unchanged shape, one new field):{ "note": { "id": "kmn-opqr-stu", "title": "", "body": "Sprint retro", "parent_id": "abc-defg-hij", "created_at": "2026-08-05T09:00:00.000Z", "updated_at": "2026-08-05T09:00:00.000Z", "deleted_at": null, "is_public": false, "public_id": null, "published_at": null, "author_handle": null } }Error
400:{ "error": "Invalid parent_id" }Validation rules
parent_idabsent,null, or""→ root note. No error.parent_id→400.parent_idthat failsisValidNoteId→400.parent_idthatresolveCanonicalNoteId(userId, parentId)cannot resolve →400. That single call enforces tenant isolation (another user's note is unresolvable) and liveness (archived notes are excluded unlessincludeArchived) at once. Store the canonical id it returns, not the raw input, so aliased ids normalize.parent_idwhen the active note is present in the livenoteslist (see Implementation Notes), so the realistic 400 is a genuine bug or a cross-tab archive race.parent_idis set once at insert, on a row that cannot yet have children, and there is no reparent endpoint. Defence in depth is in the tree builder, which must be cycle-safe (see below) so a hand-edited or corrupted row degrades to root rows instead of hanging the render.{ title, body }keep working unchanged; every existing note reads backparent_id: null.Lifecycle semantics (locked)
parent_id. Unchanged rows.parent_idno longer resolves, so the tree builder renders them at root.ON DELETE SET NULLpromotes children to rootArchiving a parent deliberately does not cascade-archive children. Cascading would make one
×click hide an arbitrary amount of work, andrestoreNotehas no record of which children it archived to restore them precisely.Implementation Notes
New files
lib/note-tree.ts— pure, dependency-free, fully unit-testable. The load-bearing module.Sibling order:
updated_at DESC, i.e. today'ssortNotesByRecentapplied within each level. Recency still drives the eye; it just no longer flattens structure.lib/note-tree.test.ts— see Tests.Modified files
lib/db.ts— theALTER TABLE+ index above, insideensureSchema().lib/types.ts—parent_id: string | nullonNote.lib/notes.ts—parent_idinNoteRow,NOTE_COLUMNS,mapNote;createNote(userId, { title, body, parentId })inserting the fourth column.app/api/notes/route.ts— parse + validateparent_id, resolve to canonical, pass tocreateNote.components/agentnote-app.tsx—createNoteRow(input?: { body?: string; parentId?: string | null }), sendingparent_idonly when set.noteLinkOptions.createNotepassesparentId: activeIdRef.current, guarded: only when that id is present innotesRef.current(a live note).activeIdRefis already in scope (used at:1261).createNote(⌘N /+) unchanged — noparentId, so root.collapsedstate:useState<Set<string>>, persisted tolocalStorageunderagentnote.tree.collapsedalongside the existingWRAP_STORAGE_KEYpattern.activeIdthat deletesancestorIds(notes, activeId)fromcollapsed.sortedNotes.map(...)list withflattenNoteTree(...).map(...), rendering a chevron button whenhasChildrenand indenting via a--depthcustom property.#tagfilter stays flat. WhentagFilteris set, render today's filtered flat list — a filter is a search view, not a tree. Keep this explicit in a comment so it does not read as an oversight.app/globals.css— indentation on.zed-note-item__hit(padding-left: calc(12px + var(--depth) * 12px)), a.zed-note-item__chevrondisclosure control sized to the existing 20px icon-button grammar, and a fixed-width empty slot for childless rows so titles stay aligned.README.md— a short section stating the create-vs-link rule.Flow
[[Retroin noteP→ CodeMirror[[source offersCreate "Retro"(lib/editor/note-links.ts:151-156).createAndInsert(:70-93) removes the trigger text, then awaitsoptions.createNote("Retro").noteLinkOptions.createNotecallscreateNoteRow({ body: "Retro", parentId: P.id }).POST /api/notes { body, parent_id }→resolveCanonicalNoteIdvalidates ownership + liveness →createNoteinserts withparent_id.parent_id) is merged intonotesand broadcast asupsert; peer tabs rebuild their tree from the same field.createAndInsertinserts[Retro](/n/{child})at the caret — throughyCollabintoY.Textwhen CRDT is on, exactly as today. The body write is unchanged; only the row's metadata is new.Retroappears indented underP,Pgains a chevron.Tests
lib/note-tree.test.ts(pure, node env):updated_at DESCflattenNoteTreeemits depth-first order with correctdepth/hasChildrenparent_idis absent from the input (archived parent) renders at root, not droppeda.parent = b,b.parent = a) and a self-cycle (a.parent = a) both terminate and yield root rows — no infinite recursion, no lost notescollapsedhides a whole subtree, not just direct childrenancestorIdsreturns the chain nearest-first and[]for a root / unknown idlib/notes-parent.test.ts(mockqueryin the style oflib/notes-revisions.test.ts):createNotewithparentIdwrites it; without, writesnullmapNotesurfacesparent_idapp/api/notesroute validation (in the style oflib/crdt/doc-routes.test.ts):note-linkslevel:linkCompletion'sapplyinserts Markdown and never callsoptions.createNote(extendslib/editor/note-links.test.ts)parent_id→400, no row written (tenant isolation)parent_id→400parent_id→400parent_id→201,parent_id: nullEdge Cases And Risks
parent_idis a user-supplied foreign key intonotes. Routing it throughresolveCanonicalNoteId(userId, …)rather than trusting the string is mandatory — a raw insert would let a crafted POST attach a note under another tenant's row and leak its id through the tree.buildNoteTreemust demote unresolvable-parent notes to roots; this has a dedicated test because "note vanished" reads as data loss to the user, and this repo has a history of exactly that class of report (Data loss: cross-tab upsert with forceBody overwrites an actively-edited note body #51, Data loss: stale-tab LWW PUT still truncates note bodies after #51/#56 dirty guards (RCA 2026-07-30 uvk-cumd-omo) #57, fix: stop 409-rebased stale buffers from silently clobbering newer note bodies (0804 wipe RCA) #73).400on a mid-typing archive race. If the parent is archived in another tab between the keystroke and the POST, the create fails andcreateAndInserthas already removed the trigger text — the user loses[[Retro. Rare, and identical to today's behavior for any create failure (offline, 500). The client-side live-note guard closes the common case. Accepted; not worth a retry path in v1.notesrow field. No Yjs document changes, no new update types, no change to thecrdt_managed_body409 gate. Stated explicitly because the natural instinct is to sync it through the doc.ADD COLUMN … IF NOT EXISTSon a nullable column with no default is a catalog-only operation in Postgres — no table rewrite, safe on the productionnotestable. The self-referencing FK does require a validation scan; on a table this size that is negligible.text-overflow: ellipsisalready applies; no depth cap in v1.parent_id: nulland renders exactly as today. Old bundles that POST withoutparent_idkeep creating root notes./p/…payloads are byte-identical.Non-Goals
/n/links in a body./p/…public payloads, or publishing a linked subgraph.GET /api/notes?parent=…endpoint — the flat list plusparent_idis enough to build the tree client-side without an N+1.Acceptance Criteria
Data model
notes.parent_id TEXT NULL REFERENCES notes(id) ON UPDATE CASCADE ON DELETE SET NULLexists, withnotes_user_parent_idxon(user_id, parent_id).ensureSchema()is idempotent — running it against an already-migrated DB is a no-op, and against a pre-migration DB adds the column without touching existing rows.Note.parent_idis returned byGET /api/notes,GET /api/notes/{id}, and SSRinitialNotes.PublicNoteis unchanged.Create vs link
[[→Create "X"inside notePcreates a note withparent_id = P.id./→New notewith a title insidePcreates a note withparent_id = P.id.[[, or/→Link to note, changes noparent_idon either note.+create notes withparent_id = null.[Title](/n/{id}), no[[persisted.Validation
parent_idbelonging to another user →400, no row created.parent_idof an archived note →400.parent_id→400.parent_idgiven as an alias / hyphenless id is stored canonicalized.Sidebar (Zed feel)
→/←on a focused row expand/collapse it;⌘→/⌘←expand/collapse all.auto_reveal_entriesspirit).upsertbroadcast.#tagfilter active, the list is flat (documented, not accidental).Repo
pnpm test,pnpm lint,pnpm build, andtsc --noEmitare green.QA Plan
pnpm test— newlib/note-tree.test.tsand parent-validation tests pass; the existing 312 stay green.pnpm lint && npx tsc --noEmit && pnpm build.ensureSchema()twice against a local Postgres; second run is a no-op. Verify with\d notesthat the column, FK, and index exist.NEXT_PUBLIC_AGENTNOTE_CRDT=1):[[Child→Create "Child". Assert: link inserted at the caret, still editing A,Childappears indented under A.[[→ pick existing note B. Assert: B does not move under A./n/{Child}directly. Assert: A auto-expands andChildis selected./: create a sub-note in tab 1, assert it appears nested in tab 2 without a reload.Childis still listed, now at root. Restore A. Assert:Childis nested again.agentnote.dev(service-worker/bundle cache), then re-run step 4's first two checks against production.Suggested PR Scope
One PR, M/L (~500 lines). Schema, API validation, and the tree renderer are one dependency chain — the column is unobservable without the sidebar, and the sidebar is untestable without the column. Splitting would ship a migration nobody can verify.
lib/note-tree.tscarries the logic and is pure, so review effort concentrates in one small, heavily-tested file.