github ZenNotes/zennotes v2.46.0
ZenNotes v2.46.0

5 hours ago

ZenNotes 2.46.0: comment threads your assistant can join, a Kanban board per folder, unbind a shortcut outright, ignored keys for tap-hold remappers, saved Tasks filters, custom templates on remote vaults, and a calendar in the @ menu

Seven features and three fixes. Home-row-mods users can list the no-op key their remapper sends, and the app stops seeing it. The Kanban Folder board now groups by each note's folder, or by the children of one folder you name. Note comments thread and carry a name, and the MCP server can read, answer, open and resolve them, so a draft can be reviewed with Claude or Codex inside the Comments panel instead of a chat window. A shortcut can be removed instead of remapped onto some chord you would never press: every keymap row has an Unbind button, :unbind action.id does the same from the ex line, and "action.id" = "" under [keymaps] in config.toml carries it between machines. And custom templates now work in the self-hosted web client and on a desktop connected to a ZenNotes server, stored in the vault's .zennotes/templates/ like everywhere else, with a change in one client showing up in the others. The @ menu ends with Date…, a keyboard-first calendar for any day, next to Today, Yesterday, Tomorrow and Now. A Tasks filter can be saved under a name and recalled from a chip, :filter <name>, the F picker, or the command palette, with the list kept in config.toml as [saved_filters]. zn create and the MCP tools now file notes where the app does on a vault in root mode, display math renders inside callouts in both views, and Typst formulas are the size KaTeX's are.

