github ZenNotes/zennotes v2.55.0
ZenNotes v2.55.0

latest release: web-2.55.0-web.h37369c6e25817727
3 hours ago

ZenNotes 2.55.0: an active workflow whose trigger: names an event now runs by itself for the note you change in the app (on note-saved, on note-created, on note-moved, on tag-added), over that one note, a moment after it settles, with a receipt toast that carries Undo. Fixes: on a Kanban board wider than the window, dragging a card to the board's left or right edge now scrolls the board, so the card reaches an off-screen column in one drag; a long column scrolls the same way at its top and bottom, a dragged column header scrolls the board too, and after a drop the cursor sits on the card you moved; a new note renamed from its title field takes the name you give it, where a creation date left behind by a moved note used to make every rename onto that name fail without a word, and a rename that cannot happen now says why; and a workflow run or the MCP server moving a note now takes the note's comments and creation date with it, and undo brings them back; and undoing a workflow that moved a symlinked note puts the link back and never touches the file behind it; and a workflow step that edits or creates a note no longer changes the note's creation date; a workflow move on a vault served by the Go server carries the note's comments too; and a symlinked note moved to another folder keeps pointing at its file; and a workflow's "Notes in folder" step can name the vault root on a vault whose notes live there.; the move prompt (:mv, Move…) speaks the sidebar's language, so on a vault whose notes live at the root it offers the vault's own folders instead of inbox/…; and with Vim mode off nothing on screen names a Vim key any more: no pane ring or m chip in the sidebar, no j/k strips in the connections and comments panels, no letters in tooltips, and the Kanban board and the Tasks calendar follow the same Vim-off rule as the lists.

Released on September 23, 2026 as v2.55.0, at 00db7036. The release branch v2.55.0 was cut from main at 67be33c9 (the 2.54.1 release, after its channel commits) and fast-forwarded into main through PR #845. One feature, event triggers for workflows (#843, asked for by @vinivinoo); the fixes #838 from @uNyanda, #839, #840 and #844 from @vinivinoo, five follow-ups to #839, the Vim-off sweep from a screenshot @iamsethmedina posted on X, and the core half of #842 for the phone shells. The commits: e06770ca Fix(kanban), ecc74315 Fix(rename), 1cdb0e81 Fix(workflows), 25f40403 Fix(workflows) for symlinked notes, 57acec48 Fix(workflows) for creation dates, ab600cba Fix(workflows) for remote vaults (with b35c983 in znserver), e7232665 Fix(notes) for relative links, dd58fe4c Fix(workflows) for the vault root as a source, 1488f50b Fix(search) marking the New note form's key hints for the phone shells (#842, no change on the desktop), c8525283 Feat(workflows) for event triggers, 6f423584 Fix(notes) for the move prompt on root vaults, cdccd554 Fix(ui) for Vim mode off, 98e9ebda Docs(help), then the version bump b251ff0a and, after the release PR's Windows job caught three symlink assertions comparing readlink output against forward slashes, the test-only 00db7036 Test(workflows), which carries the tag.

