feat: true sub-notes as a parent_id tree in the sidebar (Zed project-panel feel) - #81
Merged
Conversation
Creating a note from inside another note now nests it. Linking to an existing note still does not — that split is the whole feature, and it revises #77's "sub-notes are not a nested DB hierarchy" lock. - `notes.parent_id` (nullable, self-referencing) via the existing idempotent `ensureSchema()` path. `ON DELETE SET NULL`, never CASCADE: deleting a parent promotes its children to root rather than silently taking a subtree the user never selected. - `POST /api/notes` accepts `parent_id` and fails closed on anything that does not resolve to a live note the caller owns. Validation runs through `resolveCanonicalNoteId`, which enforces tenant scoping, liveness, and alias normalization in one call — `parent_id` is a user-supplied FK into `notes`, so trusting the string would let a crafted POST nest under another tenant's row. - `lib/note-tree.ts` turns the flat list into sidebar rows. Orphan- and cycle-safe by construction: an archived parent leaves its children at root (their edge intact, so restoring re-nests them), and a corrupt cycle degrades to flat rows. Every live note gets exactly one row — a disappeared note reads as data loss. - Sidebar renders the tree with indent + disclosure chevron, `→`/`←` on a focused row, and `⌘→`/`⌘←` for the whole tree. The ⌘-arrow binding is scoped to the sidebar: this listener is on `window`, and ⌘←/⌘→ are line-boundary motions in the editor. Selecting a nested note expands its ancestors (Zed `auto_reveal_entries`). Collapse state persists. - A `#tag` filter stays flat — nesting matches under parents the filter excluded would draw structure the results do not have. Hierarchy is a notes-row field, so the CRDT, publish, and note_revisions paths are untouched; the body still stores only `[Title](/n/{id})`. Closes #80 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This was referenced Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #80.
Creating a note from inside another note now nests it. Linking to an existing note still does not. That split is the whole feature, and it revises the lock in #77 ("sub-notes are not a nested DB hierarchy") while leaving everything #79 shipped intact.
[[Name→Create "…", or/→ New note with a titleparent_id= the note you were writing in. Link still inserted at the caret, no navigation away.[[, or/→ Link to note⌘N/ sidebar+A body full of
/n/…links implies no hierarchy at all — the tree comes from where a note was created, never from parsing text. That also means the wire format is unchanged: bodies still store only[Title](/n/{id}).Schema
Added through the existing idempotent
ensureSchema()path, same style asdeleted_at/author_handle.ADD COLUMNon a nullable column with no default is catalog-only in Postgres — no table rewrite on the livenotestable.ON DELETE SET NULL, deliberately notCASCADE. Permanently deleting a parent promotes its children to root rather than silently taking a subtree the user never selected.Lifecycle semantics
SET NULLArchiving deliberately does not cascade: one
×click must not hide an arbitrary amount of work, andrestoreNotehas no record of which children it would need to restore.Security
parent_idis a user-supplied foreign key intonotes, so it is never trusted as a string. Validation runs throughresolveCanonicalNoteId, which enforces tenant scoping, liveness, and alias normalization in one call. Anything that does not resolve to a live note the caller owns is a400— fail closed, so a create meant to nest never quietly produces a root note. Covered by tests for the cross-tenant, archived, malformed, and non-string cases.The client only sends
parent_idwhen the active note is still in the live list, so the realistic 400 is a genuine bug rather than a cross-tab archive race.lib/note-tree.tsAll the logic, pure and dependency-free. Two invariants it is built around:
parent_idis set once at insert, on a row that cannot yet have children; there is no reparent endpoint), but the renderer walks user-controlled data, so a hand-edited row degrades to flat rows instead of unbounded recursion. Reachability is tracked separately from render state, so a note hidden under a collapsed parent stays hidden rather than being "rescued" to root.Sidebar (Zed project-panel feel)
Indent + disclosure chevron,
→/←on a focused row,⌘→/⌘←for the whole tree, collapse state persisted tolocalStorage. Selecting a nested note expands its ancestors (auto_reveal_entriesspirit). Collapse state is stored as collapsed ids, not expanded ones, so a freshly created sub-note is visible under its parent without opening anything.The
⌘←/⌘→binding is scoped to the sidebar. This handler sits onwindow, and⌘←/⌘→are line-boundary motions in CodeMirror — an unscoped binding would swallow them exactly the way plain⌘Bonce did (#77).A
#tagfilter renders flat on purpose: nesting matches under parents the filter excluded would draw structure the result set does not have. Called out in a comment so it does not read as an oversight.CRDT
Untouched. Hierarchy is a
notesrow field, not document content — no Yjs changes, no new update types, no change to thecrdt_managed_body409 gate.Verification
pnpm test— 345 passing, up from 312 (+33)tsc --noEmitclean;pnpm buildcompilespnpm lint: 9 errors / 6 warnings, byte-identical toorigin/main(verified by linting a clean worktree atorigin/mainwith the samenode_modules). All are pre-existingreact-hooks/refsandreact-hooks/set-state-in-effectfindings, three of them in files this PR never touches. This branch adds zero lint problems, butpnpm lintis not green onmaintoday — see Residuals.ON DELETE SET NULL/ON UPDATE CASCADEviapg_constraint; index confirmed on(user_id, parent_id)ensureSchema()idempotent across repeated runsparent_id = NULLcreateNote→listNotes→flattenNoteTreenests correctly through real SQLThe verification harness was temporary and is not part of this diff.
Not verified locally
No browser smoke test: the only
.env.localon this machine predates Clerk, so the app cannot boot locally. Everything above was verified through tests and a real database instead. The sidebar interaction changes (chevron, arrow keys, auto-reveal) are the part that warrants a look on the deploy preview.Residuals
pnpm lintfails onmaintoday (9 pre-existing errors from thereact-hooksrules). Out of scope here — fixing it means touchingcodemirror-editor.tsx,publish-panel.tsx, and the ref-assignment block inagentnote-app.tsx, none of which this PR is about. Worth its own issue.sticky_scroll), single-child chain folding (auto_fold_dirs), indent guides.text-overflow: ellipsisalready applies). No depth cap.Operator: hard-refresh open
agentnote.devtabs after the deploy lands.🤖 Generated with Claude Code