✨ New

  • Ignore the no-op key a tap-hold remapper sends. (#732, requested by @potter1402) Kanata, QMK and ZMK tap-hold layers emit a harmless extra key with every keystroke, on Linux usually the Katakana/Hiragana key, which reads as KanaMode. Neovim ignores keys it does not map; here every stray keydown reset a pending sequence, so jk never left insert mode and typed itself, dd deleted nothing, and a leader chord or hint mode died halfway. Settings → Keymap gains an Ignored keys row: press Record a key, then the no-op, and it is listed by name (its physical code when the layout reports Unidentified). From then on the app never sees it, in the editor, the panels, the hints and the shortcuts alike. :ignorekey <key> adds one from the ex line (bare, it opens the page), and ignored_keys = ["KanaMode"] under [editor] in config.toml is the portable form.

    How to test locally: set insert_escape = "jk" under [vim] in a scratch config.toml, open a note, enter insert mode and type j, press the Katakana/Hiragana key (or any key you can spare, such as F24), then k. Before: the editor stays in insert mode with jk typed. Record that key under Settings → Keymap → Ignored keys and repeat: jk leaves insert mode, d d deletes the line, and config.toml has ignored_keys = ["KanaMode"].

  • The Kanban Folder board groups by each note's folder, or by one folder's children. (#730, requested by @andradejoelwp) "Group by: Folder" grouped by the four built-in folders, so a vault organised as Projects/<name>/… got one column holding everything plus an empty Quick, and people kept a hand-maintained @status token per task to get project columns. The board now gives every folder that holds tasks its own column (the Inbox root, Projects/alpha, Areas, Quick Notes), system folders first and subfolders alphabetical, with no empty columns. Point it at one folder and its children become the columns: :folderroot Projects on the board, the chip next to the group-by menu, Settings → Tasks → Folder board, or kanban_folder_root = "Projects" under [view] in config.toml. Deeper notes roll up to their child, notes in the root itself get the root's column, and notes outside it share an Other folders column. The root is saved per vault like the group-by and travels with your dotfiles. A remapped inbox and a root-mode vault classify the way the sidebar does, column titles and order still stick (ids are the note directories now), and folder columns stay read-only: moving the note is how a card changes column.

    How to test locally: make a vault with inbox/Projects/alpha/A.md, inbox/Projects/beta/meetings/K.md, inbox/Areas/Home.md and inbox/Plan.md, each with a - [ ] task, then open Tasks, press 3, and pick Folder. Before: Inbox and an empty Quick. After: Inbox, Areas, Projects/alpha, Projects/beta/meetings. Type :folderroot Projects: alpha and beta (the kickoff note rolled up) plus Other folders; config.toml has kanban_folder_root = "Projects". Bare :folderroot returns to one column per folder.

  • Discuss a note with an assistant through its comments. (#738, requested by @gverger) Comments were flat and unsigned, and only the app could reach them, so reviewing a draft with an assistant meant pasting the note into a chat and carrying the answers back by hand. Now comments thread and carry a name: a reply files under the comment it answers, every entry shows who wrote it (You, or the assistant's name), and a on a thread (or the Reply arrow) opens a reply box with ⌘↵ to send. The MCP server gains four tools on the same sidecar: list_comments returns each thread with the anchored passage, the line it sits on in the note today, the author and the replies; reply_to_comment answers in a thread; add_comment opens a new one anchored to a passage copied from the note (the editor marks the text) or a note-level remark; resolve_comment closes a settled thread or reopens it. Replies are signed with the connected client's name (Claude Code, Claude, Codex), so its words and yours stay apart in the panel, and they arrive live through the vault watcher. The server instructions steer a model toward answering in the threads rather than editing the note body, and toward resolving only when you say so. zn comment list|add|reply|resolve does the same from a terminal, against a folder or a ZenNotes server. Everything stays in the .comments.json beside the note, so the web client and every MCP client see the same threads.

    How to test locally: launch the built app as above, open a note, select a passage and press ⌘⌥M, type a comment, ⌘↵. In a terminal, node apps/desktop/out/main/cli.js comment list inbox/<Note>.md shows the thread with its line; node apps/desktop/out/main/cli.js comment reply inbox/<Note>.md <id> "Answer" --author Claude lands under it in the panel within a second, signed Claude. For the MCP path, connect Claude Code to zn mcp and ask it to review the note's comments. Before 2.46: comments have no author, no replies, and no tool reaches them.

  • Unbind a shortcut instead of hiding it on an obscure key. Settings, Keymap gains an Unbind button on every row, next to Change and Reset, and inside the recorder in place of the old Clear (Backspace still clears a recording). An unbound row reads Unbound with the Custom badge, Unbind greys out, and Reset brings the shipped default back. Nothing fires the action until it gets a key again: the shortcut, sequence, leader chord or editor chord simply stops existing, and that includes the leader and pane prefixes themselves. Everything that advertises keys follows suit: the which-key hints drop an unbound leader action, the command palette shows no chord for it, tooltips lose their parenthetical, and the in-app manual prints "Unbound" where the key used to be. An unbound action never counts as a conflict either, so another global shortcut may take the key it used to hold.

  • :unbind action.id from the ex line. In Vim mode, :unbind global.toggleSidebar removes the key and a toast names what it was (Unbound Toggle sidebar (was ⌘1)). Bare :unbind, or an id the catalog does not know, opens Settings on the Keymap page, which lists every action id, instead of guessing. Tab completes it in the : wildmenu like every other ex command.

  • Portable through config.toml. An unbind is written as "global.commandPalette" = "" # unbound under [keymaps], so it travels with your dotfiles, and a hand edit that sets any action to "" applies live like every other config change. The generated reference block explains the convention above the table. TOML has no null, which is why the empty string is the spelling.

    How to test locally: cd apps/desktop && npx electron-vite build, then from the repo root launch apps/desktop/out/main/index.js with ZEN_PERF=1, ZENNOTES_USER_DATA_PATH and ZENNOTES_CONFIG_DIR pointing at scratch folders. Press ⌘, (Ctrl+, on Windows and Linux), open Keymap, filter "command palette", press Unbind. Before: ⇧⌘P opens the palette. After: it does nothing, the row reads Unbound, and config.toml in the scratch config dir has "global.commandPalette" = "" # unbound. Open a note, type :unbind vim.leaderOpenBuffers, press Space: the o entry is gone from the leader hints. Reset on the row brings ⇧⌘P back.

  • Custom templates on remote vaults. (#723, requested by @ajselzilic; the server and web groundwork was first explored by @flokchvtr on their web-feature-parity branch) Settings, Templates used to say that custom templates require a local vault in the web client and in desktop remote mode, because nothing served the vault's .zennotes/templates/ over HTTP. The server now does, through four authenticated routes (list, read, write, delete) that mirror the desktop module byte for byte: the same slug rules, the same flat-directory and .md-only validation, the same adr, adr-2 de-duplication and rename-removes-previous behaviour, atomic writes under the vault lock, and the configured note-size limit (a 413 past it). It advertises them as supportsCustomTemplates, and both the web client and the desktop remote client gate on that flag: an older server keeps the section read-only with a note naming the fix (update the server and reload, or reconnect the workspace), and the built-in templates keep working. Both watchers, the server's and the desktop's, now report .zennotes/templates/ under a templates scope of their own, so a template saved in one client appears in the others without a reconnect, and a change-feed gap re-lists them as well. A local vault behaves exactly as before. Found on the way: the desktop read the remote workspace info before the connection existed and kept that copy, so it never learned what the server advertises; it re-reads once the vault is connected.

    How to test locally: cd apps/server && go build -o /tmp/zennotes-server ./cmd/zennotes-server, then ZENNOTES_CONFIG_PATH=/tmp/zs.json ZENNOTES_VAULT_PATH=/path/to/vault ZENNOTES_AUTH_TOKEN=tok ZENNOTES_BIND=127.0.0.1:7878 /tmp/zennotes-server. Web: npm run dev:web, open http://localhost:5180, sign in with tok, Settings, Templates. Before: "Custom templates require a local vault". After: New template, and Save lands /path/to/vault/.zennotes/templates/<slug>.md; Edit renames the file, Delete removes it. Drop a .md with name: frontmatter into that folder from a terminal and it appears in the list on its own. Desktop: Settings, Vault, Connect to Remote Vault… with the same URL and token, then the same steps; against a server older than 2.46 the section says the server needs an update instead.

  • Save a Tasks filter under a name, and get it back in one keystroke. (#731, requested by @andradejoelwp) The filter box narrows the Tasks views to one project, one area, one context, but it was transient: the same query had to be retyped every time, which is why people stopped reaching for it and went looking for a grouping mode instead. Grouping has one axis; filters compose. A query worth typing twice is now saved under a name, as the [saved_filters] table in config.toml ("Project alpha" = "@project:alpha", one line each), so it is diffable, syncs with the rest of your preferences, and a hand edit applies live. Recall is the point, so it is cheap from everywhere: a chip row under the Tasks header shows the saved filters in file order, a click applies one and a second click clears it; :filter <name> applies the saved query whenever the text is a saved name (any other text filters literally, as before); F in Vim mode opens a picker that narrows as you type; and the command palette lists every saved filter as "Tasks: name", which opens the view already filtered from any note. Saving is a chip too: an unsaved query shows Save filter…, which asks for a name and offers the existing ones for an overwrite; :savefilter <name> does the same from the ex line, :delfilter <name> forgets one, and a chip's right-click menu renames or deletes it. Names match regardless of case, edits keep the chip order, and the web client keeps its saved filters in the browser. With Vim mode off, the chips and the palette are the way in, as with every other single-key shortcut in the list.

    How to test locally: launch the built app as above with a scratch config dir, then add to its config.toml: [saved_filters] and "Project alpha" = "@project:alpha". Open Tasks: the chip is there, and clicking it puts @project:alpha in the filter box with the count chip reading M of N. Type :f !high, then :savefilter Urgent: a new chip appears and config.toml gains Urgent = "!high". Append "Blocked" = "@status:blocked" to the file by hand: the chip appears without a restart. Open a note and press ⇧⌘P, type Tasks: Blocked, Enter: the Tasks view opens filtered. Before 2.46: none of this exists; the query is gone the moment you clear it.

  • Pick any date from the @ menu. (#743, requested by @uNyanda) @ offered Today, Yesterday, Tomorrow and Now; any other day meant typing it out by hand. The list now ends with Date… (@date, @cal and @pick narrow to it), and Enter on it opens a calendar on today with today's cell already focused, so nothing needs a Tab or a mouse: arrows move a day or a week, PageUp/PageDown change the month, with Shift the year, Home/End go to the ends of the week, and Enter inserts the ISO date (2026-10-17, the same shape the quick options write) exactly where the @ stood, with focus back in the note. A digit pressed on the grid jumps to the text field, so a date can be typed outright, and the grid follows it as it takes shape; a day the month does not have (2026-02-30) is refused rather than rolled into March. With Vim mode on, h j k l move and t jumps back to today; with Vim mode off, letters stay inert, as they do in every list. Escape leaves the note as it was before the @. The quick options are unchanged, the calendar honours the week start from Settings, Calendar, and it works wherever the @ menu does, the pinned reference pane and the web client included.

    How to test locally: launch the built app as above, open a note, type @: the list ends with Date…. Type date and press Enter. Before: @date matched nothing and the menu just closed. After: a calendar opens on today; press → → ↓ then PageDown, then Enter, and the note reads the day nine days from today in next month, where the @ was. Type @date, Enter, then 2027-03-14 and Enter: that date is inserted. Type @date, Enter, Escape: nothing is left behind.

🐛 Fixes

  • zn create and the MCP tools follow primaryNotesLocation: root. (#745 by @diazkev314) With Settings, Vault set to "Vault root", zn create and the MCP create_note tool still wrote new notes into inbox/, and vault_info reported inbox, while zn list and zn search found root-level notes fine. The CLI and MCP decided the mode from the vault's layout first and only consulted vault.json when the layout was ambiguous, a rule written when root mode was new so that a vault switched in Settings but not yet migrated kept filing next to its old notes. The app does the opposite: an explicit setting is the answer and the layout is consulted only when vault.json leaves it unstated. With old notes still in inbox/, the two halves disagreed on the same vault. The file wins now on every side: an explicit primaryNotesLocation decides, and only a vault without one (or one a sandboxed process cannot read) is inferred from its layout, as the app infers it. Reads were already right and are unchanged.

    How to test locally: make a scratch vault with .zennotes/vault.json containing {"primaryNotesLocation": "root"} and a leftover inbox/Old.md, then node apps/desktop/out/main/cli.js create --vault /path/to/vault --title Test --body test --json. Before: "path": "inbox/Test.md". After: "path": "Test.md", and the MCP vault_info tool reports primaryNotesLocation: root with create_note landing at the root too.

  • Display math renders inside callouts, in the editor and the reading view. (#748 by @OstrichDowneyJr) A $$…$$ block inside an Obsidian-style callout stayed raw in the editor, because the > in front of its fence counted as prose and the live preview only renders fences that own their line. In the reading view it was worse: the fence normalizer, after copying a canonical block that came earlier in the note, re-scanned that block's closing fence as an opener and paired it with the callout's > $$, which it took for content hugging a fence; the rewrite left a bare $$ outside the quote, and with Typst selected that block swallowed everything after it into one failing formula (with KaTeX, into one long one). Both views now treat a fence inside a quote as a fence: the editor strips the quote markers from the formula and gives the rendered block the callout card's own classes so the card stays whole around it, and the normalizer looks past the markers, closes a block only at its own quote depth, and moves past a canonical block instead of re-reading its closing fence. A bare $ on its own line is still not a display block in ZenNotes (with either engine, that is $$), but a span demoted to text inside a callout no longer carries the > of every line it spans.

    How to test locally: with Settings, Editor, Math renderer set to Typst, put > [!note], > $$, > x_1 = frac(det W_1, det A), > $$ in a note under a normal $$…$$ block. Before: the callout's formula stays raw in the editor, and in the reading view the callout loses its body while a red Typst error starting with > x_1 swallows the rest of the note. After: both blocks render the same, inside the card in the editor and inside the callout in the reading view.

  • Typst formulas are the size KaTeX formulas are, and their rules follow the text color. (#746 by @cyperion) With Typst selected, inline math sat visibly smaller than the surrounding text, where KaTeX's fit. KaTeX draws Computer Modern at 1.21 times the text size, its own stylesheet's choice, because the family sits small on its em square and a plain 1em reads undersized next to prose. The Typst SVG was sized at that plain 1em, and New Computer Modern shares the metrics, so the same formula came out a fifth smaller: 52 px wide against KaTeX's 63 for E = h nu at the default text size. It is now sized with the same 1.21 factor in both the editor and the reading view, display blocks included, so switching engines no longer changes how big the math is. The Math size setting still scales on top. Found on the way: the recolor that makes Typst glyphs follow the theme only knew fills, so the rules a formula draws as shapes, a square root's bar and a fraction line, kept Typst's black stroke and vanished on a dark theme. Strokes are recolored now too.

    How to test locally: write The energy of a photon is $E = h nu$ here. with Settings, Editor, Math renderer set to Typst, then compare against the KaTeX rendering of $E = h\nu$. Before: the Typst formula is noticeably smaller than the text, and on a dark theme the bar of $$ sqrt(p^2 c^2 + m^2 c^4) $$ is black. After: the two engines render the formula at the same width, the text and the math read as one size, and the bar is the text color.

🧰 For contributors

  • The convention lives in packages/app-core/src/lib/keymaps.ts: UNBOUND_BINDING (""), isUnboundBinding, isKeymapUnbound, and UNBOUND_LABEL. getKeymapBinding returns the empty string for an unbound action instead of falling back to the default, normalizeKeymapOverrides keeps blank strings, matchesShortcutBinding and eventMatchesUserOverride return false for them, and getKeymapDisplay returns "" so a caller can leave the chip out; labelWithShortcut builds tooltips that never read "Go back ()". The store's setKeymapBinding(id, null) clears an override and setKeymapBinding(id, "") stores an unbind.

  • Any new consumer of a binding must treat "" as "no key". CodeMirror files an empty key name without complaint and would run the command on a keydown whose key is empty, so editor keymap entries go through keyBindingsFor in vim-half-page-keymap.ts, which returns nothing for an unbound action; VimNav's leader and pane prefix tokens fall back to UNBOUND_BINDING, which never equals a token read off an event, rather than to Space and Ctrl+W; codemirror-vim mappings already dropped a null sequence.

  • :unbind is registered in Editor.tsx next to :harper and listed in MANUAL_EX_NAMES; SettingsNavigationTarget gained "keymaps" so the command can open that page. The config writer in apps/desktop/src/main/app-config.ts appends # unbound to empty entries and documents "" in the [keymaps] header.

  • Tests: keymaps.test.ts (normalization, resolution, matching, conflicts, tooltips), vim-half-page-keymap.test.ts (no keymap entry for an unbound action), SettingsModal.test.ts (the Unbind row and the Unbound state), and app-config.test.ts (the config.toml round trip). Both docs surfaces are updated: the in-app manual (Keymaps section, config-file entry, :unbind ex row) and the website's docs.blade.php and docs-sections.json, whose DocsPage tests pass.

  • Verification, this pass: monorepo typecheck clean; app-core 1899 tests and the desktop config suite green; the built desktop app driven over CDP with isolated userData and config through 24 checks: the palette before and after the row Unbind, config.toml contents, :unbind with a known and an unknown id, the leader hints before and after, a hand-edited "" picked up live, and Reset restoring the default. Tab completion of :unb checked the same way. Captioned demo: media/keymap-unbind.mp4 (30 seconds, H.264, 1920×1080, CRF 17) with the captions burned into a strip under the app frame, since the clip has no voice, plus media/keymap-unbind.vtt for the website's caption track. Recorded from the built app at 1600×820 with sticky which-key hints so the overlay stays readable.

  • Remote templates (#723): apps/server/internal/vault/templates.go is a SYNCED COPY of apps/desktop/src/main/templates.ts (slug, path and de-duplication rules; a template's id is custom:<filename stem>, so both sides must land the same filename); apps/server/internal/httpserver/templates.go holds the four routes and writeError maps vault.ErrInvalidTemplate to 400. The Go watcher gained a templates scope branch before its dot-path drop, plus watchSubdirs, which adds directories created together with a parent that only raised one Create (.zennotes/templates/ in a vault that had no .zennotes/ yet stayed unwatched until a restart). The bridge contract gained ServerCapabilities.supportsCustomTemplates? and the templates member of VaultChangeScope; the desktop watcher mirrors the scope; RemoteServerClient gained the four template methods; the desktop IPC handlers dispatch through requireRemoteTemplates, reading the connect-time remoteServerCapabilities rather than re-fetching; the web bridge derives both the request gate and getCapabilities() from the same cached /capabilities response; store.applyChange re-lists templates on the new scope and after a resync; store.init re-reads the workspace info after getCurrentVault connects. Settings gates on remoteWorkspaceInfo.capabilities in remote mode and on the host capability otherwise.

  • #745 lives in readPrimaryNotesLocation in apps/desktop/src/mcp/vault-ops.ts, shared by the CLI's local backend and the MCP server; the inbox-notes count that used to outrank the file is gone, and the layout fallback mirrors inferPrimaryNotesLocation in main/vault.ts. Three tests in vault-ops.test.ts pin the order: file says root with old inbox notes, file says inbox with loose root notes, and no file at all. Verified through the running app over CDP: the vault switched to Vault root in Settings, then zn create with no --vault (it follows the vault the app has open) and the MCP vault_info and create_note tools over stdio, with both new notes appearing in the app at the root while the old ones stayed in inbox/. Captioned demo: media/cli-root-mode-745.mp4 plus media/cli-root-mode-745.vtt (1920×1080).

  • #748 lives in two places. normalizeBlockMathFences in packages/app-core/src/lib/markdown.ts now splits every line into its blockquote prefix and content, matches fences on the content, re-emits the prefix on whatever it writes (an empty quote line where a blank would end the quote), pairs closers only at the same quote depth, and copies a canonical block through with i = close + 1 instead of i++ (the re-scan that paired fences across the callout edge). The currency guard's raw source slice drops the markers of continuation lines. In cm-math-render.ts, quoteDepthOf accepts markers before an opening $$, stripQuoteMarkers takes them off the formula, and BlockMathWidget gained a frame of cm-callout cm-callout-<group> (or cm-wq-quote) resolved from the outermost Blockquote node, so the widget is styled as part of the card. Tests in markdown.test.ts and cm-math-render.test.ts; verified in the built app in edit and preview with Typst selected, on the pre-fix and fixed builds of the same note. Captioned before/after: media/typst-callout-748.mp4 plus media/typst-callout-748.vtt (1920×1080).

  • #746 lives in styleSvg in packages/app-core/src/lib/typst-math-render.ts: KATEX_EM_SCALE = 1.21 applied when the SVG's pt dimensions become em sizes, and BLACK_PAINT_RE, which turns every black fill or stroke (#000000, #000, black, rgb(0,0,0)) into currentColor; Typst exports glyphs as black fills and shape rules as black strokes, and only the fills were handled. styleSvg is exported now, with typst-math-render.test.ts pinning the factor and both recolors. Measured in the built app before and after (inline E = h nu at 16 px text: 52.4 px wide before, 63.4 after, KaTeX 63.2). Captioned before/after: media/typst-size-746.mp4 plus media/typst-size-746.vtt (1920×1080).

  • #732: packages/app-core/src/lib/ignored-keys.ts holds normalizeIgnoredKeys, isIgnoredKeyEvent (key or code, case-insensitive), ignoredKeyTokenFromEvent (key, else code for Unidentified/Dead/Process), setIgnoredKeysRecorderActive and installIgnoredKeysGuard, a single capture-phase guard on window for keydown, keyup and keypress that is installed at App.tsx module load (an effect would run after VimNav's) and reads the live list from the store. The pref ignoredKeys is portable (ignored_keys list under [editor]), with setIgnoredKeys, addIgnoredKey and removeIgnoredKey; Settings renders IgnoredKeysRow above the shortcut editor and :ignorekey sits next to :unbind in Editor.tsx. Tests: ignored-keys.test.ts (jsdom), store.test.ts, the config round trip. Verified in the built app over CDP with realistic timing (the no-op 15 ms after the key): the report reproduced with an empty list, then fixed with the key recorded; eleven checks. Captioned demo: media/ignored-keys-732.mp4 plus media/ignored-keys-732.vtt (1920×1080).

  • #730: folderColumns, noteLocationOf and FolderBoardLayout in TasksKanban.tsx (exported for tests) place a task by splitting its path through systemFolderForDirName, so a remapped system folder strips its real prefix and a root-mode note has none; column ids are vault-relative directories, NO_VALUE_COLUMN_ID is the Other folders bucket (pinned last by arrangeColumns), and kanbanColumnTitles/kanbanColumnOrder grammars now accept path ids (FOLDER_COLUMN_TITLE_KEY_RE, MAX_KANBAN_COLUMN_ID_LENGTH). The pref kanbanFolderRoot (normalizeKanbanFolderRoot, setKanbanFolderRoot) is portable and a VaultViewSettings override; the board's pickFolderRoot prompt lists the folders that hold tasks; :folderroot lives in the Tasks view ex line; Settings gained KanbanFolderRootRow. Tests in TasksKanban.test.ts, store.test.ts and the config round trip. Verified in the built app over CDP (ten checks). Captioned demo: media/folder-board-730.mp4 plus media/folder-board-730.vtt (1920×1080).

  • #738: the record shape lives in packages/shared-domain/src/note-comments.ts (normalizeNoteComment(s) with the optional author and parentId, orphaned replies kept as top-level comments, threadNoteComments, threadRootOf, the anchor helpers that used to sit in app-core, lineOfOffset), imported by the desktop main process (vault.ts lost its copy), the MCP vault-ops (sidecar read/write) and the panel; the Go NoteComment struct and normalizeComment mirror it. apps/desktop/src/mcp/comment-ops.ts composes list/add/reply/resolve from a backend's readNote, listComments and writeComments (new VaultBackend members, local through vault-ops, remote through the existing /api/comments/* routes), and returns thread views a model can act on. server.ts registers the four tools and captures clientInfo.name in oninitialized for commentAuthorForClient. CommentsPanel renders threads (rows stay the top-level cards, so j/k walk conversations), ReplyRow, the reply composer and the a action; VimNav maps a to it; EditorPane draws markers for top-level comments only. Tests: note-comments.test.ts, comment-ops.test.ts, server.test.ts (tool list, author mapping, reply threading), cli/commands/comments.test.ts, and a Go round trip. Verified in the built app with a real zn mcp session driven as claude-code, sixteen checks. Captioned demo: media/comment-threads-738.mp4 plus media/comment-threads-738.vtt (1920×1080).

  • #731: the map helpers live in packages/app-core/src/lib/saved-task-filters.ts (normalizeSavedTaskFilters validates the file and localStorage, findSavedTaskFilterName and savedTaskFilterQuery match case-insensitively, withSavedTaskFilter, withoutSavedTaskFilter and renameSavedTaskFilter keep the insertion order, which is the chip order and the file order). The store carries savedTaskFilters as a portable pref (PORTABLE_PREF_KEYS in shared-domain, [saved_filters] in the desktop writer's map tables, seeded from loadPrefs() like every other pref) with saveTaskFilter, renameSavedTaskFilter, deleteSavedTaskFilter and applySavedTaskFilter. TasksView owns the chip row, the Save filter… chip, the F picker (tasks.savedFilters, a new view-actions keymap entry in both the catalog and keymaps.ts), and the :filter, :savefilter and :delfilter ex forms in its local ex line; buildCommands adds one "Tasks: name" entry per saved filter, opening the view first because openTasksView resets the filter. Tests: saved-task-filters.test.ts, the [saved_filters] round trip in app-config.test.ts, the palette entries in commands.test.ts, plus the keymap drift test. Verified in the built app over CDP with Vim on (23 checks) and off (9). Captioned demo: media/saved-filters-731.mp4 plus media/saved-filters-731.vtt (34 seconds, 1920×1080). Note for the next person driving this file over CDP: the shell's grep wrapper skips TasksView.tsx as binary; use /usr/bin/grep -a.

  • #743: the calendar is packages/app-core/src/components/DatePickerModal.tsx on ui/Modal (focus trap, Escape, focus restore), hosted by DatePickerHost next to the prompt and confirm hosts in App.tsx, and requested through promptDate() in lib/date-prompt-requests.ts, the calendar twin of promptApp: any code without React context (a CodeMirror completion's apply, an ex command) can await an ISO string or null. The date math is pure in lib/date-picker.ts (parseISODate rejects days the month does not have, addMonths clamps the day, buildMonthGrid, moveDate, and datePickerMoveForKey, which gates h/j/k/l and t on Vim mode). The Date… item in cm-date-shortcuts.ts removes the trigger before the calendar opens and inserts at the recorded position, re-clamped to the document length. The modal carries data-prompt-modal, so VimNav and the list views hand over the keyboard as they do for the text prompt. A month change replaces every grid cell, which drops focus to the body before React's effect runs, so the refocus decision is recorded by the move itself rather than read from document.activeElement; the built app caught this where jsdom did not. Tests: date-picker.test.ts, DatePickerModal.test.ts (keyboard, paging with focus, typed dates, Vim gating, cancel), and cm-date-shortcuts.test.ts (menu order, queries, the hand-off with a mocked prompt). Verified in the built app over CDP with Vim on and off, thirteen checks. Captioned demo: media/date-picker-743.mp4 plus media/date-picker-743.vtt (31 seconds, 1920×1080).

  • Tests for #723: Go (vault slug, path, de-duplication, symlink and listing rules; HTTP auth, CRUD, traversal, 413 and the capability; watcher scope and late walk), desktop (server-client.test.ts drives the four routes against a loopback server, watcher.test.ts sees the scope), app-core (store.test.ts for the scope, the resync fan-out and the post-connect re-read; SettingsModal.test.ts for the remote gate with and without the flag). Verified live: the web client against a real server (create, rename, delete, and a file dropped into the vault arriving through the WebSocket feed) and the desktop in remote mode over CDP through twelve checks. Captioned demo: media/remote-templates.mp4 plus media/remote-templates.vtt (28 seconds, H.264, 1920×1080), recorded from the built app connected to a local ZenNotes server.


Local-first and keyboard-first, as always.

Don't miss a new zennotes release

NewReleases is sending notifications on new releases.