Skip to content

feat: true sub-notes as a parent_id tree in the sidebar (Zed project-panel feel) - #81

Merged
chasehuh merged 1 commit into
mainfrom
task/subnote-tree
Aug 5, 2026
Merged

feat: true sub-notes as a parent_id tree in the sidebar (Zed project-panel feel)#81
chasehuh merged 1 commit into
mainfrom
task/subnote-tree

Conversation

@chasehuh

@chasehuh chasehuh commented Aug 5, 2026

Copy link
Copy Markdown
Member

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.

"link은 그냥 다른 곳으로 가는 거고, 그 안에서 생성하거나 하는 놈들은 sub note."

Action Result
[[NameCreate "…", or /New note with a title True sub-note — parent_id = the note you were writing in. Link still inserted at the caret, no navigation away.
Picking an existing note in [[, or /Link to note Peer hyperlink only. No parentage changes on either note.
⌘N / sidebar + Root note. Unchanged.

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

ALTER TABLE notes
  ADD COLUMN IF NOT EXISTS parent_id TEXT
  REFERENCES notes(id) ON UPDATE CASCADE ON DELETE SET NULL;

CREATE INDEX IF NOT EXISTS notes_user_parent_idx ON notes (user_id, parent_id);

Added through the existing idempotent ensureSchema() path, same style as deleted_at / author_handle. ADD COLUMN on a nullable column with no default is catalog-only in Postgres — no table rewrite on the live notes table.

ON DELETE SET NULL, deliberately not CASCADE. Permanently deleting a parent promotes its children to root rather than silently taking a subtree the user never selected.

Lifecycle semantics

Event Edge Sidebar
Archive a parent Kept, untouched Children render at root; restoring the parent re-nests them
Permanently delete a parent SET NULL Children survive, now at root
Archive a child Kept Moves to the Archived section (flat, as today)

Archiving deliberately does not cascade: one × click must not hide an arbitrary amount of work, and restoreNote has no record of which children it would need to restore.

Security

parent_id is a user-supplied foreign key into notes, so it is never trusted as a string. Validation runs through resolveCanonicalNoteId, which enforces tenant scoping, liveness, and alias normalization in one call. Anything that does not resolve to a live note the caller owns is a 400fail 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_id when 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.ts

All the logic, pure and dependency-free. Two invariants it is built around:

Sidebar (Zed project-panel feel)

Indent + disclosure chevron, / on a focused row, ⌘→/⌘← for the whole tree, collapse state persisted to localStorage. Selecting a nested note expands its ancestors (auto_reveal_entries spirit). 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 on window, and ⌘←/⌘→ are line-boundary motions in CodeMirror — an unscoped binding would swallow them exactly the way plain ⌘B once did (#77).

A #tag filter 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 notes row field, not document content — no Yjs changes, no new update types, no change to the crdt_managed_body 409 gate.

Verification

  • pnpm test345 passing, up from 312 (+33)
  • tsc --noEmit clean; pnpm build compiles
  • pnpm lint: 9 errors / 6 warnings, byte-identical to origin/main (verified by linting a clean worktree at origin/main with the same node_modules). All are pre-existing react-hooks/refs and react-hooks/set-state-in-effect findings, three of them in files this PR never touches. This branch adds zero lint problems, but pnpm lint is not green on main today — see Residuals.
  • Migration and query paths verified against a real Postgres 16 (throwaway container), not just mocks:
    • column added to a pre-migration table with existing rows untouched
    • FK confirmed as ON DELETE SET NULL / ON UPDATE CASCADE via pg_constraint; index confirmed on (user_id, parent_id)
    • ensureSchema() idempotent across repeated runs
    • hard-deleting a parent leaves the child alive with parent_id = NULL
    • createNotelistNotesflattenNoteTree nests correctly through real SQL
    • archived parent → child at root with its edge intact → restore re-nests
    • hyphenless parent id resolves to canonical; another user's note and an archived note both refused

The verification harness was temporary and is not part of this diff.

Not verified locally

No browser smoke test: the only .env.local on 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

Operator: hard-refresh open agentnote.dev tabs after the deploy lands.

🤖 Generated with Claude Code

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>
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
memo Ready Ready Preview Aug 5, 2026 7:03am

Request Review

@chasehuh
chasehuh merged commit 0f3f1f0 into main Aug 5, 2026
2 checks passed
@chasehuh
chasehuh deleted the task/subnote-tree branch August 5, 2026 07:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

True sub-notes: parent_id tree in sidebar (Zed project-panel feel)

1 participant