Skip to content

ZenNotes 2.29.0: block references end to end, and workflows reach the self-hosted web app - #605

Merged
adibhanna merged 15 commits into
mainfrom
v2.29.0
Aug 17, 2026
Merged

ZenNotes 2.29.0: block references end to end, and workflows reach the self-hosted web app#605
adibhanna merged 15 commits into
mainfrom
v2.29.0

Conversation

@adibhanna

@adibhanna adibhanna commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

The 2.29.0 release branch so far. Two issues from @uNyanda, both keyboard-flow papercuts that turned out to have more behind them than the reports suggested.

Block references actually reach the block (#601)

[[Note^block-id]] opened the right note and stopped there, while [[Note#Heading]] scrolled to its section correctly.

The cause was one deliberate null. wikilinkHeadingAnchor recognized ^ well enough to strip it so the note resolved, then reported "no heading here", and all six click surfaces read that as "just open the note". Each carried its own copy of the same heading ? openWikilinkHeading : selectNote branch, which is why a missing anchor kind stayed missing: adding one meant remembering six call sites. They now share openWikilinkTarget, which dispatches on the raw target.

Since block references were unimplemented rather than broken, this covers the whole feature:

  • Following [[Note^id]] scrolls to the block from a click, a Cmd/Ctrl-click, gd, and the reading view. [[^id]] targets a block in the current note.
  • Typing ^ inside a wikilink lists the target's ids with each block's own text beside it, mirroring the # heading source.
  • ![[Note^id]] embeds just that block, a bullet's children included, and shows a notice when the id is gone instead of inlining the whole note.
  • The Connections panel names the block a note reached for.
  • The ^id marker is hidden in the reading view and the editor, returning when the cursor is on the line.

Ids are letters, digits and hyphens ending a line, so 2^3 and x ^ y stay arithmetic and a marker inside a fence stays an example.

Worth a look in review: parseOutline's loop became scanMarkdownLines, shared with the block scanner, so the frontmatter and fence rules do not get a fifth copy.

The forward-task picker takes Enter on the first match (#600)

Half of that issue was already shipped: Ctrl+J / Ctrl+K worked, because #467 put the key handling in PromptModal rather than in the folder picker. Nothing on screen said so.

The auto-select was genuinely missing, and it bit harder here than in the folder picker. This prompt only accepts an existing note, so Enter after typing a filter submitted the raw text, failed validation, and answered "Pick an existing note". The picker now preselects the first match, and the hint reads ↑↓ or ⌃J/⌃K pick a note · Enter to forward.

Verification

npm run typecheck clean at 7/7 and npm run test:run clean at 6/6, with 53 new unit tests.

Both features were also driven over CDP against a throwaway vault with isolated user-data and config directories, per the verification doctrine:

  • Block link: the editor moves from the top of the note to the marked bullet, and in the same frame the sibling bullets render with their markers hidden while the cursor line keeps its own. The heading path is unchanged.
  • Forward picker: Ctrl+J, Ctrl+J, Ctrl+K walk the selection 0, 1, 0; typing res leaves the first match highlighted; Enter rewrites the source line to - [>] … [[Research]] and appends the backlinked copy to the target.

Demo clips for both are in docs/releases/v2.29.0/media/ (gitignored), ready for the release page.

Docs

Both mirrored surfaces move together: packages/app-core/src/lib/help.ts here, and the website half in ZenNotes/website#9.

Note that the house flow is a fast-forward of main at release time rather than a merge, so this PR is for review rather than for merging with the button.


Landed on the branch since this PR opened

  • Typst commands complete inside math (#604, by @flokchvtr): Greek letters, operators, arrows, sets, and functions with rendered previews and editable snippets like frac(a, b), from two letters on, inside $…$ and math fences.
  • Workflows reach the self-hosted web app and Docker image (#608): the server grew a full protected workflow API (create, edit, run, history, delete, undo) with transactional runs, before-bytes conflict checks, rollback, and crash recovery; the web client plans runs locally and applies through it. Capability-gated, so older servers stay read-only.
  • Cloud sync makes durable progress through asset-heavy vaults: binary uploads are checkpointed one request at a time so a timeout no longer erases the batch's progress, and cloud settings recover when the network returns.
  • Task forwarding carries its subtree (#611) and a forwarded task is one task on the Tasks page (#610): the whole indented block travels with the parent, and [>] records no longer inherit daily-note dates or dot the calendar on every day they passed through.
  • The Nix desktop wrapper launches with working GPU output (#607).

Review hardening pass

A full /code-review high ran against this PR before release; every verified finding was fixed on the branch in three commits (08dde4e, 90d24e2, 185d038). Highlights:

  • Block-anchor stripping now happens at the source with the parser's own fence/frontmatter-aware grammar, shared by the reading view, the live preview, and the DOCX export, so prose that merely resembles a marker is never deleted and code samples never blank in the editor. Standalone markers after frontmatter no longer anchor the YAML, embeds carry loose children and whole fences, and Obsidian's [[Note#^id]], [[#^id]], and (Note.md#^id) spellings all reach the block.
  • A workflow save whose slug changed only by case could delete the file it had just written on case-insensitive filesystems; both the Go server and the desktop applier now compare file identity. Web workflows can shrink oversized notes, survive notes containing invalid UTF-8, name the server cap when a run is over it, and appear in the command palette and vim ex-commands on capable web workspaces.
  • All four task-parser copies now know the [>] forwarded state, so carried tasks stop doubling as phantom open work in MCP tools, zn, and the web client, and the rollover and forwarding walks share one subtree function so loose lists travel whole.

Verification after the pass: typecheck 7/7, shared-domain 1485 / app-core 1667 / desktop 586 tests, go vet plus the full Go suite with three new regression tests, and nine CDP checks against the rebuilt app covering the review's executable repros.

adibhanna and others added 15 commits August 15, 2026 19:00
The "Forward task to" picker listed every other note in the vault and filtered
as you typed, but the selection stayed on the raw text in the input. Pressing
Enter therefore submitted what you had typed as a note name, failed the
picker's own check that the destination exists, and answered "Pick an existing
note". An arrow key or Tab first was the only way through.

This is the friction #467 removed from the folder picker, and it landed harder
here. A folder picker accepts a brand-new path, so an unmatched Enter just
creates it; this picker only accepts a note that already exists, so the missing
preselection turned into an error message instead of a no-op.

The fix is the same opt-in flag that change introduced: autoHighlightFirst, so
the first match is selected once a non-empty query is typed. The prompt
construction moves into a pure buildForwardTaskPrompt() alongside the
build*Prompt helpers in move-note.ts, which is what makes the behaviour
testable; forwardTaskWithPicker keeps the store lookup and the write.

Ctrl+J / Ctrl+K, asked for in the issue, already worked: #467 put the key
handling in PromptModal itself, so every prompt with suggestions inherited it.
Nothing on screen said so, so the hint line now reads "↑↓ or ⌃J/⌃K pick a note
· Enter to forward" and the manual entries say the same.

Verified by driving the built app over CDP against a throwaway vault: Ctrl+J,
Ctrl+J, Ctrl+K walk the selection 0, 1, 0; typing "res" leaves the first match
highlighted; Enter rewrites the source line to - [>] … [[Research]] and appends
the backlinked copy to the target note.
ZenNotes advertised block references and half-supported them. The link opened
the right note and stopped there, leaving you to find the block by hand, while
[[Note#Heading]] scrolled to its section correctly.

The cause was one deliberate null. wikilinkHeadingAnchor recognized `^` well
enough to strip it so the note resolved, then reported "no heading here", and
all six click surfaces read that as "just open the note". Each of them had its
own copy of the same `heading ? openWikilinkHeading : selectNote` branch, which
is why a missing anchor kind stayed missing: adding one meant remembering six
call sites. They now share openWikilinkTarget, which dispatches on the raw
target, so the next anchor kind lands everywhere at once.

Block references are a whole feature now rather than a syntax the app
tolerated:

- Following [[Note^id]] scrolls to the marked block from a click, a
  Cmd/Ctrl-click, the gd motion, and the reading view. [[^id]] targets a block
  in the current note.
- Typing `^` inside a wikilink lists the target's ids with each block's own
  text beside it, mirroring the `#` heading source. Whichever marker opens the
  anchor owns it, so a target cannot be both.
- ![[Note^id]] embeds just that block, a bullet's children included, and shows
  a notice when the id is gone instead of silently inlining the whole note.
- The Connections panel names the block a note reached for, recovered from the
  anchors that backlink resolution strips.
- The `^id` marker is addressing, not prose, so it is hidden in the reading
  view and in the editor, and returns when the cursor is on the line.

Ids are letters, digits and hyphens ending a line, so 2^3 and x ^ y stay
arithmetic and a marker inside a fence stays an example. A marker alone on its
line tags the paragraph above it, which is how a block gets named without
touching its text.

The block scanner does not carry its own copy of the frontmatter and fence
rules. parseOutline's loop became scanMarkdownLines and both walk it, because a
fifth copy of that logic is exactly the foot-gun this repo keeps paying for.

Verified by driving the built app over CDP against a throwaway vault: a block
link moves the editor from the top of the note to the marked bullet, its
sibling bullets render with their markers hidden while the cursor line keeps
its own, and the heading path is unchanged.
* editor: Typst word completion in math regions (slice 1)

The sibling of the LaTeX source for notes whose typesetter is Typst:
bare-word commands (sum, alpha, frac) complete from two letters on
inside the same math regions, gated on mathRendererOf so each source
stays out of the other typesetter's notes. Argument-taking functions
insert as snippets with Typst syntax (frac(a, b), sum_(i=1)^(n),
mat(1, 2; 3, 4)); the icon slot shows the exact Unicode glyph
(α, ∑, ∫, ℝ) — no typesetting needed for previews yet.

Starter table (~90 words): greek, core constructs, accents, set/logic
symbols, named functions.

* editor: compiled Typst previews in the completion row (slice 2)

Templated options now typeset their preview through the shared Typst
render queue: the Unicode glyph paints immediately, the compiled SVG
swaps in when ready, and the svg cache makes every later popup
instant. The preview source is the snippet template with its fields
unwrapped (frac(${a}, ${b}) previews as frac(a, b)), so preview and
insertion cannot drift apart. Glyph-only entries (greek, symbols)
keep their exact Unicode form — no compile needed.

* editor: arrows, comparisons, sets and text styles in Typst completion (slice 3)

Extends the table with the remaining everyday families: arrows with their
ASCII shorthands noted in the detail (arrow.r / ->), comparisons (lt.eq,
gt.eq, eq.not), sets (inter, nothing, subset.eq, in.not), circled
operators under their canonical post-0.13 names (plus.o, times.o — the
.circle spellings are deprecated in the bundled compiler), the dif
differential, and text styles (bold, upright, cal, bb) as snippets with
compiled previews. Every name compile-checked against typst 0.15.

---------

Co-authored-by: Adib Hanna <adibhanna@gmail.com>
Expose libglvnd through the shell wrapper so ANGLE can dlopen libEGL.so.1, and use makeShellWrapper so the conditional Wayland Ozone flag expands at runtime.
Forwarding moved a single line: the parent flipped to [>] and a copy
landed in the destination, while every indented subtask stayed behind
as orphaned open work. The task lost its breakdown exactly when it
moved notes.

forwardTaskSubtreeAtIndex now carries the whole indented block, using
the same walk the daily rollover uses (deeper indent until a blank
line, dedent, or fence), so the two carry mechanisms agree on what
belongs to a task. The destination gets a faithful pre-flip copy:
done, cancelled, and in-progress children keep their state, tokens
travel verbatim, and nesting is re-based under the unindented parent
copy. In the source, open subtasks ([ ] and [/]) flip to [>] beside
the parent so they stop reading as live work; done and cancelled
children deliberately keep their state, because that history is what
the record preserves. Only the parent carries the [[Target]] link.

Verified in the built app over CDP against a scratch vault, plus new
shared-domain and store tests. In-app help describes the new behavior;
the website docs mirror is updated alongside.
…al (#610)

Carrying a task across daily notes left a [>] record in each note, and
two things turned those records back into apparent live work. The daily
due inference stamped every record with its note's date, so the
Forwarded group showed the same task once per hop, each with a
different due date. And the calendar kept every origin on its day (a
side decision from #476), so one task drew dots on every date it had
ever been carried through.

Both stop at the shared layer. inferDailyTaskDueDates now exempts
forwarded records: the work left that note, so the note's date says
nothing about when it is due. bucketTasksByDueDate drops undated
forwarded records: a record of a move is not unscheduled work. A
forwarded record with an explicit due: written on its line keeps both
its date chip and its calendar slot, which is exactly the case #476
chose to protect (the copy is written without the due: token).

The Forwarded group itself stays: it is the bullet-journal record,
collapsed by default, and now shows its entries without resurrected
dates. Verified in the built app over CDP: a 15th -> 16th -> 17th carry
chain now puts one task on one day instead of three.
…605 review)

The reading view stripped ^ids per mdast text node, which deleted real
prose (a mid-line ^word before emphasis lost itself AND its joining
space) while leaving genuine anchors on non-final paragraph lines
visible. The editor hide was a bare per-line regex, so literal ^tails
inside code fences and frontmatter were blanked. The standalone-marker
walk climbed the raw line array, so a ^id after the frontmatter
anchored, and embedded, the YAML itself; the embed walk stopped at the
first blank line, dropping loose children and once slicing an indented
fence open so the rest of the embedding note rendered as code.

One grammar now decides everything. scanMarkdownLines and the
block-anchor logic move to shared-domain; parseBlockAnchors owns
fence and frontmatter boundaries for navigation, extraction, and a new
stripBlockAnchorMarkers that removes markers from the SOURCE, line-
preserving, before remark ever runs. The live preview hides only what
the parser accepts (anchors memoized per document), the DOCX export
strips through the same function, and a standalone marker after a
fenced block now tags the fence, whole.

Obsidian's spellings work as-is: [[Note#^id]], [[#^id]], and
markdown-style (Note.md#^id) all parse as block references, internal
links carry a raw anchor that openWikilinkTarget types in one place,
and [[Note#^ completes block ids. Completion also stops serving a
session-long snapshot: the body cache validates against the note's
updatedAt, so an id created seconds ago appears immediately.

Connections rows keep their excerpt and append the block pointer
instead of replacing one with the other, and only backlink rows pay
for the block scan.
…tems (#605 review)

Four web-workflow edges from the release review, one of them data loss:

- Saving a workflow whose slug differed from the previous filename only
  by case deleted the file that was just written: on a case-insensitive
  filesystem both spellings name one physical file, and the cleanup
  compared path strings. Both the Go server and the desktop applier now
  compare file identity (os.SameFile / dev+ino) before removing.
- The apply guard counted a change's BEFORE bytes against MaxNoteBytes,
  so an oversized note could never be shrunk, moved, or trashed from
  the web client. Only what a run writes counts now.
- A note carrying invalid UTF-8 made every apply 409 forever: JSON
  coerces the bad bytes to U+FFFD on the way to the browser, and the
  client can only echo that back. The conflict check now also accepts
  the wire view of the disk bytes, matching what /notes/read served.
- An over-cap run failed as "invalid workflow run counts" right after
  the dry run promised success; the errors now name the limit crossed.

Alongside: the command palette gates workflows on canManageWorkflows
instead of runtime === 'desktop', so capable web workspaces get the
palette rows and vim ex-commands their view already had; the web
bridge derives supportsWorkflows from the cached /capabilities response
instead of mutating a const, and fires its two independent pre-apply
requests concurrently; undo reads each journaled file once instead of
twice while holding the exclusive vault lock; and the three synced
copies of the workflow op schema now name each other.
…review)

The MCP/CLI task parser and the Go server both matched [>] in their
task regex but mapped no forwarded state, so a forwarded record read as
an OPEN task. With subtree forwarding (#611) flipping whole blocks to
[>], every carried task doubled in MCP listings, zn task list, and the
web client: the records beside their live copies. All four copies of
the parser now agree on the state; MCP's open filter, zn's default
listing (with a [>] glyph under --all), and the server's JSON carry it.

The subtree walk itself moves into one shared taskBlockEnd used by BOTH
carry mechanisms, daily rollover and forwarding, so they can never
disagree about which lines belong to a task. Loose lists now travel
whole (a blank line no longer strands children live in the source under
a forwarded parent), and mixed tab/space children re-base by whitespace
count instead of being copied at their absolute depth.

Also in this pass: the new Typst completion strings and the branch's
new comments lose their em dashes (the house rule), the in-app manual
gains the Obsidian block-link spellings and the math command
completion it was missing, and the Nix wrapper comment reads without
the dash too.
@adibhanna adibhanna changed the title ZenNotes 2.29.0: block references, and a forward-task picker that takes Enter ZenNotes 2.29.0: block references end to end, and workflows reach the self-hosted web app Aug 17, 2026
@adibhanna
adibhanna merged commit 70dd72f into main Aug 17, 2026
7 checks passed
@adibhanna
adibhanna deleted the v2.29.0 branch August 17, 2026 19:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants