Skip to content

feat: outline the current note's headings in the sidebar - #895

Open
ShaoSK wants to merge 2 commits into
team-reflect:masterfrom
ShaoSK:feat/note-outline
Open

feat: outline the current note's headings in the sidebar#895
ShaoSK wants to merge 2 commits into
team-reflect:masterfrom
ShaoSK:feat/note-outline

Conversation

@ShaoSK

@ShaoSK ShaoSK commented Jul 20, 2026

Copy link
Copy Markdown

Closes #870.

Adds a collapsible Outline section to the note context sidebar — the intra-note navigation aid proposed in #870.

What it does

  • Lists the current note's headings, indented by level.
  • Live-updates as you edit (add / remove / rename a heading).
  • Click a heading to jump to it in the editor.
  • Highlights the section you're currently reading as you scroll (scroll-spy), keeping the active row in view.
  • Always shown; renders "No headings" when the note has none.

Scope / alignment with the discussion

  • Note context sidebar only — the daily stream is untouched.
  • One collapsible section, styled like the existing "Similar notes" — no new chrome, nothing that competes with the editor.
  • Reuses parseNote(...).headings and the editor's existing revealHeading; apps/desktop only, no packages/core or Rust changes.

How it works

  • A small per-path store (mirroring editor-handle-registry) carries the parsed headings from the note pane to the sidebar via useSyncExternalStore.
  • Scroll-spy is position-based (the last heading whose top has passed the scroll container's top edge), recomputed on scroll / resize / editor DOM mutations, so it attaches as soon as headings paint and the final heading activates correctly.

Checks

  • pnpm typecheck and pnpm lint clean.
  • Unit tests for the store, the revealHeading passthrough, the scroll-spy hook, and the outline section (colocated *.test.ts(x)).
  • No Rust touched, so cargo test is not applicable.

Notes

  • Currently always-on (no setting) — happy to gate it behind an editor setting if you'd prefer opt-in.
  • Daily notes opened on their own use the daily context sidebar, so they don't show the outline (kept out of scope per "don't interfere with the daily stream"). Can extend if wanted.

Summary by CodeRabbit

  • New Features
    • Added an Outline section to the note sidebar, showing a list of document headings with indentation.
    • Highlights the heading currently in view while you scroll.
    • Clicking a heading moves the editor caret to the matching heading (and scrolls it into view).
    • The outline updates automatically as the note content changes, including initial loading.
    • Shows a “No headings” message when the outline is empty.
  • Tests
    • Added/expanded suites covering outline rendering, navigation behavior, active-heading tracking, live outline store updates, and the editor’s reveal-heading API.

Add a collapsible "Outline" section to the note context sidebar listing the
current note's headings. It updates live as you edit, jumps to a heading on
click, and highlights the section you're currently reading as you scroll.

Scoped to single notes (not the daily stream); reuses parseNote's headings
and the editor's revealHeading; apps/desktop only, no core or Rust changes.
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds a live Markdown-derived heading outline to the desktop note sidebar, with active-heading tracking, click-to-reveal navigation, per-note subscription state, editor-handle support, and tests for storage, rendering, scrolling, and mocks.

Changes

Note outline navigation

Layer / File(s) Summary
Outline state and editor publishing
apps/desktop/src/editor/note-outline-store.ts, apps/desktop/src/components/note-pane.tsx, apps/desktop/src/editor/note-outline-store.test.ts
Stores outlines per note path, publishes parsed headings on initial load and editor changes, and clears them when the note pane unmounts.
Heading reveal editor API
apps/desktop/src/editor/note-editor.tsx, apps/desktop/src/editor/reveal-heading.test.tsx, apps/desktop/src/**/\*.test.*
Adds revealHeading to NoteEditorHandle, delegates it to the underlying editor, and updates editor mocks.
Sidebar outline and active heading
apps/desktop/src/components/context-sidebar/outline-section.tsx, apps/desktop/src/components/context-sidebar/use-active-heading.ts, apps/desktop/src/components/context-sidebar/*test*, apps/desktop/src/components/context-sidebar/note-context-sidebar.tsx
Displays indented heading buttons, marks the active row, reveals selected headings, and recalculates the active index during scrolling and editor DOM changes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant NotePane
  participant OutlineStore
  participant OutlineSection
  participant NoteEditor
  NotePane->>OutlineStore: publishOutlineFromMarkdown(path, markdown)
  OutlineStore-->>OutlineSection: notify outline subscriber
  OutlineSection->>OutlineStore: read current headings
  OutlineSection->>NoteEditor: revealHeading(selected heading text)
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.89% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding a heading outline to the note sidebar.
Linked Issues check ✅ Passed The PR matches #870 by adding a note-sidebar outline with live updates, click-to-jump, and scroll-based highlighting, limited to apps/desktop.
Out of Scope Changes check ✅ Passed The changes stay focused on the sidebar outline feature and its supporting tests, with no unrelated feature work or core/Rust edits.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
apps/desktop/src/components/context-sidebar/use-active-heading.ts (1)

7-7: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

ACTIVE_LINE_OFFSET is duplicated as a magic number in the test.

This constant isn't exported, so use-active-heading.test.ts hardcodes const LINE = 12 with a comment stating it must be "kept in sync" manually — a silent-drift risk if the offset ever changes.

♻️ Proposed fix
-const ACTIVE_LINE_OFFSET = 12
+export const ACTIVE_LINE_OFFSET = 12

Then in the test, import and reuse it instead of the hardcoded 12.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/components/context-sidebar/use-active-heading.ts` at line 7,
Export ACTIVE_LINE_OFFSET from use-active-heading.ts and update
use-active-heading.test.ts to import and reuse it instead of defining the
duplicated LINE = 12 value, removing the manual sync comment.
apps/desktop/src/components/note-pane.tsx (2)

234-261: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider extracting the outline-publish wiring into a small hook.

NotePaneComponent is already a large multi-concern component; this new block (seed publish effect + onChange wrapper) is self-contained business logic that could live in its own hook (e.g. usePublishNoteOutline(path, editorSeed, onEditorChange)).

As per coding guidelines, "Move large mutation handlers, parsing, persistence, and business logic into helpers or hooks instead of embedding them in components."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/components/note-pane.tsx` around lines 234 - 261, Extract
the outline publishing logic from NotePaneComponent into a dedicated hook, such
as usePublishNoteOutline, accepting path, editorSeed, and the document
editor-change callback. Move the onEditorChange wrapper and publish/cleanup
effect into that hook, preserving their existing dependencies and clearOutline
cleanup behavior, then use the hook from NotePaneComponent.

Source: Path instructions


247-254: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Every keystroke now runs a second full markdown parse for the outline.

onDocumentEditorChange(markdown) almost certainly already parses markdown for persistence/rename-tracking; publishOutlineFromMarkdown runs an independent parseNote over the same string on every keystroke via onEditorChange. For large notes this doubles parse cost on the hot typing path.

♻️ Possible mitigation

Debounce/throttle the outline publish (e.g. via requestIdleCallback or a short setTimeout), or, if document.onEditorChange already exposes a parsed AST/headings list, reuse it instead of re-parsing:

-  const onEditorChange = useCallback(
-    (markdown: string) => {
-      onDocumentEditorChange(markdown)
-      publishOutlineFromMarkdown(path, markdown)
-    },
-    [onDocumentEditorChange, path],
-  )
+  const onEditorChange = useCallback(
+    (markdown: string) => {
+      onDocumentEditorChange(markdown)
+      scheduleOutlinePublish(path, markdown) // debounced wrapper
+    },
+    [onDocumentEditorChange, path],
+  )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/components/note-pane.tsx` around lines 247 - 254, Update the
onEditorChange callback in note-pane.tsx to avoid synchronously running
publishOutlineFromMarkdown on every keystroke alongside onDocumentEditorChange.
Debounce or defer outline publishing using the component’s existing
lifecycle/cleanup patterns, or reuse parsed headings/AST data if
onDocumentEditorChange exposes it, while preserving outline updates and
preventing stale scheduled work.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/desktop/src/components/context-sidebar/use-active-heading.ts`:
- Around line 41-46: Update the reset logic in the active-heading hook to key
only on the note path, removing headingCount from paneKey so heading edits
within the same note do not reset active. Preserve the path-change reset
behavior, and add or adjust coverage for a headingCount-only change with an
unchanged path.
- Line 52: Update the selector construction in the active-heading lookup to
escape path before interpolating it into the querySelector selector, using
CSS.escape(path) or an equivalent ref/registry lookup. Preserve the existing
aria-label matching behavior for paths without special characters.

In `@apps/desktop/src/components/note-pane.tsx`:
- Around line 256-261: Guard the outline-publishing effect around
publishOutlineFromMarkdown so it does not process protected/conflicted notes
whose editorSeed contains raw conflict markers. Reuse the same document
protection/conflict state used by the document.protected early return and
detectConflictMarkers logic, while preserving clearOutline cleanup and normal
outline behavior for non-conflicted notes.

---

Nitpick comments:
In `@apps/desktop/src/components/context-sidebar/use-active-heading.ts`:
- Line 7: Export ACTIVE_LINE_OFFSET from use-active-heading.ts and update
use-active-heading.test.ts to import and reuse it instead of defining the
duplicated LINE = 12 value, removing the manual sync comment.

In `@apps/desktop/src/components/note-pane.tsx`:
- Around line 234-261: Extract the outline publishing logic from
NotePaneComponent into a dedicated hook, such as usePublishNoteOutline,
accepting path, editorSeed, and the document editor-change callback. Move the
onEditorChange wrapper and publish/cleanup effect into that hook, preserving
their existing dependencies and clearOutline cleanup behavior, then use the hook
from NotePaneComponent.
- Around line 247-254: Update the onEditorChange callback in note-pane.tsx to
avoid synchronously running publishOutlineFromMarkdown on every keystroke
alongside onDocumentEditorChange. Debounce or defer outline publishing using the
component’s existing lifecycle/cleanup patterns, or reuse parsed headings/AST
data if onDocumentEditorChange exposes it, while preserving outline updates and
preventing stale scheduled work.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f1941556-3792-4fb3-a3d5-41d021ab663d

📥 Commits

Reviewing files that changed from the base of the PR and between 6e18731 and f7df791.

📒 Files selected for processing (16)
  • apps/desktop/src/components/context-sidebar/note-context-sidebar.tsx
  • apps/desktop/src/components/context-sidebar/outline-section.test.tsx
  • apps/desktop/src/components/context-sidebar/outline-section.tsx
  • apps/desktop/src/components/context-sidebar/use-active-heading.test.ts
  • apps/desktop/src/components/context-sidebar/use-active-heading.ts
  • apps/desktop/src/components/note-pane.tsx
  • apps/desktop/src/components/route-content.test.tsx
  • apps/desktop/src/editor/note-editor.tsx
  • apps/desktop/src/editor/note-outline-store.test.ts
  • apps/desktop/src/editor/note-outline-store.ts
  • apps/desktop/src/editor/reveal-heading.test.tsx
  • apps/desktop/src/editor/use-note-document.test.tsx
  • apps/desktop/src/editor/use-template-slash-items.test.ts
  • apps/desktop/src/lib/attach-files.test.ts
  • apps/desktop/src/mobile/mobile-screen.test.tsx
  • apps/desktop/src/mobile/screens/tasks.test.tsx

Comment thread apps/desktop/src/components/context-sidebar/use-active-heading.ts Outdated
if (headingCount === 0) {
return
}
const pane = window.document.querySelector(`[aria-label="Editing ${path}"]`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

sed -n '1,140p' apps/desktop/src/components/context-sidebar/use-active-heading.ts

Repository: team-reflect/reflect-open

Length of output: 4042


🌐 Web query:

MDN CSS.escape attribute selector quoted value quote escaping querySelector WebView2 WKWebView support

💡 Result:

The CSS.escape static method is the standard, recommended approach for escaping arbitrary strings for use within CSS selectors, including attribute selectors [1][2][3]. When using querySelector with attribute selectors, you should use CSS.escape on the attribute value to ensure it is treated as a valid CSS identifier or string, preventing syntax errors caused by special characters [2][4][5]. For an attribute selector such as [attr=value], the proper implementation is: document.querySelector([data-id="${CSS.escape(id)}"]); Note that CSS.escape handles the necessary escaping of characters—such as quotes, spaces, and other symbols—that would otherwise break the selector syntax [1][2][3]. You generally do not need to add extra quotes manually inside the template literal when using CSS.escape, as it effectively prepares the string to be safely concatenated into the selector [1][6]. Regarding platform support: CSS.escape is widely supported in modern environments [7][8]. - WebView2: Supported (consistent with Chromium support) [7][8]. - WKWebView: Supported (consistent with Safari support, which added it in version 10.1) [7][8]. Since both modern WebView2 and WKWebView are built on engines that support this API (Chromium and WebKit, respectively), it is safe to use in these environments without a polyfill [9][7][8]. If you need to support very old versions of these WebViews, you may need to include a polyfill, such as the one available from the Mathias Bynens CSS.escape repository [3].

Citations:


Escape path before building the selector. querySelector will throw on quotes and other selector metacharacters in path; use CSS.escape(path) here, or switch to a ref/registry lookup.

🛡️ Proposed fix
-    const pane = window.document.querySelector(`[aria-label="Editing ${path}"]`)
+    const pane = window.document.querySelector(`[aria-label="Editing ${CSS.escape(path)}"]`)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const pane = window.document.querySelector(`[aria-label="Editing ${path}"]`)
const pane = window.document.querySelector(`[aria-label="Editing ${CSS.escape(path)}"]`)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/components/context-sidebar/use-active-heading.ts` at line
52, Update the selector construction in the active-heading lookup to escape path
before interpolating it into the querySelector selector, using CSS.escape(path)
or an equivalent ref/registry lookup. Preserve the existing aria-label matching
behavior for paths without special characters.

Comment thread apps/desktop/src/components/note-pane.tsx Outdated
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
apps/desktop/src/components/context-sidebar/use-active-heading.ts (1)

55-55: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Escape path before building the selector.

querySelector will throw on quotes and other selector metacharacters in path; use CSS.escape(path) here.

🛡️ Proposed fix
-    const pane = window.document.querySelector(`[aria-label="Editing ${path}"]`)
+    const pane = window.document.querySelector(`[aria-label="Editing ${CSS.escape(path)}"]`)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/components/context-sidebar/use-active-heading.ts` at line
55, Update the selector construction in the active-heading logic to pass path
through CSS.escape before interpolating it into the aria-label querySelector
selector. Preserve the existing label format and query behavior for ordinary
paths while ensuring paths containing quotes or selector metacharacters cannot
break the selector.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@apps/desktop/src/components/context-sidebar/use-active-heading.ts`:
- Line 55: Update the selector construction in the active-heading logic to pass
path through CSS.escape before interpolating it into the aria-label
querySelector selector. Preserve the existing label format and query behavior
for ordinary paths while ensuring paths containing quotes or selector
metacharacters cannot break the selector.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 673488a5-534b-4693-bcd5-05c5796da5bd

📥 Commits

Reviewing files that changed from the base of the PR and between f7df791 and 0b2888c.

📒 Files selected for processing (3)
  • apps/desktop/src/components/context-sidebar/use-active-heading.test.ts
  • apps/desktop/src/components/context-sidebar/use-active-heading.ts
  • apps/desktop/src/components/note-pane.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/desktop/src/components/context-sidebar/use-active-heading.test.ts
  • apps/desktop/src/components/note-pane.tsx

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an always-present, collapsible Outline section to the note context sidebar in apps/desktop, powered by live heading extraction from the editor and a scroll-spy hook to track the active section.

Changes:

  • Introduces a per-note-path outline store and wires NotePane to publish/clear headings as the editor loads and edits.
  • Adds an OutlineSection sidebar component and a useActiveHeading scroll-spy hook to highlight and keep the active row in view.
  • Exposes NoteEditorHandle.revealHeading (delegating to Meowdown) so clicking an outline row can navigate within the editor; updates/introduces tests accordingly.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
apps/desktop/src/mobile/screens/tasks.test.tsx Updates NoteEditor mock to include revealHeading.
apps/desktop/src/mobile/mobile-screen.test.tsx Updates NoteEditor mock to include revealHeading.
apps/desktop/src/lib/attach-files.test.ts Updates NoteEditor mock to include revealHeading.
apps/desktop/src/editor/use-template-slash-items.test.ts Updates NoteEditor mock to include revealHeading.
apps/desktop/src/editor/use-note-document.test.tsx Updates NoteEditor mock to include revealHeading.
apps/desktop/src/editor/reveal-heading.test.tsx Adds a unit test ensuring NoteEditorHandle.revealHeading delegates to Meowdown.
apps/desktop/src/editor/note-outline-store.ts Adds module-level outline store keyed by note path (get/subscribe/publish/clear).
apps/desktop/src/editor/note-outline-store.test.ts Tests outline store behavior and markdown->headings parsing.
apps/desktop/src/editor/note-editor.tsx Adds revealHeading to NoteEditorHandle and delegates to the inner editor handle.
apps/desktop/src/components/route-content.test.tsx Updates NoteEditor mock to include revealHeading.
apps/desktop/src/components/note-pane.tsx Publishes outline headings on load and on editor changes; clears on unmount.
apps/desktop/src/components/context-sidebar/use-active-heading.ts Adds scroll-spy hook to compute active heading index based on DOM positions.
apps/desktop/src/components/context-sidebar/use-active-heading.test.ts Tests scroll-spy logic and update triggers (scroll/DOM paint/mutations).
apps/desktop/src/components/context-sidebar/outline-section.tsx Adds the Outline sidebar section UI using the store + scroll-spy + editor navigation.
apps/desktop/src/components/context-sidebar/outline-section.test.tsx Tests outline rendering, placeholder state, aria-current, and click navigation.
apps/desktop/src/components/context-sidebar/note-context-sidebar.tsx Inserts OutlineSection into the note context sidebar layout.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +45 to +49
const [previousPath, setPreviousPath] = useState(path)
if (previousPath !== path) {
setPreviousPath(path)
setActive(0)
}
Comment on lines +55 to +58
const pane = window.document.querySelector(`[aria-label="Editing ${path}"]`)
if (pane === null) {
return
}
Comment on lines +256 to +267
useEffect(() => {
// Protected notes (sync conflicts land here) render raw, conflict-marked
// content instead of the editor, so parsing `editorSeed` would outline
// headings from both conflict sides rather than the note's real structure.
if (document.protected) {
return
}
publishOutlineFromMarkdown(path, editorSeed)
return () => {
clearOutline(path)
}
}, [path, editorSeed, document.protected])
Comment on lines +248 to +252
const onEditorChange = useCallback(
(markdown: string) => {
onDocumentEditorChange(markdown)
publishOutlineFromMarkdown(path, markdown)
},
Comment on lines +37 to +41
const onSelect = useCallback(
(text: string) => {
noteEditorHandleFor(path)?.revealHeading(text)
},
[path],
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.

Proposal: optional heading outline for long notes in the note sidebar

2 participants