✨ Features

  • A workflow runs by itself when you save, create, move or tag a note (#843, asked for by @vinivinoo). Since 2.20 a workflow's trigger: line accepted on note-saved and its siblings, and the manual said so: they parsed but did not fire, and a workflow written for them did nothing until you ran it by hand. Now they fire. Give an active workflow trigger: on note-saved (or on note-created, on note-moved, on tag-added) and it runs on its own when you make that change in the app, about a second and a half after the note settles: a burst of autosaves while you type is one run, made once you pause, and a note you are still typing in waits for its next save. note-moved fires with the note's new path for a rename, a move, Archive, Trash and Restore; tag-added fires for a save that gave the note a tag it did not have. The run sees only the note that changed: every source (all, folder, tag, search, current) resolves over that one note, so the reporter's all | contains type: topic | move Topics means "file this note under Topics as soon as it says so", and nothing is read across the vault on every save. Add a condition to fire only when the note matches: trigger: on note-saved where folder = inbox takes the same field op value a where step takes, evaluated by the engine itself. There is no confirmation to answer; the receipt is a toast that carries Undo, a run that changed nothing (a tag the note already has, a move into the folder it is already in) says nothing, a note the run would write that has unsaved edits is left alone and named, and a workflow whose condition the engine cannot read says so once instead of every time you pause. An editor open on a note the run moves follows it, the way it follows a rename you make yourself: the tab, the caret and the undo history come along, and the Undo on the receipt carries them back; the canvas's own Run and its Undo now do the same. Only edits made in this app fire: a change that arrives by sync or from another device does not (two synced desktops both firing on each other's writes would bounce an append between them forever; the single-executor model in the design document stays a design), what a run itself writes never fires again, a folder rename does not fire for the notes inside it, and the phone apps do not fire at all. status: draft or trigger: manual silences one workflow; Settings → Workflows → Event triggers (workflow_event_triggers under [view] in config.toml, on by default) silences them all on this device. A schedule trigger still parses without firing. Desktop local vaults, desktop remote workspaces and the self-hosted web client all fire, since the triggers live in the shared core and run through the same host API as a palette run.

🐛 Fixes

  • Dragging on the Kanban board scrolls it at the edge (#838, from @uNyanda). On a board with more columns than fit in the window, dragging a card toward the board's left or right edge never scrolled it, so a card could not reach an off-screen column in one drag: released at the edge, it landed in whatever column was under the pointer, and you scrolled and dragged again. The board moves cards with its own pointer-based drag rather than the browser's drag-and-drop, so it never had the browser's edge scrolling. Now it has its own: hold a dragged card within about 64 px of the board's left or right edge and the board scrolls that way, gently at the inner side of that band and up to 1200 px a second at the edge; past the edge (into the sidebar or a neighbouring pane) it keeps full speed. Near the top or bottom of a long column the column scrolls, so a card can go anywhere in it, and a column header dragged to reorder scrolls the board the same way. The drop highlight and the insertion line follow the board while it scrolls, which also fixes them going stale when the board was scrolled with the wheel mid-drag. After a drop the cursor sits on the card you moved (a dropped column takes the cursor with it), the way a click, a right-click and Shift+H/L already put it on their card, so the keyboard carries on from there; without that, the board jumped back to the first column the moment a card landed on the far side. This is core, so the web client picks it up with this version and the phone shells when they adopt this core (not exercised on a phone).

  • A new note takes the name you give it, and a rename that cannot happen says why (#839, from @vinivinoo). Right-click under the folders, New note, type a name in the title field, Enter: in a vault where a note of that name had once been moved away, the note stayed "Untitled", every retry did the same, and nothing on screen said why. Since 2.51 each note keeps its creation date in a small file under the vault's .zennotes/note-metadata folder, and anything that moves a note without it (a workflow run, the MCP server, a file manager, git, sync) leaves that file behind under the old name. Creating a note with that name already threw the stale date away; renaming or moving a note onto the name refused instead, and only the developer console heard about it. Now renames and moves onto a name with no note there discard a leftover date the same way, so the rename goes through and the note keeps its own creation date. A rename that still cannot happen tells you: a name another note already has says "A note named “test” already exists in this folder", and the one leftover that still blocks a name, a comments file from an earlier note, is named in the message so it can be moved aside (taking over another note's discussion or deleting it would both be wrong). Every rename in the app goes through this, from the title field, the sidebar, the note list and :rename. A folder moved onto a leftover tree of dates still refuses. The two writers that left those files behind are fixed as well, in the next entry.

  • A workflow that moves a note takes its comments and creation date along, and undo brings them back (follow-up to #839). A note's discussion lives in .zennotes/comments and its creation date in .zennotes/note-metadata, both filed under the note's path. The workflow steps that move a note (move, rename, archive and trash) moved only the Markdown file: the comments stayed behind under the old name, no longer attached to the note, where the next note given that name took them over or a rename onto it was refused, and the creation date stayed behind too, so the moved note showed the time of the run as its creation date. The MCP server carried the date but not the comments. Now both carry both files with the note, the way the app's own rename and move always have. A workflow run records them before it moves them, so Undo, and the rollback of a run that fails partway, puts them back where they were. That includes the creation date of a note ZenNotes never saved (one from before 2.51, or from git, sync or a file manager): undo writes such a note back as a new file, which used to give it the moment of the undo as its date, so the run now writes the date down before moving the note. A leftover creation date waiting at the destination is replaced, and undo brings it back; a leftover comments file stops the run before anything changes, with the file named in the message, as a rename in the app already does. The MCP server now also removes a note's comments when it deletes the note, empties the Trash or deletes a folder, as the app does, so a new note with that name starts without them. Big moves got faster rather than slower: a run records everything one move touches with a single disk sync, and undo moves an untouched comments or date file back instead of rewriting it (400 notes with comments and dates: the move 2.7 s against 4.0 s before, the undo 3.0 s against 2.5 s). Not covered yet: on a vault served by the Go server, a workflow move still leaves the comments behind.

  • Undoing a workflow that moved a symlinked note puts the link back, and never touches the file behind it (follow-up to the entry above). A note can be a symlink to a file that lives elsewhere, outside the vault included, and the app lists and edits it like any other note. A workflow move, rename, archive or trash renames the link itself, so the file behind it never moves. Undo did not know that: it took the destination for a file the run had created and deleted what the link pointed at, which for a link out of the vault is a file the vault never owned; then it wrote a plain copy where the link had been, and left the link at the destination, pointing at nothing. Now a run records that a path was a link, with the link's text, and undo moves that same link back where it stood and removes only what the run put down, as itself. A link is only ever moved back, never made from the record, so an edited or synced record cannot plant a link and write through it. A move also no longer lands on a link that points at nothing, which the rename would have replaced for good. Records written before this change still undo; without the link's text the note comes back as a copy, and the file behind the link is left alone.

  • A workflow step that edits a note no longer changes the note's creation date (follow-up to the entries above). Since 2.51 a note's creation date lives in a small file the app keeps beside it, and a note the app has never saved (one from before 2.51, or one that arrived through git, sync or a file manager) shows its file's birth time instead. Saving a note replaces its file with a new one, so the editor writes the date down first. The workflow steps that edit a note (add-tag, remove-tag, set-frontmatter, append, prepend, write-section, write and apply-template) replaced the file the same way without doing that, so every note they touched that had no date file yet took the run's time as its creation date, and undoing the run reset it again to the time of the undo. Now a run writes the date down before it edits such a note, once, the way the editor does, and the note keeps its date through the run and through undo. A note the run creates (create-each, or write to a new path) gets its own birth time as its date: a date file left behind by an earlier note of that name is discarded, as creating a note in the app already does, and undo puts that file back with everything else. A note's existing date file is left alone, one the app cannot read included, so a damaged one cannot fail a run.

  • A workflow move on a vault served by the Go server carries the note's comments too (follow-up to the entries above). On a remote vault the app plans a run and sends the server the file changes to make, and a move arrives there as a delete plus a write. The server carries comments on its own renames and moves, but a workflow move left them behind under the old name, the way the desktop applier did before this release. Now the plan also lists the moves it made, and the server carries each note's comments with it, refuses a destination where an earlier note's comments still sit (with the same message the desktop shows), and records what it moved so Undo puts it back, in the same record format the desktop writes, so a vault opened by both can undo either's runs. An older server ignores the new field and behaves as before; an older app sends no moves and gets what it always got. This ships with the next znserver release; the creation-date file is the desktop's and is not part of it.

  • A symlinked note moved to another folder keeps pointing at its file (follow-up to the symlinked-note entry above). A link's relative text is read from the link's own folder, so a note that is a link like ../sources/Real.md moved from inbox/ into inbox/Topics/ named inbox/sources/Real.md from there, which does not exist: the note opened empty after the move. That is what mv and Finder do, and it is wrong for a note the user expects to keep reading the same file wherever it is filed. Now every move of a note or folder, from the app, from the MCP server or from a workflow, re-bases a relative link's text on the file it named from the old folder, so it still names that file from the new one; an absolute link needs nothing. Undo and a rollback rename the link back and spell its text as it was. Links inside a moved folder move with their folder and keep resolving unless they pointed out of it, which is not followed. The Go server does the same for its own moves from its next release.

  • A workflow's "Notes in folder" step can name the vault root (#840, from @vinivinoo). On a vault whose primary notes live at the root (Settings → Vault, "Vault root", the Obsidian-style layout), the "Notes in folder" source could not start from those notes: its folder list offered only the directories notes sit in (Areas, Projects, archive...), the root itself has no directory name (an empty one is refused on purpose, since folder "" once matched the whole vault, Trash and Archive included), and typing inbox gave "0 notes" because no note there has an inbox directory. The engine already read folder trash and folder archive as the Trash and the Archive wherever the vault keeps them; now all four system names work that way. folder inbox is the primary notes area, which on such a vault is the root and everything under it (not the Quick Notes, the Archive or the Trash), folder quick is the Quick Notes, and the in filter reads the four names the same way. The folder list leads with the four, each with the name the sidebar shows for it, and inbox reads "Vault root" on a root-mode vault, so typing root finds it. A vault with remapped system folders gets the same: folder inbox means the inbox wherever it was mapped, and a directory name still narrows to that directory. The step's description in the inspector, the manual's grammar card and the website say so.

  • The move prompt speaks the sidebar's language (#844, from @vinivinoo). On a vault whose notes live at the root, :mv (and Move… from the context menu, the note list, the Archive view, the palette, and a multi-selection) opened on inbox and offered inbox/Areas, inbox/Projects, inbox/Daily Notes: folders the vault does not have, because the prompt spoke in the bucket's terms and every folder of the notes area is filed under the inbox bucket whatever the layout. Now it speaks the way the sidebar does. Destinations are folders of your notes area as you see them (Areas, Work/Research, no inbox/ in front), empty for the notes root (labelled Vault root when your notes live there, or Inbox, or whatever you renamed the inbox to), and archive/… for the Archive, by that name even when the archive folder is called something else on disk. The prompt opens on the note's current folder in that spelling, Enter on an empty field moves to the root, and picking a row moves at once. The same words are refused for the same reasons as before: hidden names and parent references, and the Quick Notes and the Trash, which have their own actions. The older inbox/Work spelling, which the manual taught, still lands in Work on an Inbox vault; on a root vault it does too, unless a real folder named inbox sits at the root, in which case it means that folder. Moving a folder or a database from the sidebar uses the same vocabulary, and stays within the notes area. The manual's three mentions and the website were reworded; a typed :mv Work/Research or :mv archive/Reference moves directly, as before.

  • With Vim mode off, nothing on screen names a Vim key (reported on X by @iamsethmedina). With Vim mode off, the app still showed things that only mean something with it on: a click into the sidebar or the note list drew the pane ring that answers "which pane does Ctrl+W h/j/k/l move from", the sidebar row under the cursor carried an m chip for a key that did nothing, the connections panel's strip and its cursor card offered j/k and p, the comments panel's strip and the badges under a comment offered n, a, e, r and d, and tooltips promised (a), (o), (x), (c), (3), (or m) and :w. Every single-letter key is Vim mode's (with it off, the lists answer arrows, Enter, Escape, and Tab or Space where a surface uses them), so with Vim off those rings, chips, strips and key names are simply not there now; with Vim on nothing changes. Two Tasks surfaces were the exception to that rule: the Kanban board and the Tasks calendar kept h/l, j/k, x, g, the bracket and angle keys and the calendar's e, m, dd, T and a live with Vim off, and their hint lines said so. They now follow the rule the Tasks list already followed: with Vim off the arrows move, Space toggles, Enter opens, Tab picks, drag and right-click do the rest, and the hint lines name exactly that. The calendar panel beside a note does the same (its m grab mode is Vim's), and the Tags view's hint line and the quick capture window's command list follow suit.

🧰 For contributors

  • The fix is e06770ca. packages/app-core/src/lib/drag-autoscroll.ts is new and store-free: edgeScrollVelocity(pointer, start, end) returns a signed px/s for one axis (a 64 px band that shrinks to a third on a narrow scroller, a quadratic ramp, the top speed held past the edge), and createDragAutoScroller({ horizontal, vertical }) runs the requestAnimationFrame loop that keeps scrolling while the mouse rests, carries sub-pixel steps so the slow end of the band still moves, and caps a stalled frame at 50 ms. TasksKanban.tsx makes one scroller for card drags (the board sideways, the column body under the pointer up and down) and one for header drags (the board), keeps the latest pointer on each drag, re-aims the drop from a capture scroll listener on the board, and moves the cursor before moveTaskOnBoard: that call's flushSync commit runs React 18's passive effects synchronously, so the focus effect's scrollIntoView would otherwise run under the old cursor and yank the board back. Tests: packages/app-core/src/lib/drag-autoscroll.test.ts, 14 cases under jsdom with a hand-driven frame queue (the ramp, the dead zone, a resting pointer, the scroll limit, sub-pixel carry, a stalled frame, the vertical target, stop()).

  • The live harness is tools/check-kanban-autoscroll.mjs (local, in this folder, with cdp-lib.mjs beside it): eight scenarios over CDP in the built desktop app with both stores isolated. It makes the test window ignore OS mouse input (setIgnoreMouseEvents(true) through the main-process inspector) because a real mouse passing over the window sends pointer events with the same pointerId as the scripted drag, and in early runs that pushed the board sideways by 2 to 30 px. The clip is tools/demo838.mjs plus tools/burn838.py, and tools/record-before.sh records the take on the code before the fix.

  • #839 is ecc74315. Desktop main gains relocateNote in apps/desktop/src/main/vault.ts (the follow-up moves it to note-sidecars.ts), which renameNote, moveNote and moveBetweenFolders (archive, trash, restore) now share: when no note sits at the destination it refuses on leftover comments with a message naming the file, discards a leftover creation date with removeNoteCreation (the rule createNote already follows), then hands the note, comments and metadata triple to relocateFolderTrees as before. renameNote also checks for a real note at the target first and says so plainly. The MCP copy changed in moveWithCreationMetadata (note-creation-metadata.ts): a single note's leftover destination date is removed instead of refused, while a folder's metadata tree still refuses. The follow-up replaces that function with the shared relocateNote. In the renderer, the store's renameNote toasts Could not rename “<title>”: <reason> through the existing humanIpcError when no host asked to handle the failure. Tests: the parameterized creation-metadata test that pinned the refusal now expects the rename for both desktop and MCP, plus "never hands a stale destination date to a renamed note that has none" (both clients), a move and a trash onto stale dates (vault-creation-metadata.test.ts), the two refusal messages (vault.test.ts), and the toast plus the host-handled path (store.test.ts).

  • The #839 live harness is tools/check-rename-new-note.mjs: sidebar right-click, New note, test typed into the title field, Enter, over CDP in the built app with both stores isolated, across seven vault layouts (root inferred, inbox, root set in vault.json with and without an old inbox/, a leftover date, leftover comments, a taken name). The clip is tools/demo839.mjs plus tools/burn839.py, and tools/record-before-839.sh records the take on the three files as they were before ecc74315.

  • The #839 follow-up is 1cdb0e81. apps/desktop/src/main/note-sidecars.ts is new and free of Electron: relocateNote, relocateFolderTrees, noteCommentsPath, noteCommentsRoot and a shared leftoverCommentsMessage moved there from vault.ts unchanged, so desktop main, the MCP server and the workflow applier follow one policy. The MCP server (src/mcp/vault-ops.ts) renames, moves, trashes and restores through relocateNote, renames folders through relocateFolderTrees with their comments and metadata trees, and removes comments on delete, Empty Trash and folder delete; moveWithCreationMetadata is gone. In workflow-apply.ts, a path op reads both sidecars before it journals anything (sidecarsToCarry: leftover comments throw the shared message, a leftover date is journalled and replaced, and a note with no date file first gets one from prepareNoteCreation, except in a temporary folder session), journals them with the note in one append and one sync (journalPathOp), then moves them after the note. A sidecar entry is { note, sidecar, before }: the file is derived from the note's path at restore time, after resolveVaultPath, so an edited or synced ledger cannot aim a restore anywhere else. Crash-journal lines carry no path key and the finished ledger keeps the entries in a separate sidecars array with an after hash, so an older ZenNotes or the Go server reading a ledger skips them and still undoes the notes. Undo and rollback restore sidecars after the notes (restoreSidecars): an untouched file goes back by rename once its bytes are compared, empty sidecar folders are pruned (a folder rename refuses a destination whose sidecar folder exists), restored still counts notes, and drift and failures are reported under the note's path. rollBackRun also drops a trailing full stop from its cause, which had doubled on the create-note refusal too. Tests: note-sidecars.test.ts (new, 16 cases: desktop and MCP each keep a note's comments through a rename, a move, Trash and back and a folder rename, refuse leftover comments with the file named, and drop them on delete, Empty Trash and folder delete; all 8 MCP cases fail on the previous MCP code) and 20 new cases in workflow-apply.test.ts (each path op, undo, rollback, a note moved twice, a leftover date, leftover comments, a never-saved note's date, a temporary folder session, drift, an undo and a rollback that cannot restore a sidecar, the crash journal's order and recovery, a ledger from before this change, a forged ledger entry, one sync per move, the rename on undo). 19 of the 20 fail on the previous applier; the ledger from before this change undoes on both, by design.

  • The follow-up's live harness is tools/check-workflow-sidecars.mjs: a "Move Topics" workflow run from the Workflows view over CDP in the built app with both stores isolated, then Undo from the receipt, a run refused by leftover comments, and the moved note opened with its comments panel; 22 checks.

  • The symlinked-note follow-up is 25f40403, in workflow-apply.ts. A note's journal entry gains an optional link, the symlink text read at first touch (withLinkText, in the crash journal and the ledger alike; parseNoteEntry reads it back). uniqueRel treats a link that points at nothing as taken (pathTaken, an lstat). restoreEntries first maps the links the run left at paths it found empty, by their text; an entry with link whose path is empty then gets that link renamed back (its text re-read first) before the bytes are compared, a before: null path is removed as itself, and only a recorded link that still has the same text has its target removed, which is the dangling-link case the old code was written for. Tests: 9 in symlinked notes › moved (undo, rollback, an edit through the link then a move, moved twice, a relative link, the crash journal and recovery, a ledger without link text, an edited ledger that names a link, a link at the destination); 8 fail on 1cdb0e81, the planted-link case guards the fix.

  • Its live harness is tools/check-workflow-symlink.mjs, and tools/record-before-symlink.sh runs it twice: on the 1cdb0e81 applier (built in place, restored by hash) and on the fix.

  • The creation-date follow-up is 57acec48, in workflow-apply.ts. keepCreationDate writes a note's date down through prepareNoteCreation when the note has no date file (a present one is left alone, readable or not; nothing in a temporary folder session), and both writers call it before they journal: applyTextOpToVault for an existing note, sidecarsToCarry for a move, where the block that did this inline moved into the helper. Creating a note journals a leftover date file as a sidecar entry and removes it, so undo restores it; journalPathOp became journalOp and absorbed journalTouch, since a text op now journals a sidecar too. Tests: 11 in a note keeps its creation date (six text ops through the run and the undo, the date written down before the note, a date file left alone, a temporary folder session, a created note over a leftover date and its undo, a created note with no date file of its own); 8 fail on 25f40403.

  • Its live harness is tools/check-workflow-created-at.mjs, run by tools/record-before-created-at.sh the same way (the 25f40403 applier first, then the fix).

  • The remote follow-up is ab600cba in this repo and b35c983 in ZenNotes/znserver. prepareWorkflowRun (packages/shared-domain/src/workflows/prepare-run.ts) gains moves: Array<{ from, to }>, one per path op as it lands (a suffixed destination included), in order; the web bridge and the desktop's remote client pass the prepared run through unchanged. On the server, PreparedWorkflowRun.Moves is validated against the run's changes (both ends must be note paths the run names), planWorkflowSidecarMoves reads the comments each move carries before anything is written (through the moves before it, so a chain carries them on from the middle path) and refuses a leftover at a destination with the desktop's wording, the ledger gains a sidecars array in the desktop's format (workflow_sidecars.go, custom JSON so after is null where the run removed a file and absent where it cannot know), apply moves the comments after the notes and rolls everything back if that fails, and undo restores them after the notes and names drifted comments under the note. Empty comments folders are pruned, since the server's relocateFolderTrees refuses an existing destination tree too. Tests: workflow_sidecars_test.go (8: a move, a refusal, a chain, a move outside the changes, a rename that cannot happen, a desktop-written ledger with comments and a creation date, drift, a forged entry) and a moves case plus assertions in prepare-run.test.ts.

  • Its live harness is tools/check-remote-workflow-comments.mjs: it starts the server binary it is given on a loopback port with a scratch vault, launches the desktop in remote mode against it, and runs the move, the undo and the refusal from the Workflows view; run once with a binary built from the server's 2712ef3 and once with the fix.

  • The relative-link follow-up is e7232665. rebaseMovedLink(from, to) in note-sidecars.ts runs inside the shared renameDirectory (so every desktop and MCP move, and every rollback through it, re-bases the moved entry's text, a temp link renamed over it so there is no moment without a link) and after the applier's rename in movePathInVault. On undo, restoreEntries matches a moved link by the file it resolves to rather than by its text, re-bases it on the way back, and re-spells it with the recorded text only when that text names the very file the link now names (respellLink). Tests: the depth-changing move and its undo, a text path.relative would not have produced, and a desktop and MCP parity case in note-sidecars.test.ts; tools/check-workflow-symlink.mjs gained a relative link. The Go mirror is rebaseMovedLink in znserver's internal/vault/relink.go, called from its relocateFolderTrees after each rename and on the rollback path (6c6554a), with a MoveNote test and a round-trip test.

  • #840 is dd58fe4c. In the engine (packages/shared-domain/src/workflows/engine.ts), bucketNamed and inFolderOrBucket replace the folder source's trash/archive-only bucket match and the in filter shares them: a note matches when its directory is the name or under it, or when the name is one of the four system names and the reader classified the note into that bucket (WorkflowNote.system, which the app-core reader stamps from NoteMeta.folder; in root mode every primary note is inbox). fieldValue('folder') is untouched, so where folder = ... still compares the directory. In the inspector (NodeInspector.tsx) the folder vocabulary is ComboboxOption { value, hint? }; optionsFor matches the hint as well as the value and ThemedCombobox renders the hint beside it (the other comboboxes still pass plain strings). WorkflowsView builds vaultFolders as the four system names first, hinted with getSystemFolderLabel (the sidebar's labels, custom ones included) or "Vault root" for inbox when vaultSettings.primaryNotesLocation is root, then the directories from the note paths, a directory that carries a system name folded into its bucket entry. The folder and in descriptions in nodes.ts carry the rule, which also puts "Notes in folder" in front of anyone who types "root" into the step picker. Tests: engine.test.ts, a notes at the vault root block (the root and its subfolders, a directory name still narrows, quick/archive/in keep their meaning) and folder inbox/in inbox after a remap; three of the four fail on the committed engine.

  • Its live harness is tools/check-workflow-root-folder.mjs: a root-mode scratch vault, the "Root Notes" workflow opened from the Workflows view, the counts read off the canvas, the folder combobox emptied, read, searched for "root" and committed with Enter, over CDP in the built app with both stores isolated.

  • The core half of #842 is 1488f50b: the New note form's hint group (↑↓ pick · ↵ create · esc back) and the Shift+↵ chip in its Open it button carry data-keyboard-hints (SearchCreateForm.tsx), so the phone shells can hide them with one rule (.zn-mobile.zn-phone .z-palette [data-keyboard-hints] { display: none }) without touching the footer that also holds Back and Create. Nothing changes on the desktop; the shells' rules are zennotesios#31 and zennotesandroid#83 (branch fix/842-keyboard-hints) and take effect when each adopts a core with the marker. The live harness is tools/check-search-create-hints.mjs (the footer text, the two markers, and Shift+↵ still opening the existing note, over CDP in the built app).

  • #843 is c8525283. Nothing changes in the engine: lib/note-events.ts (new, import-free) is the bus the store rings from its own write paths (persistNote rings note-saved and, when the returned meta carries a tag the note lacked, tag-added; the five createNote sites ring note-created; renameNote, moveNote and changeNoteLifecycle ring note-moved with the new path), and lib/workflow-events.ts (new) is the dispatcher installed lazily at vault open: it answers on every save from the workflow index alone (WorkflowIndexEntry gained trigger), arms a 1.5 s per-note timer (EVENT_SETTLE_MS), merges the events one note collected, fires them in a fixed order (created, moved, saved, tag-added), skips a note that is dirty again, re-arms when a palette run is at its confirmation, and stops when a run reports the note dirty. lib/workflow-trigger.ts gained runWorkflowForEvent next to the palette's runWorkflowById, sharing loadWorkflows and applyAndReport: the reader is scoped to the fired note (createVaultReader({ notes: [meta], current: () => meta })), the where condition is planned as current | where <condition> over that reader (triggerConditionHolds, invalid reported once per condition through toastOnce), no confirmation, a fired note that is dirty returns unsaved, other dirty notes are dropped from the ops and named, a background receipt with no written path is silent, and outcomes are ran | nothing | unsaved | busy | skipped. The store gained followWorkflowMoves(moves, { reverting }): it shields the promised from paths in renamesInFlight before the host applies the run and afterwards carries the tab, buffer and undo history to to through rewriteFolderWorkspace, asking the disk (not the note list, which a coalesced refresh can answer stale) that to exists and from is gone and that the file reads exactly as the buffer did (a suffixed landing is someone else's file); reverting lets an undo take the restored bytes into a clean buffer. promisedMoves(ops, systemFolderDirs) in workflow-run.ts folds a note moved twice to its last stop, and WorkflowRunRecord carries moves so both undo surfaces replay them backwards. The kill switch is workflowEventTriggers (store pref, PORTABLE_PREF_KEYS, PORTABLE_DEFAULTS, [view] workflow_event_triggers in the desktop TOML map) with a ToggleRow under Settings → Workflows. Tests: workflow-events.test.ts (15: routing, settle timer, bursts, per-note timers, event order, index order, kill switch and feature switch, host without runs, dirty wait, busy retry carrying only the remaining events, unsaved stop, idempotent install), workflow-trigger-events.test.ts (18: the reporter's workflow over the fired note, all scoped, silent no-write run, rolled-back report, the where condition through, held, frontmatter-reading and invalid-once, dirty fired note, another dirty note named and skipped, stale trigger, draft, kill switch, unlisted note, no host support, busy behind a palette confirm, the record carrying the moves and undo replaying them), promisedMoves (2) in workflow-run.test.ts, the index test extended, commands.test.ts fixtures carrying the trigger.

  • Its live harness is tools/check-workflow-triggers.mjs: two launches of the built app over CDP with both stores isolated, the reporter's workflow plus an on note-created one, real typing into the editor, the filesystem polled for the move, Undo pressed on the toast, Mod+N for the new note, and a second launch with workflow_event_triggers = false.

  • #844 is 6f423584. lib/move-note.ts gained moveNoteVocabulary(settings, labels, folders): whether the notes area is the root, the root's label and phrase, the Archive's label and directory name, the reserved first segments (quick, trash and their directory names), and whether a real inbox folder sits at the root of a root vault. parseMoveNoteTarget(value, vocabulary) reads the sidebar spelling (empty is the root; archive or the archive's directory name is the Archive; a leading inbox is the notes area unless it is that real folder), validateMoveNoteTarget owns the hidden-segment and control-character rules that notes.ts used to add on top, plus the reserved-name refusal (skipped for a value spelled inbox/trash, which is a folder of that name), buildMoveNotePrompt(note, folders, vocabulary) computes the initial value from noteFolderSubpath instead of the path's first segment and turns on allowEmptySubmit, and the directory prompt and its validator take the vocabulary too. Every caller passes it and treats only null as Cancel: notes.ts (single and bulk moves, with a shared moveVocabulary(state)), Editor.tsx (:mv, with or without a target), commands.ts, NoteList.tsx, Sidebar.tsx, ArchiveView.tsx and browse-actions.ts. Tests: move-note.test.ts (new, 15 cases: labels, remapped archive, parsing in both spellings and on a root vault with a real inbox folder, validation, suggestions and their order and details, initial values, the directory prompt and its messages), note-actions.test.ts (initial values now "" and Work), browse-actions.test.ts (suggestions ['', 'Home'] labelled Inbox, an empty answer moves a nested folder to the root, Work and inbox/Work both name the current parent).

  • Its live harness is tools/check-move-prompt.mjs: :mv typed on the Vim ex line of the built app over CDP, both stores isolated, on a root vault and on an Inbox vault, the prompt's value, placeholder and rows read from the DOM, a row clicked, a target typed, and the files polled.

  • The Vim-off audit is cdccd554. The pattern was hints gated on "panel focused and cursor row" but never on vimMode, while the keys they name live in VimNav, which returns early with Vim off. Every such surface now reads vimMode from the store next to the hint (Sidebar.tsx's RowKeyHint and its panel-focused ring, NoteList.tsx's ring, ConnectionsPanel.tsx's strip and its three row chips, CommentsPanel.tsx's strip, which is no longer mounted with Vim off so it does not hold its blank row open, and CommentCard's badges, EditorPane.tsx's breadcrumb tooltip, TagView.tsx's clear-tags tooltip and hint line, QuickCaptureApp.tsx's :w hint through a vimMode prop on CommandOverlay) or takes it as a prop (TasksRow vimMode; TaskStateBox and the Kanban card take toggleKey, so the checkbox tooltip names the list's actual toggle binding, which TasksView's toggleKeyLabel already resolved, and nothing with Vim off). TasksKanban.tsx and TasksCalendar.tsx gain the one-line guard the lists have (if (!vimMode && e.key.length === 1 && e.key !== ' ') return, after the modifier guard) plus mode-aware hint lines, and CalendarPanel.tsx's key handler the same guard; TasksCalendar's handler effect now lists vimMode in its dependencies, which the existing i/c gating had missed (a stale closure after toggling Vim in Settings with the calendar open). TasksView.tsx's footer comment no longer describes the board and the calendar as predating the gating. Nothing changes with Vim on, and no test asserted a hint with Vim off, so none changed. The manual's "Tasks, tags, and trash views" keyboard section now says its single keys belong to Vim mode and lists what stays live with it off, in 98e9ebda, mirrored on the website in 7c796be on website/docs-2.55 and live on zennotes.org/docs ahead of the release through website PR #36 (merge 25f61e59, with the #840, #843 and #844 docs).

  • Its live harness is tools/check-vim-off-hints.mjs: two launches of the built app over CDP with both stores isolated, [vim] enabled = false then true in the scratch config.toml, on a vault with a note that links to another, a task and a comment. It clicks the Inbox row, opens the note and the connections panel (with a click inside it, since the aside claims focus on mousedown and is not itself focusable), then the comments panel, then the Tasks list, board and calendar, and reads the DOM: the panel-focused class, the m chip, every visible kbd and chip (visibility walked up the ancestors, since a strip faded out by its container still answers opacity 1 on the chip itself), the tooltips and the hint lines; on the board it presses x and then Space and reads the note file back, so the gating itself is proven, not only its hints. 28 checks; tools/out-vim-off/after/ holds the twelve screenshots.

Demos

  • 838-kanban-drag-autoscroll.mp4 (captions also in media/838-kanban-drag-autoscroll.vtt): 51 s, 1920 × 1080 at 30 fps, recorded from the built app over CDP with both stores isolated and Vim mode on, on a scratch vault with one "Launch board" note and eight custom statuses (Backlog through Done, plus No status), real mouse and key events, a pointer dot drawn into the page, captions and key badges burned in. The first 11 s run on the 2.54.1 code: the card held at the right edge, the board does not move, and the release drops it in QA. The next 40 s run on the fix: the board scrolls to Done, the card drops there with the cursor on it, Shift+H moves it to Shipped, the left edge scrolls back, a 24-card column scrolls to its end, and the Backlog header is dragged past Done.

  • 839-rename-new-note.mp4 (captions also in media/839-rename-new-note.vtt): 36 s, 1920 × 1080 at 30 fps, recorded from the built app over CDP with both stores isolated and Vim mode on, on a root-mode scratch vault (Areas, Indexes, Projects, Topics, Zettelkasten) where an older note named "test" sits in Topics and its creation date was left behind at the root, the way a workflow move leaves it. Real mouse and key events, a pointer dot drawn into the page, captions and key badges burned in. The first 17 s run on the 2.54.1 code: right-click under the folders, New note, test, Enter, and the note stays "Untitled", then a second try from the title does the same. The next 19 s run on the fix: the same steps rename it to "test", and a second new note renamed to "test" shows "Could not rename “Untitled”: A note named “test” already exists in this folder".

  • 839-workflow-sidecars.mp4 (captions also in media/839-workflow-sidecars.vtt): 52 s, 1920 × 1080 at 30 fps, recorded from the packaged 2.54.1 app (the first 18 s) and from the built app with the fix (the rest) over CDP with both stores isolated and Vim mode on, on a scratch vault with a commented #topic note, a second #topic note and a "Move Topics" workflow; real key and mouse events, a pointer dot drawn into the page, captions and key badges burned in. Before: the workflow files the note under Topics and the comments panel on the moved note is empty. After: the discussion moved with the note, Undo on the receipt puts both back, and a destination that still holds an earlier note's discussion refuses the whole run with the file named.

  • 839-workflow-symlink.mp4 (captions also in media/839-workflow-symlink.vtt): 43 s, same recording setup, on a scratch vault where inbox/Linked.md is a symlink to a file outside the vault, marked as one in the sidebar. Both phases run "Move Topics" and Undo from the receipt; the closing caption is written from what is on disk at that moment. Before (2.54.1): the note comes back as a plain copy and the file outside the vault is gone. After: the link itself is back and the file was never touched.

  • 843-workflow-event-triggers.mp4 (captions also in media/843-workflow-event-triggers.vtt): 21 s, 1920 × 1080 at 30 fps, recorded from the built app over CDP with both stores isolated and Vim off (the typing has to be plain text), on a scratch vault with two inbox notes and the reporter's "Move Topics" workflow (trigger: on note-saved, all | contains type: topic | move Topics); real mouse and key events, a pointer dot drawn into the page, captions burned in. No before phase: before this release the trigger parsed and nothing happened, which does not film. The Workflows view shows the workflow, Dune is opened from search, type: topic is typed at its end, the typing stops, the note appears under Topics with the editor still on it, the receipt toast offers Undo, and Undo puts note and editor back in the inbox.

Verification

  • Unit: npm run typecheck clean, 7 of 7 tasks (3 from the turbo cache); npm run test:run green, shared-domain 59 files and 1685 tests (turbo cache, package unchanged), app-core 232 files and 2660 tests (1 skipped), desktop 59 files and 891 tests (4 skipped). The manual's wording change landed after that run; app-core was typechecked again and the test file that reads the manual passed (3 of 3). New: drag-autoscroll.test.ts, 14 tests.

  • Live, in the built desktop app over CDP with both stores isolated (tools/check-kanban-autoscroll.mjs), 1280 × 800, a board of eight custom statuses plus No status that is 1736 px wider than its pane, one note of 39 tasks. On the 2.54.1 code (tools/kanban-before.json, 4 of 13 checks): a card held 1.6 s at the right edge left the board at 0 of 1736 and the release wrote @status:review; at the left edge the release wrote @status:blocked; a 30-card column stayed at scrollTop 0 of 1556, so the card could only reach position 8 of 30; a header drag could only reorder among the visible columns (Backlog landed third); a wheel scroll mid-drag moved the board to 1200 but the highlight stayed on Doing while the pointer was over Shipped. The four that passed on both builds: a drag through the middle of the board does not scroll, a release off the board writes nothing, and the wheel-scrolled drop still lands in the column under the pointer (the release-point resolution from 2.21). On the fix (tools/kanban-after.json, 18 of 18): a 2.5 s hold scrolled the board from 0 to 1736, the card wrote @status:done, and the board stayed at 1736 after the drop; the left edge scrolled from 1736 to 0 and the card wrote @status:backlog; the 30-card column scrolled to 1488 of 1556, the card landed 30th of 30 and the column kept its scroll; a header drag scrolled to 1736 and put Backlog after Done with no jump back; the highlight followed a wheel scroll to Shipped; Shift+H after the far drop moved that card to Shipped with the board still at 1736; the two controls unchanged. Stills: tools/right-held-before.png (the card at the edge, the board unmoved), tools/right-held-after.png, tools/right-drop-after.png (the board at its end, the card in Done with the cursor ring) and tools/shift-h-after-drop.png.

  • How to test locally: npm run build --workspace @zennotes/desktop, then node docs/releases/v2.55.0/tools/check-kanban-autoscroll.mjs after. By hand: npm run dev, write tasks tagged @status:backlog through @status:done in a note, put kanban_statuses = ["backlog", "todo", "doing", "review", "qa", "blocked", "shipped", "done"] under [view] in config.toml, open Tasks, press 3, pick Custom status under Group by, and narrow the window until columns go off-screen. Drag a card from Backlog to the right edge of the board and hold. Before: nothing moves and the card lands in the column under the pointer. After: the board scrolls, the card drops into Done, the cursor ring sits on it, and Shift+H moves it one column left.

  • Not exercised live: the web client (same core, same TasksKanban) and the phone shells.

  • #839 unit: npm run typecheck clean, 7 of 7 tasks; npm run test:run green, shared-domain 1685 (turbo cache, package unchanged), app-core 232 files and 2662 tests (1 skipped), desktop 59 files and 896 tests (4 skipped). The flipped creation-metadata test and the new ones fail on the code before ecc74315 by construction (a refused rename throws; a swallowed failure leaves no toast).

  • #839 live, in the built desktop app over CDP with both stores isolated (tools/check-rename-new-note.mjs): on the code before the fix, the report's layout (a root-mode vault with .zennotes/note-metadata/test.md.metadata.json left behind) kept Untitled.md after test plus Enter, with only renameNote failed ... The destination folder or its comments already exist in the console; four other layouts (root inferred, inbox, root set in vault.json, and the same with an old inbox/) renamed fine, which is why the plain flow never showed it. On the fix: the same layout renames to test.md, whose creation date is its own (September 23, the file's birth time) rather than the leftover September 22 one; leftover comments at test refuse with the toast "Could not rename “Untitled”: Comments from an earlier note named “test” are still in .zennotes/comments/test.md.comments.json. Move or delete that file to use this name."; a real test.md refuses with "A note named “test” already exists in this folder"; root-mode (set in vault.json and inferred) and inbox-mode renames are unchanged and show no toast.

  • #839, how to test locally: npm run build --workspace @zennotes/desktop, then node docs/releases/v2.55.0/tools/check-rename-new-note.mjs orphanMeta. By hand: npm run dev, create .zennotes/note-metadata/test.md.metadata.json in a vault containing {"version":1,"createdAt":1790000000000}, right-click the empty sidebar area under the folders, New note, type test, Enter. Before: the note stays "Untitled" with no message. After: it becomes "test"; doing it again with another new note shows the "already exists" toast.

  • Not exercised live: the web client (same core, same EditorPane title field) and the phone shells.

  • #839 follow-up, unit: npm run typecheck clean, 7 of 7 tasks, desktop run fresh (a first attempt aborted with exit 134 before printing a line; the re-run passed); npm run test:run green, shared-domain 1685 and app-core 2662 (1 skipped) from the turbo cache (neither package changed), desktop 60 files and 932 tests (4 skipped) run fresh. The 36 new tests are described under For contributors; all 8 MCP cases and 19 of the 20 applier cases fail on the code before the change (checked by running them against copies of the committed vault-ops.ts and workflow-apply.ts).

  • #839 follow-up, live, in the built desktop app over CDP with both stores isolated (tools/check-workflow-sidecars.mjs, 22 of 22 checks): a scratch vault with inbox/Chemistry.md (a comment anchored to "Reactions", a creation date of January 15, 2025) and inbox/Physics.md (no sidecars, so its date is the file's birth time), and a "Move Topics" workflow (topics = tag #topic, topics | move inbox/Topics) run from the Workflows view (Space a, Run, confirm). After the run both notes and their comments and dates sit under inbox/Topics, nothing is left under the old names, Physics has its birth time written down, and the app reports both original dates and reads the discussion at the new path. After Undo from the receipt ("Undone. 4 files restored to how they were."), everything is back, no empty sidecar folders are left for inbox/Topics, and the app reports both original dates, although undo wrote both notes back as new files. With a leftover .zennotes/comments/inbox/Topics/Physics.md.comments.json the run fails with "Comments from an earlier note named “Physics” are still in .zennotes/comments/inbox/Topics/Physics.md.comments.json. Move or delete that file to use this name. The run was rolled back; your vault is unchanged." and the vault and the leftover are untouched. With the leftover gone, a second run, then Mod+P "Chemistry" opens inbox/Topics/Chemistry.md with "Show comments (1)" on its toolbar and the discussion in the panel. Stills: tools/out-sidecars/1-moved.png, 2-undone.png, 3-refused.png, 4-comments-followed.png.

  • #839 follow-up, cost (a unit-level benchmark on this Mac, 400 notes moved by one workflow run, then undone, two rounds): the committed code 4.0 s to move and 2.2 to 2.6 s to undo; this change 2.7 s and 3.0 s when every note has comments and a date, 4.7 to 5.1 s and 3.9 to 4.0 s when none has a date file yet (writing each date down the first time costs a synced write). Before the batching and the rename, the same run took 12.8 to 14.2 s and 8.0 to 8.4 s.

  • #839 follow-up, how to test locally: npm run build --workspace @zennotes/desktop, then node docs/releases/v2.55.0/tools/check-workflow-sidecars.mjs. By hand: npm run dev with workflows_enabled = true under [view] in config.toml; tag a note #topic, add a comment to it, and add a workflow with topics = tag #topic and topics | move inbox/Topics. Open Workflows (Space a), Run, confirm, open the moved note. Before: its comments panel is empty and the old comments file is still under .zennotes/comments/inbox/. After: the comment is on the moved note; Undo on the receipt puts note and comment back in inbox/.

  • Symlinked-note follow-up, unit: npm run typecheck clean, 7 of 7, desktop run fresh; npm run test:run green, shared-domain 1685 and app-core 2662 from the cache (unchanged), desktop 60 files and 941 tests (4 skipped) run fresh. The exact report reproduced first in a throwaway test: a link in inbox/ to a file in a temp folder outside the vault, one move op, undo, and the outside file was gone.

  • Symlinked-note follow-up, live (tools/check-workflow-symlink.mjs, built app over CDP, both stores isolated): inbox/Linked.md is a link to <tmp>/outside/Linked source.md, tagged #topic, beside a plain inbox/Plain.md; the "Move Topics" workflow is run from the Workflows view, then Undo from the receipt. On the 1cdb0e81 applier, 4 of 12 checks fail after the undo: the outside file is gone, inbox/Linked.md is a plain copy (the app no longer lists it as a link), and inbox/Topics/Linked.md is left as a dead link. On the fix, 12 of 12: the link moves and comes back as a link with the same text, the outside file is untouched throughout, nothing is left under inbox/Topics, and the app reads and opens the note through the link. Stills: tools/out-symlink/before/2-undone.png, tools/out-symlink/after/2-undone.png, tools/out-symlink/after/3-open-after-undo.png.

  • Symlinked-note follow-up, how to test locally: npm run build --workspace @zennotes/desktop, then node docs/releases/v2.55.0/tools/check-workflow-symlink.mjs, or zsh docs/releases/v2.55.0/tools/record-before-symlink.sh for the before and after passes. By hand: npm run dev with workflows_enabled = true under [view] in config.toml; ln -s /some/folder/Real.md <vault>/inbox/Linked.md, put #topic in it, add a workflow with topics = tag #topic and topics | move inbox/Topics, run it from Workflows (Space a, Run, confirm), then Undo on the receipt. Before: /some/folder/Real.md is gone, inbox/Linked.md is a plain file, inbox/Topics/Linked.md is a dead link. After: the link is back in inbox/, /some/folder/Real.md untouched, nothing under inbox/Topics.

  • Creation-date follow-up, unit: npm run typecheck clean, 7 of 7, desktop run fresh; npm run test:run green, shared-domain 1685 and app-core 2662 (1 skipped) from the cache (unchanged), desktop 60 files and 952 tests (4 skipped) run fresh. The report reproduced first in a throwaway test: a note with no date file, a second's wait, one append, and the date the app reads moved by exactly that second.

  • Creation-date follow-up, live (tools/check-workflow-created-at.mjs, built app over CDP, both stores isolated): two #topic notes with no date file, a date left behind under inbox/Chemistry notes.md, and a "File topics" workflow (topics | add-tag #filed, topics | create-each "inbox/{{title}} notes") run from the Workflows view, then Undo. On the 25f40403 applier, 5 of 12 checks fail: both edited notes show the run's time as their creation date, the created "Chemistry notes" shows the leftover January 2020 date, and after Undo both notes show the undo's time. On the fix, 12 of 12: the dates the app shows for both notes are their birth times before the run, after it and after the undo, each note has a date file holding that date, the created note has its own birth time and the leftover file is gone, and Undo brings the leftover back with the rest. Stills: tools/out-created-at/before/2-undone.png, tools/out-created-at/after/1-applied.png, tools/out-created-at/after/2-undone.png.

  • Creation-date follow-up, how to test locally: npm run build --workspace @zennotes/desktop, then node docs/releases/v2.55.0/tools/check-workflow-created-at.mjs, or zsh docs/releases/v2.55.0/tools/record-before-created-at.sh for both passes. By hand: npm run dev with workflows_enabled = true under [view] in config.toml; copy a Markdown file into inbox/ with Finder (so it has no date file) and put #topic in it; read its createdAt from window.zen.listNotes() in the developer console; add a workflow with topics = tag #topic and topics | add-tag #filed and run it from Workflows (Space a, Run, confirm). Before: createdAt becomes the time of the run. After: it stays, through Undo too.

  • Remote follow-up, unit: npm run typecheck clean, 7 of 7, every package run fresh; npm run test:run green, shared-domain 59 files and 1686 tests, app-core 2662 (1 skipped), desktop 956 (4 skipped), all run fresh. In znserver, go vet ./... clean and go test ./... green with the 8 new tests (the rename-that-cannot-happen case is skipped as root, where a read-only folder does not bind).

  • Remote follow-up, live (tools/check-remote-workflow-comments.mjs, desktop built app in remote mode over CDP, both stores isolated, a znserver on 127.0.0.1:7879 with a scratch vault): with a server built from 2712ef3, 6 of 13 checks fail: the note moved but its comments stayed under the old name, the app read no discussion at the new path, and a leftover comments file at a destination was silently accepted. With the fix, 13 of 13: comments follow the note and come back on undo, no empty comments folder is left, and the leftover refuses the run with "Comments from an earlier note named “Physics” are still in .zennotes/comments/inbox/Topics/Physics.md.comments.json. Move or delete that file to use this name. The run was rolled back; your vault is unchanged." Stills: tools/out-remote/before/1-moved.png, tools/out-remote/after/2-undone.png, tools/out-remote/after/3-refused.png.

  • Remote follow-up, how to test locally: build the server (cd ~/Developer/opensource/znserver && go build -o /tmp/zennotes-server ./cmd/zennotes-server) and the desktop (npm run build --workspace @zennotes/desktop), then node docs/releases/v2.55.0/tools/check-remote-workflow-comments.mjs /tmp/zennotes-server. By hand: run the server with ZENNOTES_DEFAULT_VAULT_PATH, ZENNOTES_BIND=127.0.0.1:7879 and ZENNOTES_AUTH_TOKEN, connect the desktop to it as a remote workspace, add a comment to a #topic note and a workflow with topics = tag #topic and topics | move inbox/Topics, run it (Space a, Run, confirm), open the moved note. Before: no comments; .zennotes/comments/inbox/<note>.md.comments.json still sits in the server's vault. After: the discussion is on the moved note; Undo brings it back.

  • Relative-link follow-up, live: tools/check-workflow-symlink.mjs now also moves inbox/Relative.md, a link to ../../outside/Relative source.md, into inbox/Topics: 17 of 17 checks, the link re-based to ../../../outside/Relative source.md and readable in the app at the new place, and back with its original text after Undo. How to test locally: ln -s ../sources/Real.md <vault>/inbox/Rel.md with sources/Real.md beside inbox/, then move the note into a subfolder from the sidebar. Before: the note opens empty. After: it opens as before, and readlink shows ../../sources/Real.md.

  • #840 unit: npm run typecheck clean, 7 of 7 tasks run fresh; npm run test:run green, shared-domain 59 files and 1690 tests, app-core 232 files and 2662 tests (1 skipped), desktop 60 files and 956 tests (4 skipped), all three run fresh. The four new engine cases: three fail on the committed engine (checked by swapping the committed engine.ts in and out), and the fourth pins what a directory name already did. The website docs mirror passed php artisan test --filter=DocsPage (6 tests).

  • #840 live, in the built desktop app over CDP with both stores isolated (tools/check-workflow-root-folder.mjs): a root-mode scratch vault (.zennotes/vault.json with primaryNotesLocation: root) holding Dune.md, Areas/Gym/Plan.md, Projects/Compiler.md, quick/Scratch.md and archive/Old.md, and a "Root Notes" workflow (notes = folder inbox, gym = notes | in Areas) opened from the Workflows view (Space a). On the build before the fix, 5 of 9 checks fail: the wire shows notes · 0 and the filter gym · 0 notes, the emptied folder list offers archive, Areas, Areas/Gym, Projects, quick, typing root offers nothing, and Enter leaves root in the field (the step turns red with "folder needs a name" while the field is empty, which is the same dead end the reporter's screenshot shows). On the fix, 9 of 9: notes · 3 (Dune, Plan, Compiler: not the Quick Note, not the archived one) and gym · 1 note; the list leads with inbox Vault root, quick Quick Notes, archive Archive, trash Trash and then the directories; root narrows it to inbox Vault root; Enter writes inbox back. Stills: tools/out-root-folder/before/2-picker.png (directories only), tools/out-root-folder/after/2-picker.png (the four system names with their hints) and tools/out-root-folder/after/3-picker-root.png (the search for "root").

  • #840, how to test locally: npm run build --workspace @zennotes/desktop, then node docs/releases/v2.55.0/tools/check-workflow-root-folder.mjs after. By hand: npm run dev with workflows_enabled = true under [view] in config.toml, on a vault with "primaryNotesLocation": "root" in .zennotes/vault.json (or Settings → Vault → Vault root) and a few notes at the root and in subfolders; open Workflows (Space a), New workflow, add a "Notes in folder" step, click its folder field and clear it. Before: only directories are offered, and inbox gives 0 notes on the wire. After: inbox Vault root heads the list, typing root finds it, and the wire carries every note at the root, subfolders included.

  • #842 (core half), unit: app-core tsc --noEmit clean and its vitest suite green (232 files, 2662 tests, 1 skipped) after the change. Live on the desktop (tools/check-search-create-hints.mjs, built app over CDP, both stores isolated, 10 of 10 checks): the search offers Create "Arrakis", the form's footer still reads ↑↓ pick ↵ create esc back Back Create, one [data-keyboard-hints] element (the hint group); the name retyped as the existing Dune shows "Dune" already exists in Inbox. Change the name, or open it. with the button Open it Shift+↵ and a second marker on the kbd; Shift+↵ opens inbox/Dune.md. Live on the phone: a core candidate packed from this tree (prepare-boundary-release.mjs core --allow-dirty, core-2.54.1-core.h98aa38b1d8ca1707) adopted into the iPhone shell as a try-out (ZEN_ALLOW_DIRTY_CORE=1 npm run core:adopt -- --from ..., restored to the published h75d82a521571dc20 set afterwards, boundaries green), built and run on the iPhone 17 Pro Max (iOS 27) simulator: menu, Search, Hint check 842, the Create row, and the form's footer shows Back and Create only; with Untitled 9 (an existing note) the red line shows and the button reads just Open it. Not exercised: the Android emulator (the rule is the same text; Adib chose to skip the boot) and a tablet with a hardware keyboard.

  • #842, how to test locally: npm run build --workspace @zennotes/desktop, then node docs/releases/v2.55.0/tools/check-search-create-hints.mjs. By hand on the desktop: Mod+P, type a name no note has, Enter on the Create row: the footer still shows the hints (nothing changes here). On a phone shell built with a core that carries this commit: menu, Search, type a name, tap the Create row. Before: the footer reads ↑↓ pick · ↵ create · esc back beside Back and Create, and Open it carries a Shift+↵ chip. After: only Back and Create, and a plain Open it.

  • #843 unit: npm run typecheck clean, 7 of 7 tasks run fresh; npm run test:run green, shared-domain 59 files and 1690 tests (turbo cache, package unchanged), app-core 234 files and 2698 tests (1 skipped) run fresh, desktop 60 files and 956 tests (4 skipped) run fresh. The suites described under For contributors are new; the trigger flow's existing 8 cases still pass with the shared rungs extracted.

  • #843 live (tools/check-workflow-triggers.mjs, built app over CDP, both stores isolated, Vim off so the typing is plain, 15 of 15 checks, repeated three times): a scratch vault with inbox/Dune.md and the reporter's workflow verbatim (trigger: on note-saved, all | contains type: topic | move Topics) plus a Stamp new workflow (trigger: on note-created, all | add-tag #fresh). Dune opened from search, type: topic typed at its end: within the poll the file is at Topics/Dune.md with the typed text, inbox/Dune.md is gone, the toast reads Move Topics: Applied 1 change across 2 notes. with Undo 2 notes, and the editor is still on Dune, now at Topics/Dune.md with the breadcrumb Inbox › Topics › Dune. Undo from the toast puts the file back under inbox/, the editor goes back with it showing the restored text, and 2.5 s later nothing has fired for the restore. Mod+N: the new Untitled.md reads # Untitled\n\n#fresh\n within the poll and the toast says Stamp new: Applied 1 change across 1 note.. Second launch with workflow_event_triggers = false: the same typing leaves inbox/Dune.md in place with the text, no Topics/, no receipt. On the build before the change the first three checks fail by design (nothing fires; the manual said so). Stills: tools/out-triggers/after/1-saved-and-filed.png (the editor on the moved note, toast with Undo) and 2-undone.png.

  • #843, how to test locally: npm run build --workspace @zennotes/desktop, then node docs/releases/v2.55.0/tools/check-workflow-triggers.mjs. By hand: npm run dev with workflows_enabled = true under [view] in config.toml; in Workflows (Space a) create a workflow with trigger: on note-saved, status: active and the line all | contains type: topic | move Topics; open any inbox note and type type: topic, then stop typing. Before: nothing happens (the trigger parsed but never fired). After: about a second and a half later the note is under Topics/, you are still in it, and a toast offers Undo 2 notes; press it and the note is back in the inbox with you still in it. Settings → Workflows → Event triggers off, and typing the same again leaves the note alone.

  • #844 unit: app-core tsc --noEmit clean and its suite green after the change (app-core 235 files and 2713 tests (1 skipped), run fresh); the 15 new move-note.test.ts cases plus the two updated action suites. The palette suite failed once when five suites ran in one vitest call while the app was being rebuilt beside them, and passed alone and in the full run; noted, not chased.

  • #844 live (tools/check-move-prompt.mjs, built app over CDP, both stores isolated, Vim on, 13 of 13 checks): on a root vault (Dune.md, Areas/Gym/Plan.md, Projects/Compiler.md, archive/Old.md) :mv on Dune opens with an empty field, the placeholder Vault root (type a folder to change), the description Pick a folder, or type a path like Work/Research (empty = Vault root) or archive/Reference., and the rows Vault root, Areas, Projects, Areas/Gym, archive; clicking Areas moves the file to Areas/Dune.md with the editor following, and :mv Projects on Plan moves it straight to Projects/Plan.md. On an Inbox vault the rows are Inbox, Work, archive, and typing inbox/Work still lands the note in inbox/Work/. On the build before the change the root vault opened on inbox with the rows inbox, inbox/Areas, inbox/Projects, inbox/Areas/Gym and the first six checks failed, which is the report. Stills: tools/out-move-prompt/before/1-root-prompt.png, tools/out-move-prompt/after/1-root-prompt.png, tools/out-move-prompt/after/3-inbox-prompt.png.

  • #844, how to test locally: npm run build --workspace @zennotes/desktop, then node docs/releases/v2.55.0/tools/check-move-prompt.mjs. By hand: npm run dev on a vault with "primaryNotesLocation": "root" in .zennotes/vault.json and a folder or two at the root; open a note and run :mv (Vim) or Move… from its context menu. Before: the field says inbox and the rows read inbox/Areas, inbox/Projects. After: the field is empty with Vault root as its placeholder, the rows read Vault root, Areas, Projects, archive, and Enter on Areas moves the note there.

  • Vim-off audit, unit: npm run typecheck clean, 7 of 7 tasks; npm run test:run green, 5 of 5 tasks, shared-domain 59 files and 1690 tests (turbo cache, package unchanged), app-core 235 files and 2713 tests (1 skipped) and desktop 60 files and 956 tests (4 skipped), both run fresh. No test changed: none asserted a hint with Vim off. (A plain npx vitest run started from the repo root, without turbo, reports 269 failing files: it runs every package's tests under no config. Use the turbo script.)

  • Vim-off audit, live (tools/check-vim-off-hints.mjs, built app over CDP, both stores isolated, 28 of 28 checks): with Vim off, a click on the Inbox row draws no ring and no chip; the connections panel clicked into shows no chip; the comments panel shows no strip and no badge (the strip is not in the DOM); the Tasks tooltips read New task, Open note (Enter), Toggle task, Kanban; the board's header and footer read Drag to move a card · drag a header to reorder columns · ←/→ · ↑/↓ · Space · Enter · right-click actions and ←/→ column · ↑/↓ card · Space toggle · Enter open · right-click actions, x on the focused card leaves - [ ] read the appendix alone on disk and Space checks it; the calendar's lines read ←/→/↑/↓ day · Tab pick · Space toggle · drag to move · right-click actions. With Vim on: the ring, the m chip, the j/k move · ↑↓ move · p preview · ↵ open · esc note strip plus the cursor card's p preview, the comments strip and badges, New task (a), Open note (Enter / o), Toggle task (x), Kanban (3), the board's and the calendar's lines with h/l, j/k, x, g and dd, and x checks the focused card. Screenshots of every step in tools/out-vim-off/after/.

  • Vim-off audit, Vim-on regression check on the built app (Adib asked): npm run test:vim-editor, npm run test:sidebar-vim and npm run test:editor-improvements all passed on the cdccd554 build, one after another with no overlapping instance (editor motions incl. f/F/t/T, H/L scrolling, wrapped-line j/k; sidebar j/k/G/gg over a windowed 5000-note list; folds, Ctrl+Tab, Preview cursor). With Vim on every changed expression is vimMode && <the old condition> or an early return that only fires with Vim off, so nothing Vim does changed.

  • Vim-off audit, how to test locally: npm run build --workspace @zennotes/desktop, then node docs/releases/v2.55.0/tools/check-vim-off-hints.mjs after. By hand: npm run dev, turn Vim mode off in Settings (or enabled = false under [vim] in ~/.config/zennotes/config.toml), click a folder in the sidebar: before, the whole sidebar wore a 2 px accent ring and the row showed an m chip; after, the row is highlighted and nothing else. Open a note that has a comment and show the comments panel: before, a j/k ↵ n a e r d esc strip under the header and letter badges under the comment; after, neither. Tasks → Kanban: before, the header read h/l · j/k · g group-by · x · Enter and x checked the focused card; after, the lines read arrows, Space and Enter, x does nothing and Space checks. Turn Vim mode on and everything is back.

Distribution channels

  • Release workflow: the tag's run 35909650669 built every platform and finished green on the first attempt, 47 minutes from tag to the last upload (19:29 to 20:16 UTC on September 23). 30 assets: the same 25 installers and manifests as 2.54.1, plus the five demo clips uploaded by hand right after the tag. macOS is Developer ID signed and notarized. Both macOS apps carry ZenNotes CLI 0.4.0 as the bundled zn (read from Contents/Resources/terminal/manifest.json inside the arm64 DMG).
  • AUR: 0b2712c in the separate zennotes-bin clone (zennotes-bin 2.55.0-1), mirrored on desktop main and v2.55.0 as 601dece2; aur-check.yml passed (runs 35911254745 on main and 35911252809 on v2.55.0). The tarball downloaded from the release has SHA-256 65386cd77e67ba5fddbdcccac6299a7f3829ced3812f96523cda64616f01b465, equal to GitHub's asset digest and to the pinned value.
  • Nix: nix-update.yml run 35911256261 opened bot PR #846, lifted byte-for-byte onto main and v2.55.0 as a1427f3a and closed with a one-line comment pointing at that commit; nix-build.yml run 35911477327 passed on main first try. desktopHash (sha256-ZThs135nul/dvczKximafzgpztOBL5ZSPNpkYW8BtGU=) is the same tarball digest as the AUR pin, in SRI form.
  • nixpkgs: PR #561418 amended in place to zennotes-desktop: 2.45.0 -> 2.55.0 (fork commit 718a3826, force-pushed to zennotes-desktop-2.46.0, body in nixpkgs-pr-body.md). Only version, npmDepsHash (sha256-G3e4cDdxyZVy6B9s48dTIpzv9IPgEvzoqO0vuDIdQiM=) and the source hash (sha256-2OgL5FW1I8tCJYvrKI3boJywGBFaZHZdRNXEZU3IZww=, both lifted from packaging/nix/release-data.json) change; the refetch of upstream master was skipped again, as the 2.54.1 handoff advises.
  • Homebrew: 153b749 in the tap (zennotes 2.55.0), mirrored on desktop main and v2.55.0 as e417342c. The cask pins both DMG digests from the GitHub API (arm64 ef0ced18885e132b085b3ffdfb99a7cacfe9eaf798c6c3a1f47be3a3564cd6f2, x64 aab9c5bbf93463a8f2964414312c6e53d6296070d6bf141a3af90040e22cfa33), and both equal a local shasum of the downloaded DMGs.
  • Website: the docs for #840, #843, #844 and the Vim keys went live ahead of the release through PR #36 (25f61e59, Adib's call); the 2.55.0 releases entry (one feature, ten fixes, five clips) merged as PR #37 (fc54b479, a merge commit) at 20:19 UTC, after releases/latest had flipped. Laravel Cloud deployed it from main; the release page led with 2.55.0 at 20:22 UTC, with its features section and the event-triggers clip answering 200.
  • Docker and the server: the Go server is released separately from ZenNotes/znserver; its 2.55.0 release (the remote-comments and relink fixes, b35c983 and 6c6554a) waits for the 2.55.0 web boundary artifact and is listed under what is still open.
  • npm run verify:channels -- 2.55.0: passed at 20:19 UTC: Homebrew tap, AUR, Nix, and all seven website download redirects serve 2.55.0. releases/latest on GitHub answers v2.55.0. The live release page leads with 2.55.0 (48 documented releases).

Release validation

  • Release PR #845: the first CI run was green everywhere except Build (windows-latest), a real finding: three assertions in apps/desktop/src/main/workflow-apply.test.ts (from the relative-link fix e7232665) compared the text of a symlink a run had put back with ../sources/Real.md and got ..\sources\Real.md, because Windows stores a symlink's target with backslashes whatever text created it; the app had written the recorded text as is. Adib approved the test-only 00db7036 (a linkText helper reading the target with forward slashes), the second run passed every check (CI 35908506425, browser 35908506523, candidate 35908506421, CodeQL), and main was fast-forwarded onto that commit without a merge commit.
  • Before the cut, on the bumped branch (b251ff0a) and repeated on 00db7036 for the packaged half: npx turbo run typecheck --force 7 of 7; npx turbo run test:run --force green (shared-domain 59 files and 1690 tests, app-core 235 files and 2713 tests with 1 skipped, desktop 60 files and 956 tests with 4 skipped); apps/desktop npm run build:prod; npm run pack to a signed dist/mac-arm64/ZenNotes.app; the skill's packaged-launch-check.mjs on that app (a CDP page target in 1.4 s, reporting 2.55.0); and the three smoke suites on the built app (test:vim-editor, test:sidebar-vim, test:editor-improvements), all green, 18:58 to 19:04 UTC and again 19:22 UTC.
  • The release workflow's tag-push path behaved as it did for 2.54.1: each platform's installers went up one at a time and its update manifest last (Linux: the x64 tarball 19:43:00, latest-linux.yml 19:43:17, latest-linux-arm64.yml 19:43:41; Windows: exe 20:06:02, blockmap 20:06:03, latest.yml 20:06:14; macOS: arm64 DMG 20:15:04, x64 DMG 20:15:29, latest-mac.yml 20:15:46). macOS was the slowest job at 46 minutes (19:29 to 20:16). No asset was uploaded twice and nothing had to be rebuilt.
  • Verified by hand after the uploads: verify-installers.py 2.55.0 --download downloaded the 13 installers named by the four manifests in full and every one matches its manifest's size and SHA-512 ("ALL GOOD"); the arm64 macOS app inside the DMG is accepted by Gatekeeper as Notarized Developer ID (Lumary Labs LLC) with a valid stapled ticket (spctl -a -vv accepted, stapler validate worked), reads version 2.55.0 and bundles zn 0.4.0. The DMG was attached read-only with diskutil image attach and ejected afterwards.
  • Expected red marks, none of them about the release: CI run 35911254830 on main (the AUR packaging commit 601dece2) was cancelled by the next push, the Nix packaging commit a1427f3a, which CI then ran on and passed (35911477165); CI on the Homebrew mirror e417342c (35914748906) was still running when these notes were published. The same pattern appeared on 2.54.1.

Local-first and keyboard-first, as always.

Don't miss a new zennotes release

NewReleases is sending notifications on new releases.