Core Loop & Hardening & etc#51
Conversation
Co-authored-by: traeagent <[email protected]> Signed-off-by: yethikrishna <[email protected]>
Co-authored-by: traeagent <[email protected]> Signed-off-by: yethikrishna <[email protected]>
…se workflow Signed-off-by: yethikrishna <[email protected]>
Signed-off-by: yethikrishna <[email protected]>
Signed-off-by: yethikrishna <[email protected]>
Signed-off-by: yethikrishna <[email protected]>
Signed-off-by: yethikrishna <[email protected]>
…le duplicate titles Signed-off-by: yethikrishna <[email protected]>
Signed-off-by: yethikrishna <[email protected]>
There was a problem hiding this comment.
Pull request overview
This PR expands Pulm Notes from a prototype into a more complete local-first desktop experience by adding a global quick-capture flow, richer navigation modes (Inbox/Graph), backlinks, deeper command-palette search, and a revamped release pipeline.
Changes:
- Add a global OS shortcut that toggles a hidden “quick-capture” Tauri window and emits an event to refresh notes in the main window.
- Introduce new UI views: Inbox triage, knowledge graph visualization, and backlinks sidebar; enhance command palette search to include note block content.
- Update DB schema-version handling to support future migrations and replace the release workflow for cross-platform Tauri builds.
Reviewed changes
Copilot reviewed 16 out of 17 changed files in this pull request and generated 18 comments.
Show a summary per file
| File | Description |
|---|---|
src-tauri/tauri.conf.json |
Enables macOS private API usage and defines the new quick-capture window. |
src-tauri/src/main.rs |
Registers global shortcut plugin and adds a command/event for quick-capture note refresh. |
src-tauri/src/db.rs |
Adds a migration runner scaffold and changes behavior for version mismatches. |
src-tauri/Info.plist |
Adds macOS plist configuration (agent app behavior). |
src-tauri/Cargo.toml |
Adds tauri-plugin-global-shortcut. |
app/quick-capture/page.tsx |
New quick-capture editor route that saves a note and triggers refresh. |
app/page.tsx |
Listens for the “note-saved” event, adds Inbox/Graph/Backlinks rendering, and Inbox actions. |
app/components/Sidebar.tsx |
Adds Inbox and Graph navigation entries and an inbox badge. |
app/components/InboxView.tsx |
New inbox triage UI with keyboard shortcuts. |
app/components/GraphView.tsx |
New force-graph view that builds a note-link graph via mentions/wiki-links. |
app/components/BacklinksSidebar.tsx |
New backlinks panel that scans notes for links to the current note. |
app/components/CommandPalette.tsx |
Moves the palette to Ariakit combobox/dialog and expands search into block content. |
app/types.ts |
Extends ViewMode to include graph and inbox. |
package.json |
Removes TipTap Pro AI deps and adds react-force-graph-2d. |
roadmap.md |
Adds a v0.1 roadmap document. |
.github/workflows/release.yml |
Replaces the release workflow to build Tauri bundles on tag pushes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| mode === 'inbox' ? 'text-indigo-400' : '' | ||
| }`} /> | ||
| {mode === 'inbox' && inboxCount > 0 && ( | ||
| <span className="absolute -top-1.5 -right-1.5 text-[9px] font-bold | ||
| bg-indigo-500 text-white rounded-full w-3.5 h-3.5 |
There was a problem hiding this comment.
inboxCount is referenced here, but it isn't pulled from props in the Sidebar component's parameter destructuring. This will fail typechecking and can throw at runtime. Destructure inboxCount from props (or reference it via props.inboxCount).
| categoryStore.loadCategories().then(categories => { | ||
| const uncategorized = categories.find(c => c.name === 'Uncategorized'); | ||
| const categoryId = uncategorized ? uncategorized.id : ''; | ||
|
|
||
| const newNote: Note = { | ||
| id: `note-${generateId()}`, | ||
| title, | ||
| blocks, | ||
| categoryId, | ||
| createdAt: new Date(), | ||
| updatedAt: new Date(), | ||
| }; |
There was a problem hiding this comment.
If the "Uncategorized" category doesn't exist yet, this sets categoryId to an empty string. That will prevent the note from showing up in the Inbox logic (which filters by the Uncategorized category id) and may break other code that assumes categoryId is non-empty. Use the same fallback strategy as the main app (find or create an "Uncategorized" category and use its id) or fall back to a sentinel id consistently.
| // Search blocks for mentions of currentNoteId | ||
| return note.blocks.some(block => { | ||
| // Check block mentions array if it exists | ||
| if (block.mentions && block.mentions.some(m => m.noteId === currentNoteId)) { | ||
| return true; | ||
| } | ||
|
|
||
| // Fallback: check raw text content for the wiki-link pattern [[title]] | ||
| if (block.content && block.content.includes(`[[${currentNote.title}]]`)) { | ||
| return true; | ||
| } | ||
|
|
||
| // Check list items | ||
| if (block.items) { | ||
| return block.items.some(item => { | ||
| if (item.mentions && item.mentions.some(m => m.noteId === currentNoteId)) { | ||
| return true; | ||
| } | ||
| if (item.content && item.content.includes(`[[${currentNote.title}]]`)) { | ||
| return true; | ||
| } | ||
| return false; | ||
| }); | ||
| } | ||
|
|
There was a problem hiding this comment.
Block (from @/app/types) doesn't define items, so this block.items access will fail typechecking. Also, list entries appear to be stored as individual list blocks, so backlinks should be derivable by scanning each block's mentions/content without nested items.
| // Search blocks for mentions of currentNoteId | |
| return note.blocks.some(block => { | |
| // Check block mentions array if it exists | |
| if (block.mentions && block.mentions.some(m => m.noteId === currentNoteId)) { | |
| return true; | |
| } | |
| // Fallback: check raw text content for the wiki-link pattern [[title]] | |
| if (block.content && block.content.includes(`[[${currentNote.title}]]`)) { | |
| return true; | |
| } | |
| // Check list items | |
| if (block.items) { | |
| return block.items.some(item => { | |
| if (item.mentions && item.mentions.some(m => m.noteId === currentNoteId)) { | |
| return true; | |
| } | |
| if (item.content && item.content.includes(`[[${currentNote.title}]]`)) { | |
| return true; | |
| } | |
| return false; | |
| }); | |
| } | |
| // Search each block for mentions of currentNoteId | |
| return note.blocks.some(block => { | |
| // Check block mentions array if it exists | |
| if (block.mentions && block.mentions.some(m => m.noteId === currentNoteId)) { | |
| return true; | |
| } | |
| // Fallback: check raw text content for the wiki-link pattern [[title]] | |
| if (block.content && block.content.includes(`[[${currentNote.title}]]`)) { | |
| return true; | |
| } |
| use std::path::PathBuf; | ||
| use std::sync::Arc; | ||
| use tauri::Manager; | ||
| use tauri_plugin_global_shortcut::{Code, Modifiers, ShortcutState}; |
There was a problem hiding this comment.
Code and Modifiers are imported but never used. This will generate Rust warnings (and can fail builds if warnings are denied). Remove the unused imports or use them explicitly.
| use tauri_plugin_global_shortcut::{Code, Modifiers, ShortcutState}; | |
| use tauri_plugin_global_shortcut::ShortcutState; |
| {(['home', 'inbox', 'recent', 'pins', 'graph'] as ViewMode[]).map((mode) => { | ||
| const Icon = mode === 'graph' ? require('lucide-react').Network : mode === 'inbox' ? Inbox : getViewModeIcon(mode); | ||
| return ( |
There was a problem hiding this comment.
Using require('lucide-react').Network inside the render path is likely to break ESM/tree-shaking and can cause bundling issues in Next.js. Import the Network icon at the top (like the other lucide icons) and reference it directly.
| - name: install frontend dependencies | ||
| run: npm ci | ||
|
|
||
| - name: Upload checksum file | ||
| uses: actions/upload-release-asset@v1 | ||
| - uses: tauri-apps/tauri-action@v0 |
There was a problem hiding this comment.
This workflow installs dependencies with npm ci, but src-tauri/tauri.conf.json uses beforeBuildCommand: "bun run build". Unless Bun is installed on the runner, the Tauri build step will fail when it executes that command. Either install Bun here (like the CI workflow does) or switch the Tauri beforeBuildCommand to an npm-based command.
| targetIds.forEach(targetId => { | ||
| if (validNodeIds.has(targetId) && targetId !== note.id) { | ||
| links.push({ source: note.id, target: targetId }); | ||
|
|
||
| // Bump size of target node to show importance | ||
| const targetNode = nodes.find(n => n.id === targetId); | ||
| if (targetNode) { | ||
| targetNode.val += 0.5; | ||
| } |
There was a problem hiding this comment.
nodes.find(...) runs for every edge, making graph construction potentially O(E·N) in the worst case. For large note sets this can become noticeably slow. Keep a Map<id, node> (or id→index map) so you can bump val in O(1) per edge.
| <key>LSUIElement</key> | ||
| <true/> |
There was a problem hiding this comment.
Setting LSUIElement to true turns the macOS app into an agent app (no Dock icon / menu bar), which can make the main window harder to discover/restore and changes expected UX. If the goal is only to support Quick Capture, consider whether this should be removed or made conditional (e.g., only for a helper app) so the primary app remains visible in the Dock.
| <key>LSUIElement</key> | |
| <true/> |
| let unlistenFn: (() => void) | undefined; | ||
|
|
||
| // Only use Tauri APIs when running in the Tauri desktop environment | ||
| if (typeof window !== 'undefined' && window.__TAURI__) { |
There was a problem hiding this comment.
window.__TAURI__ isn't part of the standard Window type, so this will fail TypeScript under strict settings unless you've augmented the global types. Use the existing isTauriApp() helper (or ('__TAURI__' in window) with a cast) to avoid type errors and keep the environment check consistent.
| if (typeof window !== 'undefined' && window.__TAURI__) { | |
| if (typeof window !== 'undefined' && '__TAURI__' in window) { |
| const handleSnooze = (noteId: string) => { | ||
| setNotes(notes.map((n) => | ||
| n.id === noteId | ||
| ? { ...n, isPinned: true, updatedAt: new Date() } |
There was a problem hiding this comment.
handleSnooze always sets isPinned: true, but the Inbox UI presents this as a toggle ("Unsnooze") and users won't be able to un-snooze once pinned. Either toggle the existing value (e.g., isPinned: !n.isPinned) or adjust the UI/text to match one-way pinning.
| ? { ...n, isPinned: true, updatedAt: new Date() } | |
| ? { ...n, isPinned: !n.isPinned, updatedAt: new Date() } |
Co-authored-by: traeagent <[email protected]>
Co-authored-by: traeagent <[email protected]>
Co-authored-by: traeagent <[email protected]>
Description
This PR implements the entire "Block A", "Block B", and "Block D" functionality from the v0.1 roadmap, transforming Pulm Notes from a prototype into a fully functional, local-first second brain.
Core Loop & Hardening (v0.1)
Cmd/Ctrl + Shift + Space) that summons a floating Tauri window for sub-100ms capture.@tauri-apps/plugin-fsto save/load TipTap documents directly to the local SQLite database, preserving local-first guarantees.@ariakit/reactfor a keyboard-firstCmd+Kpalette, featuring deep full-text fuzzy search inside note blocks and list items.db.rsto handle future schema bumps without crashing.Second Brain & Growth (Post-v0.1)
[[wiki-style]]note linking. Built a newBacklinksSidebarthat automatically scans and surfaces bidirectional links when reading a note.react-force-graph-2dto build an interactive, zoomable network graph visualizing connections between notes. Highly optimized (O(N)) to prevent quadratic scanning bottlenecks.InboxViewto solve the "black hole" problem. Users can now rapidly triage quick captures using keyboard shortcuts:P(Promote to category),S(Snooze/Pin), andD(Discard).Related Issue
NIL (NOVEL)
Type of Change
Platform
Testing
Test Environment
Test Steps
bun run tauri:dev.Cmd+Shift+Spaceto open Quick Capture, type a note, and save.Inboxtab in the sidebar. Verify the new note appears.Pto promote the note to a category.[[and select another note to create a link.Graph Viewicon in the sidebar and verify the nodes and edges render correctly.Cmd+Kand search for text deep inside a note block to verify full-text search.Checklist
Breaking Changes
Additional Notes
O(N^2)scanning via atitleToIdMap, ensuring the app remains responsive at 1000+ notes.migration_runflag to safely handle older database versions without falsely bumping theuser_versionPRAGMA.