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 acceptedon note-savedand 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 workflowtrigger: on note-saved(oron 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-movedfires with the note's new path for a rename, a move, Archive, Trash and Restore;tag-addedfires 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'sall | contains type: topic | move Topicsmeans "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 = inboxtakes the samefield op valueawherestep 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 anappendbetween 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: draftortrigger: manualsilences one workflow; Settings → Workflows → Event triggers (workflow_event_triggersunder[view]inconfig.toml, on by default) silences them all on this device. Ascheduletrigger 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-metadatafolder, 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/commentsand its creation date in.zennotes/note-metadata, both filed under the note's path. The workflow steps that move a note (move,rename,archiveandtrash) 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,archiveortrashrenames 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,writeandapply-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, orwriteto 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.mdmoved frominbox/intoinbox/Topics/namedinbox/sources/Real.mdfrom there, which does not exist: the note opened empty after the move. That is whatmvand 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, sincefolder ""once matched the whole vault, Trash and Archive included), and typinginboxgave "0 notes" because no note there has aninboxdirectory. The engine already readfolder trashandfolder archiveas the Trash and the Archive wherever the vault keeps them; now all four system names work that way.folder inboxis 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 quickis the Quick Notes, and theinfilter reads the four names the same way. The folder list leads with the four, each with the name the sidebar shows for it, andinboxreads "Vault root" on a root-mode vault, so typingrootfinds it. A vault with remapped system folders gets the same:folder inboxmeans 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 oninboxand offeredinbox/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, noinbox/in front), empty for the notes root (labelled Vault root when your notes live there, or Inbox, or whatever you renamed the inbox to), andarchive/…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 olderinbox/Workspelling, which the manual taught, still lands in Work on an Inbox vault; on a root vault it does too, unless a real folder namedinboxsits 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/Researchor:mv archive/Referencemoves 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
mchip for a key that did nothing, the connections panel's strip and its cursor card offeredj/kandp, the comments panel's strip and the badges under a comment offeredn,a,e,randd, 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 (itsmgrab 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.tsis 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), andcreateDragAutoScroller({ horizontal, vertical })runs therequestAnimationFrameloop 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.tsxmakes 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 capturescrolllistener on the board, and moves the cursor beforemoveTaskOnBoard: that call'sflushSynccommit runs React 18's passive effects synchronously, so the focus effect'sscrollIntoViewwould 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, withcdp-lib.mjsbeside 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 samepointerIdas the scripted drag, and in early runs that pushed the board sideways by 2 to 30 px. The clip istools/demo838.mjsplustools/burn838.py, andtools/record-before.shrecords the take on the code before the fix. -
#839 is
ecc74315. Desktop main gainsrelocateNoteinapps/desktop/src/main/vault.ts(the follow-up moves it tonote-sidecars.ts), whichrenameNote,moveNoteandmoveBetweenFolders(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 withremoveNoteCreation(the rulecreateNotealready follows), then hands the note, comments and metadata triple torelocateFolderTreesas before.renameNotealso checks for a real note at the target first and says so plainly. The MCP copy changed inmoveWithCreationMetadata(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 sharedrelocateNote. In the renderer, the store'srenameNotetoastsCould not rename “<title>”: <reason>through the existinghumanIpcErrorwhen 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,testtyped into the title field, Enter, over CDP in the built app with both stores isolated, across seven vault layouts (root inferred, inbox, root set invault.jsonwith and without an oldinbox/, a leftover date, leftover comments, a taken name). The clip istools/demo839.mjsplustools/burn839.py, andtools/record-before-839.shrecords the take on the three files as they were beforeecc74315. -
The #839 follow-up is
1cdb0e81.apps/desktop/src/main/note-sidecars.tsis new and free of Electron:relocateNote,relocateFolderTrees,noteCommentsPath,noteCommentsRootand a sharedleftoverCommentsMessagemoved there fromvault.tsunchanged, 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 throughrelocateNote, renames folders throughrelocateFolderTreeswith their comments and metadata trees, and removes comments on delete, Empty Trash and folder delete;moveWithCreationMetadatais gone. Inworkflow-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 fromprepareNoteCreation, 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, afterresolveVaultPath, so an edited or synced ledger cannot aim a restore anywhere else. Crash-journal lines carry nopathkey and the finished ledger keeps the entries in a separatesidecarsarray with anafterhash, 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),restoredstill counts notes, and drift and failures are reported under the note's path.rollBackRunalso 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 inworkflow-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, inworkflow-apply.ts. A note's journal entry gains an optionallink, the symlink text read at first touch (withLinkText, in the crash journal and the ledger alike;parseNoteEntryreads it back).uniqueReltreats a link that points at nothing as taken (pathTaken, an lstat).restoreEntriesfirst maps the links the run left at paths it found empty, by their text; an entry withlinkwhose path is empty then gets that link renamed back (its text re-read first) before the bytes are compared, abefore: nullpath 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 insymlinked 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 on1cdb0e81, the planted-link case guards the fix. -
Its live harness is
tools/check-workflow-symlink.mjs, andtools/record-before-symlink.shruns it twice: on the1cdb0e81applier (built in place, restored by hash) and on the fix. -
The creation-date follow-up is
57acec48, inworkflow-apply.ts.keepCreationDatewrites a note's date down throughprepareNoteCreationwhen 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:applyTextOpToVaultfor an existing note,sidecarsToCarryfor 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;journalPathOpbecamejournalOpand absorbedjournalTouch, since a text op now journals a sidecar too. Tests: 11 ina 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 on25f40403. -
Its live harness is
tools/check-workflow-created-at.mjs, run bytools/record-before-created-at.shthe same way (the25f40403applier first, then the fix). -
The remote follow-up is
ab600cbain this repo andb35c983inZenNotes/znserver.prepareWorkflowRun(packages/shared-domain/src/workflows/prepare-run.ts) gainsmoves: 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.Movesis validated against the run's changes (both ends must be note paths the run names),planWorkflowSidecarMovesreads 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 asidecarsarray in the desktop's format (workflow_sidecars.go, custom JSON soafteris 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'srelocateFolderTreesrefuses 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 amovescase plus assertions inprepare-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's2712ef3and once with the fix. -
The relative-link follow-up is
e7232665.rebaseMovedLink(from, to)innote-sidecars.tsruns inside the sharedrenameDirectory(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 inmovePathInVault. On undo,restoreEntriesmatches 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 textpath.relativewould not have produced, and a desktop and MCP parity case innote-sidecars.test.ts;tools/check-workflow-symlink.mjsgained a relative link. The Go mirror isrebaseMovedLinkin znserver'sinternal/vault/relink.go, called from itsrelocateFolderTreesafter 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),bucketNamedandinFolderOrBucketreplace thefoldersource's trash/archive-only bucket match and theinfilter 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 fromNoteMeta.folder; in root mode every primary note isinbox).fieldValue('folder')is untouched, sowhere folder = ...still compares the directory. In the inspector (NodeInspector.tsx) the folder vocabulary isComboboxOption { value, hint? };optionsFormatches the hint as well as the value andThemedComboboxrenders the hint beside it (the other comboboxes still pass plain strings).WorkflowsViewbuildsvaultFoldersas the four system names first, hinted withgetSystemFolderLabel(the sidebar's labels, custom ones included) or "Vault root" forinboxwhenvaultSettings.primaryNotesLocationisroot, then the directories from the note paths, a directory that carries a system name folded into its bucket entry. Thefolderandindescriptions innodes.tscarry the rule, which also puts "Notes in folder" in front of anyone who types "root" into the step picker. Tests:engine.test.ts, anotes at the vault rootblock (the root and its subfolders, a directory name still narrows, quick/archive/inkeep their meaning) andfolder inbox/in inboxafter 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 theShift+↵chip in its Open it button carrydata-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 (branchfix/842-keyboard-hints) and take effect when each adopts a core with the marker. The live harness istools/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 (persistNoteringsnote-savedand, when the returned meta carries a tag the note lacked,tag-added; the fivecreateNotesites ringnote-created;renameNote,moveNoteandchangeNoteLifecycleringnote-movedwith the new path), andlib/workflow-events.ts(new) is the dispatcher installed lazily at vault open: it answers on every save from the workflow index alone (WorkflowIndexEntrygainedtrigger), 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.tsgainedrunWorkflowForEventnext to the palette'srunWorkflowById, sharingloadWorkflowsandapplyAndReport: the reader is scoped to the fired note (createVaultReader({ notes: [meta], current: () => meta })), thewherecondition is planned ascurrent | where <condition>over that reader (triggerConditionHolds, invalid reported once per condition throughtoastOnce), no confirmation, a fired note that is dirty returnsunsaved, other dirty notes are dropped from the ops and named, a background receipt with no written path is silent, and outcomes areran | nothing | unsaved | busy | skipped. The store gainedfollowWorkflowMoves(moves, { reverting }): it shields the promisedfrompaths inrenamesInFlightbefore the host applies the run and afterwards carries the tab, buffer and undo history totothroughrewriteFolderWorkspace, asking the disk (not the note list, which a coalesced refresh can answer stale) thattoexists andfromis gone and that the file reads exactly as the buffer did (a suffixed landing is someone else's file);revertinglets an undo take the restored bytes into a clean buffer.promisedMoves(ops, systemFolderDirs)inworkflow-run.tsfolds a note moved twice to its last stop, andWorkflowRunRecordcarriesmovesso both undo surfaces replay them backwards. The kill switch isworkflowEventTriggers(store pref,PORTABLE_PREF_KEYS,PORTABLE_DEFAULTS,[view] workflow_event_triggersin 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,allscoped, 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) inworkflow-run.test.ts, the index test extended,commands.test.tsfixtures 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 anon note-createdone, 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 withworkflow_event_triggers = false. -
#844 is
6f423584.lib/move-note.tsgainedmoveNoteVocabulary(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,trashand their directory names), and whether a realinboxfolder sits at the root of a root vault.parseMoveNoteTarget(value, vocabulary)reads the sidebar spelling (empty is the root;archiveor the archive's directory name is the Archive; a leadinginboxis the notes area unless it is that real folder),validateMoveNoteTargetowns the hidden-segment and control-character rules thatnotes.tsused to add on top, plus the reserved-name refusal (skipped for a value spelledinbox/trash, which is a folder of that name),buildMoveNotePrompt(note, folders, vocabulary)computes the initial value fromnoteFolderSubpathinstead of the path's first segment and turns onallowEmptySubmit, and the directory prompt and its validator take the vocabulary too. Every caller passes it and treats onlynullas Cancel:notes.ts(single and bulk moves, with a sharedmoveVocabulary(state)),Editor.tsx(:mv, with or without a target),commands.ts,NoteList.tsx,Sidebar.tsx,ArchiveView.tsxandbrowse-actions.ts. Tests:move-note.test.ts(new, 15 cases: labels, remapped archive, parsing in both spellings and on a root vault with a realinboxfolder, validation, suggestions and their order and details, initial values, the directory prompt and its messages),note-actions.test.ts(initial values now""andWork),browse-actions.test.ts(suggestions['', 'Home']labelled Inbox, an empty answer moves a nested folder to the root,Workandinbox/Workboth name the current parent). -
Its live harness is
tools/check-move-prompt.mjs::mvtyped 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 onvimMode, while the keys they name live inVimNav, which returns early with Vim off. Every such surface now readsvimModefrom the store next to the hint (Sidebar.tsx'sRowKeyHintand itspanel-focusedring,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, andCommentCard's badges,EditorPane.tsx's breadcrumb tooltip,TagView.tsx's clear-tags tooltip and hint line,QuickCaptureApp.tsx's:whint through avimModeprop onCommandOverlay) or takes it as a prop (TasksRowvimMode;TaskStateBoxand the Kanban card taketoggleKey, so the checkbox tooltip names the list's actual toggle binding, whichTasksView'stoggleKeyLabelalready resolved, and nothing with Vim off).TasksKanban.tsxandTasksCalendar.tsxgain 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, andCalendarPanel.tsx's key handler the same guard;TasksCalendar's handler effect now listsvimModein its dependencies, which the existingi/cgating 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, in98e9ebda, mirrored on the website in7c796beonwebsite/docs-2.55and live on zennotes.org/docs ahead of the release through website PR #36 (merge25f61e59, 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 = falsethentruein the scratchconfig.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: thepanel-focusedclass, themchip, every visiblekbdand 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 pressesxand 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#topicnote, a second#topicnote 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 whereinbox/Linked.mdis 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: topicis 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 typecheckclean, 7 of 7 tasks (3 from the turbo cache);npm run test:rungreen, 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) andtools/shift-h-after-drop.png. -
How to test locally:
npm run build --workspace @zennotes/desktop, thennode docs/releases/v2.55.0/tools/check-kanban-autoscroll.mjs after. By hand:npm run dev, write tasks tagged@status:backlogthrough@status:donein a note, putkanban_statuses = ["backlog", "todo", "doing", "review", "qa", "blocked", "shipped", "done"]under[view]inconfig.toml, open Tasks, press3, 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 typecheckclean, 7 of 7 tasks;npm run test:rungreen, 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 beforeecc74315by 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.jsonleft behind) keptUntitled.mdaftertestplus Enter, with onlyrenameNote failed ... The destination folder or its comments already existin the console; four other layouts (root inferred, inbox, root set invault.json, and the same with an oldinbox/) renamed fine, which is why the plain flow never showed it. On the fix: the same layout renames totest.md, whose creation date is its own (September 23, the file's birth time) rather than the leftover September 22 one; leftover comments attestrefuse 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 realtest.mdrefuses with "A note named “test” already exists in this folder"; root-mode (set invault.jsonand inferred) and inbox-mode renames are unchanged and show no toast. -
#839, how to test locally:
npm run build --workspace @zennotes/desktop, thennode docs/releases/v2.55.0/tools/check-rename-new-note.mjs orphanMeta. By hand:npm run dev, create.zennotes/note-metadata/test.md.metadata.jsonin a vault containing{"version":1,"createdAt":1790000000000}, right-click the empty sidebar area under the folders, New note, typetest, 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
EditorPanetitle field) and the phone shells. -
#839 follow-up, unit:
npm run typecheckclean, 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:rungreen, 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 committedvault-ops.tsandworkflow-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 withinbox/Chemistry.md(a comment anchored to "Reactions", a creation date of January 15, 2025) andinbox/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 underinbox/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 forinbox/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.jsonthe 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" opensinbox/Topics/Chemistry.mdwith "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, thennode docs/releases/v2.55.0/tools/check-workflow-sidecars.mjs. By hand:npm run devwithworkflows_enabled = trueunder[view]inconfig.toml; tag a note#topic, add a comment to it, and add a workflow withtopics = tag #topicandtopics | 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 ininbox/. -
Symlinked-note follow-up, unit:
npm run typecheckclean, 7 of 7, desktop run fresh;npm run test:rungreen, 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 ininbox/to a file in a temp folder outside the vault, onemoveop, 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.mdis a link to<tmp>/outside/Linked source.md, tagged#topic, beside a plaininbox/Plain.md; the "Move Topics" workflow is run from the Workflows view, then Undo from the receipt. On the1cdb0e81applier, 4 of 12 checks fail after the undo: the outside file is gone,inbox/Linked.mdis a plain copy (the app no longer lists it as a link), andinbox/Topics/Linked.mdis 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 underinbox/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, thennode docs/releases/v2.55.0/tools/check-workflow-symlink.mjs, orzsh docs/releases/v2.55.0/tools/record-before-symlink.shfor the before and after passes. By hand:npm run devwithworkflows_enabled = trueunder[view]inconfig.toml;ln -s /some/folder/Real.md <vault>/inbox/Linked.md, put#topicin it, add a workflow withtopics = tag #topicandtopics | move inbox/Topics, run it from Workflows (Space a, Run, confirm), then Undo on the receipt. Before:/some/folder/Real.mdis gone,inbox/Linked.mdis a plain file,inbox/Topics/Linked.mdis a dead link. After: the link is back ininbox/,/some/folder/Real.mduntouched, nothing underinbox/Topics. -
Creation-date follow-up, unit:
npm run typecheckclean, 7 of 7, desktop run fresh;npm run test:rungreen, 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, oneappend, 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#topicnotes with no date file, a date left behind underinbox/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 the25f40403applier, 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, thennode docs/releases/v2.55.0/tools/check-workflow-created-at.mjs, orzsh docs/releases/v2.55.0/tools/record-before-created-at.shfor both passes. By hand:npm run devwithworkflows_enabled = trueunder[view]inconfig.toml; copy a Markdown file intoinbox/with Finder (so it has no date file) and put#topicin it; read itscreatedAtfromwindow.zen.listNotes()in the developer console; add a workflow withtopics = tag #topicandtopics | add-tag #filedand run it from Workflows (Space a, Run, confirm). Before:createdAtbecomes the time of the run. After: it stays, through Undo too. -
Remote follow-up, unit:
npm run typecheckclean, 7 of 7, every package run fresh;npm run test:rungreen, shared-domain 59 files and 1686 tests, app-core 2662 (1 skipped), desktop 956 (4 skipped), all run fresh. In znserver,go vet ./...clean andgo 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 from2712ef3, 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), thennode docs/releases/v2.55.0/tools/check-remote-workflow-comments.mjs /tmp/zennotes-server. By hand: run the server withZENNOTES_DEFAULT_VAULT_PATH,ZENNOTES_BIND=127.0.0.1:7879andZENNOTES_AUTH_TOKEN, connect the desktop to it as a remote workspace, add a comment to a#topicnote and a workflow withtopics = tag #topicandtopics | move inbox/Topics, run it (Space a, Run, confirm), open the moved note. Before: no comments;.zennotes/comments/inbox/<note>.md.comments.jsonstill 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.mjsnow also movesinbox/Relative.md, a link to../../outside/Relative source.md, intoinbox/Topics: 17 of 17 checks, the link re-based to../../../outside/Relative source.mdand 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.mdwithsources/Real.mdbesideinbox/, then move the note into a subfolder from the sidebar. Before: the note opens empty. After: it opens as before, andreadlinkshows../../sources/Real.md. -
#840 unit:
npm run typecheckclean, 7 of 7 tasks run fresh;npm run test:rungreen, 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 committedengine.tsin and out), and the fourth pins what a directory name already did. The website docs mirror passedphp 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.jsonwithprimaryNotesLocation: root) holdingDune.md,Areas/Gym/Plan.md,Projects/Compiler.md,quick/Scratch.mdandarchive/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 showsnotes · 0and the filtergym · 0 notes, the emptied folder list offersarchive, Areas, Areas/Gym, Projects, quick, typingrootoffers nothing, and Enter leavesrootin the field (the step turns red with "folderneeds 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) andgym · 1 note; the list leads withinbox Vault root,quick Quick Notes,archive Archive,trash Trashand then the directories;rootnarrows it toinbox Vault root; Enter writesinboxback. 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) andtools/out-root-folder/after/3-picker-root.png(the search for "root"). -
#840, how to test locally:
npm run build --workspace @zennotes/desktop, thennode docs/releases/v2.55.0/tools/check-workflow-root-folder.mjs after. By hand:npm run devwithworkflows_enabled = trueunder[view]inconfig.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, andinboxgives 0 notes on the wire. After:inbox Vault rootheads the list, typingrootfinds it, and the wire carries every note at the root, subfolders included. -
#842 (core half), unit: app-core
tsc --noEmitclean 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 offersCreate "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 existingDuneshows"Dune" already exists in Inbox. Change the name, or open it.with the buttonOpen it Shift+↵and a second marker on thekbd; Shift+↵ opensinbox/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 publishedh75d82a521571dc20set 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; withUntitled 9(an existing note) the red line shows and the button reads justOpen 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, thennode 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 backbeside Back and Create, and Open it carries aShift+↵chip. After: only Back and Create, and a plain Open it. -
#843 unit:
npm run typecheckclean, 7 of 7 tasks run fresh;npm run test:rungreen, 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 withinbox/Dune.mdand the reporter's workflow verbatim (trigger: on note-saved,all | contains type: topic | move Topics) plus aStamp newworkflow (trigger: on note-created,all | add-tag #fresh). Dune opened from search,type: topictyped at its end: within the poll the file is atTopics/Dune.mdwith the typed text,inbox/Dune.mdis gone, the toast readsMove Topics: Applied 1 change across 2 notes.withUndo 2 notes, and the editor is still on Dune, now atTopics/Dune.mdwith the breadcrumbInbox › Topics › Dune. Undo from the toast puts the file back underinbox/, the editor goes back with it showing the restored text, and 2.5 s later nothing has fired for the restore. Mod+N: the newUntitled.mdreads# Untitled\n\n#fresh\nwithin the poll and the toast saysStamp new: Applied 1 change across 1 note.. Second launch withworkflow_event_triggers = false: the same typing leavesinbox/Dune.mdin place with the text, noTopics/, 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) and2-undone.png. -
#843, how to test locally:
npm run build --workspace @zennotes/desktop, thennode docs/releases/v2.55.0/tools/check-workflow-triggers.mjs. By hand:npm run devwithworkflows_enabled = trueunder[view]inconfig.toml; in Workflows (Space a) create a workflow withtrigger: on note-saved,status: activeand the lineall | contains type: topic | move Topics; open any inbox note and typetype: topic, then stop typing. Before: nothing happens (the trigger parsed but never fired). After: about a second and a half later the note is underTopics/, you are still in it, and a toast offersUndo 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 --noEmitclean and its suite green after the change (app-core 235 files and 2713 tests (1 skipped), run fresh); the 15 newmove-note.test.tscases 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):mvon Dune opens with an empty field, the placeholderVault root (type a folder to change), the descriptionPick a folder, or type a path like Work/Research (empty = Vault root) or archive/Reference., and the rowsVault root,Areas,Projects,Areas/Gym,archive; clickingAreasmoves the file toAreas/Dune.mdwith the editor following, and:mv Projectson Plan moves it straight toProjects/Plan.md. On an Inbox vault the rows areInbox,Work,archive, and typinginbox/Workstill lands the note ininbox/Work/. On the build before the change the root vault opened oninboxwith the rowsinbox,inbox/Areas,inbox/Projects,inbox/Areas/Gymand 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, thennode docs/releases/v2.55.0/tools/check-move-prompt.mjs. By hand:npm run devon a vault with"primaryNotesLocation": "root"in.zennotes/vault.jsonand a folder or two at the root; open a note and run:mv(Vim) or Move… from its context menu. Before: the field saysinboxand the rows readinbox/Areas,inbox/Projects. After: the field is empty withVault rootas its placeholder, the rows readVault root,Areas,Projects,archive, and Enter onAreasmoves the note there. -
Vim-off audit, unit:
npm run typecheckclean, 7 of 7 tasks;npm run test:rungreen, 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 plainnpx vitest runstarted 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 readNew task,Open note (Enter),Toggle task,Kanban; the board's header and footer readDrag to move a card · drag a header to reorder columns · ←/→ · ↑/↓ · Space · Enter · right-click actionsand←/→ column · ↑/↓ card · Space toggle · Enter open · right-click actions,xon the focused card leaves- [ ] read the appendixalone 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, themchip, thej/k move · ↑↓ move · p preview · ↵ open · esc notestrip plus the cursor card'sp 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, andxchecks the focused card. Screenshots of every step intools/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-vimandnpm run test:editor-improvementsall passed on thecdccd554build, 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 isvimMode && <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, thennode docs/releases/v2.55.0/tools/check-vim-off-hints.mjs after. By hand:npm run dev, turn Vim mode off in Settings (orenabled = falseunder[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 anmchip; after, the row is highlighted and nothing else. Open a note that has a comment and show the comments panel: before, aj/k ↵ n a e r d escstrip under the header and letter badges under the comment; after, neither. Tasks → Kanban: before, the header readh/l · j/k · g group-by · x · Enterandxchecked the focused card; after, the lines read arrows, Space and Enter,xdoes 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 fromContents/Resources/terminal/manifest.jsoninside the arm64 DMG). - AUR:
0b2712cin the separatezennotes-binclone (zennotes-bin 2.55.0-1), mirrored on desktopmainandv2.55.0as601dece2;aur-check.ymlpassed (runs 35911254745 onmainand 35911252809 onv2.55.0). The tarball downloaded from the release has SHA-25665386cd77e67ba5fddbdcccac6299a7f3829ced3812f96523cda64616f01b465, equal to GitHub's asset digest and to the pinned value. - Nix:
nix-update.ymlrun 35911256261 opened bot PR #846, lifted byte-for-byte ontomainandv2.55.0asa1427f3aand closed with a one-line comment pointing at that commit;nix-build.ymlrun 35911477327 passed onmainfirst 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 commit718a3826, force-pushed tozennotes-desktop-2.46.0, body innixpkgs-pr-body.md). Onlyversion,npmDepsHash(sha256-G3e4cDdxyZVy6B9s48dTIpzv9IPgEvzoqO0vuDIdQiM=) and the sourcehash(sha256-2OgL5FW1I8tCJYvrKI3boJywGBFaZHZdRNXEZU3IZww=, both lifted frompackaging/nix/release-data.json) change; the refetch of upstream master was skipped again, as the 2.54.1 handoff advises. - Homebrew:
153b749in the tap (zennotes 2.55.0), mirrored on desktopmainandv2.55.0ase417342c. The cask pins both DMG digests from the GitHub API (arm64ef0ced18885e132b085b3ffdfb99a7cacfe9eaf798c6c3a1f47be3a3564cd6f2, x64aab9c5bbf93463a8f2964414312c6e53d6296070d6bf141a3af90040e22cfa33), and both equal a localshasumof 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, afterreleases/latesthad flipped. Laravel Cloud deployed it frommain; 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,b35c983and6c6554a) 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/lateston GitHub answersv2.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 fixe7232665) compared the text of a symlink a run had put back with../sources/Real.mdand 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-only00db7036(alinkTexthelper reading the target with forward slashes), the second run passed every check (CI 35908506425, browser 35908506523, candidate 35908506421, CodeQL), andmainwas fast-forwarded onto that commit without a merge commit. - Before the cut, on the bumped branch (
b251ff0a) and repeated on00db7036for the packaged half:npx turbo run typecheck --force7 of 7;npx turbo run test:run --forcegreen (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/desktopnpm run build:prod;npm run packto a signeddist/mac-arm64/ZenNotes.app; the skill'spackaged-launch-check.mjson 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.yml19:43:17,latest-linux-arm64.yml19:43:41; Windows: exe 20:06:02, blockmap 20:06:03,latest.yml20:06:14; macOS: arm64 DMG 20:15:04, x64 DMG 20:15:29,latest-mac.yml20: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 --downloaddownloaded 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 asNotarized Developer ID(Lumary Labs LLC) with a valid stapled ticket (spctl -a -vvaccepted,stapler validateworked), reads version 2.55.0 and bundleszn0.4.0. The DMG was attached read-only withdiskutil image attachand ejected afterwards. - Expected red marks, none of them about the release: CI run 35911254830 on
main(the AUR packaging commit601dece2) was cancelled by the next push, the Nix packaging commita1427f3a, which CI then ran on and passed (35911477165); CI on the Homebrew mirrore417342c(35914748906) was still running when these notes were published. The same pattern appeared on 2.54.1.
Local-first and keyboard-first, as always.