From 238d650490f2eb76f260acb0bd0eedbd4819c47a Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 12 Oct 2026 14:24:17 -0500 Subject: [PATCH 01/14] Fix(links): zennotes:// links open ZenNotes on Windows, and Cloud sign-in finishes there No Windows install could open a zennotes:// link (#509). electron-builder writes the `protocols` scheme into the macOS bundle only, so neither the NSIS installer nor the zip registers it, and registerAppDeepLinkProtocol returned early on everything but macOS, as it has since v1.3.4. Links in MCP output and browsers did nothing (Windows: "Get an app to open this 'zennotes' link"; Chromium: "the scheme does not have a registered handler"), and a packaged app's Cloud connect, which hands the sign-in back through zennotes://auth, could never finish on Windows. The app now registers zennotes:// for the current user at every launch on Windows as well as macOS (HKCU\Software\Classes\zennotes, command "" "%1"). That also covers the zip wherever it was unpacked, and the entry follows the app when it moves. Linux keeps the package's .desktop file: setting the default there would override the user's own xdg-mime choice. Cloud sign-in in packaged Windows and Linux builds now returns through the one-time 127.0.0.1 page (RFC 8252's loopback redirect) that development builds already used, so it needs no link handler and no browser prompt; an AppImage without desktop integration hit the same dead end. Packaged macOS keeps the zennotes:// callback its signed bundle always registers. The NSIS uninstaller still leaves the per-user key behind. Checked on a clean Windows 11 VM (build 26200). The official 2.67.0 installer and app left no handler and reproduced the report. With this change the installed app registered itself on first launch; links opened the note from Edge and through ShellExecute, with the app running and with it closed; the zip registered its own path; and Cloud connect completed against zennotes.org through 127.0.0.1. --- apps/desktop/src/main/deep-links.test.ts | 34 +++++++++++++++++++++++- apps/desktop/src/main/deep-links.ts | 22 +++++++++++++++ apps/desktop/src/main/index.ts | 12 +++++---- 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/deep-links.test.ts b/apps/desktop/src/main/deep-links.test.ts index fe4eaf09d..72c787484 100644 --- a/apps/desktop/src/main/deep-links.test.ts +++ b/apps/desktop/src/main/deep-links.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest' import { buildOpenNoteDeepLink, + cloudSignInUsesLoopback, parseCloudAuthDeepLink, parseOpenNoteDeepLink, - parseQuickCaptureDeepLink + parseQuickCaptureDeepLink, + registersDeepLinkSchemeAtLaunch } from './deep-links' describe('parseOpenNoteDeepLink', () => { @@ -104,3 +106,33 @@ describe('parseCloudAuthDeepLink', () => { expect(parseCloudAuthDeepLink('https://zennotes.org/auth?code=abc&state=state')).toBeNull() }) }) + +describe('registersDeepLinkSchemeAtLaunch', () => { + it('registers on Windows, where no installer writes the scheme (#509)', () => { + expect(registersDeepLinkSchemeAtLaunch('win32')).toBe(true) + }) + + it('keeps registering on macOS', () => { + expect(registersDeepLinkSchemeAtLaunch('darwin')).toBe(true) + }) + + it('leaves Linux to the package desktop file and the user choice', () => { + expect(registersDeepLinkSchemeAtLaunch('linux')).toBe(false) + }) +}) + +describe('cloudSignInUsesLoopback', () => { + it('returns Windows and Linux sign-ins through the loopback page (#509)', () => { + expect(cloudSignInUsesLoopback(true, 'win32')).toBe(true) + expect(cloudSignInUsesLoopback(true, 'linux')).toBe(true) + }) + + it('keeps the zennotes:// link for packaged macOS', () => { + expect(cloudSignInUsesLoopback(true, 'darwin')).toBe(false) + }) + + it('uses the loopback in development builds everywhere, as before', () => { + expect(cloudSignInUsesLoopback(false, 'darwin')).toBe(true) + expect(cloudSignInUsesLoopback(false, 'win32')).toBe(true) + }) +}) diff --git a/apps/desktop/src/main/deep-links.ts b/apps/desktop/src/main/deep-links.ts index 4eae5a9b6..f089371c0 100644 --- a/apps/desktop/src/main/deep-links.ts +++ b/apps/desktop/src/main/deep-links.ts @@ -95,6 +95,28 @@ export function buildOpenNoteDeepLink(relPath: string): string { return `${ZENNOTES_DEEP_LINK_SCHEME}://open?path=${encoded}` } +/** Whether the app registers itself for zennotes:// links each time it starts. + * Windows has to: electron-builder's `protocols` option is macOS only, so + * neither the NSIS installer nor the zip writes the scheme to the registry, + * and until 2.68 no Windows install could open a zennotes:// link at all. + * Registering per user at launch also covers the zip, wherever it was + * unpacked. Linux stays with the package's .desktop file, which already + * declares the scheme; setting the default there would override the user's + * own xdg-mime choice. */ +export function registersDeepLinkSchemeAtLaunch(platform: NodeJS.Platform): boolean { + return platform === 'darwin' || platform === 'win32' +} + +/** Whether a Cloud sign-in returns to the app through a one-time page on + * http://127.0.0.1 (RFC 8252's loopback redirect) instead of a zennotes:// + * link. The loopback needs no link handler and no browser prompt, so it is + * the default; only packaged macOS keeps the link, which its signed bundle + * always registers. Windows and Linux sign-ins used to hang on a link nothing + * handled (an AppImage without desktop integration, every Windows install). */ +export function cloudSignInUsesLoopback(packaged: boolean, platform: NodeJS.Platform): boolean { + return !packaged || platform !== 'darwin' +} + export function normalizeDeepLinkNotePath(rawPath: string | null | undefined): string | null { const trimmed = rawPath?.trim() if (!trimmed || trimmed.includes('\0')) return null diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 629df348b..92fa8d3d6 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -286,9 +286,11 @@ import { } from "../mcp/instructions-store"; import { recordBootMark, recordMainPerf } from "./perf"; import { + cloudSignInUsesLoopback, parseCloudAuthDeepLink, parseOpenNoteDeepLink, parseQuickCaptureDeepLink, + registersDeepLinkSchemeAtLaunch, ZENNOTES_DEEP_LINK_SCHEME, } from "./deep-links"; import { @@ -641,7 +643,7 @@ function openAllowedExternalUrl(url: string): void { } function registerAppDeepLinkProtocol(): void { - if (!isMac()) return; + if (!registersDeepLinkSchemeAtLaunch(process.platform)) return; try { const defaultApp = @@ -3095,11 +3097,11 @@ function registerIpc(): void { app.isPackaged, process.env["ZENNOTES_CLOUD_BASE_URL"], ); - const callbackUrl = app.isPackaged - ? undefined - : await getCloudAuthLoopbackServer().start( + const callbackUrl = cloudSignInUsesLoopback(app.isPackaged, process.platform) + ? await getCloudAuthLoopbackServer().start( async (request) => await completeCloudAuthCallback(request), - ); + ) + : undefined; let result; try { result = await getCloudAuthManager().connect(baseUrl, callbackUrl); From b73fc068cd1f6538ec6b70e07306a4a7543cefbd Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 12 Oct 2026 14:49:04 -0500 Subject: [PATCH 02/14] Fix(editor): Tab keeps headings at the margin instead of turning them into code Tab indented a heading line like any other line (#925). Markdown lets a heading start at most three spaces in, and at four it is an indented code block, so one press at the default tab size of 4 (two at 2) turned a heading into code without a word: it lost its styling, its place in the outline and its fold arrow, in Edit and in Preview. Vim's >>, > and insert-mode Ctrl-T and the phones' indent button did the same. All of those indent through @codemirror/commands' indentMore, whose transactions carry the input.indent user event, so headingIndentGuard is one transaction filter that drops the insertions landing on heading lines and keeps the rest: a selection's other lines still indent, a `#` line in fenced code or frontmatter still indents, and a list item that opens with a heading still nests, since indenting it moves the item's marker. Lines are read from the syntax tree: an ATX heading's `#`, or any line of a setext heading, at the margin or inside a list item. When a press moved nothing, an info toast says why, once while it shows. Outdenting is untouched. The guard ships inside noteMarkdown(), so every note editor and the phones have it; the template editor builds its own markdown() and adds it by hand. ZenNotes' outline, folding, level chips and heading motions still read only headings at the margin, though CommonMark accepts up to three spaces; that gap is unchanged. The in-app manual's indentation section says headings stay at the margin. Checked in the built app over CDP at tab sizes 2 and 4 and with Vim's >>: an ATX heading, both lines of a setext heading and a heading inside a list item stayed put on disk and in Preview, the notice showed, and a paragraph still indented. Before the fix the same script turned all of them into code blocks or paragraphs. --- .../src/components/TemplateEditorModal.tsx | 4 + .../src/lib/cm-heading-indent-guard.test.ts | 120 ++++++++++++++++++ .../src/lib/cm-heading-indent-guard.ts | 105 +++++++++++++++ .../app-core/src/lib/cm-markdown-language.ts | 7 +- packages/app-core/src/lib/help.ts | 2 +- 5 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 packages/app-core/src/lib/cm-heading-indent-guard.test.ts create mode 100644 packages/app-core/src/lib/cm-heading-indent-guard.ts diff --git a/packages/app-core/src/components/TemplateEditorModal.tsx b/packages/app-core/src/components/TemplateEditorModal.tsx index 7fa6ab23d..3aa94d8a5 100644 --- a/packages/app-core/src/components/TemplateEditorModal.tsx +++ b/packages/app-core/src/components/TemplateEditorModal.tsx @@ -25,6 +25,7 @@ import { singleDashParagraphs } from '../lib/cm-markdown-language' import { customCodeFenceHighlightExtension } from '../lib/cm-custom-code-languages' import { markdownLinkExtension } from '../lib/cm-markdown-links' import { markdownListIndentPlugin } from '../lib/cm-markdown-list-indent' +import { headingIndentGuard } from '../lib/cm-heading-indent-guard' import { appMarkdownSnippetExtension } from '../lib/markdown-snippets-config' import { headingFolding } from '../lib/cm-heading-fold' import { editorTabSize } from '../lib/editor-tab-size' @@ -142,6 +143,9 @@ export function TemplateEditorModal({ markdownLinkExtension, vimAwareMarkdownKeymap, markdownListIndentPlugin, + // Templates parse with their own markdown() above, not noteMarkdown(), + // so the heading indent guard (#925) is added here by hand. + headingIndentGuard(), headingFolding({ showLevelLabels: useStore.getState().showHeadingLevelLabels }), diff --git a/packages/app-core/src/lib/cm-heading-indent-guard.test.ts b/packages/app-core/src/lib/cm-heading-indent-guard.test.ts new file mode 100644 index 000000000..aea50c6e9 --- /dev/null +++ b/packages/app-core/src/lib/cm-heading-indent-guard.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest' +import { indentLess, indentMore } from '@codemirror/commands' +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { ensureSyntaxTree, indentUnit } from '@codemirror/language' +import { EditorSelection, EditorState, type Extension } from '@codemirror/state' +import { HEADING_INDENT_NOTICE, headingIndentGuard } from './cm-heading-indent-guard' +import { noteMarkdown } from './cm-markdown-language' +import { useToastStore } from './toast' + +function stateFor(doc: string, from: number, to: number, extensions: Extension[]): EditorState { + const state = EditorState.create({ + doc, + selection: EditorSelection.single(from, to), + extensions: [indentUnit.of(' '), ...extensions] + }) + ensureSyntaxTree(state, doc.length, 5_000) + return state +} + +/** Runs Tab's command (indentMore) with the cursor or selection spanning `at`. */ +function indent(doc: string, at: string, through: string = at) { + const onHeld = vi.fn() + const from = doc.indexOf(at) + const to = doc.indexOf(through) + through.length + const state = stateFor(doc, from, through === at ? from : to, [ + markdown({ base: markdownLanguage }), + headingIndentGuard(onHeld) + ]) + let next = state + indentMore({ state, dispatch: (tr) => (next = tr.state) }) + return { doc: next.doc.toString(), selection: next.selection.main, onHeld } +} + +describe('headingIndentGuard (#925)', () => { + it('keeps an ATX heading at the margin and says why', () => { + const result = indent('# Parent\n\n#### Sub one\n', 'Sub one') + expect(result.doc).toBe('# Parent\n\n#### Sub one\n') + expect(result.onHeld).toHaveBeenCalledTimes(1) + }) + + it('does not push a heading indented by hand any further', () => { + expect(indent(' #### Sub one\n', 'Sub one').doc).toBe(' #### Sub one\n') + }) + + it('holds both lines of a setext heading', () => { + const doc = 'Setext title\n------------\n\nbody\n' + expect(indent(doc, 'Setext title').doc).toBe(doc) + expect(indent(doc, '------------').doc).toBe(doc) + }) + + it('holds a heading inside a list item', () => { + const doc = '- item\n #### In item\n' + expect(indent(doc, 'In item').doc).toBe(doc) + }) + + it('indents a paragraph as before, without a notice', () => { + const result = indent('Some text\n', 'Some text') + expect(result.doc).toBe(' Some text\n') + expect(result.onHeld).not.toHaveBeenCalled() + }) + + it('still nests a list item that opens with a heading', () => { + expect(indent('- one\n- ## Topic\n', '## Topic').doc).toBe('- one\n - ## Topic\n') + }) + + it('indents a # comment inside a fenced code block', () => { + expect(indent('```\n# comment\n```\n', '# comment').doc).toBe('```\n # comment\n```\n') + }) + + it('indents the rest of a selection around headings, without a notice', () => { + const result = indent('## A\nalpha\n## B\nbeta\n', '## A', 'beta') + expect(result.doc).toBe('## A\n alpha\n## B\n beta\n') + expect(result.onHeld).not.toHaveBeenCalled() + }) + + it('leaves the cursor where it was when nothing moved', () => { + const doc = '#### Sub one\n' + const result = indent(doc, 'one') + expect(result.selection.head).toBe(doc.indexOf('one')) + }) + + it('lets a heading indented by hand come back to the margin', () => { + const state = stateFor(' ## Back\n', 4, 4, [ + markdown({ base: markdownLanguage }), + headingIndentGuard(vi.fn()) + ]) + let next = state + indentLess({ state, dispatch: (tr) => (next = tr.state) }) + expect(next.doc.toString()).toBe('## Back\n') + }) + + it('only guards indenting: typed spaces still reach the line', () => { + const state = stateFor('## Typed\n', 0, 0, [ + markdown({ base: markdownLanguage }), + headingIndentGuard(vi.fn()) + ]) + const next = state.update({ changes: { from: 0, insert: ' ' }, userEvent: 'input.type' }).state + expect(next.doc.toString()).toBe(' ## Typed\n') + }) + + it('comes with every note editor through noteMarkdown(), notice included', async () => { + useToastStore.setState({ toasts: [] }) + const doc = '---\ntitle: x\n---\n# Heading\n' + const state = stateFor(doc, doc.indexOf('Heading'), doc.indexOf('Heading'), [noteMarkdown()]) + let next = state + indentMore({ state, dispatch: (tr) => (next = tr.state) }) + expect(next.doc.toString()).toBe(doc) + // Pressing again while the notice shows does not stack a second one. + indentMore({ state: next, dispatch: (tr) => (next = tr.state) }) + await Promise.resolve() + expect(useToastStore.getState().toasts.map((t) => t.message)).toEqual([HEADING_INDENT_NOTICE]) + + // A frontmatter line is not a heading, even though plain markdown would + // read `title: x` over the closing fence as a setext heading. + const fm = stateFor(doc, doc.indexOf('title'), doc.indexOf('title'), [noteMarkdown()]) + let afterFm = fm + indentMore({ state: fm, dispatch: (tr) => (afterFm = tr.state) }) + expect(afterFm.doc.toString()).toBe('---\n title: x\n---\n# Heading\n') + }) +}) diff --git a/packages/app-core/src/lib/cm-heading-indent-guard.ts b/packages/app-core/src/lib/cm-heading-indent-guard.ts new file mode 100644 index 000000000..c6f53e985 --- /dev/null +++ b/packages/app-core/src/lib/cm-heading-indent-guard.ts @@ -0,0 +1,105 @@ +/** + * Indenting leaves heading lines at the margin (#925). + * + * Markdown lets a heading start at most three spaces in from its container. + * At four it is an indented code block (or, under a paragraph, more of that + * paragraph), so Tab, Vim's `>>`, `>` and insert-mode Ctrl-T, and the phone + * toolbar's indent button used to turn a heading into code without a word. + * ZenNotes' outline, folding, level chips and heading motions only read + * headings at the margin, so even the one to three spaces Markdown accepts + * took a heading out of them. Every one of those paths indents through + * @codemirror/commands' `indentMore` (the Vim adapter calls it too), whose + * transactions carry the `input.indent` user event, so this filter sees them + * all in one place and drops the insertions that land on heading lines. The + * other lines of a selection still indent. When a press moved nothing, a + * notice says why, so the key does not just seem dead. + * + * A line is held when its first character belongs to a heading: the `#` of + * an ATX heading, or any line of a setext heading (its text and its + * underline), at the margin or inside a list item. A list item that opens + * with a heading (`- ## Topic`) still nests, since indenting it moves the + * item's marker, not the heading inside it. Outdenting is untouched, so a + * heading indented by hand can always come back. + */ +import { ensureSyntaxTree, syntaxTree } from '@codemirror/language' +import { + EditorState, + type ChangeSpec, + type Extension, + type Line +} from '@codemirror/state' +import type { SyntaxNode, Tree } from '@lezer/common' +import { useToastStore } from './toast' + +const HEADING_NODE = /^(ATXHeading[1-6]|SetextHeading[12])$/ + +export const HEADING_INDENT_NOTICE = + 'Headings stay at the margin: indented four spaces or more, a heading turns into a code block.' + +/** Whether the line's first non-blank character belongs to a heading. */ +export function isHeadingLine( + state: EditorState, + line: Line, + tree: Tree = syntaxTree(state) +): boolean { + const indent = /^[ \t]*/.exec(line.text)?.[0].length ?? 0 + if (indent === line.length) return false + for ( + let node: SyntaxNode | null = tree.resolveInner(line.from + indent, 1); + node; + node = node.parent + ) { + if (HEADING_NODE.test(node.name)) return true + } + return false +} + +// One notice on screen at a time: pressing Tab on a heading again while it +// shows should not stack a column of them. It runs after the dispatch, and +// checks then, so presses within the same tick still add only one. +function showHeadingIndentNotice(): void { + queueMicrotask(() => { + const toasts = useToastStore.getState() + if (toasts.toasts.some((toast) => toast.message === HEADING_INDENT_NOTICE)) return + toasts.addToast(HEADING_INDENT_NOTICE, 'info') + }) +} + +export function headingIndentGuard(onHeld: () => void = showHeadingIndentNotice): Extension { + return EditorState.transactionFilter.of((tr) => { + if (!tr.docChanged || !tr.isUserEvent('input.indent')) return tr + const state = tr.startState + let end = 0 + tr.changes.iterChangedRanges((_fromA, toA) => { + end = Math.max(end, toA) + }) + const tree = ensureSyntaxTree(state, end, 50) ?? syntaxTree(state) + + const kept: ChangeSpec[] = [] + let held = 0 + tr.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => { + const line = state.doc.lineAt(fromA) + // indentMore only inserts at line starts; anything else passes through. + if (fromA === toA && fromA === line.from && isHeadingLine(state, line, tree)) { + held += 1 + return + } + kept.push({ from: fromA, to: toA, insert: inserted }) + }) + if (held === 0) return tr + if (kept.length === 0) { + onHeld() + return {} + } + const changes = state.changes(kept) + return { + changes, + // indentMore leaves the selection to the change mapping; an explicit one + // is carried back to the start document and through the kept changes. + selection: tr.selection?.map(tr.changes.invertedDesc).map(changes), + effects: tr.effects, + scrollIntoView: tr.scrollIntoView, + userEvent: 'input.indent' + } + }) +} diff --git a/packages/app-core/src/lib/cm-markdown-language.ts b/packages/app-core/src/lib/cm-markdown-language.ts index dd44ee630..55d0199d2 100644 --- a/packages/app-core/src/lib/cm-markdown-language.ts +++ b/packages/app-core/src/lib/cm-markdown-language.ts @@ -57,6 +57,7 @@ import { import { styleTags, tags as t } from '@lezer/highlight' import { isFrontmatterFence } from '@shared/markdown-lines' import { resolveCodeLanguage } from './cm-code-languages' +import { headingIndentGuard } from './cm-heading-indent-guard' type ParseRange = { from: number; to: number } @@ -253,7 +254,9 @@ export const noteLanguage = new Language( ) /** Language support for a note editor. Replaces `markdown({...})` in every - * editor so they all agree on where frontmatter ends and markdown begins. */ + * editor so they all agree on where frontmatter ends and markdown begins. + * It also carries the heading indent guard (#925), so every editor that + * parses notes keeps Tab and `>>` from turning a heading into code. */ export function noteMarkdown(): LanguageSupport { - return new LanguageSupport(noteLanguage, noteBody.support) + return new LanguageSupport(noteLanguage, [noteBody.support, headingIndentGuard()]) } diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 0254e9d70..76cc05dc6 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -382,7 +382,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Tune indentation and switch notes quickly', body: - 'Set the editor tab size from 1 through 8 spaces under Settings → Editor → Writing. Press Ctrl+Tab to switch to the most recently used note, then press it again to alternate between the same two notes.' + 'Set the editor tab size from 1 through 8 spaces under Settings → Editor → Writing. Tab and Shift+Tab indent and outdent lines (`>>` and `<<` in Vim mode). Headings stay at the margin: Markdown reads a heading indented four spaces as a code block, so indenting leaves heading lines where they are and indents the rest. Press Ctrl+Tab to switch to the most recently used note, then press it again to alternate between the same two notes.' }, { title: 'Callouts highlight the important bits', From cb98803f674c589c7dd450738364f26fd67e4a02 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 12 Oct 2026 15:56:10 -0500 Subject: [PATCH 03/14] Fix(mcp): assistants can no longer write into .zennotes An MCP edit wrote its file and only then checked that the file was a note. write_note, append_to_note, prepend_to_note, insert_at_line and replace_in_note on a path under .zennotes created or changed that file, then reported "Note not in a known folder" as though nothing had happened. An assistant could plant or alter a note's comments, its creation date, or anything else the app keeps there. noteForWrite resolves the path and requires a note folder before a byte is written, the order readNote already used. Notes, new or existing, are written as before. --- .../src/mcp/vault-ops-write-guard.test.ts | 55 +++++++++++++++++++ apps/desktop/src/mcp/vault-ops.ts | 40 +++++++------- 2 files changed, 75 insertions(+), 20 deletions(-) create mode 100644 apps/desktop/src/mcp/vault-ops-write-guard.test.ts diff --git a/apps/desktop/src/mcp/vault-ops-write-guard.test.ts b/apps/desktop/src/mcp/vault-ops-write-guard.test.ts new file mode 100644 index 000000000..45bc07c63 --- /dev/null +++ b/apps/desktop/src/mcp/vault-ops-write-guard.test.ts @@ -0,0 +1,55 @@ +import { mkdtemp, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { appendToNote, insertAtLine, prependToNote, replaceInNote, writeNote } from './vault-ops' + +// The edits an assistant makes through MCP used to write the file and check +// afterwards that it was a note, so `write_note` on `.zennotes/...` created +// the file and only then failed. These pin the order: nothing outside the note +// folders is written, and a real note still is. + +let root: string + +beforeEach(async () => { + root = await mkdtemp(path.join(os.tmpdir(), 'zennotes-mcp-write-guard-')) + await mkdir(path.join(root, 'inbox'), { recursive: true }) + await mkdir(path.join(root, '.zennotes', 'comments', 'inbox'), { recursive: true }) + await writeFile(path.join(root, 'inbox', 'Plan.md'), '# Plan\n') + await writeFile(path.join(root, '.zennotes', 'comments', 'inbox', 'Plan.md.comments.json'), '[]\n') +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +const exists = (rel: string): Promise => + stat(path.join(root, rel)).then( + () => true, + () => false + ) + +describe('MCP edits outside the note folders', () => { + it('write_note refuses a .zennotes path before creating the file', async () => { + const forged = '.zennotes/agent-edits/inbox/Plan.md.json' + await expect(writeNote(root, forged, '{}')).rejects.toThrow(/Note not in a known folder/) + expect(await exists(forged)).toBe(false) + expect(await readdir(path.join(root, '.zennotes'))).toEqual(['comments']) + }) + + it('the other edits refuse an internal file and leave it as it was', async () => { + const comments = '.zennotes/comments/inbox/Plan.md.comments.json' + await expect(appendToNote(root, comments, 'x')).rejects.toThrow(/Note not in a known folder/) + await expect(prependToNote(root, comments, 'x')).rejects.toThrow(/Note not in a known folder/) + await expect(insertAtLine(root, comments, 0, 'x')).rejects.toThrow(/Note not in a known folder/) + await expect(replaceInNote(root, comments, '[]', 'x')).rejects.toThrow(/Note not in a known folder/) + expect(await readFile(path.join(root, comments), 'utf8')).toBe('[]\n') + }) + + it('still writes a note, new or existing', async () => { + await writeNote(root, 'inbox/New.md', '# New\n') + expect(await exists('inbox/New.md')).toBe(true) + await appendToNote(root, 'inbox/Plan.md', 'More.') + expect(await readFile(path.join(root, 'inbox', 'Plan.md'), 'utf8')).toContain('More.') + }) +}) diff --git a/apps/desktop/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts index a965b2d50..2f4354f65 100644 --- a/apps/desktop/src/mcp/vault-ops.ts +++ b/apps/desktop/src/mcp/vault-ops.ts @@ -599,6 +599,20 @@ function resolveSafe(root: string, rel: string): string { return abs } +/** + * Resolve a note an edit is about to write, and refuse anything outside the + * note folders BEFORE a byte lands. The edits used to write first and check + * after, so `write_note` on `.zennotes/...` created the file and only then + * reported an error: an assistant could plant or alter a note's comments, + * its creation date, or anything else the app keeps there. + */ +async function noteForWrite(root: string, rel: string): Promise<{ abs: string; folder: NoteFolder }> { + const abs = resolveSafe(root, rel) + const folder = await folderOf(root, abs) + if (!folder) throw new Error(`Note not in a known folder: ${rel}`) + return { abs, folder } +} + async function folderOf(root: string, abs: string): Promise { const rel = toPosix(path.relative(root, abs)) if (!rel || rel.startsWith('..')) return null @@ -889,12 +903,10 @@ export async function readNote(root: string, rel: string): Promise } export async function writeNote(root: string, rel: string, body: string): Promise { - const abs = resolveSafe(root, rel) + const { abs, folder } = await noteForWrite(root, rel) await fs.mkdir(path.dirname(abs), { recursive: true }) await prepareNoteCreation(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, body, 'utf8') - const folder = await folderOf(root, abs) - if (!folder) throw new Error(`Note not in a known folder: ${rel}`) return await readMeta(root, abs, folder) } @@ -1839,12 +1851,10 @@ export function appendToBody(body: string, text: string): string { } export async function appendToNote(root: string, rel: string, text: string): Promise { - const abs = resolveSafe(root, rel) + const { abs, folder } = await noteForWrite(root, rel) const body = await fs.readFile(abs, 'utf8') await prepareNoteCreation(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, appendToBody(body, text), 'utf8') - const folder = await folderOf(root, abs) - if (!folder) throw new Error(`Note not in a known folder: ${rel}`) return await readMeta(root, abs, folder) } @@ -1858,12 +1868,10 @@ export function prependToBody(body: string, text: string): string { } export async function prependToNote(root: string, rel: string, text: string): Promise { - const abs = resolveSafe(root, rel) + const { abs, folder } = await noteForWrite(root, rel) const body = await fs.readFile(abs, 'utf8') await prepareNoteCreation(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, prependToBody(body, text), 'utf8') - const folder = await folderOf(root, abs) - if (!folder) throw new Error(`Note not in a known folder: ${rel}`) return await readMeta(root, abs, folder) } @@ -1901,18 +1909,12 @@ export async function replaceInNote( replace: string, occurrence: 'first' | 'all' = 'first' ): Promise<{ meta: NoteMeta; replacements: number }> { - const abs = resolveSafe(root, rel) + const { abs, folder } = await noteForWrite(root, rel) const body = await fs.readFile(abs, 'utf8') const { body: next, replacements } = replaceInBody(body, find, replace, occurrence) - if (replacements === 0) { - const folder = await folderOf(root, abs) - if (!folder) throw new Error(`Note not in a known folder: ${rel}`) - return { meta: await readMeta(root, abs, folder), replacements: 0 } - } + if (replacements === 0) return { meta: await readMeta(root, abs, folder), replacements: 0 } await prepareNoteCreation(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, next, 'utf8') - const folder = await folderOf(root, abs) - if (!folder) throw new Error(`Note not in a known folder: ${rel}`) return { meta: await readMeta(root, abs, folder), replacements } } @@ -1922,12 +1924,10 @@ export async function insertAtLine( lineNumber: number, text: string ): Promise { - const abs = resolveSafe(root, rel) + const { abs, folder } = await noteForWrite(root, rel) const body = await fs.readFile(abs, 'utf8') await prepareNoteCreation(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, insertAtLineInBody(body, lineNumber, text), 'utf8') - const folder = await folderOf(root, abs) - if (!folder) throw new Error(`Note not in a known folder: ${rel}`) return await readMeta(root, abs, folder) } From 57f64bc16e83848cf06b446b2cf1bdc945b9cd8d Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 12 Oct 2026 15:56:20 -0500 Subject: [PATCH 04/14] Feat(mcp): review an assistant's edits, change by change, in the note When an assistant changed a note through MCP, ZenNotes showed the new text and nothing else: seeing what changed meant leaving the app for a diff, and there was no taking back one change while keeping the rest (Kevin, on Discord). Now the MCP server keeps what the note said before, beside it in .zennotes/agent-edits/.json, and the app offers the review in the note itself. Recording. Before write_note, append_to_note, prepend_to_note, insert_at_line, replace_in_note or toggle_task changes a note of a local vault, the server writes the note's text to the record, makes the edit, then stores the note as it now reads, naming the assistant from its MCP client name. The record reaches disk before the edit, the workflow journal's rule. Two engines write records, this one and the Go zn a managed install runs, so the writing side is string comparisons only: a change someone else made between two edits is noted as outside, never merged. Edits to one note take turns. Settings > MCP > Review agent edits ([mcp] review_agent_edits in config.toml) turns recording off; both engines read it without the app. Reading. @shared/agent-edits folds the outside changes into the baseline with the three-way merge Cloud conflicts use, then merges the note as it is now against the assistant's last text, so the review shows only the assistant's changes. Where the user rewrote a changed line, the user's version wins. Review. A banner names the assistant and when it edited; Review changes opens CodeMirror's merge view in the note (@codemirror/merge, loaded on demand; the renderer chunk rule keeps it out of vendor-editor, so launch carries none of it). Each change shows the removed lines above the new ones with Accept and Reject, the bands running the full width of the editor; the note is read-only and Live Preview off while reviewing; Accept all, Reject all and Done sit on a bar above. Reject is an ordinary edit, saved before the record moves on; each decision saves progress, and the last one deletes the record. Keys: Mod+Alt+Down and Up, Mod+Shift+Enter, Mod+Shift+Backspace, and in Vim mode ]c [c dp do, all rebindable. dp and do cannot be codemirror-vim mappings, which run the d operator as soon as it fully matches, so review mode reads normal-mode sequences ahead of Vim; Escape after a lone d cancels it rather than leaving the review. The palette lists every note waiting. Records follow their notes through relocateNote, folder renames, deletes, both trashes, creation and workflow runs (journalled for undo as a third sidecar kind, which older ledger readers skip). Cloud sync carries them, the watcher reports them as an agent-edits change scope, and the bridge gains supportsAgentEditReview with three optional methods, desktop only for now. Not covered: vaults on a self-hosted server, notes an assistant creates, and moves and deletions. Design: docs/ideas/agent-edit-review.md. Checked in the built app over CDP with a real MCP client named claude-code driving the MCP server process: the banner, both changes inline, Reject by button restoring and saving the old line, Accept by keyboard, and the record and its folder deleted; then the same in Vim mode with do, ]c and dp. --- apps/desktop/electron.vite.config.ts | 5 + apps/desktop/src/main/agent-edit-files.ts | 155 ++++++++++ apps/desktop/src/main/app-config.ts | 7 +- apps/desktop/src/main/index.ts | 35 +++ apps/desktop/src/main/note-sidecars.ts | 16 +- apps/desktop/src/main/vault.ts | 20 +- apps/desktop/src/main/watcher.ts | 24 ++ apps/desktop/src/main/workflow-apply.test.ts | 17 ++ apps/desktop/src/main/workflow-apply.ts | 21 +- .../src/mcp/agent-edit-recorder.test.ts | 129 ++++++++ apps/desktop/src/mcp/agent-edit-recorder.ts | 152 ++++++++++ apps/desktop/src/mcp/server.ts | 13 +- apps/desktop/src/mcp/vault-ops.ts | 20 +- apps/desktop/src/preload/index.ts | 10 +- docs/ideas/agent-edit-review.md | 172 +++++++++++ package-lock.json | 14 + packages/app-core/package.json | 1 + .../src/components/AgentEditReview.tsx | 279 ++++++++++++++++++ .../app-core/src/components/EditorPane.tsx | 62 ++++ .../app-core/src/components/SettingsModal.tsx | 34 +++ .../app-core/src/lib/agent-review-runtime.ts | 19 ++ .../app-core/src/lib/cm-agent-review.test.ts | 138 +++++++++ packages/app-core/src/lib/cm-agent-review.ts | 257 ++++++++++++++++ packages/app-core/src/lib/commands.ts | 19 ++ packages/app-core/src/lib/help.ts | 5 + packages/app-core/src/lib/keymaps.ts | 90 +++++- packages/app-core/src/store.ts | 82 ++++- packages/app-core/src/styles/index.css | 48 +++ packages/bridge-contract/src/app-config.ts | 1 + packages/bridge-contract/src/bridge.ts | 14 + packages/bridge-contract/src/ipc.ts | 43 +++ .../shared-domain/src/agent-edits.test.ts | 165 +++++++++++ packages/shared-domain/src/agent-edits.ts | 186 ++++++++++++ packages/shared-domain/src/app-config.ts | 4 + packages/shared-domain/src/cloud-sync.test.ts | 2 + packages/shared-domain/src/cloud-sync.ts | 3 + packages/shared-domain/src/keymaps-catalog.ts | 8 + 37 files changed, 2241 insertions(+), 29 deletions(-) create mode 100644 apps/desktop/src/main/agent-edit-files.ts create mode 100644 apps/desktop/src/mcp/agent-edit-recorder.test.ts create mode 100644 apps/desktop/src/mcp/agent-edit-recorder.ts create mode 100644 docs/ideas/agent-edit-review.md create mode 100644 packages/app-core/src/components/AgentEditReview.tsx create mode 100644 packages/app-core/src/lib/agent-review-runtime.ts create mode 100644 packages/app-core/src/lib/cm-agent-review.test.ts create mode 100644 packages/app-core/src/lib/cm-agent-review.ts create mode 100644 packages/shared-domain/src/agent-edits.test.ts create mode 100644 packages/shared-domain/src/agent-edits.ts diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 72b81d3e4..d587e3f93 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -112,6 +112,11 @@ function rendererManualChunk(id: string): string | undefined { return 'vendor-editor-languages' } + // The merge view only serves reviewing an assistant's edits, which + // cm-agent-review.ts imports on demand. The rule below would pull it into + // vendor-editor, onto every launch; left alone it stays in that lazy chunk. + if (id.includes('/@codemirror/merge/')) return undefined + if ( id.includes('/@codemirror/') || id.includes('/codemirror/') || diff --git a/apps/desktop/src/main/agent-edit-files.ts b/apps/desktop/src/main/agent-edit-files.ts new file mode 100644 index 000000000..cbd871259 --- /dev/null +++ b/apps/desktop/src/main/agent-edit-files.ts @@ -0,0 +1,155 @@ +// Agent edit records on disk: `.zennotes/agent-edits/.json`, one per +// note an assistant changed through MCP and nobody has reviewed yet (see +// @shared/agent-edits and docs/ideas/agent-edit-review.md). The MCP server +// writes them and the app reviews them, and the MCP server cannot import +// vault.ts (it pulls in Electron), so this stays free of Electron like +// note-sidecars.ts. +import { promises as fs } from 'node:fs' +import { randomUUID } from 'node:crypto' +import path from 'node:path' +import { + AGENT_EDITS_DIR, + AGENT_EDITS_SUFFIX, + latestAgentEdit, + parseAgentEditRecord, + serializeAgentEditRecord, + type AgentEditRecord +} from '@shared/agent-edits' +import type { AgentEditSummary } from '@shared/ipc' +import { renameWithRetry } from './atomic-write' + +const INTERNAL_VAULT_DIR = '.zennotes' + +function toPosix(rel: string): string { + return rel.split(path.sep).join('/') +} + +export function agentEditsRoot(root: string): string { + return path.join(root, INTERNAL_VAULT_DIR, AGENT_EDITS_DIR) +} + +/** The record file for a vault-relative note path, refusing any escape. */ +export function agentEditPath(root: string, rel: string): string { + const base = path.resolve(agentEditsRoot(root)) + const abs = path.resolve(base, `${toPosix(rel)}${AGENT_EDITS_SUFFIX}`) + if (!abs.startsWith(base + path.sep)) throw new Error(`Path escapes the vault: ${rel}`) + return abs +} + +/** The note's record, or null when there is none or it is not readable. */ +export async function readAgentEditRecord(root: string, rel: string): Promise { + try { + return parseAgentEditRecord(JSON.parse(await fs.readFile(agentEditPath(root, rel), 'utf8'))) + } catch { + return null + } +} + +/** Write the record (temp file, fsync, rename), or remove it for `null`. */ +export async function writeAgentEditRecord( + root: string, + rel: string, + record: AgentEditRecord | null +): Promise { + const file = agentEditPath(root, rel) + if (record === null) { + await removeAgentEditFile(root, file) + return + } + await fs.mkdir(path.dirname(file), { recursive: true }) + const temporary = `${file}.${randomUUID()}.tmp` + try { + const handle = await fs.open(temporary, 'wx') + try { + await handle.writeFile(serializeAgentEditRecord(record)) + await handle.sync() + } finally { + await handle.close() + } + await renameWithRetry(temporary, file) + } finally { + await fs.rm(temporary, { force: true }) + } +} + +/** The folder holding the records of every note under `folderRel`, for + * moving or deleting a whole folder with its notes. */ +export function agentEditFolderPath(root: string, folderRel: string): string { + const base = path.resolve(agentEditsRoot(root)) + const abs = path.resolve(base, toPosix(folderRel)) + if (!abs.startsWith(base + path.sep)) throw new Error(`Path escapes the vault: ${folderRel}`) + return abs +} + +/** Remove a note's record, or with `directory` every record under a folder: + * a deleted note has nothing left to review. */ +export async function removeAgentEditRecord( + root: string, + rel: string, + directory = false +): Promise { + if (!directory) { + await removeAgentEditFile(root, agentEditPath(root, rel)) + return + } + const folder = agentEditFolderPath(root, rel) + await fs.rm(folder, { recursive: true, force: true }) + await pruneEmptyDirs(root, path.dirname(folder)) +} + +async function removeAgentEditFile(root: string, file: string): Promise { + await fs.rm(file, { force: true }) + await pruneEmptyDirs(root, path.dirname(file)) +} + +/** Leave no empty folders behind, agent-edits itself included: a vault whose + * changes have all been reviewed carries no trace of them. */ +async function pruneEmptyDirs(root: string, from: string): Promise { + const stop = path.resolve(agentEditsRoot(root)) + for (let dir = path.resolve(from); dir === stop || dir.startsWith(stop + path.sep); dir = path.dirname(dir)) { + try { + await fs.rmdir(dir) + } catch { + break + } + } +} + +/** Every record in the vault, newest edit first. Unreadable files are skipped. */ +export async function listAgentEditRecords(root: string): Promise { + const base = agentEditsRoot(root) + const found: AgentEditSummary[] = [] + async function walk(dir: string): Promise { + let entries + try { + entries = await fs.readdir(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const abs = path.join(dir, entry.name) + if (entry.isDirectory()) { + await walk(abs) + continue + } + if (!entry.isFile() || !entry.name.endsWith(AGENT_EDITS_SUFFIX)) continue + const notePath = toPosix(path.relative(base, abs)).slice(0, -AGENT_EDITS_SUFFIX.length) + let record: AgentEditRecord | null = null + try { + record = parseAgentEditRecord(JSON.parse(await fs.readFile(abs, 'utf8'))) + } catch { + record = null + } + if (!record) continue + const latest = latestAgentEdit(record) + found.push({ + notePath, + agent: latest?.agent ?? 'Assistant', + at: latest?.at ?? 0, + edits: record.edits.length + }) + } + } + await walk(base) + return found.sort((a, b) => b.at - a.at) +} diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index bc34b59d3..26bd5aa6d 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -39,7 +39,7 @@ interface ScalarFieldMap { } // Order sections are emitted in. Keeps the file stable across rewrites. -const SECTION_ORDER = ['vim', 'search', 'editor', 'appearance', 'typography', 'view'] as const +const SECTION_ORDER = ['vim', 'search', 'editor', 'appearance', 'typography', 'view', 'mcp'] as const // Scalar (string / number / boolean) portable prefs → [section].key (snake_case). const SCALAR_FIELDS: Partial> = { @@ -117,6 +117,11 @@ const SCALAR_FIELDS: Partial> = { tomlKey: 'sync_title_heading_on_rename', comment: "renaming a note rewrites its leading '# heading' to match" }, + reviewAgentEdits: { + section: 'mcp', + tomlKey: 'review_agent_edits', + comment: 'keep what a note said before an assistant edits it through MCP, so you can review the change' + }, markdownSnippets: { section: 'editor', tomlKey: 'markdown_snippets', diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 92fa8d3d6..5bda4baf0 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -116,6 +116,12 @@ import { writeNoteComments, writeNote, } from "./vault"; +import { + listAgentEditRecords, + readAgentEditRecord, + writeAgentEditRecord, +} from "./agent-edit-files"; +import { parseAgentEditRecord } from "@shared/agent-edits"; import { initAppConfig, getPortableConfigSnapshot, @@ -3931,6 +3937,35 @@ function registerIpc(): void { }, ); + // Agent edit records (docs/ideas/agent-edit-review.md), local vaults only: a + // remote vault's notes are edited through its server, which does not record + // assistant edits yet, so there is nothing to review there. + handle(IPC.VAULT_LIST_AGENT_EDITS, async () => { + if (isRemoteWorkspaceActive()) return []; + return await listAgentEditRecords(requireVault().root); + }); + + handle(IPC.VAULT_READ_AGENT_EDIT, async (_e, relPath: string) => { + if (isRemoteWorkspaceActive() || typeof relPath !== "string") return null; + return await readAgentEditRecord(requireVault().root, relPath); + }); + + handle( + IPC.VAULT_WRITE_AGENT_EDIT, + async (_e, relPath: string, record: unknown) => { + if (isRemoteWorkspaceActive()) return; + if (typeof relPath !== "string") throw new Error("A note path is required"); + const v = requireVault(); + if (isEphemeralRoot(v.root)) return; + // The renderer is not trusted with the shape: only a well-formed record + // (or null, which deletes it) reaches the vault, and only inside + // .zennotes/agent-edits (agentEditPath refuses anything else). + const parsed = record === null ? null : parseAgentEditRecord(record); + if (record !== null && !parsed) throw new Error("Not an agent edit record"); + await writeAgentEditRecord(v.root, relPath, parsed); + }, + ); + handle(IPC.VAULT_SCAN_TASKS, async () => { if (isRemoteWorkspaceActive()) return await requireRemoteWorkspaceClient().scanTasks(); diff --git a/apps/desktop/src/main/note-sidecars.ts b/apps/desktop/src/main/note-sidecars.ts index 58c3b8a38..bba4e33d6 100644 --- a/apps/desktop/src/main/note-sidecars.ts +++ b/apps/desktop/src/main/note-sidecars.ts @@ -1,7 +1,8 @@ -// A note's two app-owned sidecars, and moving a note (or a folder) together -// with them. Comments live at `.zennotes/comments/.comments.json` and -// the creation date at `.zennotes/note-metadata/.metadata.json` (see -// note-creation-metadata.ts). Desktop main, the MCP server and the workflow +// A note's app-owned sidecars, and moving a note (or a folder) together with +// them. Comments live at `.zennotes/comments/.comments.json`, the +// creation date at `.zennotes/note-metadata/.metadata.json` (see +// note-creation-metadata.ts), and an assistant's unreviewed edit at +// `.zennotes/agent-edits/.json` (see agent-edit-files.ts). Desktop main, the MCP server and the workflow // applier all move notes, and the MCP server cannot import vault.ts (it pulls // in Electron), so this stays free of Electron and is the one place that knows // how a note's sidecars travel with it. @@ -10,6 +11,7 @@ import { randomUUID } from 'node:crypto' import path from 'node:path' import { NOTE_COMMENTS_DIR, NOTE_COMMENTS_SUFFIX } from '@shared/note-comments' import { noteMetadataPath, removeNoteCreation } from './note-creation-metadata' +import { agentEditPath, removeAgentEditRecord } from './agent-edit-files' const INTERNAL_VAULT_DIR = '.zennotes' @@ -136,12 +138,16 @@ export async function relocateNote( if (!(await pathExists(toAbs))) { if (await pathExists(toComments)) throw new Error(leftoverCommentsMessage(root, toAbs)) await removeNoteCreation(root, toRel) + // Like a creation date, an edit record with no note beside it belongs to + // nobody, and reviewing it against another note would be wrong. + await removeAgentEditRecord(root, toRel) } await relocateFolderTrees( [ [resolveSafe(root, fromRel), toAbs], [noteCommentsPath(root, fromRel), toComments], - [await noteMetadataPath(root, fromRel), await noteMetadataPath(root, toRel)] + [await noteMetadataPath(root, fromRel), await noteMetadataPath(root, toRel)], + [agentEditPath(root, fromRel), agentEditPath(root, toRel)] ], persistSettings ) diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index 25e192f87..b23062fff 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -5,6 +5,7 @@ import { relocateFolderTrees, relocateNote } from './note-sidecars' +import { agentEditFolderPath, removeAgentEditRecord } from './agent-edit-files' import { promises as fs, type Dirent } from 'node:fs' import { execFile, spawn } from 'node:child_process' import { randomUUID } from 'node:crypto' @@ -1610,7 +1611,7 @@ async function typstPreambleFolderFor(root: string): Promise { */ export function vaultChangeAffectsSettings(ev: VaultChangeEvent): boolean { if (ev.scope === 'vault-settings') return true - if (ev.scope === 'comments' || ev.scope === 'database') return false + if (ev.scope === 'comments' || ev.scope === 'agent-edits' || ev.scope === 'database') return false // A root-level entry (`Notes.md`, or a directory named `Notes`) is what the // inference looks at; anything nested carries a separator. return !ev.path.includes('/') @@ -3499,6 +3500,7 @@ export async function createNote( const abs = path.join(dir, `${finalTitle}.md`) const body = `# ${finalTitle}\n\n` await removeNoteCreation(root, toPosix(path.relative(root, abs))) + await removeAgentEditRecord(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, body, 'utf8') invalidateNoteMetaCache(root, toPosix(path.relative(root, abs))) invalidateVaultTextSearchCache(root) @@ -3530,6 +3532,7 @@ export async function createExcalidraw( } const abs = path.join(dir, `${finalTitle}.excalidraw`) await removeNoteCreation(root, toPosix(path.relative(root, abs))) + await removeAgentEditRecord(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, JSON.stringify(emptyExcalidrawDocument(), null, 2), 'utf8') invalidateNoteMetaCache(root, toPosix(path.relative(root, abs))) return await readMeta(root, abs, folder) @@ -3571,6 +3574,7 @@ export async function convertObsidianExcalidraw(root: string, rel: string): Prom } const destAbs = path.join(dir, `${finalTitle}.excalidraw`) await removeNoteCreation(root, toPosix(path.relative(root, destAbs))) + await removeAgentEditRecord(root, toPosix(path.relative(root, destAbs))) await fs.writeFile(destAbs, JSON.stringify(scene, null, 2), 'utf8') invalidateNoteMetaCache(root, toPosix(path.relative(root, destAbs))) return await readMeta(root, destAbs, folder) @@ -3590,6 +3594,7 @@ export async function importExternalNote(root: string, sourceAbsPath: string): P const destAbs = path.join(destDir, `${finalTitle}.md`) const body = await fs.readFile(source, 'utf8') await removeNoteCreation(root, toPosix(path.relative(root, destAbs))) + await removeAgentEditRecord(root, toPosix(path.relative(root, destAbs))) await fs.writeFile(destAbs, body, 'utf8') await fs.rm(source, { force: true }) const rel = toPosix(path.relative(root, destAbs)) @@ -3742,6 +3747,7 @@ export async function trashNoteToSystem(root: string, rel: string): Promise console.warn('Agent edit cleanup pending', error)) invalidateNoteMetaCache(root, rel) invalidateVaultTextSearchCache(root) return meta @@ -3771,6 +3777,7 @@ export async function emptyTrash(root: string): Promise { [await noteMetadataPath(root, trashRel, true), path.join(temporary, 'metadata')] ], async () => {}) await fs.rm(temporary, { recursive: true, force: true }).catch(error => console.warn('Trash cleanup pending', error)) + await removeAgentEditRecord(root, trashRel, true).catch((error) => console.warn('Agent edit cleanup pending', error)) invalidateNoteMetaCache(root) invalidateVaultTextSearchCache(root) } @@ -3800,6 +3807,9 @@ export async function deleteNote(root: string, rel: string): Promise { // Once detached, cleanup cannot attach the old discussion to a new note. await fs.rm(temporary, { recursive: true, force: true }).catch(error => console.warn('Note cleanup pending', error)) } + await removeAgentEditRecord(root, toPosix(path.relative(root, abs))).catch((error) => + console.warn('Agent edit cleanup pending', error) + ) invalidateNoteMetaCache(root, rel) invalidateVaultTextSearchCache(root) } @@ -4057,7 +4067,8 @@ export async function renameFolderTrees( const oldComments = resolveSafe(noteCommentsRoot(root), toPosix(path.relative(root, oldAbs))) const newComments = resolveSafe(noteCommentsRoot(root), toPosix(path.relative(root, newAbs))) await relocateFolderTrees([[oldAbs, newAbs], [oldComments, newComments], - [await noteMetadataPath(root, oldRelative, true), await noteMetadataPath(root, newRelative, true)]], persistSettings) + [await noteMetadataPath(root, oldRelative, true), await noteMetadataPath(root, newRelative, true)], + [agentEditFolderPath(root, toPosix(path.relative(root, oldAbs))), agentEditFolderPath(root, toPosix(path.relative(root, newAbs)))]], persistSettings) invalidateNoteMetaCache(root) invalidateVaultTextSearchCache(root) } @@ -4133,6 +4144,7 @@ export async function deleteFolder( }) if (isEphemeralRoot(root) && !hasComments) { await fs.rm(abs, { recursive: true, force: true }) + await removeAgentEditRecord(root, toPosix(path.relative(root, abs)), true) invalidateNoteMetaCache(root) invalidateVaultTextSearchCache(root) return @@ -4157,6 +4169,9 @@ export async function deleteFolder( await fs .rm(temporary, { recursive: true, force: true }) .catch((error) => console.warn('Folder cleanup pending', error)) + await removeAgentEditRecord(root, toPosix(path.relative(root, abs)), true).catch((error) => + console.warn('Agent edit cleanup pending', error) + ) invalidateNoteMetaCache(root) invalidateVaultTextSearchCache(root) } @@ -4399,6 +4414,7 @@ export async function duplicateNote(root: string, rel: string): Promise + onEvent({ + kind, + path: agentEditPath, + folder: folderForRelativePath(agentEditPath, this.settings) ?? 'inbox', + scope: 'agent-edits' + }) + ) + return + } // A template is not a note: its own scope has the renderer re-list // templates instead of the note tree (#723). Another window on this // vault, or a synced dotfile, is how one changes behind the app's back. diff --git a/apps/desktop/src/main/workflow-apply.test.ts b/apps/desktop/src/main/workflow-apply.test.ts index 3d4cdebe5..b87275274 100644 --- a/apps/desktop/src/main/workflow-apply.test.ts +++ b/apps/desktop/src/main/workflow-apply.test.ts @@ -657,6 +657,23 @@ describe("a note's sidecars travel with it", () => { expect(await pathExists(path.join(root, '.zennotes', 'note-metadata', 'archive'))).toBe(false) }) + it('carries an assistant edit waiting for review, and undo brings it back', async () => { + const root = await makeVault() + await seedWithSidecars(root, 'inbox/demo/Note.md') + const record = '{"version":1,"before":"old\\n","after":"body\\n","edits":[],"outside":[]}\n' + const recordRel = (note: string): string => `.zennotes/agent-edits/${note}.json` + await mkdir(path.join(root, '.zennotes', 'agent-edits', 'inbox', 'demo'), { recursive: true }) + await writeFile(path.join(root, recordRel('inbox/demo/Note.md')), record) + + const receipt = await apply(root, [{ kind: 'archive', path: 'inbox/demo/Note.md' }]) + expect(await readOrNull(root, recordRel('archive/demo/Note.md'))).toBe(record) + expect(await readOrNull(root, recordRel('inbox/demo/Note.md'))).toBeNull() + + await undoWorkflowRun(root, receipt.runId) + expect(await readOrNull(root, recordRel('inbox/demo/Note.md'))).toBe(record) + expect(await pathExists(path.join(root, '.zennotes', 'agent-edits', 'archive'))).toBe(false) + }) + it('undo moves an untouched sidecar back instead of rewriting it', async () => { const root = await makeVault() await seedWithSidecars(root, 'inbox/Note.md') diff --git a/apps/desktop/src/main/workflow-apply.ts b/apps/desktop/src/main/workflow-apply.ts index b9ffab843..d1298f820 100644 --- a/apps/desktop/src/main/workflow-apply.ts +++ b/apps/desktop/src/main/workflow-apply.ts @@ -99,6 +99,7 @@ import { import { WORKFLOWS_REL_DIR } from '@shared/workflows-view' import { isEphemeralRoot } from './ephemeral-vaults' import { noteMetadataPath, noteMetadataRoot, prepareNoteCreation } from './note-creation-metadata' +import { agentEditPath, agentEditsRoot } from './agent-edit-files' import { leftoverCommentsMessage, noteCommentsPath, @@ -206,15 +207,17 @@ interface NoteJournalEntry extends WorkflowJournalEntry { link?: string } -/** The two files `.zennotes` keeps for a note. */ -type SidecarKind = 'comments' | 'metadata' +/** The files `.zennotes` keeps for a note: its comments, its creation date, + * and an assistant's edits waiting for review (agent-edit-files.ts). */ +type SidecarKind = 'comments' | 'metadata' | 'agent-edits' -const SIDECAR_KINDS: readonly SidecarKind[] = ['comments', 'metadata'] +const SIDECAR_KINDS: readonly SidecarKind[] = ['comments', 'metadata', 'agent-edits'] /** How a failure names a sidecar, since the user knows it by what it holds. */ const SIDECAR_LABELS: Record = { comments: 'comments', - metadata: 'creation date' + metadata: 'creation date', + 'agent-edits': 'assistant edits waiting for review' } /** @@ -322,11 +325,15 @@ export function resolveVaultPath(root: string, rel: string): string { */ async function sidecarPathOf(root: string, note: string, sidecar: SidecarKind): Promise { const rel = toPosix(path.relative(path.resolve(root), resolveVaultPath(root, note))) - return sidecar === 'comments' ? noteCommentsPath(root, rel) : await noteMetadataPath(root, rel) + if (sidecar === 'comments') return noteCommentsPath(root, rel) + if (sidecar === 'agent-edits') return agentEditPath(root, rel) + return await noteMetadataPath(root, rel) } function sidecarRootOf(root: string, sidecar: SidecarKind): string { - return sidecar === 'comments' ? noteCommentsRoot(root) : noteMetadataRoot(root) + if (sidecar === 'comments') return noteCommentsRoot(root) + if (sidecar === 'agent-edits') return agentEditsRoot(root) + return noteMetadataRoot(root) } /** Every vault path an op could touch, for the pre-flight containment check. */ @@ -1892,7 +1899,7 @@ function parseSidecarEntry(value: unknown): LedgerSidecarEntry | null { if (!isRecord(value)) return null const { note, sidecar, before, after } = value if (typeof note !== 'string') return null - if (sidecar !== 'comments' && sidecar !== 'metadata') return null + if (sidecar !== 'comments' && sidecar !== 'metadata' && sidecar !== 'agent-edits') return null if (typeof before !== 'string' && before !== null) return null const entry: LedgerSidecarEntry = { note, sidecar, before } if (typeof after === 'string' || after === null) entry.after = after diff --git a/apps/desktop/src/mcp/agent-edit-recorder.test.ts b/apps/desktop/src/mcp/agent-edit-recorder.test.ts new file mode 100644 index 000000000..c0987a71e --- /dev/null +++ b/apps/desktop/src/mcp/agent-edit-recorder.test.ts @@ -0,0 +1,129 @@ +import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentEditBaseline } from '@shared/agent-edits' +import { createBackend, type VaultBackend } from '../cli/backend' +import { readAgentEditRecord } from '../main/agent-edit-files' +import { callTool } from './server' + +// Agent edit records written by the MCP tools (docs/ideas/agent-edit-review.md), +// run against a real folder through the local backend, so the record the app +// reviews is exactly what these write. + +let root: string +let configDir: string +let backend: VaultBackend +const NOTE = 'inbox/Plan.md' +const previousConfigDir = process.env.ZENNOTES_CONFIG_DIR + +beforeEach(async () => { + root = await mkdtemp(path.join(os.tmpdir(), 'zennotes-agent-edits-')) + configDir = await mkdtemp(path.join(os.tmpdir(), 'zennotes-agent-edits-config-')) + process.env.ZENNOTES_CONFIG_DIR = configDir + await mkdir(path.join(root, 'inbox'), { recursive: true }) + await writeFile(path.join(root, NOTE), '# Plan\n\n- [ ] Ship the beta\nThe migration runs at night.\n') + backend = createBackend({ kind: 'local', root }) +}) + +afterEach(async () => { + if (previousConfigDir === undefined) delete process.env.ZENNOTES_CONFIG_DIR + else process.env.ZENNOTES_CONFIG_DIR = previousConfigDir + await rm(root, { recursive: true, force: true }) + await rm(configDir, { recursive: true, force: true }) +}) + +const noteText = () => readFile(path.join(root, NOTE), 'utf8') + +describe('recording assistant edits', () => { + it('keeps the note from before an edit, and the note after it', async () => { + const original = await noteText() + await callTool('replace_in_note', { path: NOTE, find: 'at night', replace: 'on Sunday' }, backend) + const record = await readAgentEditRecord(root, NOTE) + expect(record).not.toBeNull() + expect(record!.before).toBe(original) + expect(record!.after).toBe(await noteText()) + expect(record!.edits.map((e) => [e.agent, e.tool])).toEqual([['Assistant', 'replace_in_note']]) + expect(agentEditBaseline(record!, await noteText())).toBe(original) + }) + + it('stacks every edit of a session onto one record', async () => { + const original = await noteText() + await callTool('append_to_note', { path: NOTE, text: 'A new line.' }, backend) + await callTool('prepend_to_note', { path: NOTE, text: 'Top.' }, backend) + await callTool('insert_at_line', { path: NOTE, lineNumber: 2, text: 'Inserted.' }, backend) + await callTool('write_note', { path: NOTE, body: `${await noteText()}Written.\n` }, backend) + const record = await readAgentEditRecord(root, NOTE) + expect(record!.before).toBe(original) + expect(record!.after).toBe(await noteText()) + expect(record!.edits.map((e) => e.tool)).toEqual([ + 'append_to_note', + 'prepend_to_note', + 'insert_at_line', + 'write_note' + ]) + }) + + it('records a task toggled through its id', async () => { + const original = await noteText() + await callTool('toggle_task', { id: `${NOTE}#0` }, backend) + expect(await noteText()).toContain('- [x] Ship the beta') + const record = await readAgentEditRecord(root, NOTE) + expect(record!.before).toBe(original) + expect(record!.edits.map((e) => e.tool)).toEqual(['toggle_task']) + }) + + it('leaves no record when nothing changed', async () => { + const result = (await callTool( + 'replace_in_note', + { path: NOTE, find: 'nowhere in the note', replace: 'x' }, + backend + )) as { replacements: number } + expect(result.replacements).toBe(0) + expect(await readAgentEditRecord(root, NOTE)).toBeNull() + await expect(stat(path.join(root, '.zennotes', 'agent-edits'))).rejects.toThrow() + }) + + it('keeps an earlier record as it was when a later edit changes nothing', async () => { + await callTool('append_to_note', { path: NOTE, text: 'First.' }, backend) + const before = await readAgentEditRecord(root, NOTE) + await callTool('replace_in_note', { path: NOTE, find: 'nowhere', replace: 'x' }, backend) + expect(await readAgentEditRecord(root, NOTE)).toEqual(before) + }) + + it('records nothing for tools that only read', async () => { + await callTool('read_note', { path: NOTE }, backend) + await callTool('list_notes', {}, backend) + expect(await readAgentEditRecord(root, NOTE)).toBeNull() + }) + + it('records nothing when Review agent edits is off', async () => { + await writeFile(path.join(configDir, 'config.toml'), '[mcp]\nreview_agent_edits = false\n') + await callTool('append_to_note', { path: NOTE, text: 'Unrecorded.' }, backend) + expect(await noteText()).toContain('Unrecorded.') + expect(await readAgentEditRecord(root, NOTE)).toBeNull() + }) + + it('takes concurrent edits to one note in turn, losing none', async () => { + await Promise.all( + ['one', 'two', 'three'].map((word) => callTool('append_to_note', { path: NOTE, text: word }, backend)) + ) + const record = await readAgentEditRecord(root, NOTE) + expect(record!.edits).toHaveLength(3) + expect(record!.after).toBe(await noteText()) + }) +}) + +describe('writes into .zennotes', () => { + it('are refused before a byte lands, so an assistant cannot plant a record', async () => { + const forged = '.zennotes/agent-edits/inbox/Plan.md.json' + await expect(callTool('write_note', { path: forged, body: '{}' }, backend)).rejects.toThrow( + /Note not in a known folder/ + ) + await expect(stat(path.join(root, forged))).rejects.toThrow() + const comments = '.zennotes/comments/inbox/Plan.md.comments.json' + await expect(callTool('append_to_note', { path: comments, text: 'x' }, backend)).rejects.toThrow( + /Note not in a known folder/ + ) + }) +}) diff --git a/apps/desktop/src/mcp/agent-edit-recorder.ts b/apps/desktop/src/mcp/agent-edit-recorder.ts new file mode 100644 index 000000000..b02e31ccb --- /dev/null +++ b/apps/desktop/src/mcp/agent-edit-recorder.ts @@ -0,0 +1,152 @@ +// Keeping what a note said before an assistant changed it, so the user can +// review the change in the app (docs/ideas/agent-edit-review.md). Every tool +// that rewrites a note's text runs through `runRecordingAgentEdit`: it writes +// the record BEFORE the edit (the workflow journal's rule, so a process that +// dies mid-edit still leaves the bytes to compare against), lets the tool do +// its work, and then notes how the note looks now. +// +// The Go `zn` (ZenNotes/tui internal/mcp), which a managed install runs, +// mirrors this file: same tools, same order, same record (see +// @shared/agent-edits for why the writing side is kept to string comparisons). +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { parse as parseToml } from 'smol-toml' +import { agentEditRecordable, beginAgentEdit, finishAgentEdit } from '@shared/agent-edits' +import { readAgentEditRecord, writeAgentEditRecord } from '../main/agent-edit-files' +import type { VaultBackend } from '../cli/backend' +import { portableConfigDir, splitTaskId } from './vault-ops' + +function stringArg(args: Record, key: string): string | null { + const value = args[key] + return typeof value === 'string' && value.trim() ? value : null +} + +/** The tools that change a note's text, and where each one names the note. + * Creating, moving, renaming and deleting notes are not recorded: a new + * note has nothing to compare against, and Trash already keeps the rest. */ +const RECORDED_TOOLS: Record) => string | null> = { + write_note: (args) => stringArg(args, 'path'), + append_to_note: (args) => stringArg(args, 'path'), + prepend_to_note: (args) => stringArg(args, 'path'), + insert_at_line: (args) => stringArg(args, 'path'), + replace_in_note: (args) => stringArg(args, 'path'), + toggle_task: (args) => { + const id = stringArg(args, 'id') + if (!id) return null + try { + return splitTaskId(id).rel + } catch { + return null + } + } +} + +export function isRecordedTool(name: string): boolean { + return name in RECORDED_TOOLS +} + +/** + * Settings → MCP → "Review agent edits", from the portable config file the + * app writes (`[mcp] review_agent_edits`). On when the file or the key is + * missing, matching the app's default; off stops recording, and records + * already written stay reviewable. + */ +export async function reviewAgentEditsEnabled(): Promise { + try { + const raw = await fs.readFile(path.join(portableConfigDir(), 'config.toml'), 'utf8') + const parsed = parseToml(raw) as { mcp?: { review_agent_edits?: unknown } } + const value = parsed.mcp?.review_agent_edits + return typeof value === 'boolean' ? value : true + } catch { + return true + } +} + +// Tool calls are handled concurrently, and two edits to one note racing on +// its record would lose one of them, so edits to the same note take turns. +const queues = new Map>() + +function oneAtATime(key: string, run: () => Promise): Promise { + const previous = queues.get(key) ?? Promise.resolve() + const next = previous.then(run, run) + queues.set(key, next) + const forget = (): void => { + if (queues.get(key) === next) queues.delete(key) + } + next.then(forget, forget) + return next +} + +async function noteText(backend: VaultBackend, rel: string): Promise { + try { + return (await backend.readNote(rel)).body + } catch { + return null + } +} + +/** + * Run a tool, recording its edit when it rewrites a note of a local vault. + * Recording never stands in the way of the edit the user asked for: if the + * record cannot be written, the tool still runs and the change is simply not + * offered for review. + */ +export async function runRecordingAgentEdit( + toolName: string, + args: Record, + backend: VaultBackend, + agent: string, + run: () => Promise +): Promise { + const noteOf = RECORDED_TOOLS[toolName] + // A remote vault's notes are written by its server; recording there needs + // the server's own change (see the design doc). + if (!noteOf || backend.kind !== 'local') return await run() + const rel = noteOf(args) + if (!rel) return await run() + + return await oneAtATime(`${backend.root}\0${rel}`, async () => { + if (!(await reviewAgentEditsEnabled())) return await run() + const before = await noteText(backend, rel) + // Not a note we can read (the tool reports its own error), or one too + // large to keep copies of. + if (before === null || !agentEditRecordable(before)) return await run() + + const existing = await readAgentEditRecord(backend.root, rel) + const started = beginAgentEdit(existing, before) + try { + await writeAgentEditRecord(backend.root, rel, started) + } catch (error) { + console.error(`zennotes mcp: could not record the edit to ${rel}:`, error) + return await run() + } + + let result: T + let failure: unknown = null + try { + result = await run() + } catch (error) { + failure = error + } + + const after = await noteText(backend, rel) + try { + if (after === null || after === before) { + // Nothing changed (no match, a task that moved, a failed write): + // the record goes back to what it was. + await writeAgentEditRecord(backend.root, rel, existing) + } else { + await writeAgentEditRecord( + backend.root, + rel, + finishAgentEdit(started, after, { at: Date.now(), agent, tool: toolName }) + ) + } + } catch (error) { + console.error(`zennotes mcp: could not finish the record for ${rel}:`, error) + } + + if (failure !== null) throw failure + return result! + }) +} diff --git a/apps/desktop/src/mcp/server.ts b/apps/desktop/src/mcp/server.ts index 3fed6e0ff..588e561dd 100644 --- a/apps/desktop/src/mcp/server.ts +++ b/apps/desktop/src/mcp/server.ts @@ -20,6 +20,7 @@ import { import { resolveInstructions } from './instructions-store.js' import { createBackend, type VaultBackend } from '../cli/backend.js' +import { runRecordingAgentEdit } from './agent-edit-recorder.js' import { resolveDefaultTarget, sameVault, type VaultTarget } from '../cli/vault-target.js' import { RemoteRequestError } from '../main/remote/connection.js' import type { NoteFolder } from './vault-ops.js' @@ -956,7 +957,9 @@ export async function callTool( ): Promise { const tool = TOOLS.find((t) => t.schema.name === name) if (!tool) throw new Error(`Unknown tool: ${name}`) - return await tool.handler(args, backend) + return await runRecordingAgentEdit(name, args, backend, defaultCommentAuthor(), () => + tool.handler(args, backend) + ) } export function listToolNames(): string[] { @@ -1125,9 +1128,11 @@ export async function runMcpServer(options: McpServerOptions = {}): Promise, backend, { - previous - }) + const toolArgs = (args ?? {}) as Record + // An edit to a note is recorded so the user can review it in the app. + const result = await runRecordingAgentEdit(name, toolArgs, backend, defaultCommentAuthor(), () => + tool.handler(toolArgs, backend, { previous }) + ) const payload = typeof result === 'string' ? result : JSON.stringify(result, null, 2) return { content: [{ type: 'text', text: payload }] } diff --git a/apps/desktop/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts index 2f4354f65..889c9a3e6 100644 --- a/apps/desktop/src/mcp/vault-ops.ts +++ b/apps/desktop/src/mcp/vault-ops.ts @@ -1,5 +1,6 @@ import { readNoteCreatedAt, prepareNoteCreation, removeNoteCreation, noteMetadataPath } from '../main/note-creation-metadata' import { noteCommentsPath, noteCommentsRoot, relocateFolderTrees, relocateNote } from '../main/note-sidecars' +import { agentEditFolderPath, removeAgentEditRecord } from '../main/agent-edit-files' /** * Vault operations used by the MCP server. Mirrors the filesystem * behavior of src/main/vault.ts, but without Electron dependencies — @@ -330,7 +331,7 @@ export async function readVaultRootFromConfig(): Promise { * in main/app-config.ts, which reaches it through Electron's `app`; this * process has none. Note this is NOT `userDataDir()` above; the portable * prefs live beside it, in an XDG-style location the user can sync. */ -function portableConfigDir(): string { +export function portableConfigDir(): string { const explicit = process.env.ZENNOTES_CONFIG_DIR?.trim() if (explicit) return explicit const xdg = process.env.XDG_CONFIG_HOME?.trim() @@ -603,8 +604,8 @@ function resolveSafe(root: string, rel: string): string { * Resolve a note an edit is about to write, and refuse anything outside the * note folders BEFORE a byte lands. The edits used to write first and check * after, so `write_note` on `.zennotes/...` created the file and only then - * reported an error: an assistant could plant or alter a note's comments, - * its creation date, or anything else the app keeps there. + * reported an error: an assistant could plant comments, creation dates or + * agent edit records, the files that say what a note used to be. */ async function noteForWrite(root: string, rel: string): Promise<{ abs: string; folder: NoteFolder }> { const abs = resolveSafe(root, rel) @@ -992,6 +993,8 @@ export async function createNote( const abs = path.join(dir, `${finalTitle}.md`) const content = body ?? `# ${finalTitle}\n\n` await removeNoteCreation(root, toPosix(path.relative(root, abs))) + // A record left by an earlier note of this name would offer a bogus review. + await removeAgentEditRecord(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, content, 'utf8') return await readMeta(root, abs, folder) } @@ -1135,6 +1138,7 @@ export async function deleteNote(root: string, rel: string): Promise { // be taken over by the next note created under this name. await fs.rm(noteCommentsPath(root, notePath), { force: true }) await removeNoteCreation(root, notePath) + await removeAgentEditRecord(root, notePath) } export async function emptyTrash(root: string): Promise { @@ -1145,6 +1149,7 @@ export async function emptyTrash(root: string): Promise { const trashRel = toPosix(path.relative(root, trashDir)) await fs.rm(resolveSafe(noteCommentsRoot(root), trashRel), { recursive: true, force: true }) await removeNoteCreation(root, trashRel, true) + await removeAgentEditRecord(root, trashRel, true) } catch { /* no trash dir */ } @@ -1180,13 +1185,15 @@ export async function renameFolder( } const oldRel = toPosix(path.relative(root, oldAbs)) const newRel = toPosix(path.relative(root, newAbs)) - // The folder's comments and creation dates move as one with it, the way the - // desktop's renameFolderTrees does; a failure puts all three back. + // The folder's comments, creation dates and agent edit records move as one + // with it, the way the desktop's renameFolderTrees does; a failure puts all + // of them back. await relocateFolderTrees( [ [oldAbs, newAbs], [resolveSafe(noteCommentsRoot(root), oldRel), resolveSafe(noteCommentsRoot(root), newRel)], - [await noteMetadataPath(root, oldRel, true), await noteMetadataPath(root, newRel, true)] + [await noteMetadataPath(root, oldRel, true), await noteMetadataPath(root, newRel, true)], + [agentEditFolderPath(root, oldRel), agentEditFolderPath(root, newRel)] ], async () => {} ) @@ -1206,6 +1213,7 @@ export async function deleteFolder( await fs.rm(abs, { recursive: true, force: true }) await fs.rm(resolveSafe(noteCommentsRoot(root), rel), { recursive: true, force: true }) await removeNoteCreation(root, rel, true) + await removeAgentEditRecord(root, rel, true) } /* ---------- Text search ---------------------------------------------- */ diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 06db5c49d..b8a36e174 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -80,6 +80,8 @@ import type { ListNotesPageResponse, NoteComment, NoteCommentInput, + AgentEditRecord, + AgentEditSummary, NoteContent, NoteFolder, NoteMeta, @@ -129,7 +131,8 @@ const DESKTOP_CAPABILITIES: ZenCapabilities = { supportsCustomTemplates: true, supportsCustomCodeLanguages: true, supportsWorkflows: true, - supportsUndoFile: true + supportsUndoFile: true, + supportsAgentEditReview: true } const DESKTOP_APP_INFO: ZenAppInfo = { @@ -541,6 +544,11 @@ const api: ZenBridge = { ipcRenderer.invoke(IPC.VAULT_READ_COMMENTS, relPath), writeNoteComments: (relPath: string, comments: NoteCommentInput[]): Promise => ipcRenderer.invoke(IPC.VAULT_WRITE_COMMENTS, relPath, comments), + listAgentEdits: (): Promise => ipcRenderer.invoke(IPC.VAULT_LIST_AGENT_EDITS), + readAgentEdit: (relPath: string): Promise => + ipcRenderer.invoke(IPC.VAULT_READ_AGENT_EDIT, relPath), + writeAgentEdit: (relPath: string, record: AgentEditRecord | null): Promise => + ipcRenderer.invoke(IPC.VAULT_WRITE_AGENT_EDIT, relPath, record), scanTasks: (): Promise => ipcRenderer.invoke(IPC.VAULT_SCAN_TASKS), scanTasksForPath: (relPath: string): Promise => ipcRenderer.invoke(IPC.VAULT_SCAN_TASKS_FOR, relPath), diff --git a/docs/ideas/agent-edit-review.md b/docs/ideas/agent-edit-review.md new file mode 100644 index 000000000..c86c0f095 --- /dev/null +++ b/docs/ideas/agent-edit-review.md @@ -0,0 +1,172 @@ +# Reviewing agent edits + +Status: v1 built on the 2.68.0 branch (2026-10-12), not released. Requested by Kevin +(Discord, 2026-10-09); related to #448 (per-note history), which this does not solve. +The desktop side is in this repo; the Go `zn` side is on ZenNotes/tui branch +`agent-edit-review` and ships with a zn release. + +## The problem + +People drive their vaults with AI assistants through the MCP server (`zn mcp`): +Claude Code, Codex, Cursor and others call `replace_in_note`, `append_to_note`, +`write_note` and friends. When an assistant edits a note, ZenNotes shows the new +text and nothing else. To see what changed, people leave the app: Kevin keeps his +vault in Git and reads the diff in his terminal. There is no way to take back one +change and keep the rest, and no way to tell the assistant "not like this" next to +the change itself. + +The app cannot tell an agent's edit apart on its own: `zn mcp` is a separate +process that writes the file directly, so the app sees an outside change, the same +as a `git pull` or another editor. + +## What v1 does + +1. **The MCP server records** what a note said before an assistant changed it. +2. **The app shows** that a note has changes to review: a banner on the note, and + one palette entry per note waiting ("Review Agent Changes: "). +3. **The review happens in the note**: each change inline, the removed lines above + the new ones, with Accept and Reject on each, and Accept all / Reject all / Done. +4. **Comments** on a change are ordinary comments, so the assistant reads them with + `list_comments`, which it already can. +5. When every change is decided, the record is deleted. + +Decided with Adib (2026-10-12): + +- Records live **in the vault**, beside comments, so they travel with Git and Cloud. +- Recording is **on by default**, with a setting to turn it off. Nothing happens + unless an assistant the user connected edits a note; ZenNotes runs no AI itself. +- The review is **inline in the note**, not a dialog. +- The change bands run the full width of the editor (Adib, after the first look). + +## The record + +One file per note: `.zennotes/agent-edits/.json`, next to +`.zennotes/comments/.comments.json`. + +```json +{ + "version": 1, + "before": "the note before the first recorded edit", + "after": "the note right after the latest edit, or null while an edit is in flight", + "edits": [{ "at": 1791820000000, "agent": "Claude Code", "tool": "replace_in_note" }], + "outside": [{ "from": "the note after one edit", "to": "the note the next edit started from" }] +} +``` + +### Two writers, one reader + +Two MCP engines write records: the TypeScript server in `apps/desktop/src/mcp` +and the Go `zn` (ZenNotes/tui `internal/mcp`), which is what a managed install +runs. So the writing side is kept to string comparisons and JSON, and every merge +happens in the app (`packages/shared-domain/src/agent-edits.ts`): + +- **Writing** (`beginAgentEdit` / `finishAgentEdit`, mirrored in Go + `internal/vault/agent_edits.go`): read the note, write the record with + `after: null` BEFORE the edit (the workflow journal's rule: a process that dies + mid-edit leaves a record that shows more than the assistant did, never less), + make the edit, then store the note as `after`. If the note no longer matches + `after` when the next edit starts, someone else changed it: the writer appends + `{from: after, to: note}` to `outside` and never merges. Past 20 outside changes + it starts a fresh record. An edit that changes nothing puts the record back as it + was. Edits to one note take turns (a per-note lock), since MCP calls run in + parallel. +- **Reading** (`agentEditBaseline`): fold each outside change into `before` with + the three-way merge Cloud conflicts use (`mergeCloudSyncText`), then merge the + note as it is now against `after`. The result is the note without the + assistant's changes but with everyone else's, so the review shows only the + assistant's work. Where the user rewrote lines the assistant changed, the user's + version wins: those lines have been dealt with, and an all-rewritten record is + deleted instead of shown empty. + +Recorded tools: `write_note`, `append_to_note`, `prepend_to_note`, +`insert_at_line`, `replace_in_note`, `toggle_task` (the note comes from the task id). +Not recorded in v1: `create_note` (a new note has nothing to compare against), +moves, renames, trash and deletes (Trash keeps those), and vaults on a +self-hosted server (their MCP writes go through the Go server, which needs its own +change). Notes over 1 MB are not recorded. + +The assistant's name comes from the MCP `initialize` request's client name, mapped +the way comment authors already are (`claude-code` is "Claude Code", `codex-cli` is +"Codex"). + +### Where records go + +- **Moves.** A record moves with its note wherever ZenNotes moves notes: + `relocateNote` and folder renames (desktop and MCP), the Go vault's rename, move + and archive. A record waiting at a destination with no note belongs to nobody + and is discarded, like a stale creation date. +- **Deletes.** Deleting a note, emptying Trash, deleting a folder, or sending a + note to the system trash removes its record. +- **Creation.** Creating, importing or duplicating a note at a path clears a stale + record there. +- **Sync.** Cloud sync carries `.zennotes/agent-edits/*.json` (temp files stay + local), so a review can happen on another computer. +- **Cleanup.** An emptied `agent-edits` folder is removed: a vault whose changes + were all reviewed carries no trace. + +### A fix this needed + +The MCP edit paths used to write a file and only then check it was a note: +`write_note` on `.zennotes/...` created the file before failing. An assistant could +have planted its own record (or comments, or creation dates). Both engines now +refuse anything outside the note folders before a byte lands. + +## The review + +- **Banner**: "Claude Code changed this note 4 minutes ago" with **Review changes**. + From Preview, the button switches the pane to Edit first. +- **Review mode** (`packages/app-core/src/lib/cm-agent-review.ts`, imported on + demand so `@codemirror/merge` stays off the boot path; the renderer chunk rule + in `electron.vite.config.ts` excludes it from `vendor-editor` for that reason): + - CodeMirror's `unifiedMergeView` against the comparison text above. + - Accept moves a change into the comparison text; Reject puts the old text back + as an ordinary edit, saved before the record is updated. + - The note is read-only, and Live Preview is off (a diff is about the source). + - A bar on top: "Reviewing Claude Code's changes: 2 left", **Accept all**, + **Reject all**, **Done**. +- **Progress**: each decision saves the record (`reviewedAgentEdit`), so a half-done + review survives a restart. The last decision deletes it. +- **Keys** (all rebindable in the keymap settings): + +| Action | Without Vim | Vim | +| --- | --- | --- | +| Next / previous change | Mod+Alt+↓ / ↑ | `]c` / `[c` | +| Accept change | Mod+Shift+Enter | `dp` (diff put) | +| Reject change | Mod+Shift+Backspace | `do` (diff obtain) | +| Leave, keeping what is undecided | Escape, Done | Escape (when no command is half-typed), Done | + +`dp` and `do` cannot be Vim mappings: codemirror-vim runs a full match as soon as +it has one, so `d` always starts the delete operator first. Review mode reads +normal-mode keys ahead of Vim instead, holds a key that continues a sequence, and +lets every other key through. The note is read-only, so a held `d` costs nothing, +and Escape after it cancels the `d` rather than leaving the review. + +- **Comments**: select text in a change and comment as usual; the assistant reads + it with `list_comments`. To say why a change was rejected, comment before + rejecting it. + +## Settings + +Settings → MCP → **Review agent edits** (on by default), stored in the portable +config file as `[mcp] review_agent_edits`, which both MCP engines read without the +app running. Turning it off stops recording; records already written stay +reviewable. + +## Verified + +Built app over CDP, with a real MCP client named `claude-code` driving the real MCP +server process: the record, the banner, the review with both changes inline, Reject +by button, Accept by keyboard, the record deleted at the end; and the same in Vim +mode with `do`, `]c` and `dp` (scripts in the session scratchpad, `agent-review/e2e.mjs`). + +## Later + +- New notes created by an assistant ("Keep" or "Move to Trash"). +- Vaults on a self-hosted server: recording in the Go server's write path. +- Phones: reviewing synced records needs the phone bridges to read and write them. +- Telling the assistant what was rejected (an MCP tool reading recent decisions). +- An agent edit landing while the note has unsaved typing is still overwritten by + the app's save of the buffer, as before; the record is then cleaned up, so the + assistant's edit is lost silently. Worth a conflict prompt. +- Per-note history (#448): a bounded, always-on history of every change, with the + same review view. diff --git a/package-lock.json b/package-lock.json index 1ee25d8f9..7f195659a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1923,6 +1923,19 @@ "crelt": "^1.0.5" } }, + "node_modules/@codemirror/merge": { + "version": "6.12.2", + "resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz", + "integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/highlight": "^1.0.0", + "style-mod": "^4.1.0" + } + }, "node_modules/@codemirror/search": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz", @@ -16398,6 +16411,7 @@ "@codemirror/language": "^6.10.6", "@codemirror/language-data": "^6.5.1", "@codemirror/lint": "^6.9.7", + "@codemirror/merge": "^6.12.2", "@codemirror/search": "^6.5.8", "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.35.3", diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 70fab4d98..192942d4a 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -42,6 +42,7 @@ "@codemirror/language": "^6.10.6", "@codemirror/language-data": "^6.5.1", "@codemirror/lint": "^6.9.7", + "@codemirror/merge": "^6.12.2", "@codemirror/search": "^6.5.8", "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.35.3", diff --git a/packages/app-core/src/components/AgentEditReview.tsx b/packages/app-core/src/components/AgentEditReview.tsx new file mode 100644 index 000000000..4ec3a79c8 --- /dev/null +++ b/packages/app-core/src/components/AgentEditReview.tsx @@ -0,0 +1,279 @@ +import { useCallback, useLayoutEffect, useRef, useState, type MutableRefObject } from 'react' +import type { Compartment, Extension } from '@codemirror/state' +import type { EditorView } from '@codemirror/view' +import { agentEditBaseline, latestAgentEdit, reviewedAgentEdit } from '@shared/agent-edits' +import type { AgentEditRecord } from '@shared/ipc' +import { loadAgentReview } from '../lib/agent-review-runtime' +export { REVIEW_AGENT_EDITS_EVENT } from '../lib/agent-review-runtime' +import { getKeymapBinding, isUnboundBinding, type KeymapId } from '../lib/keymaps' +import { useToastStore } from '../lib/toast' +import { toCodeMirrorKey } from '../lib/vim-half-page-keymap' +import { useStore } from '../store' +import { Button } from './ui/Button' + +/** + * Reviewing an assistant's edits to a note (docs/ideas/agent-edit-review.md): + * the banner that says a note has changes to review, the bar shown while + * reviewing, and the hook that puts a pane's editor in and out of review mode. + */ + +function timeAgo(at: number, now: number): string { + const minutes = Math.round((now - at) / 60000) + if (minutes < 1) return 'just now' + if (minutes === 1) return 'a minute ago' + if (minutes < 60) return `${minutes} minutes ago` + const hours = Math.round(minutes / 60) + if (hours === 1) return 'an hour ago' + if (hours < 24) return `${hours} hours ago` + const days = Math.round(hours / 24) + if (days === 1) return 'yesterday' + if (days < 7) return `${days} days ago` + return `on ${new Date(at).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}` +} + +export interface AgentReviewSession { + path: string + agent: string + remaining: number +} + +export function AgentEditNoteBanner({ + path, + reviewing, + onReview +}: { + path: string + reviewing: boolean + onReview: () => void +}): JSX.Element | null { + const summary = useStore((s) => s.agentEdits[path] ?? null) + if (!summary || reviewing) return null + return ( +
+

+ {summary.agent} changed this note {timeAgo(summary.at, Date.now())}. +

+ +
+ ) +} + +export function AgentReviewBar({ + session, + onAcceptAll, + onRejectAll, + onDone +}: { + session: AgentReviewSession + onAcceptAll: () => void + onRejectAll: () => void + onDone: () => void +}): JSX.Element { + const keep = (event: React.MouseEvent): void => event.preventDefault() + return ( +
+

+ Reviewing {session.agent}’s changes:{' '} + {session.remaining === 1 ? '1 left' : `${session.remaining} left`} +

+ + + +
+ ) +} + +function keySpecs(id: KeymapId): string[] { + const binding = getKeymapBinding(useStore.getState().keymapOverrides, id) + return isUnboundBinding(binding) ? [] : [toCodeMirrorKey(binding)] +} + +/** A Vim sequence binding ("d p") as the keys it is typed with. */ +function vimSequence(id: KeymapId): string[][] { + const binding = getKeymapBinding(useStore.getState().keymapOverrides, id) + if (isUnboundBinding(binding)) return [] + const keys = binding.split(' ').filter(Boolean) + return keys.length > 0 ? [keys] : [] +} + +function notify(message: string): void { + useToastStore.getState().addToast(message, 'info') +} + +/** + * Review mode for one pane's editor. The editor is reused across notes, so + * the review always ends before the pane shows another note: the layout + * effect below runs ahead of the pane's own document swap. + */ +export function useAgentEditReview(options: { + viewRef: MutableRefObject + reviewCompartmentRef: MutableRefObject + livePreviewCompartmentRef: MutableRefObject + /** Live Preview's extensions as the pane would have them now. */ + livePreviewExtensions: () => Extension + activePath: string | null +}): { + session: AgentReviewSession | null + /** Review `path` as soon as this pane's editor shows it (now, or once it + * has opened the note and left Preview). */ + requestReview: (path: string) => void + /** Called by the pane whenever its editor may have become ready. */ + flushPendingReview: () => void + stopReview: () => void + acceptAll: () => void + rejectAll: () => void +} { + const { viewRef, reviewCompartmentRef, livePreviewCompartmentRef, livePreviewExtensions, activePath } = options + const [session, setSession] = useState(null) + const sessionRef = useRef(null) + sessionRef.current = session + const pendingRef = useRef(null) + + const stopReview = useCallback(() => { + const view = viewRef.current + const compartment = reviewCompartmentRef.current + if (view && compartment && sessionRef.current) { + const effects = [compartment.reconfigure([])] + const livePreview = livePreviewCompartmentRef.current + if (livePreview) effects.push(livePreview.reconfigure(livePreviewExtensions())) + view.dispatch({ effects }) + } + sessionRef.current = null + setSession(null) + }, [viewRef, reviewCompartmentRef, livePreviewCompartmentRef, livePreviewExtensions]) + + const startReview = useCallback( + async (path: string) => { + const read = window.zen.readAgentEdit + const write = window.zen.writeAgentEdit + const view = viewRef.current + const compartment = reviewCompartmentRef.current + if (!read || !write || !view || !compartment || path !== activePath) return + const record = await read(path) + if (!record) { + await useStore.getState().refreshAgentEdit(path) + return + } + const current = view.state.doc.toString() + const original = agentEditBaseline(record, current) + if (original === current) { + // The assistant put its change back, or every line it touched has + // since been rewritten: nothing is left to decide. + await write(path, null) + await useStore.getState().refreshAgentEdit(path) + notify('Nothing left to review in this note.') + return + } + const review = await loadAgentReview() + if (viewRef.current !== view || path !== activePath) return + const agent = latestAgentEdit(record)?.agent ?? 'Assistant' + let latest: AgentEditRecord = record + + const extension = review.agentReviewExtension({ + original, + keys: { + next: keySpecs('editor.reviewNextChange'), + previous: keySpecs('editor.reviewPreviousChange'), + accept: keySpecs('editor.reviewAcceptChange'), + reject: keySpecs('editor.reviewRejectChange'), + vim: useStore.getState().vimMode + ? { + next: vimSequence('vim.reviewNextChange'), + previous: vimSequence('vim.reviewPreviousChange'), + accept: vimSequence('vim.reviewAcceptChange'), + reject: vimSequence('vim.reviewRejectChange') + } + : undefined + }, + onDecision: (comparison, remaining) => { + void (async () => { + // A rejected change is an edit: save it before the record says it + // was decided, so a crash cannot leave the two disagreeing. + await useStore.getState().persistNote(path) + latest = (await read(path)) ?? latest + if (remaining === 0) { + await write(path, null) + stopReview() + notify(`Every change from ${agent} is decided.`) + } else { + await write(path, reviewedAgentEdit(latest, comparison, view.state.doc.toString())) + } + await useStore.getState().refreshAgentEdit(path) + })().catch((error) => console.error('Saving the review failed', error)) + }, + onRemaining: (remaining) => { + setSession((s) => (s && s.remaining !== remaining ? { ...s, remaining } : s)) + }, + onExit: stopReview + }) + + const effects = [compartment.reconfigure(extension)] + const livePreview = livePreviewCompartmentRef.current + // A diff is about the Markdown itself, so the review shows the source. + if (livePreview) effects.push(livePreview.reconfigure([])) + view.dispatch({ effects, selection: { anchor: 0 } }) + review.nextChange(view) + view.focus() + const next = { path, agent, remaining: review.remainingChanges(view.state) } + sessionRef.current = next + setSession(next) + }, + [activePath, viewRef, reviewCompartmentRef, livePreviewCompartmentRef, stopReview] + ) + + // The pane is about to show another note: leave the review first. + useLayoutEffect(() => { + if (sessionRef.current && sessionRef.current.path !== activePath) stopReview() + }, [activePath, stopReview]) + + const flushPendingReview = useCallback(() => { + const path = pendingRef.current + if (!path || path !== activePath || !viewRef.current) return + if (useStore.getState().noteContents[path] == null) return + pendingRef.current = null + void startReview(path) + }, [activePath, viewRef, startReview]) + + const requestReview = useCallback( + (path: string) => { + pendingRef.current = path + flushPendingReview() + }, + [flushPendingReview] + ) + + const acceptAll = useCallback(() => { + const view = viewRef.current + if (!view) return + void loadAgentReview().then((review) => review.acceptAllChanges(view)) + }, [viewRef]) + + const rejectAll = useCallback(() => { + const view = viewRef.current + if (!view) return + void loadAgentReview().then((review) => review.rejectAllChanges(view)) + }, [viewRef]) + + return { session, requestReview, flushPendingReview, stopReview, acceptAll, rejectAll } +} diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 52515be52..cd2771b09 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -146,6 +146,12 @@ import { shouldShowSelectionToolbar } from '../lib/cm-selection-toolbar' import { editorCursorPosition } from '../lib/editor-cursor-position' import { EditorSelectionToolbar } from './EditorSelectionToolbar' import { CloudConflictNoteBanner } from './CloudConflictNoteBanner' +import { + AgentEditNoteBanner, + AgentReviewBar, + REVIEW_AGENT_EDITS_EVENT, + useAgentEditReview +} from './AgentEditReview' import { appMarkdownSnippetExtension } from '../lib/markdown-snippets-config' import { LazyDiagramTabView, LazyPreview as Preview } from './LazyPreview' import { ConnectionsPanel } from './ConnectionsPanel' @@ -1137,6 +1143,9 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const scrolloffCompartmentRef = useRef(null) const drawSelectionCompartmentRef = useRef(null) const tabSizeCompartmentRef = useRef(null) + // Review mode for an assistant's edits (AgentEditReview.tsx): empty until a + // review starts. + const agentReviewCompartmentRef = useRef(null) // history() lives in a compartment so we can reset undo history on a note // switch — otherwise Cmd+Z crosses notes and overwrites the current one (#247). // The outgoing note's history is set aside first and handed back when that @@ -1171,6 +1180,19 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { * sync effect updates it whenever we swap the view's document. */ const viewPathRef = useRef(null) + // Declared here, ahead of the layout effect that swaps the editor's + // document, so a review always ends before the pane shows another note. + const agentReviewLivePreview = useCallback( + (): Extension => (useStore.getState().livePreview ? currentWysiwygExtensions(viewPathRef.current) : []), + [] + ) + const agentReview = useAgentEditReview({ + viewRef, + reviewCompartmentRef: agentReviewCompartmentRef, + livePreviewCompartmentRef, + livePreviewExtensions: agentReviewLivePreview, + activePath: activeTab + }) /** The newest entry of the store's `recentPathRewrites` this editor has * accounted for. Only newer ones can explain a path change as a rename, so * an old rename can never make a real note switch look like one. */ @@ -1378,6 +1400,26 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { setFocusedPanel ]) + // The palette's Review Agent Changes: the active pane opens the review, + // leaving Preview first, since the review happens in the editor. + const { requestReview: requestAgentReview } = agentReview + const reviewAgentChanges = useCallback( + (path: string) => { + if (modeRef.current === 'preview') applyPaneMode('edit') + requestAgentReview(path) + }, + [applyPaneMode, requestAgentReview] + ) + useEffect(() => { + if (!isActive) return + const handler = (event: Event): void => { + const path = (event as CustomEvent<{ path?: string }>).detail?.path + if (path) reviewAgentChanges(path) + } + window.addEventListener(REVIEW_AGENT_EDITS_EVENT, handler) + return () => window.removeEventListener(REVIEW_AGENT_EDITS_EVENT, handler) + }, [isActive, reviewAgentChanges]) + // `zen:toggle-outline` — routed only to the active pane, same pattern // as the connections toggle. useEffect(() => { @@ -1976,6 +2018,8 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const drawSelectionCompartment = new Compartment() const tabSizeCompartment = new Compartment() const historyCompartment = new Compartment() + const agentReviewCompartment = new Compartment() + agentReviewCompartmentRef.current = agentReviewCompartment vimCompartmentRef.current = vimCompartment harperCompartmentRef.current = harperCompartment editorKeymapCompartmentRef.current = editorKeymapCompartment @@ -2035,6 +2079,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { void importPastedImagesRef.current?.(files, view) }), commentDecorationField, + agentReviewCompartment.of([]), wordWrapCompartment.of(s0.wordWrap ? EditorView.lineWrapping : []), scrolloffCompartment.of(scrollOff(s0.editorScrollOff)), markdownCompartment.of( @@ -3981,6 +4026,10 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { content?.path ?? null, editorHydration ) + const { flushPendingReview } = agentReview + useEffect(() => { + if (editorReady && mode !== 'preview') flushPendingReview() + }, [editorReady, mode, content?.path, flushPendingReview]) const previewSourceMarkdown = showPreview ? content?.body ?? '' : '' const previewSettleKey = showPreview && content ? content.path : '' const { settledMarkdown: previewMarkdown, isStale: previewIsStale } = useSettledMarkdown( @@ -4405,6 +4454,19 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { sibling that renders nothing keeps the editor's DOM in place when it comes and goes. */} + reviewAgentChanges(content.path)} + /> + {agentReview.session?.path === content.path && ( + + )}
+ +
); } +function AgentEditReviewSetting(): JSX.Element { + const reviewAgentEdits = useStore((s) => s.reviewAgentEdits); + const setReviewAgentEdits = useStore((s) => s.setReviewAgentEdits); + return ( +
+ +
+ ); +} + function McpInstructionsEditor(): JSX.Element { const [payload, setPayload] = useState(null); const [draft, setDraft] = useState(""); diff --git a/packages/app-core/src/lib/agent-review-runtime.ts b/packages/app-core/src/lib/agent-review-runtime.ts new file mode 100644 index 000000000..312ba1aa0 --- /dev/null +++ b/packages/app-core/src/lib/agent-review-runtime.ts @@ -0,0 +1,19 @@ +/** + * The always-loaded side of review mode (cm-agent-review.ts is imported on + * demand, so @codemirror/merge stays off the boot path). + */ +type AgentReviewModule = typeof import('./cm-agent-review') + +/** The window event that asks the active pane to review a note. */ +export const REVIEW_AGENT_EDITS_EVENT = 'zen:review-agent-edits' + +export function requestAgentEditReview(path: string): void { + window.dispatchEvent(new CustomEvent(REVIEW_AGENT_EDITS_EVENT, { detail: { path } })) +} + +let loaded: AgentReviewModule | null = null + +export async function loadAgentReview(): Promise { + loaded ??= await import('./cm-agent-review') + return loaded +} diff --git a/packages/app-core/src/lib/cm-agent-review.test.ts b/packages/app-core/src/lib/cm-agent-review.test.ts new file mode 100644 index 000000000..59f490c24 --- /dev/null +++ b/packages/app-core/src/lib/cm-agent-review.test.ts @@ -0,0 +1,138 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { EditorView } from '@codemirror/view' +import { + acceptAllChanges, + acceptChange, + agentReviewExtension, + isReviewing, + nextChange, + rejectAllChanges, + rejectChange, + remainingChanges +} from './cm-agent-review' +import { getCM, vim } from '@replit/codemirror-vim' + +const ORIGINAL = 'one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n' +// The assistant changed line 2 and line 9. +const CURRENT = 'one\nTWO\nthree\nfour\nfive\nsix\nseven\neight\nNINE\nten\n' + +let views: EditorView[] = [] + +function review(original = ORIGINAL, current = CURRENT, withVim = false) { + const onDecision = vi.fn() + const onExit = vi.fn() + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + doc: current, + extensions: [ + ...(withVim ? [vim()] : []), + agentReviewExtension({ + original, + keys: { + next: [], + previous: [], + accept: [], + reject: [], + vim: withVim + ? { next: [[']', 'c']], previous: [['[', 'c']], accept: [['d', 'p']], reject: [['d', 'o']] } + : undefined + }, + onDecision, + onExit + }) + ] + }) + views.push(view) + return { view, onDecision, onExit } +} + +/** Put the cursor on the line holding `text`. */ +function cursorOn(view: EditorView, text: string): void { + const at = view.state.doc.toString().indexOf(text) + view.dispatch({ selection: { anchor: at } }) +} + +afterEach(() => { + for (const view of views) view.destroy() + views = [] +}) + +describe('agent review mode', () => { + it('shows each of the assistant’s changes and keeps the note read-only', () => { + const { view } = review() + expect(isReviewing(view.state)).toBe(true) + expect(remainingChanges(view.state)).toBe(2) + expect(view.state.readOnly).toBe(true) + }) + + it('accepting a change keeps the text and reports what is left', () => { + const { view, onDecision } = review() + cursorOn(view, 'TWO') + expect(acceptChange(view)).toBe(true) + expect(view.state.doc.toString()).toBe(CURRENT) + expect(remainingChanges(view.state)).toBe(1) + expect(onDecision).toHaveBeenLastCalledWith(ORIGINAL.replace('two', 'TWO'), 1) + }) + + it('rejecting a change puts the old text back', () => { + const { view, onDecision } = review() + cursorOn(view, 'NINE') + expect(rejectChange(view)).toBe(true) + expect(view.state.doc.toString()).toBe(CURRENT.replace('NINE', 'nine')) + expect(remainingChanges(view.state)).toBe(1) + expect(onDecision).toHaveBeenLastCalledWith(ORIGINAL, 1) + }) + + it('accepts or rejects everything at once', () => { + const accepted = review() + expect(acceptAllChanges(accepted.view)).toBe(true) + expect(remainingChanges(accepted.view.state)).toBe(0) + expect(accepted.onDecision).toHaveBeenLastCalledWith(CURRENT, 0) + + const rejected = review() + expect(rejectAllChanges(rejected.view)).toBe(true) + expect(rejected.view.state.doc.toString()).toBe(ORIGINAL) + expect(remainingChanges(rejected.view.state)).toBe(0) + }) + + it('moves between changes', () => { + const { view } = review() + view.dispatch({ selection: { anchor: 0 } }) + expect(nextChange(view)).toBe(true) + const line = view.state.doc.lineAt(view.state.selection.main.head).number + expect(line).toBe(2) + }) + + it('leaves on Escape when nothing is selected', () => { + const { view, onExit } = review() + view.contentDOM.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + expect(onExit).toHaveBeenCalledTimes(1) + }) + + it('takes Vim’s diff keys ahead of the d operator', () => { + const { view, onExit } = review(ORIGINAL, CURRENT, true) + expect(getCM(view)).not.toBeNull() + const key = (k: string) => + view.contentDOM.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })) + cursorOn(view, 'TWO') + key('d') + key('o') + expect(view.state.doc.toString()).toBe(CURRENT.replace('TWO', 'two')) + expect(remainingChanges(view.state)).toBe(1) + // A d cancelled with Escape does not end the review. + key('d') + key('Escape') + expect(onExit).not.toHaveBeenCalled() + key(']') + key('c') + expect(view.state.doc.lineAt(view.state.selection.main.head).number).toBe(9) + key('d') + key('p') + expect(remainingChanges(view.state)).toBe(0) + expect(view.state.doc.toString()).toBe(CURRENT.replace('TWO', 'two')) + }) +}) diff --git a/packages/app-core/src/lib/cm-agent-review.ts b/packages/app-core/src/lib/cm-agent-review.ts new file mode 100644 index 000000000..7fce64e81 --- /dev/null +++ b/packages/app-core/src/lib/cm-agent-review.ts @@ -0,0 +1,257 @@ +/** + * Review mode: an assistant's edits to a note, shown inline in the note + * itself (docs/ideas/agent-edit-review.md). CodeMirror's merge view draws + * each change against the comparison text, the note without the assistant's + * changes (@shared/agent-edits `agentEditBaseline`): the removed lines above + * the new ones, with Accept and Reject on each. + * + * Accept moves the change into the comparison text, so it stops showing. + * Reject puts the old text back into the note, an ordinary edit the editor + * saves like any other. The note is read-only while it is being reviewed: + * the only edits are the decisions, so the review always shows exactly the + * assistant's changes and the user's progress can be saved as one text. + * + * This module is imported on demand (agent-review-runtime.ts), so + * @codemirror/merge never rides the boot path. + */ +import { + acceptChunk, + getChunks, + getOriginalDoc, + goToNextChunk, + goToPreviousChunk, + rejectChunk, + unifiedMergeView, + updateOriginalDoc +} from '@codemirror/merge' +import { ChangeSet, EditorState, Prec, StateField, type Extension } from '@codemirror/state' +import { EditorView, keymap, type KeyBinding } from '@codemirror/view' +import { getCM } from '@replit/codemirror-vim' + +/** Marks an editor that is reviewing; the Vim keys and the palette look for it. */ +const reviewing = StateField.define({ + create: () => true, + update: (value) => value +}) + +export interface AgentReviewKeys { + /** CodeMirror key specs (the modifier shortcuts, in or out of Vim mode). */ + next: string[] + previous: string[] + accept: string[] + reject: string[] + /** Vim normal-mode sequences, one key per entry: `[['d', 'p']]`. */ + vim?: { + next: string[][] + previous: string[][] + accept: string[][] + reject: string[][] + } +} + +export interface AgentReviewOptions { + /** The comparison text: the note without the assistant's changes. */ + original: string + /** CodeMirror key specs for the configurable review shortcuts. */ + keys: AgentReviewKeys + /** After each decision: the comparison text so far, and how many changes are left. */ + onDecision: (original: string, remaining: number) => void + /** Whenever the count may have changed, decisions or not: the assistant can + * edit the note again while it is being reviewed, and its new changes show + * up as changes too. */ + onRemaining?: (remaining: number) => void + /** Escape, with nothing selected: leave the review, keeping what is undecided. */ + onExit: () => void +} + +export function isReviewing(state: EditorState): boolean { + return state.field(reviewing, false) ?? false +} + +export function remainingChanges(state: EditorState): number { + return getChunks(state)?.chunks.length ?? 0 +} + +export function nextChange(view: EditorView): boolean { + return goToNextChunk(view) +} + +export function previousChange(view: EditorView): boolean { + return goToPreviousChunk(view) +} + +export function acceptChange(view: EditorView): boolean { + return acceptChunk(view) +} + +export function rejectChange(view: EditorView): boolean { + return rejectChunk(view) +} + +/** Keep every remaining change: the comparison text becomes the note. */ +export function acceptAllChanges(view: EditorView): boolean { + const original = getOriginalDoc(view.state) + if (original.eq(view.state.doc)) return false + const changes = ChangeSet.of( + [{ from: 0, to: original.length, insert: view.state.doc }], + original.length + ) + view.dispatch({ + effects: updateOriginalDoc.of({ doc: view.state.doc, changes }), + userEvent: 'accept' + }) + return true +} + +/** Put back everything still undecided: the note becomes the comparison text. */ +export function rejectAllChanges(view: EditorView): boolean { + const original = getOriginalDoc(view.state) + if (original.eq(view.state.doc)) return false + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: original }, + userEvent: 'revert' + }) + return true +} + +function control(type: 'accept' | 'reject', action: (event: MouseEvent) => void): HTMLElement { + const button = document.createElement('button') + button.type = 'button' + button.className = `zen-review-control zen-review-${type}` + button.textContent = type === 'accept' ? 'Accept' : 'Reject' + button.title = type === 'accept' ? 'Keep this change' : 'Put back what this change replaced' + // On mousedown, like the merge view's own buttons: a click would first move + // the cursor, and the chunk is found from where the button sits. + button.onmousedown = action + return button +} + +type VimStateLike = { + insertMode?: boolean + visualMode?: boolean + inputState?: { operator?: unknown; keyBuffer?: unknown[] } +} + +function vimState(view: EditorView): VimStateLike | null { + return (getCM(view)?.state?.vim as VimStateLike | undefined) ?? null +} + +function vimIdle(vim: VimStateLike): boolean { + if (vim.insertMode || vim.visualMode) return false + const input = vim.inputState + return !input?.operator && !(input?.keyBuffer && input.keyBuffer.length > 0) +} + +/** + * Vim's diff-mode keys (`]c` `[c` `dp` `do`) for the review. They cannot be + * Vim mappings: codemirror-vim runs a full match the moment it has one, so + * `d` would always start the delete operator before `dp` could match. While + * reviewing, this reads normal-mode keys ahead of Vim instead: a key that + * continues one of the sequences is held, a complete sequence runs, and any + * other key goes on to Vim. The note is read-only, so the `d` held while + * waiting for `p` or `o` costs nothing. + */ +function vimReviewKeys(keys: NonNullable): Extension { + const sequences = [ + ...keys.next.map((seq) => ({ seq, run: nextChange })), + ...keys.previous.map((seq) => ({ seq, run: previousChange })), + ...keys.accept.map((seq) => ({ seq, run: acceptChange })), + ...keys.reject.map((seq) => ({ seq, run: rejectChange })) + ].filter((entry) => entry.seq.length > 0) + let held: string[] = [] + return Prec.highest( + EditorView.domEventHandlers({ + keydown(event, view) { + if (event.key === 'Escape' && held.length > 0) { + // A half-typed `d`: Escape cancels it, it does not end the review. + held = [] + return true + } + const vim = vimState(view) + if ( + !vim || + !vimIdle(vim) || + event.ctrlKey || + event.metaKey || + event.altKey || + event.key.length !== 1 + ) { + held = [] + return false + } + const typed = [...held, event.key] + const complete = sequences.find( + ({ seq }) => seq.length === typed.length && seq.every((key, i) => key === typed[i]) + ) + if (complete) { + held = [] + complete.run(view) + return true + } + if (sequences.some(({ seq }) => seq.length > typed.length && typed.every((key, i) => seq[i] === key))) { + held = typed + return true + } + held = [] + return false + } + }) + ) +} + +/** + * Escape leaves the review only when it has nothing else to do: a selection + * (Vim's visual mode, a mouse drag) is cleared first, and in Vim mode Escape + * still cancels a half-typed command (the `d` of `dp`) or leaves insert mode, + * which the read-only note can still enter. + */ +function escapeLeavesReview(view: EditorView): boolean { + if (!view.state.selection.main.empty) return false + const vim = vimState(view) + return !vim || vimIdle(vim) +} + +export function agentReviewExtension(options: AgentReviewOptions): Extension { + const bind = (specs: string[], run: (view: EditorView) => boolean): KeyBinding[] => + specs.map((key) => ({ key, run, preventDefault: true })) + return [ + reviewing, + // Scopes the review's colors and buttons in index.css above the merge + // view's own theme, which is injected at run time. + EditorView.editorAttributes.of({ class: 'zen-agent-review' }), + unifiedMergeView({ + original: options.original, + mergeControls: control, + gutter: true, + syntaxHighlightDeletions: true, + collapseUnchanged: { margin: 3, minSize: 6 } + }), + EditorState.readOnly.of(true), + options.keys.vim ? vimReviewKeys(options.keys.vim) : [], + Prec.highest( + keymap.of([ + ...bind(options.keys.next, nextChange), + ...bind(options.keys.previous, previousChange), + ...bind(options.keys.accept, acceptChange), + ...bind(options.keys.reject, rejectChange), + { + key: 'Escape', + run: (view) => { + if (!escapeLeavesReview(view)) return false + options.onExit() + return true + } + } + ]) + ), + EditorView.updateListener.of((update) => { + const decided = update.transactions.some( + (tr) => tr.isUserEvent('accept') || tr.isUserEvent('revert') + ) + if (decided) { + options.onDecision(getOriginalDoc(update.state).toString(), remainingChanges(update.state)) + } + if (decided || update.docChanged) options.onRemaining?.(remainingChanges(update.state)) + }) + ] +} diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 1b8542659..23a515b3d 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -6,6 +6,7 @@ * reflect the store state at that moment (toggle labels flip, context- * sensitive commands like "Unarchive" only show up when applicable). */ +import { requestAgentEditReview } from './agent-review-runtime' import { isTagsViewActive, isTasksViewActive, isTrashViewActive, useStore } from '../store' import { confirmApp } from './confirm-requests' import { requestSettingsTarget } from './settings-navigation' @@ -1888,6 +1889,24 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma when: () => hasPendingCloudReview(), run: () => openPendingCloudReview(getState().activeNote?.path) }, + // One entry per note an assistant edited through MCP that has not been + // reviewed yet (docs/ideas/agent-edit-review.md), newest first. A record + // whose note is gone (deleted outside ZenNotes) has nothing to open. + ...Object.values(getState().agentEdits) + .filter((summary) => getState().notes.some((note) => note.path === summary.notePath)) + .sort((a, b) => b.at - a.at) + .map( + (summary): Command => ({ + id: `app.agentEdits.review:${summary.notePath}`, + title: `Review Agent Changes: ${(summary.notePath.split('/').pop() ?? summary.notePath).replace(/\.md$/i, '')}`, + category: 'Note', + keywords: `assistant agent mcp ai changes diff review accept reject ${summary.agent} ${summary.notePath}`, + run: async () => { + await getState().selectNote(summary.notePath) + requestAgentEditReview(summary.notePath) + } + }) + ), { id: 'app.cloud.reviewVaultRemoval', title: 'Review Cloud Vault', diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 76cc05dc6..1ee5dac59 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -1307,5 +1307,10 @@ export const HELP_CLI: HelpCard[] = [ title: 'MCP for AI agents', body: '`zn mcp` starts the ZenNotes MCP server in stdio mode, the same one Claude Code, Claude Desktop, and Codex use under the hood. Once `zn` is installed, Settings → MCP installs configure the clients to launch `zn mcp` directly, so the install path is one stable absolute path that survives app moves. The server works on the vault the app has open: a folder on this machine, or a self-hosted ZenNotes server you connected from Settings → Vault. `vault_info` says which. Switch vaults in the app and the server follows: the assistant\'s next tool call is refused with a note naming the old and the new vault, and once `vault_info` confirms the switch it carries on in the new one, so nothing planned in one vault lands in the other. A server that requires a token needs one the MCP server can read, because the app keeps its own copy in the OS secret store: with `zn` installed, run `zn connect --no-default` once in a terminal (it saves the token for `zn` and `zn mcp` without changing which vault they follow), or set `ZENNOTES_REMOTE_TOKEN` in the MCP client\'s environment. To point an agent somewhere else, give the command the same flags every other `zn` command takes: `zn mcp --vault work` or `zn mcp --server home --token ` in the client\'s config, or `ZENNOTES_SERVER` / `ZENNOTES_VAULT` in its environment. Beyond reading and writing notes, the server can hold a review with you through comments: ask the assistant to read a note\'s comments and answer them, and its replies land in the Comments panel under yours, signed with its name (`list_comments`, `add_comment`, `reply_to_comment`, `resolve_comment`).' + }, + { + title: 'Review an assistant’s edits', + body: + 'When an assistant changes a note through MCP (`write_note`, `append_to_note`, `prepend_to_note`, `insert_at_line`, `replace_in_note`, `toggle_task`), ZenNotes keeps what the note said before, beside it in `.zennotes/agent-edits/`. The note then shows a banner, “Claude Code changed this note 4 minutes ago”, and **Review changes** opens the review in the note itself: each change inline, the removed lines above the new ones, with **Accept** and **Reject** on each and **Accept all**, **Reject all** and **Done** on top. Reject puts the old text back. The note is read-only while you review, and once every change is decided the kept copy is deleted. What you typed after the assistant is yours and is not part of the review. Keys: Ctrl+Alt+↓ and Ctrl+Alt+↑ (Cmd+Option on macOS) move between changes, Ctrl+Shift+Enter (Cmd+Shift+Enter) accepts and Ctrl+Shift+Backspace (Cmd+Shift+Backspace) rejects; in Vim mode, Vim’s diff-mode keys: `]c` and `[c` move, `dp` keeps a change and `do` puts back what it replaced. Escape or **Done** leaves the review and keeps what is undecided. The palette lists every note waiting (**Review Agent Changes**). To tell the assistant why, comment on a change before you decide it; it reads comments with `list_comments`. Turn it off in Settings → MCP → **Review agent edits**. Not kept yet: notes in a vault on a self-hosted server, new notes an assistant creates, and moves, renames and deletions (Trash keeps those).' } ] diff --git a/packages/app-core/src/lib/keymaps.ts b/packages/app-core/src/lib/keymaps.ts index 5ec9da216..2f9476b33 100644 --- a/packages/app-core/src/lib/keymaps.ts +++ b/packages/app-core/src/lib/keymaps.ts @@ -98,6 +98,10 @@ export type KeymapId = | "vim.harperSuggest" | "vim.harperAddWord" | "vim.harperIgnore" + | "vim.reviewNextChange" + | "vim.reviewPreviousChange" + | "vim.reviewAcceptChange" + | "vim.reviewRejectChange" | "nav.moveDown" | "nav.moveUp" | "nav.moveLeft" @@ -129,7 +133,11 @@ export type KeymapId = | "editor.foldHeading" | "editor.unfoldHeading" | "editor.toggleCheckbox" - | "editor.reflowParagraph"; + | "editor.reflowParagraph" + | "editor.reviewNextChange" + | "editor.reviewPreviousChange" + | "editor.reviewAcceptChange" + | "editor.reviewRejectChange"; export type KeymapOverrides = Partial>; @@ -992,6 +1000,50 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ vimOnly: true, maxTokens: 2, }, + { + id: "vim.reviewNextChange", + kind: "sequence", + scope: "vim-editor", + group: "vim", + title: "Next change to review", + description: "While reviewing an assistant's edits, jump to the next change. Vim's own diff-mode key.", + defaultBinding: "] c", + vimOnly: true, + maxTokens: 2, + }, + { + id: "vim.reviewPreviousChange", + kind: "sequence", + scope: "vim-editor", + group: "vim", + title: "Previous change to review", + description: "While reviewing an assistant's edits, jump to the previous change.", + defaultBinding: "[ c", + vimOnly: true, + maxTokens: 2, + }, + { + id: "vim.reviewAcceptChange", + kind: "sequence", + scope: "vim-editor", + group: "vim", + title: "Accept change", + description: "While reviewing an assistant's edits, keep the change at the cursor (diff put).", + defaultBinding: "d p", + vimOnly: true, + maxTokens: 2, + }, + { + id: "vim.reviewRejectChange", + kind: "sequence", + scope: "vim-editor", + group: "vim", + title: "Reject change", + description: "While reviewing an assistant's edits, put back what the change replaced (diff obtain).", + defaultBinding: "d o", + vimOnly: true, + maxTokens: 2, + }, { id: "vim.harperPrevious", kind: "sequence", @@ -1284,6 +1336,42 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ description: "Fold the heading section, the list item or the callout at the cursor; on a line under a list item or inside a foldable callout, fold that.", defaultBinding: "Mod+Alt+F", }, + { + id: "editor.reviewNextChange", + kind: "shortcut", + scope: "vim-editor", + group: "view-actions", + title: "Next change to review", + description: "While reviewing an assistant's edits, jump to the next change.", + defaultBinding: "Mod+Alt+ArrowDown", + }, + { + id: "editor.reviewPreviousChange", + kind: "shortcut", + scope: "vim-editor", + group: "view-actions", + title: "Previous change to review", + description: "While reviewing an assistant's edits, jump to the previous change.", + defaultBinding: "Mod+Alt+ArrowUp", + }, + { + id: "editor.reviewAcceptChange", + kind: "shortcut", + scope: "vim-editor", + group: "view-actions", + title: "Accept change", + description: "While reviewing an assistant's edits, keep the change at the cursor.", + defaultBinding: "Mod+Shift+Enter", + }, + { + id: "editor.reviewRejectChange", + kind: "shortcut", + scope: "vim-editor", + group: "view-actions", + title: "Reject change", + description: "While reviewing an assistant's edits, put back what the change at the cursor replaced.", + defaultBinding: "Mod+Shift+Backspace", + }, { id: "editor.unfoldHeading", kind: "shortcut", diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 1f5f3cb96..42e36ac10 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -24,6 +24,7 @@ import { normalizeTasksExcludedFolder } from '@shared/tasks-excluded-folders' import { cloudSyncPathKey } from '@zennotes/shared-domain/cloud-sync' import { useCloudSyncStatusStore } from './lib/cloud-auto-sync' import type { + AgentEditSummary, AssetMeta, DateNotePatternSettings, DeletedAsset, @@ -449,7 +450,8 @@ async function refreshVaultIndexes(): Promise { state.refreshAssets(), state.loadCustomTemplates(), state.loadWorkflowIndex(), - state.refreshRootContentHidden() + state.refreshRootContentHidden(), + state.refreshAgentEdits() ]) // A run the app died in the middle of left changes nobody was told about, // and this is the first moment anyone is back to be told. Dynamically @@ -604,6 +606,9 @@ interface Prefs { * so the title line stops drifting from the filename. Never adds a heading * to a note that has none. (#455) */ syncTitleHeadingOnRename: boolean + /** Keep what a note said before an assistant edits it through MCP, so the + * edit can be reviewed. The MCP server reads it from config.toml. */ + reviewAgentEdits: boolean /** Auto-close markdown delimiters while typing: `**`+Space → `**|**`, * ```` ``` ````+Enter expands a fenced block. Off restores plain typing. */ markdownSnippets: boolean @@ -1098,6 +1103,7 @@ export const DEFAULT_PREFS: Prefs = { persistUndoHistory: false, defaultPaneMode: 'edit', syncTitleHeadingOnRename: true, + reviewAgentEdits: true, markdownSnippets: true, textReplacementsEnabled: true, textReplacements: { '->': '→' }, @@ -1297,6 +1303,8 @@ function normalizePrefs(p: Partial): Prefs { typeof p.syncTitleHeadingOnRename === 'boolean' ? p.syncTitleHeadingOnRename : DEFAULT_PREFS.syncTitleHeadingOnRename, + reviewAgentEdits: + typeof p.reviewAgentEdits === 'boolean' ? p.reviewAgentEdits : DEFAULT_PREFS.reviewAgentEdits, markdownSnippets: typeof p.markdownSnippets === 'boolean' ? p.markdownSnippets @@ -2355,6 +2363,7 @@ function collectPrefs(s: { persistUndoHistory: boolean defaultPaneMode: PaneMode syncTitleHeadingOnRename: boolean + reviewAgentEdits: boolean markdownSnippets: boolean textReplacementsEnabled: boolean textReplacements: TextReplacements @@ -2466,6 +2475,7 @@ function collectPrefs(s: { persistUndoHistory: s.persistUndoHistory, defaultPaneMode: s.defaultPaneMode, syncTitleHeadingOnRename: s.syncTitleHeadingOnRename, + reviewAgentEdits: s.reviewAgentEdits, markdownSnippets: s.markdownSnippets, textReplacementsEnabled: s.textReplacementsEnabled, textReplacements: s.textReplacements, @@ -3001,6 +3011,8 @@ interface Store { defaultPaneMode: PaneMode /** Renaming a note rewrites its leading `# Heading` to match. Persisted. (#455) */ syncTitleHeadingOnRename: boolean + /** Record assistant edits made through MCP for review. Persisted (portable). */ + reviewAgentEdits: boolean /** Auto-close markdown delimiters while typing. Persisted. */ markdownSnippets: boolean textReplacementsEnabled: boolean @@ -3258,6 +3270,10 @@ interface Store { noteDirty: Record /** Comment sidecars keyed by note path. Loaded lazily per open note. */ noteComments: Record + /** Notes an assistant edited through MCP that the user has not reviewed + * yet, keyed by note path (docs/ideas/agent-edit-review.md). Empty on + * hosts without `supportsAgentEditReview`. */ + agentEdits: Record activeCommentId: string | null closedTabStack: ClosedTabEntry[] @@ -3601,6 +3617,7 @@ interface Store { setPersistUndoHistory: (on: boolean) => void setDefaultPaneMode: (mode: PaneMode) => void setSyncTitleHeadingOnRename: (on: boolean) => void + setReviewAgentEdits: (on: boolean) => void setMarkdownSnippets: (on: boolean) => void setTextReplacementsEnabled: (on: boolean) => void setTextReplacements: (replacements: TextReplacements) => void @@ -3833,6 +3850,10 @@ interface Store { /** Persist a specific note to disk. */ persistNote: (path: string, duringFolderMutation?: boolean) => Promise loadNoteComments: (path: string) => Promise + /** Re-list every note with assistant edits to review. */ + refreshAgentEdits: () => Promise + /** Re-read one note's record after it changed on disk. */ + refreshAgentEdit: (path: string) => Promise addNoteComment: (input: NoteCommentInput) => Promise updateNoteComment: ( path: string, @@ -4511,6 +4532,7 @@ function rewriteFolderWorkspace( : [] }), tasksLoading: false, + agentEdits: remap(s.agentEdits, (summary, notePath) => ({ ...summary, notePath })), noteComments: remap(s.noteComments, (comments, notePath) => comments.map((comment) => ({ ...comment, notePath })) ), @@ -5798,6 +5820,7 @@ export const useStore = create((set, get) => { persistUndoHistory: loadPrefs().persistUndoHistory, defaultPaneMode: loadPrefs().defaultPaneMode, syncTitleHeadingOnRename: loadPrefs().syncTitleHeadingOnRename, + reviewAgentEdits: loadPrefs().reviewAgentEdits, markdownSnippets: loadPrefs().markdownSnippets, textReplacementsEnabled: loadPrefs().textReplacementsEnabled, textReplacements: loadPrefs().textReplacements, @@ -5909,6 +5932,7 @@ export const useStore = create((set, get) => { noteContents: {}, noteDirty: {}, noteComments: {}, + agentEdits: {}, activeCommentId: null, closedTabStack: [], @@ -7710,7 +7734,9 @@ export const useStore = create((set, get) => { tasksSurfaceVisible(get()) ? get().refreshTasks() : Promise.resolve(), // Templates ride the feed too (scope 'templates'), so a gap may have // swallowed a template saved on another device. - get().loadCustomTemplates() + get().loadCustomTemplates(), + // So do agent edit records (scope 'agent-edits'). + get().refreshAgentEdits() ]) const stateAfter = get() const openTabs = [...new Set(allLeaves(stateAfter.paneLayout).flatMap((leaf) => leaf.tabs))] @@ -7779,6 +7805,11 @@ export const useStore = create((set, get) => { await get().loadNoteComments(ev.path) return } + if (ev.scope === 'agent-edits') { + // An assistant edited the note through MCP, or a review moved on. + await get().refreshAgentEdit(ev.path) + return + } if (ev.scope === 'templates') { // A custom template changed on disk: another client on this vault, a // synced dotfile, or this app's own save. Re-list the templates, not @@ -8061,6 +8092,49 @@ export const useStore = create((set, get) => { } }, + refreshAgentEdits: async () => { + const list = window.zen.listAgentEdits + if (!list || !window.zen.getCapabilities().supportsAgentEditReview) { + set({ agentEdits: {} }) + return + } + const isCurrent = captureFolderActionContext(get) + try { + const summaries = await list() + if (!isCurrent()) return + set({ agentEdits: Object.fromEntries(summaries.map((summary) => [summary.notePath, summary])) }) + } catch (err) { + console.error('listAgentEdits failed', err) + } + }, + + refreshAgentEdit: async (path) => { + const read = window.zen.readAgentEdit + if (!read || !window.zen.getCapabilities().supportsAgentEditReview) return + const isCurrent = captureFolderActionContext(get) + try { + const record = await read(path) + if (!isCurrent()) return + set((s) => { + const next = { ...s.agentEdits } + if (record) { + const latest = record.edits.at(-1) + next[path] = { + notePath: path, + agent: latest?.agent ?? 'Assistant', + at: latest?.at ?? 0, + edits: record.edits.length + } + } else { + delete next[path] + } + return { agentEdits: next } + }) + } catch (err) { + console.error('readAgentEdit failed', err) + } + }, + loadNoteComments: async (path) => { return trackCommentOperation(path, [], async () => { const isCurrent = captureFolderActionContext(get) @@ -8913,6 +8987,10 @@ export const useStore = create((set, get) => { set({ syncTitleHeadingOnRename: on }) savePrefs(collectPrefs(get())) }, + setReviewAgentEdits: (on) => { + set({ reviewAgentEdits: on }) + savePrefs(collectPrefs(get())) + }, setMarkdownSnippets: (on) => { set({ markdownSnippets: on }) savePrefs(collectPrefs(get())) diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index ed6fdd5c4..45e46dede 100644 --- a/packages/app-core/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -5997,3 +5997,51 @@ html[data-completed-task-style="gray-strikethrough"] .prose-zen li.task-list-ite color: rgb(var(--z-green)); background-color: rgb(var(--z-green) / 0.12); } + + +/* Reviewing an assistant's edits (cm-agent-review.ts): the merge view's + changes in the theme's own red and green instead of its fixed ones, and its + Accept / Reject buttons in the app's button shape. Scoped by the editor's + `zen-agent-review` class so they outrank the merge view's injected theme. */ +/* The bands run to the editor's right edge, not just the text column: a + border image with `fill`, pushed out to the right by its outset. That is + ink overflow, so it adds no horizontal scrolling, and the scroller clips + it at the edge of the pane. */ +.cm-editor.zen-agent-review .cm-deletedChunk { + background-color: transparent; + border-image-source: linear-gradient(rgb(var(--z-red) / 0.07), rgb(var(--z-red) / 0.07)); + border-image-slice: 0 fill; + border-image-outset: 0 100vw 0 0; +} +.cm-editor.zen-agent-review .cm-deletedChunk .cm-deletedText { + background: rgb(var(--z-red) / 0.24); +} +.cm-editor.zen-agent-review .cm-changedLine { + background-color: transparent; + border-image-source: linear-gradient(rgb(var(--z-green) / 0.08), rgb(var(--z-green) / 0.08)); + border-image-slice: 0 fill; + border-image-outset: 0 100vw 0 0; +} +.cm-editor.zen-agent-review .cm-changedText { + background: rgb(var(--z-green) / 0.24); +} +.cm-editor.zen-agent-review .cm-deletedChunk .zen-review-control { + margin: 0 2px; + padding: 1px 8px; + border: 1px solid theme("colors.paper.300"); + border-radius: 6px; + background: theme("colors.paper.100"); + color: theme("colors.ink.800"); + font-family: inherit; + font-size: 11px; + line-height: 1.5; + cursor: pointer; +} +.cm-editor.zen-agent-review .cm-deletedChunk .zen-review-accept:hover { + border-color: rgb(var(--z-green) / 0.55); + background: rgb(var(--z-green) / 0.14); +} +.cm-editor.zen-agent-review .cm-deletedChunk .zen-review-reject:hover { + border-color: rgb(var(--z-red) / 0.55); + background: rgb(var(--z-red) / 0.14); +} diff --git a/packages/bridge-contract/src/app-config.ts b/packages/bridge-contract/src/app-config.ts index 16b2d3221..d072623ad 100644 --- a/packages/bridge-contract/src/app-config.ts +++ b/packages/bridge-contract/src/app-config.ts @@ -38,6 +38,7 @@ export const PORTABLE_PREF_KEYS = [ 'persistUndoHistory', 'defaultPaneMode', 'syncTitleHeadingOnRename', + 'reviewAgentEdits', 'markdownSnippets', 'textReplacementsEnabled', 'textReplacements', diff --git a/packages/bridge-contract/src/bridge.ts b/packages/bridge-contract/src/bridge.ts index ab67435fd..d64f0e980 100644 --- a/packages/bridge-contract/src/bridge.ts +++ b/packages/bridge-contract/src/bridge.ts @@ -18,6 +18,8 @@ import type { ListNotesPageResponse, NoteComment, NoteCommentInput, + AgentEditRecord, + AgentEditSummary, NoteContent, NoteFolder, NoteMeta, @@ -124,6 +126,11 @@ export interface ZenCapabilities { * machine-local that is not the vault (`readNoteUndoHistory` and friends). * Absent everywhere but the desktop app, which hides the setting. (#793) */ supportsUndoFile?: boolean + /** The host can read and write agent edit records for a local vault + * (`listAgentEdits` and friends), so assistant edits made through MCP can + * be reviewed. Absent where records are not reachable yet (the web client + * and the phones), which hides the review. */ + supportsAgentEditReview?: boolean } export interface ZenAppInfo { @@ -338,6 +345,13 @@ export interface ZenBridge { readNote(relPath: string): Promise readNoteComments(relPath: string): Promise writeNoteComments(relPath: string, comments: NoteCommentInput[]): Promise + /** Notes with assistant edits to review, newest first. Only on hosts with + * `supportsAgentEditReview`. */ + listAgentEdits?(): Promise + /** A note's agent edit record, or null when there is none. */ + readAgentEdit?(relPath: string): Promise + /** Save a review's progress, or delete the record with `null`. */ + writeAgentEdit?(relPath: string, record: AgentEditRecord | null): Promise scanTasks(): Promise scanTasksForPath(relPath: string): Promise /** Resolves to null when the `.csv` no longer exists (e.g. a stale tab). */ diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index e0540fa9a..34dce51ce 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -47,6 +47,9 @@ export const IPC = { VAULT_READ_NOTE: 'vault:read-note', VAULT_READ_COMMENTS: 'vault:read-comments', VAULT_WRITE_COMMENTS: 'vault:write-comments', + VAULT_LIST_AGENT_EDITS: 'vault:list-agent-edits', + VAULT_READ_AGENT_EDIT: 'vault:read-agent-edit', + VAULT_WRITE_AGENT_EDIT: 'vault:write-agent-edit', VAULT_WRITE_NOTE: 'vault:write-note', VAULT_APPEND_NOTE: 'vault:append-note', VAULT_CREATE_NOTE: 'vault:create-note', @@ -697,6 +700,42 @@ export interface NoteContent extends NoteMeta { body: string } +/** One assistant edit in an agent edit record (docs/ideas/agent-edit-review.md). */ +export interface AgentEditEntry { + /** Epoch milliseconds. */ + at: number + /** The assistant's display name, as comments carry it ("Claude Code"). */ + agent: string + /** The MCP tool that made the edit. */ + tool: string +} + +/** A change someone else made between two assistant edits. */ +export interface AgentEditOutsideChange { + from: string + to: string +} + +/** What a note said before an assistant changed it through MCP, kept beside + * the note at `.zennotes/agent-edits/.json` until the user reviews the + * change. Built and read by `@shared/agent-edits`. */ +export interface AgentEditRecord { + version: 1 + before: string + after: string | null + edits: AgentEditEntry[] + outside: AgentEditOutsideChange[] +} + +/** A note with assistant edits to review, for the banner and the palette. */ +export interface AgentEditSummary { + notePath: string + agent: string + /** The newest edit, epoch milliseconds. */ + at: number + edits: number +} + export interface NoteComment { id: string /** Path relative to the vault root for the note this comment belongs to. */ @@ -975,10 +1014,14 @@ export type VaultChangeKind = 'add' | 'change' | 'unlink' /** `templates` is a custom template under `.zennotes/templates/` changing on * disk; the path is that file's vault-relative path, and the client re-lists * templates rather than notes. */ +/** `agent-edits` is a note's agent edit record under `.zennotes/agent-edits/` + * changing (an assistant edited the note through MCP, or a review moved on); + * like `comments`, the path is the NOTE's vault-relative path. */ export type VaultChangeScope = | 'content' | 'vault-settings' | 'comments' + | 'agent-edits' | 'database' | 'folder' | 'templates' diff --git a/packages/shared-domain/src/agent-edits.test.ts b/packages/shared-domain/src/agent-edits.test.ts new file mode 100644 index 000000000..493006f39 --- /dev/null +++ b/packages/shared-domain/src/agent-edits.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_AGENT_EDIT_ENTRIES, + MAX_AGENT_EDIT_NOTE_BYTES, + MAX_AGENT_EDIT_OUTSIDE, + agentEditBaseline, + agentEditHasChanges, + agentEditOrigin, + agentEditRecordable, + agentEditSidecarPath, + beginAgentEdit, + finishAgentEdit, + latestAgentEdit, + notePathFromAgentEditSidecar, + parseAgentEditRecord, + reviewedAgentEdit, + serializeAgentEditRecord, + type AgentEditRecord +} from './agent-edits' + +const edit = (at: number, tool = 'replace_in_note') => ({ at, agent: 'Claude Code', tool }) + +/** Records one assistant edit that turns `current` into `next`. */ +function record(existing: AgentEditRecord | null, current: string, next: string, at = 1) { + return finishAgentEdit(beginAgentEdit(existing, current), next, edit(at)) +} + +describe('agent edit records', () => { + it('keep the note from before the first edit, and the note after it', () => { + const started = beginAgentEdit(null, 'a\nb\n') + expect(started).toEqual({ version: 1, before: 'a\nb\n', after: null, edits: [], outside: [] }) + expect(finishAgentEdit(started, 'a\nB\n', edit(5))).toEqual({ + version: 1, + before: 'a\nb\n', + after: 'a\nB\n', + edits: [edit(5)], + outside: [] + }) + }) + + it('stack a second edit onto the same record', () => { + const first = record(null, 'a\nb\n', 'a\nB\n', 1) + const second = record(first, 'a\nB\n', 'A\nB\n', 2) + expect(second.before).toBe('a\nb\n') + expect(second.after).toBe('A\nB\n') + expect(second.outside).toEqual([]) + expect(second.edits.map((e) => e.at)).toEqual([1, 2]) + }) + + it('note what someone else changed between two edits, without merging it', () => { + const first = record(null, 'a\nb\nc\n', 'a\nB\nc\n', 1) + // The user changes the last line, then the assistant edits the first one. + const second = record(first, 'a\nB\nC\n', 'A\nB\nC\n', 2) + expect(second.before).toBe('a\nb\nc\n') + expect(second.outside).toEqual([{ from: 'a\nB\nc\n', to: 'a\nB\nC\n' }]) + // The review folds it in: only the assistant's two lines are compared. + expect(agentEditOrigin(second)).toBe('a\nb\nC\n') + expect(agentEditBaseline(second, 'A\nB\nC\n')).toBe('a\nb\nC\n') + }) + + it('show nothing to review once the assistant puts the note back', () => { + const first = record(null, 'a\nb\n', 'a\nB\n') + const second = record(first, 'a\nB\n', 'a\nb\n') + expect(agentEditHasChanges(second, 'a\nb\n')).toBe(false) + }) + + it('cap the edit list but keep the texts', () => { + let current: AgentEditRecord | null = null + let text = 'line 0\n' + for (let i = 1; i <= MAX_AGENT_EDIT_ENTRIES + 5; i++) { + const next = `line ${i}\n` + current = record(current, text, next, i) + text = next + } + expect(current!.edits).toHaveLength(MAX_AGENT_EDIT_ENTRIES) + expect(current!.before).toBe('line 0\n') + expect(latestAgentEdit(current!)!.at).toBe(MAX_AGENT_EDIT_ENTRIES + 5) + }) + + it('start over rather than keep outside changes forever', () => { + let current: AgentEditRecord = record(null, 'x\n', 'x1\n', 0) + for (let i = 1; i <= MAX_AGENT_EDIT_OUTSIDE; i++) { + current = record(current, `user ${i}\n`, `agent ${i}\n`, i) + } + expect(current.outside).toHaveLength(MAX_AGENT_EDIT_OUTSIDE) + const fresh = record(current, 'user again\n', 'agent again\n', 99) + expect(fresh.before).toBe('user again\n') + expect(fresh.outside).toEqual([]) + expect(fresh.edits.map((e) => e.at)).toEqual([99]) + }) +}) + +describe('agentEditBaseline', () => { + const reviewed = record(null, 'a\nb\nc\n', 'a\nB\nc\n') + + it('is the old text when nothing else changed', () => { + expect(agentEditBaseline(reviewed, 'a\nB\nc\n')).toBe('a\nb\nc\n') + expect(agentEditHasChanges(reviewed, 'a\nB\nc\n')).toBe(true) + }) + + it('carries the user’s later lines, so only the assistant’s change is reviewed', () => { + expect(agentEditBaseline(reviewed, 'a\nB\nc\nd\n')).toBe('a\nb\nc\nd\n') + }) + + it('leaves out a change the user has since rewritten', () => { + expect(agentEditBaseline(reviewed, 'a\nX\nc\n')).toBe('a\nX\nc\n') + expect(agentEditHasChanges(reviewed, 'a\nX\nc\n')).toBe(false) + }) + + it('falls back to the old text when the last edit never finished', () => { + const unfinished = beginAgentEdit(reviewed, 'a\nB\nc\n') + expect(unfinished.after).toBeNull() + expect(agentEditBaseline(unfinished, 'a\nB\nc\nd\n')).toBe('a\nb\nc\n') + }) + + it('picks up a half-done review where it stopped', () => { + const two = record(null, 'a\nb\nc\n', 'A\nb\nC\n') + // The user accepted the first change and has not decided the second. + const progress = reviewedAgentEdit(two, 'A\nb\nc\n', 'A\nb\nC\n') + expect(agentEditBaseline(progress, 'A\nb\nC\n')).toBe('A\nb\nc\n') + // Typing afterwards still stays out of the review. + expect(agentEditBaseline(progress, 'A\nb\nC\nmore\n')).toBe('A\nb\nc\nmore\n') + }) +}) + +describe('agent edit files', () => { + it('live beside the note’s comments and map back to the note', () => { + expect(agentEditSidecarPath('.zennotes', 'inbox/Plan.md')).toBe('.zennotes/agent-edits/inbox/Plan.md.json') + expect(notePathFromAgentEditSidecar('.zennotes', '.zennotes/agent-edits/inbox/Plan.md.json')).toBe('inbox/Plan.md') + expect(notePathFromAgentEditSidecar('.zennotes', '.zennotes/comments/inbox/Plan.md.comments.json')).toBeNull() + }) + + it('round-trip through JSON and reject anything that is not a record', () => { + const second = record(record(null, 'a\n', 'b\n', 7), 'c\n', 'd\n', 8) + expect(parseAgentEditRecord(JSON.parse(serializeAgentEditRecord(second)))).toEqual(second) + expect(parseAgentEditRecord(null)).toBeNull() + expect(parseAgentEditRecord({ version: 2, before: 'a', after: null, edits: [] })).toBeNull() + expect(parseAgentEditRecord({ version: 1, before: 1, after: null })).toBeNull() + expect(parseAgentEditRecord({ version: 1, before: 'a', after: 3 })).toBeNull() + }) + + it('keep only well-formed entries', () => { + const parsed = parseAgentEditRecord({ + version: 1, + before: 'a', + edits: [{ at: 1, agent: ' Codex ', tool: 'write_note' }, { at: 'x' }, null, { at: 2 }], + outside: [{ from: 'a', to: 'b' }, { from: 1 }] + }) + expect(parsed).toEqual({ + version: 1, + before: 'a', + after: null, + edits: [ + { at: 1, agent: 'Codex', tool: 'write_note' }, + { at: 2, agent: 'Assistant', tool: '' } + ], + outside: [{ from: 'a', to: 'b' }] + }) + }) + + it('skip notes too large to keep copies of', () => { + expect(agentEditRecordable('x'.repeat(MAX_AGENT_EDIT_NOTE_BYTES))).toBe(true) + expect(agentEditRecordable('x'.repeat(MAX_AGENT_EDIT_NOTE_BYTES + 1))).toBe(false) + }) +}) diff --git a/packages/shared-domain/src/agent-edits.ts b/packages/shared-domain/src/agent-edits.ts new file mode 100644 index 000000000..f780c7a42 --- /dev/null +++ b/packages/shared-domain/src/agent-edits.ts @@ -0,0 +1,186 @@ +import { mergeCloudSyncText } from './cloud-sync-merge' +import type { AgentEditEntry, AgentEditOutsideChange, AgentEditRecord } from './ipc' + +export type { AgentEditEntry, AgentEditOutsideChange, AgentEditRecord } + +/** + * Agent edit records (docs/ideas/agent-edit-review.md). When an assistant + * changes a note through the MCP server, the note's previous text is kept + * beside it as `.zennotes/agent-edits/.json` until the user has + * reviewed the change in the app: accepted it, or put the old text back. + * + * Two MCP engines write records: the TypeScript server in apps/desktop/src/mcp + * and the Go `zn` (ZenNotes/tui), which is the one a managed install runs. So + * the writing side is deliberately dumb, string comparisons and JSON, and + * everything that needs a merge happens here, in the app: + * + * - `before`: the note before the first recorded edit. + * - `after`: the note right after the latest edit, or `null` while an edit is + * being made. The record reaches disk before the edit, the way the workflow + * journal does, so a process that dies mid-edit leaves a record that shows + * more than the assistant did, never less. + * - `outside`: changes someone else made between two assistant edits, as the + * note after one edit (`from`) and the note the next edit started from + * (`to`). A writer only appends here when they differ; it never merges. + * + * The app folds `outside` into `before` and then into the comparison text, so + * the review shows only the assistant's changes: whatever the user typed is + * theirs. Where the user rewrote lines the assistant had changed, the user's + * version wins: those lines have been dealt with. + */ + +export const AGENT_EDITS_DIR = 'agent-edits' +export const AGENT_EDITS_SUFFIX = '.json' +export const AGENT_EDIT_RECORD_VERSION = 1 + +/** Notes larger than this are not recorded: a record holds several copies. */ +export const MAX_AGENT_EDIT_NOTE_BYTES = 1024 * 1024 +/** How many edits a record lists; older entries drop off, the texts do not. */ +export const MAX_AGENT_EDIT_ENTRIES = 50 +/** + * How many outside changes a record keeps apart. Past this, a writer starts + * a fresh record from the note as it is: the earlier assistant edits are then + * no longer offered for review, which forgets a review but changes no note. + */ +export const MAX_AGENT_EDIT_OUTSIDE = 20 + + +/** Vault-relative posix path of the record for `notePath`. */ +export function agentEditSidecarPath(internalDir: string, notePath: string): string { + return `${internalDir}/${AGENT_EDITS_DIR}/${notePath}${AGENT_EDITS_SUFFIX}` +} + +/** The note path a record path belongs to, or null for anything else. */ +export function notePathFromAgentEditSidecar(internalDir: string, sidecarPath: string): string | null { + const prefix = `${internalDir}/${AGENT_EDITS_DIR}/` + if (!sidecarPath.startsWith(prefix) || !sidecarPath.endsWith(AGENT_EDITS_SUFFIX)) return null + const notePath = sidecarPath.slice(prefix.length, -AGENT_EDITS_SUFFIX.length) + return notePath.length > 0 ? notePath : null +} + +export function agentEditRecordable(text: string): boolean { + return new TextEncoder().encode(text).length <= MAX_AGENT_EDIT_NOTE_BYTES +} + +function parseEntry(entry: unknown): AgentEditEntry[] { + if (!entry || typeof entry !== 'object') return [] + const e = entry as Record + const at = typeof e['at'] === 'number' && Number.isFinite(e['at']) ? e['at'] : null + const agent = typeof e['agent'] === 'string' ? e['agent'].trim().slice(0, 80) : '' + const tool = typeof e['tool'] === 'string' ? e['tool'].trim().slice(0, 80) : '' + return at === null ? [] : [{ at, agent: agent || 'Assistant', tool }] +} + +/** A record read from disk, or null when it is missing or not a record. */ +export function parseAgentEditRecord(raw: unknown): AgentEditRecord | null { + if (!raw || typeof raw !== 'object') return null + const value = raw as Record + if (value['version'] !== AGENT_EDIT_RECORD_VERSION) return null + if (typeof value['before'] !== 'string') return null + const after = value['after'] ?? null + if (after !== null && typeof after !== 'string') return null + const edits = Array.isArray(value['edits']) ? value['edits'] : [] + const outside = Array.isArray(value['outside']) ? value['outside'] : [] + return { + version: AGENT_EDIT_RECORD_VERSION, + before: value['before'], + after, + edits: edits.flatMap(parseEntry), + outside: outside.flatMap((change): AgentEditOutsideChange[] => { + if (!change || typeof change !== 'object') return [] + const c = change as Record + return typeof c['from'] === 'string' && typeof c['to'] === 'string' + ? [{ from: c['from'], to: c['to'] }] + : [] + }) + } +} + +export function serializeAgentEditRecord(record: AgentEditRecord): string { + return `${JSON.stringify(record)}\n` +} + +/** + * The record to write BEFORE an edit, given what is on disk (or null) and the + * note as it is now. The Go engine mirrors this function and the next one; + * keep them to string comparisons. + */ +export function beginAgentEdit(existing: AgentEditRecord | null, current: string): AgentEditRecord { + const fresh: AgentEditRecord = { + version: AGENT_EDIT_RECORD_VERSION, + before: current, + after: null, + edits: [], + outside: [] + } + if (!existing) return fresh + if (existing.after === null || existing.after === current) { + return { ...existing, after: null } + } + if (existing.outside.length >= MAX_AGENT_EDIT_OUTSIDE) return fresh + return { + ...existing, + after: null, + outside: [...existing.outside, { from: existing.after, to: current }] + } +} + +/** The record to write AFTER an edit. */ +export function finishAgentEdit( + started: AgentEditRecord, + current: string, + entry: AgentEditEntry +): AgentEditRecord { + return { + ...started, + after: current, + edits: [...started.edits, entry].slice(-MAX_AGENT_EDIT_ENTRIES) + } +} + +/** `before` with every outside change folded in: the note without the assistant. */ +export function agentEditOrigin(record: AgentEditRecord): string { + let origin = record.before + for (const change of record.outside) { + origin = mergeCloudSyncText(change.from, change.to, origin).text + } + return origin +} + +/** + * The text the review compares the note against: the note as it is now, + * without the assistant's changes. Without an `after` (an edit that never + * finished), that is the origin itself. + */ +export function agentEditBaseline(record: AgentEditRecord, current: string): string { + const origin = agentEditOrigin(record) + if (record.after === null || record.after === current) return origin + return mergeCloudSyncText(record.after, current, origin).text +} + +/** + * Whether anything is left to review. False once the assistant put its own + * change back, or the user has since rewritten every line it touched; the + * app then deletes the record instead of showing an empty review. + */ +export function agentEditHasChanges(record: AgentEditRecord, current: string): boolean { + return agentEditBaseline(record, current) !== current +} + +/** + * The record a partly done review leaves behind: what has been accepted is + * now part of the comparison text, and the note as it stands is the last + * known state, so no outside change is left to fold. + */ +export function reviewedAgentEdit( + record: AgentEditRecord, + baseline: string, + current: string +): AgentEditRecord { + return { ...record, before: baseline, after: current, outside: [] } +} + +/** The newest edit, for "Claude Code changed this note 4 minutes ago". */ +export function latestAgentEdit(record: AgentEditRecord): AgentEditEntry | null { + return record.edits.at(-1) ?? null +} diff --git a/packages/shared-domain/src/app-config.ts b/packages/shared-domain/src/app-config.ts index ae6279252..f1754c2d2 100644 --- a/packages/shared-domain/src/app-config.ts +++ b/packages/shared-domain/src/app-config.ts @@ -102,6 +102,10 @@ export const PORTABLE_DEFAULTS: Record = { persistUndoHistory: false, defaultPaneMode: 'edit', syncTitleHeadingOnRename: true, + // Keep what a note said before an assistant edits it through MCP, so the + // edit can be reviewed (docs/ideas/agent-edit-review.md). The MCP server + // reads it from config.toml, without the app running. + reviewAgentEdits: true, markdownSnippets: true, textReplacementsEnabled: true, textReplacements: { '->': '→' }, diff --git a/packages/shared-domain/src/cloud-sync.test.ts b/packages/shared-domain/src/cloud-sync.test.ts index 7b14ab145..e1ec7b77f 100644 --- a/packages/shared-domain/src/cloud-sync.test.ts +++ b/packages/shared-domain/src/cloud-sync.test.ts @@ -74,6 +74,7 @@ describe('shouldSyncVaultPath', () => { 'assets/diagram.png', '.zennotes/vault.json', '.zennotes/comments/inbox/Note.md.comments.json', + '.zennotes/agent-edits/inbox/Note.md.json', '.zennotes/note-metadata/inbox/Note.md.metadata.json', '.zennotes/templates/meeting.md', '.zennotes/workflows/review.json' @@ -87,6 +88,7 @@ describe('shouldSyncVaultPath', () => { 'inbox/Note.md.tmp', 'inbox/2026-08-26 Wed.md.3252272.1787800172047252.tmp', 'inbox/Note.md.bak', + '.zennotes/agent-edits/inbox/Note.md.json.6f1c2a.tmp', '.zennotes/workspace.json', '.zennotes/mobile-note-meta-cache-v1.json', '.zennotes/deleted-assets/token/file.png', diff --git a/packages/shared-domain/src/cloud-sync.ts b/packages/shared-domain/src/cloud-sync.ts index 4b4ff3372..8e9e340f7 100644 --- a/packages/shared-domain/src/cloud-sync.ts +++ b/packages/shared-domain/src/cloud-sync.ts @@ -83,6 +83,9 @@ export function shouldSyncVaultPath(path: string): boolean { return ( lower === '.zennotes/vault.json' || lower.startsWith('.zennotes/comments/') || + // What a note said before an assistant edited it, so the change can be + // reviewed on whichever device the user picks up next. + (lower.startsWith('.zennotes/agent-edits/') && lower.endsWith('.json')) || (lower.startsWith('.zennotes/note-metadata/') && lower.endsWith('.metadata.json')) || lower.startsWith('.zennotes/templates/') || lower.startsWith('.zennotes/workflows/') diff --git a/packages/shared-domain/src/keymaps-catalog.ts b/packages/shared-domain/src/keymaps-catalog.ts index 184da08d9..12fa0e7d9 100644 --- a/packages/shared-domain/src/keymaps-catalog.ts +++ b/packages/shared-domain/src/keymaps-catalog.ts @@ -130,6 +130,10 @@ export const KEYMAP_CATALOG: KeymapCatalogEntry[] = [ { id: "vim.harperSuggest", group: "vim", defaultBinding: "z =", title: "Harper suggestions at cursor" }, { id: "vim.harperAddWord", group: "vim", defaultBinding: "z g", title: "Add word to Harper dictionary" }, { id: "vim.harperIgnore", group: "vim", defaultBinding: "z G", title: "Ignore Harper suggestion" }, + { id: "vim.reviewNextChange", group: "vim", defaultBinding: "] c", title: "Next change to review" }, + { id: "vim.reviewPreviousChange", group: "vim", defaultBinding: "[ c", title: "Previous change to review" }, + { id: "vim.reviewAcceptChange", group: "vim", defaultBinding: "d p", title: "Accept change" }, + { id: "vim.reviewRejectChange", group: "vim", defaultBinding: "d o", title: "Reject change" }, { id: "nav.moveDown", group: "navigation", defaultBinding: "j", title: "Move selection down" }, { id: "nav.moveUp", group: "navigation", defaultBinding: "k", title: "Move selection up" }, { id: "nav.moveLeft", group: "navigation", defaultBinding: "h", title: "Move selection left" }, @@ -159,6 +163,10 @@ export const KEYMAP_CATALOG: KeymapCatalogEntry[] = [ { id: "editor.moveLineDown", group: "view-actions", defaultBinding: "Alt+ArrowDown", title: "Move line down" }, { id: "editor.toggleCheckbox", group: "view-actions", defaultBinding: "Mod+L", title: "Toggle checkbox" }, { id: "editor.reflowParagraph", group: "view-actions", defaultBinding: "Alt+Q", defaultBindingMac: "Ctrl+Q", title: "Reflow paragraph" }, + { id: "editor.reviewNextChange", group: "view-actions", defaultBinding: "Alt+Mod+ArrowDown", title: "Next change to review" }, + { id: "editor.reviewPreviousChange", group: "view-actions", defaultBinding: "Alt+Mod+ArrowUp", title: "Previous change to review" }, + { id: "editor.reviewAcceptChange", group: "view-actions", defaultBinding: "Shift+Mod+Enter", title: "Accept change" }, + { id: "editor.reviewRejectChange", group: "view-actions", defaultBinding: "Shift+Mod+Backspace", title: "Reject change" }, { id: "nav.localEx", group: "view-actions", defaultBinding: ":", title: "Open local ex prompt" }, { id: "nav.newQuickNote", group: "view-actions", defaultBinding: "n", title: "New quick note from Quick Notes view" }, { id: "nav.unarchive", group: "view-actions", defaultBinding: "u", title: "Unarchive selected note" } From a8c9d8f13a84b3471465e3459c25005f3dd05bb2 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 12 Oct 2026 17:01:49 -0500 Subject: [PATCH 05/14] Fix(review): the folded-lines bar wears the theme instead of a white stripe Reviewing an assistant's edits folds a long unchanged stretch of the note into an "N unchanged lines" bar. @codemirror/merge paints that bar light or dark from CodeMirror's dark-theme flag, which ZenNotes never sets (its themes are CSS variables), so every dark theme drew a near-white stripe across the note. The bar now takes the theme's own raised background and muted text, and runs the full width of the editor like the change bands around it. Found while recording the release demo: reopening a review with one change left at the end of the note folded the ten lines above it. --- packages/app-core/src/styles/index.css | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index 45e46dede..d6d6ea847 100644 --- a/packages/app-core/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -6025,6 +6025,20 @@ html[data-completed-task-style="gray-strikethrough"] .prose-zen li.task-list-ite .cm-editor.zen-agent-review .cm-changedText { background: rgb(var(--z-green) / 0.24); } +/* The unchanged stretch folded away between changes. The merge view picks + this bar's grey from CodeMirror's dark-theme flag, which ZenNotes never sets + (its themes are CSS variables), so every dark theme drew a near-white bar. */ +.cm-editor.zen-agent-review .cm-collapsedLines { + background: transparent; + border-image-source: linear-gradient(rgb(var(--z-bg-1)), rgb(var(--z-bg-1))); + border-image-slice: 0 fill; + border-image-outset: 0 100vw 0 0; + color: rgb(var(--z-grey-1)); + font-size: 11px; +} +.cm-editor.zen-agent-review .cm-collapsedLines:hover { + color: rgb(var(--z-fg-2)); +} .cm-editor.zen-agent-review .cm-deletedChunk .zen-review-control { margin: 0 2px; padding: 1px 8px; From d7fc9cf74c9af23c5a46177a75602e340f91622d Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 12 Oct 2026 17:02:07 -0500 Subject: [PATCH 06/14] Feat(windows): native builds for Windows on ARM (#928) Every release shipped Windows as x64 only: win.target lists nsis and zip with no architecture, and the release's one Windows job ran on an x64 runner, so ARM PCs (Snapdragon X and the like) ran ZenNotes through Windows' emulation. Releases now carry ZenNotes--win-arm64.exe and .zip, built natively on GitHub's windows-11-arm runner by a new "Windows arm64" job running dist:win:arm64 (a named script, because PowerShell's npm.ps1 shim can swallow the `--` before --arm64). Updates. electron-builder names every Windows feed latest.yml whatever the architecture, and both jobs upload to one release, so the ARM64 job publishes its feed as latest-arm64.yml, and either job fails if its output carries the other architecture's file names. windowsUpdateChannel() points electron-updater at that feed on ARM64 Windows: for the native build, and for an x64 copy running under emulation (app.runningUnderARM64Translation), which moves to the native build with its next update, as electron-updater already does for an Intel copy under Rosetta on a Mac. Setting a channel also turns allowDowngrade on; it goes back off. The app ships nothing native and Windows bundles no zn, so the package itself needed no other change. Proof. Nobody here has an ARM64 Windows machine, so CI's build matrix gains windows-11-arm, which also packages the ARM64 app and runs the new tooling/scripts/packaged-launch-check.mjs on it: a CDP page target, the app drawn, and AArch64 in the executable's PE header (--expect-arch). A cross-build from macOS gave an all-AArch64 package (its NSIS step needs Wine there; a Windows runner does not). Not tested: an x64 install upgraded by the ARM64 installer on real hardware, which is electron-builder's ordinary NSIS upgrade path. The README lists both installers; zennotes.org's download section (the website repo) gets the ARM64 row. --- .github/workflows/ci.yml | 15 +- .github/workflows/release.yml | 31 +++- README.md | 5 +- apps/desktop/package.json | 1 + apps/desktop/src/main/updater.test.ts | 26 ++- apps/desktop/src/main/updater.ts | 33 ++++ tooling/scripts/packaged-launch-check.mjs | 197 ++++++++++++++++++++++ 7 files changed, 304 insertions(+), 4 deletions(-) create mode 100644 tooling/scripts/packaged-launch-check.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ea32f3bc..a103ef874 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,9 @@ jobs: - ubuntu-24.04-arm - macos-latest - windows-latest + # And its Windows counterpart, where the ARM64 release build comes + # from (#928). + - windows-11-arm steps: - name: Check out repository uses: actions/checkout@v6 @@ -56,7 +59,7 @@ jobs: run: npm ci - name: Verify terminal artifacts and legacy launchers - if: matrix.os != 'windows-latest' + if: runner.os != 'Windows' run: node --test tooling/scripts/terminal-artifact.test.mjs tooling/scripts/terminal-launcher.test.mjs - name: Verify standalone shared packages @@ -97,3 +100,13 @@ jobs: - name: Typecheck and build app run: npm run build:prod + + - name: Package the ARM64 Windows app and check it launches + if: matrix.os == 'windows-11-arm' + shell: bash + # No maintainer has an ARM64 Windows machine, so this is where the + # native build is proven to start before a release ships it (#928): + # a CDP page target, the app drawn, and an ARM64 executable. + run: | + npm exec --workspace @zennotes/desktop -- electron-builder --win dir --arm64 --publish never + node tooling/scripts/packaged-launch-check.mjs dist/win-arm64-unpacked/ZenNotes.exe --expect-arch arm64 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5349e778..9ddacd42b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,7 @@ on: - all - macOS - Windows + - Windows arm64 - Linux x64 - Linux arm64 keep: @@ -66,11 +67,15 @@ jobs: env: PLATFORM: ${{ inputs.platform || 'all' }} # "Linux arm64" is GitHub's free arm64 Linux runner for public repos: - # native aarch64 artifacts with no cross-compilation. + # native aarch64 artifacts with no cross-compilation. "Windows arm64" + # is its Windows counterpart (#928). Its script names the architecture + # (dist:win:arm64), so a toolchain that came up emulated as x64 cannot + # build an x64 copy that lands on the x64 job's file names. run: | all='[ {"name":"macOS","os":"macos-latest","command":"npm run dist:mac"}, {"name":"Windows","os":"windows-latest","command":"npm run dist:win"}, + {"name":"Windows arm64","os":"windows-11-arm","command":"npm run dist:win:arm64 --workspace @zennotes/desktop"}, {"name":"Linux x64","os":"ubuntu-latest","command":"npm run dist:linux"}, {"name":"Linux arm64","os":"ubuntu-24.04-arm","command":"npm run dist:linux"} ]' @@ -178,6 +183,8 @@ jobs: - name: Collect release artifacts id: collect shell: bash + env: + PLATFORM_NAME: ${{ matrix.name }} run: | mkdir -p release-assets find dist -maxdepth 1 -type f \ @@ -187,6 +194,28 @@ jobs: echo "No release artifacts found in dist/" >&2 exit 1 fi + # The two Windows jobs upload to the same release. Each must hold + # only its own architecture, or one overwrites the other's files. + case "${PLATFORM_NAME}" in + Windows) foreign='-win-arm64' ;; + "Windows arm64") foreign='-win-x64' ;; + *) foreign='' ;; + esac + if [ -n "${foreign}" ] && ls release-assets | grep -q -- "${foreign}"; then + echo "The ${PLATFORM_NAME} build produced ${foreign} files:" >&2 + ls release-assets >&2 + exit 1 + fi + if [ "${PLATFORM_NAME}" = "Windows arm64" ]; then + # electron-builder names every Windows feed latest.yml whatever the + # architecture; that name is the x64 job's. The ARM64 feed goes out + # as latest-arm64.yml, which the ARM64 app reads (updater.ts). + if [ ! -f release-assets/latest.yml ]; then + echo "The Windows arm64 build wrote no latest.yml." >&2 + exit 1 + fi + mv release-assets/latest.yml release-assets/latest-arm64.yml + fi ls -l release-assets - name: Keep the build as a workflow artifact diff --git a/README.md b/README.md index 4f961c709..26f190317 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,10 @@ Or download the `.dmg` for your chip, open it, and drag **ZenNotes** to Applicat ### Windows -Download and run `ZenNotes--win-x64.exe`. +Download and run the installer for your PC: + +- Intel and AMD: `ZenNotes--win-x64.exe` +- ARM (Snapdragon and other Windows on ARM): `ZenNotes--win-arm64.exe` ### Linux diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 79ca73743..8162e1c50 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -34,6 +34,7 @@ "pack": "npm run build:prod && electron-builder --dir", "dist:mac": "npm run build:prod && electron-builder --mac --publish never", "dist:win": "npm run build:prod && electron-builder --win --publish never", + "dist:win:arm64": "npm run build:prod && electron-builder --win --arm64 --publish never", "dist:linux": "npm run build:prod && electron-builder --linux --publish never" }, "dependencies": { diff --git a/apps/desktop/src/main/updater.test.ts b/apps/desktop/src/main/updater.test.ts index 0b66c3030..db3cb525a 100644 --- a/apps/desktop/src/main/updater.test.ts +++ b/apps/desktop/src/main/updater.test.ts @@ -43,7 +43,9 @@ import { osReleasePrettyName, OFFLINE_POLL_MS, OFFLINE_RETRY_BASE_MS, - OFFLINE_RETRY_MAX_MS + OFFLINE_RETRY_MAX_MS, + WINDOWS_ARM64_UPDATE_CHANNEL, + windowsUpdateChannel } from './updater' describe('installLabel (:version, issue #814)', () => { @@ -95,6 +97,28 @@ describe('installLabel (:version, issue #814)', () => { }) }) +describe('windowsUpdateChannel (#928)', () => { + const channel = (platform: NodeJS.Platform, arch: string, runningUnderARM64Translation = false) => + windowsUpdateChannel({ platform, arch, runningUnderARM64Translation }) + + it('sends the ARM64 build to the ARM64 feed the release publishes', () => { + expect(WINDOWS_ARM64_UPDATE_CHANNEL).toBe('latest-arm64') + expect(channel('win32', 'arm64')).toBe('latest-arm64') + }) + + it('moves an x64 copy under ARM64 emulation to the native build', () => { + expect(channel('win32', 'x64', true)).toBe('latest-arm64') + }) + + it('leaves x64 Windows, macOS and Linux on their default feeds', () => { + expect(channel('win32', 'x64')).toBeNull() + // electron-updater picks the Mac and Linux arm64 files itself. + expect(channel('darwin', 'arm64')).toBeNull() + expect(channel('darwin', 'x64', true)).toBeNull() + expect(channel('linux', 'arm64')).toBeNull() + }) +}) + describe('osReleasePrettyName', () => { it('reads PRETTY_NAME and strips its quotes', () => { expect( diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index 530fbf6c5..64a57ca03 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -298,6 +298,27 @@ export function getAppUpdateState(): AppUpdateState { return { ...updateState } } +/** The feed of the Windows ARM64 build (#928). electron-builder names every + * Windows feed `latest.yml`, whatever the architecture, so the release + * workflow publishes the x64 build's under that name and the ARM64 build's + * as `latest-arm64.yml`. electron-updater reads `.yml` on Windows. */ +export const WINDOWS_ARM64_UPDATE_CHANNEL = 'latest-arm64' + +/** The update channel a Windows copy reads, or null for the default feed. + * An x64 copy running under ARM64 emulation reads the ARM64 feed as well, so + * its next update is the native build: electron-updater already does that + * for an Intel copy under Rosetta on a Mac. */ +export function windowsUpdateChannel(input: { + platform: NodeJS.Platform + arch: string + runningUnderARM64Translation: boolean +}): string | null { + if (input.platform !== 'win32') return null + return input.arch === 'arm64' || input.runningUnderARM64Translation + ? WINDOWS_ARM64_UPDATE_CHANNEL + : null +} + export function initAppUpdater(): void { if (initialized) return initialized = true @@ -340,6 +361,18 @@ export function initAppUpdater(): void { updater = chosen updater.autoDownload = false updater.autoInstallOnAppQuit = true + const channel = windowsUpdateChannel({ + platform: process.platform, + arch: process.arch, + runningUnderARM64Translation: app.runningUnderARM64Translation + }) + if (channel) { + updater.channel = channel + // Setting a channel also allows downgrades (electron-updater's way back + // from a beta channel). This feed is the same release line, so a newer + // version stays the only update. + updater.allowDowngrade = false + } updater.on('checking-for-update', () => { setUpdateState( diff --git a/tooling/scripts/packaged-launch-check.mjs b/tooling/scripts/packaged-launch-check.mjs new file mode 100644 index 000000000..aeaa281b4 --- /dev/null +++ b/tooling/scripts/packaged-launch-check.mjs @@ -0,0 +1,197 @@ +// The honest launch check for a PACKAGED build (CLAUDE.md, verification): +// a CDP page target has to exist and the renderer has to draw the app. +// Electron keeps a crashed main process alive behind an error dialog, and the +// error goes to the dialog, not stdout, so "process alive" and clean logs +// both lie. +// +// node tooling/scripts/packaged-launch-check.mjs [--expect-arch x64|arm64] [-- ] +// +// CI runs it on GitHub's Windows ARM64 runner (#928): no maintainer has an +// ARM64 Windows machine, so that is where the native build is proven to start +// before a release ships it. `--expect-arch` reads the machine type from a +// Windows executable's PE header, so an x64 copy cannot pass for ARM64. +// +// Both stores are isolated, and a packaged app honors the overrides only with +// ZEN_PERF=1, so the check never opens anyone's real vault. +import { execFileSync, spawn } from 'node:child_process' +import { mkdir, mkdtemp, open, rm, writeFile } from 'node:fs/promises' +import net from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const PAGE_TIMEOUT_MS = 90_000 +const RENDER_TIMEOUT_MS = 60_000 + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +/** The machine type in a Windows executable's PE header. */ +async function peMachine(file) { + const handle = await open(file, 'r') + try { + const dos = Buffer.alloc(0x40) + await handle.read(dos, 0, dos.length, 0) + if (dos.toString('latin1', 0, 2) !== 'MZ') return null + const header = Buffer.alloc(6) + await handle.read(header, 0, header.length, dos.readUInt32LE(0x3c)) + if (header.toString('latin1', 0, 4) !== 'PE\0\0') return null + const machine = header.readUInt16LE(4) + return { 0x8664: 'x64', 0xaa64: 'arm64', 0x14c: 'ia32' }[machine] ?? `0x${machine.toString(16)}` + } finally { + await handle.close() + } +} + +/** On a Mac, the MacBook's own screen (test windows stay off the external + * display); elsewhere, the primary one. Cocoa frames are bottom-left based. */ +function windowState() { + const fallback = { x: 60, y: 60, width: 1200, height: 760, isMaximized: false } + if (process.platform !== 'darwin') return fallback + try { + const script = 'ObjC.import("AppKit"); const s = $.NSScreen.screens; const p = s.objectAtIndex(0).frame; let r = null; for (let i = 0; i < s.count; i++) { const sc = s.objectAtIndex(i); if (/Built-in/.test(ObjC.unwrap(sc.localizedName))) { const f = sc.frame; r = { x: f.origin.x, y: p.size.height - (f.origin.y + f.size.height) } } } JSON.stringify(r)' + const screen = JSON.parse(execFileSync('osascript', ['-l', 'JavaScript', '-e', script], { encoding: 'utf8' }).trim()) + return screen ? { ...fallback, x: Math.round(screen.x + 40), y: Math.round(screen.y + 40) } : fallback + } catch { + return fallback + } +} + +function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() + server.close(() => resolve(port)) + }) + server.on('error', reject) + }) +} + +function connect(url) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url) + let id = 0 + const pending = new Map() + ws.addEventListener('message', (event) => { + const message = JSON.parse(String(event.data)) + pending.get(message.id)?.(message) + pending.delete(message.id) + }) + ws.addEventListener('open', () => + resolve({ + evaluate: (expression) => + new Promise((done) => { + const call = ++id + pending.set(call, (message) => done(message.result?.result?.value)) + ws.send( + JSON.stringify({ + id: call, + method: 'Runtime.evaluate', + params: { expression, returnByValue: true, awaitPromise: true } + }) + ) + }), + close: () => ws.close() + }) + ) + ws.addEventListener('error', () => reject(new Error(`could not connect to ${url}`))) + }) +} + +async function main() { + const separator = process.argv.indexOf('--', 2) + const args = separator === -1 ? process.argv.slice(2) : process.argv.slice(2, separator) + const appArgs = separator === -1 ? [] : process.argv.slice(separator + 1) + const executable = args[0] + const archFlag = args.indexOf('--expect-arch') + const expectArch = archFlag === -1 ? null : args[archFlag + 1] + if (!executable || (archFlag !== -1 && !expectArch)) { + console.error('usage: packaged-launch-check.mjs [--expect-arch x64|arm64] [-- ]') + process.exit(2) + } + + const result = { executable, pageTarget: false, rendered: false } + if (expectArch) { + result.arch = await peMachine(executable) + if (result.arch !== expectArch) { + console.log(JSON.stringify(result)) + console.error(`wanted an executable built for ${expectArch}; this one is ${result.arch ?? 'not a Windows executable'}`) + process.exit(1) + } + } + + const root = await mkdtemp(join(tmpdir(), 'zn-launch-check-')) + const vault = join(root, 'vault') + const userData = join(root, 'user-data') + const config = join(root, 'config') + await mkdir(join(vault, 'inbox'), { recursive: true }) + await writeFile(join(vault, 'inbox', 'Hello.md'), '# Hello\n\nPackaged launch check.\n') + await mkdir(userData, { recursive: true }) + await mkdir(config, { recursive: true }) + await writeFile( + join(userData, 'zennotes.config.json'), + JSON.stringify({ + workspaceMode: 'local', + vaultRoot: vault, + remoteWorkspace: null, + remoteWorkspaceProfileId: null, + remoteWorkspaceProfiles: [], + windowState: windowState(), + zoomFactor: 1, + quickCaptureHotkey: '' + }) + ) + + const port = await freePort() + const started = Date.now() + const child = spawn(executable, [`--remote-debugging-port=${port}`, ...appArgs], { + env: { ...process.env, ZEN_PERF: '1', ZENNOTES_USER_DATA_PATH: userData, ZENNOTES_CONFIG_DIR: config }, + stdio: 'ignore' + }) + const exited = new Promise((resolve) => child.once('exit', resolve)) + // Electron's helper processes outlive a killed main process on Windows + // unless the whole tree goes. + const killTree = () => { + try { + if (process.platform === 'win32') execFileSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' }) + else child.kill('SIGKILL') + } catch {} + } + process.once('exit', killTree) + + try { + let page = null + while (!page && Date.now() - started < PAGE_TIMEOUT_MS) { + if (child.exitCode !== null) break + try { + const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json() + page = targets.find((t) => t.type === 'page' && t.webSocketDebuggerUrl && t.url.includes('index.html')) + } catch {} + if (!page) await sleep(250) + } + result.pageTarget = !!page + result.seconds = Number(((Date.now() - started) / 1000).toFixed(1)) + if (page) { + const cdp = await connect(page.webSocketDebuggerUrl) + // The sidebar, or the first-run setup in front of it: either way the + // renderer loaded its bundle and drew the app. + const drawn = `!!document.querySelector('.zn-sidebar-scroll') || [...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Skip setup')` + while (!result.rendered && Date.now() - started < PAGE_TIMEOUT_MS + RENDER_TIMEOUT_MS) { + result.rendered = (await cdp.evaluate(drawn)) === true + if (!result.rendered) await sleep(250) + } + result.version = await cdp.evaluate( + `(async () => { try { return (await window.zen?.getAppInfo?.())?.version ?? null } catch { return null } })()` + ) + cdp.close() + } + } finally { + killTree() + await Promise.race([exited, sleep(5000)]) + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 500 }).catch(() => {}) + } + + console.log(JSON.stringify(result)) + process.exit(result.pageTarget && result.rendered ? 0 : 1) +} + +await main() From 2e92989e7365d91bfcc371f7651b5af09b9bc84a Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 12 Oct 2026 17:02:23 -0500 Subject: [PATCH 07/14] Fix(tables): Preview keeps the column widths you set in the editor (#920) Dragging a column border in the editor saves the widths under the table (), and Preview did read them, but its table carried max-width: 100%: whenever the widths added up to more than the reading column, every column shrank to fit and the cells wrapped again (the reporter's "hypervisor" broke over two lines). Reading width stopped below Editor width, so a table that fit the editor could overflow Preview. The editor did the same once a table outgrew it: its wrapper's max-width: 100%, from 2.4.0, predates the widths and kept #294's "scrolls if wide" from ever happening, so a drag past the edge squeezed the other columns instead of growing the table. Now a table with set widths is exactly as wide as its columns in both places, and scrolls sideways when that is wider than the column. In the editor the wrapper grows with the table (the add-column rail stays at its edge) and the widget scrolls. In Preview the table goes into a .zen-table-scroll wrapper, which takes over its data-source-line, because split scroll sync reads that stamp from direct children only. A page cannot scroll, so print media keeps the fit to the page for PDF. Reading width now goes to 1600 like Editor width (the store already clamped at 1600), and a new help topic, mirrored on the website, says where the widths live. Checked in the built app over CDP: a 300/900/300 table draws 300/900/300 in the editor, Preview and split view, each scrolling, and 177/501/177 within the page under print media; tables that fit and tables without widths are unchanged; dragging a column 500 px to the right grows the table and writes the new width to the note. --- .../app-core/src/components/SettingsModal.tsx | 2 +- packages/app-core/src/lib/help.ts | 5 +++ packages/app-core/src/lib/markdown.test.ts | 8 +++++ packages/app-core/src/lib/markdown.ts | 20 ++++++++++-- packages/app-core/src/styles/index.css | 32 ++++++++++++++++--- 5 files changed, 60 insertions(+), 7 deletions(-) diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index d91807447..4c752a5c2 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -3678,7 +3678,7 @@ export function SettingsModal(): JSX.Element { description="Maximum width for preview and split-preview content." value={previewMaxWidth} min={640} - max={1400} + max={1600} step={20} unit="px" settingId="reading-width" diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 1ee5dac59..1596588fc 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -126,6 +126,11 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ body: 'Run “New Database” from the command palette (or right-click a folder in the sidebar → New database) to create one, or just open an existing `.csv` file from the vault. ZenNotes stores a database as a `.base` folder: `data.csv` (the rows), `schema.json` (field types, select options, saved views), and the record-page notes rows open into. A database opens from its sidebar row like a note: click it, or press Enter or `l` on it in Vim mode (the row’s `m` menu carries Open database too); the chevron still expands the folder to browse its record pages, and the open grid closes like any tab. Edit cells inline in the Table view, group records in a Board by any select field, switch the raw-CSV toggle to see the underlying file, and press `o` on a row to open it as a full Markdown page whose frontmatter mirrors the row’s properties. The whole grid is keyboard-driven — see the Database grid shortcuts.' }, + { + title: 'Set the width of a table column', + body: + 'In the editor, drag the right border of a column’s header to set how wide it is. The note keeps the widths on a line under the table, ``, which other Markdown apps never show, and Preview, split view and PDF export draw the same widths. A table wider than its column scrolls sideways, in the editor and in Preview alike, instead of squeezing every column; a PDF page cannot scroll, so there the table shrinks to fit. For more room in Preview, raise Settings → Typography → Reading width (up to 1600 px, like Editor width).' + }, { title: 'Convert a Markdown table into a database', body: diff --git a/packages/app-core/src/lib/markdown.test.ts b/packages/app-core/src/lib/markdown.test.ts index f9c8d6618..0c1fe1a77 100644 --- a/packages/app-core/src/lib/markdown.test.ts +++ b/packages/app-core/src/lib/markdown.test.ts @@ -247,6 +247,14 @@ describe('table column widths (#294)', () => { const html = renderMarkdown('| A | B |\n| --- | --- |\n| 1 | 2 |\n') expect(html).not.toContain('colgroup') expect(html).not.toContain('zen-has-col-widths') + expect(html).not.toContain('zen-table-scroll') + }) + it('wraps a table with set widths so a wide one scrolls instead of squeezing (#920)', () => { + const html = renderMarkdown('# Plan\n\n| A | B |\n| --- | --- |\n| 1 | 2 |\n\n') + expect(html).toMatch(/
/) + // Scroll sync reads the stamp from the preview's direct children only, so + // it moves to the wrapper rather than staying on the wrapped table. + expect(html).not.toMatch(/]*data-source-line/) }) }) diff --git a/packages/app-core/src/lib/markdown.ts b/packages/app-core/src/lib/markdown.ts index a48b16d27..ee3d0ebab 100644 --- a/packages/app-core/src/lib/markdown.ts +++ b/packages/app-core/src/lib/markdown.ts @@ -832,6 +832,13 @@ function rehypeCustomCodeLanguages() { * turn it into a so the preview and PDF export render the columns at * the widths set by the live-table resize handles. The comment node itself is * dropped by the sanitizer. Runs after rehypeRaw so the comment is a hast node. + * + * The table goes into a `.zen-table-scroll` wrapper that scrolls sideways + * when the columns add up to more than the reading width, as the editor's + * table does (#920). Squeezing the table to fit instead shrank every column + * and re-wrapped the cells the widths were set to keep on one line. The + * wrapper takes over the table's `data-source-line`, because scroll sync + * reads that stamp from the preview's direct children only. */ function rehypeTableColWidths() { return (tree: HastRoot): void => { @@ -862,8 +869,17 @@ function rehypeTableColWidths() { })) } as unknown as HastElement node.children = [colgroup, ...(node.children ?? [])] as HastElement['children'] - const cls = (node.properties?.className as string[] | undefined) ?? [] - node.properties = { ...(node.properties ?? {}), className: [...cls, 'zen-has-col-widths'] } + const { 'data-source-line': sourceLine, dataSourceLine, ...properties } = (node.properties ?? + {}) as Record + const cls = (properties.className as string[] | undefined) ?? [] + node.properties = { ...properties, className: [...cls, 'zen-has-col-widths'] } as HastElement['properties'] + const line = sourceLine ?? dataSourceLine + siblings[index] = { + type: 'element', + tagName: 'div', + properties: { className: ['zen-table-scroll'], ...(line != null ? { 'data-source-line': line } : {}) }, + children: [node] + } as unknown as AnyNode }) } } diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index d6d6ea847..22824965a 100644 --- a/packages/app-core/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -3027,12 +3027,21 @@ html[data-completed-task-style="gray-strikethrough"] .prose-zen li.task-list-ite margin: var(--z-prose-block-gap) 0; font-size: 0.94em; } -/* A table with persisted column widths (#294): honor the exactly via - fixed layout (sizes to the columns' sum) instead of stretching to 100%. */ +/* A table with persisted column widths (#294): fixed layout makes the + authoritative, so the table is exactly as wide as its columns + instead of stretching to 100%. Wider than the reading column, it scrolls + sideways in its wrapper (markdown.ts) as the editor's table does, instead + of squeezing every column (#920). */ +.prose-zen .zen-table-scroll { + margin: var(--z-prose-block-gap) 0; + max-width: 100%; + overflow-x: auto; + overflow-y: hidden; +} .prose-zen table.zen-has-col-widths { table-layout: fixed; - width: auto; - max-width: 100%; + width: max-content; + margin: 0; } .prose-zen table.zen-has-col-widths th, .prose-zen table.zen-has-col-widths td { @@ -3051,6 +3060,13 @@ html[data-completed-task-style="gray-strikethrough"] .prose-zen li.task-list-ite .prose-zen table { max-width: 100%; } + /* A page cannot scroll: a table with set widths shrinks to fit it again. */ + .prose-zen .zen-table-scroll { + overflow: visible; + } + .prose-zen table.zen-has-col-widths { + width: auto; + } .prose-zen th, .prose-zen td { overflow-wrap: anywhere; @@ -5292,6 +5308,13 @@ html[data-completed-task-style="gray-strikethrough"] .prose-zen li.task-list-ite width: fit-content; max-width: 100%; } +/* With set widths the wrapper grows with its table, so the add-column rail + stays at the table's edge and the widget scrolls sideways (#920). Capped at + the editor's width, as before #294, the table squeezed its columns below the + widths it stored, and Preview, which honors them, disagreed with it. */ +.cm-table-wrapper:has(> table.cm-table-fixed) { + max-width: none; +} .cm-table-widget table { border-collapse: collapse; border-spacing: 0; @@ -5300,6 +5323,7 @@ html[data-completed-task-style="gray-strikethrough"] .prose-zen li.task-list-ite widths authoritative; the table sizes to their sum and scrolls if wide. */ .cm-table-widget table.cm-table-fixed { table-layout: fixed; + width: max-content; } .cm-table-widget table.cm-table-fixed .cm-table-cell { overflow-wrap: anywhere; From 12ec2d3867cba0c239cf7d44527cde66378044a7 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 12 Oct 2026 17:54:12 -0500 Subject: [PATCH 08/14] Feat(app): What's New after every update, with the ZenNotes Cloud card ZenNotes had no way to tell people what an update brought, or that ZenNotes Cloud exists, beyond release notes few read. Now the first launch after an update opens a short window: the version's highlights, a ZenNotes Cloud card (Set up ZenNotes Cloud opens Settings at Cloud, Plans and pricing goes to the site), and Full release notes on the releases page of zennotes.org. Escape, Enter on Close or the backdrop dismisses it; What's New in ZenNotes in the palette and :whatsnew bring it back. The highlights are bundled (lib/whats-new.ts), one entry per release, written in the release PR: the window works offline, and its copy is reviewed with the code it describes. An update across several versions shows each entry in between; a version without one still opens the window, with the Cloud card and the link to its notes. The Cloud card is part of every update's window, left out for someone already on Cloud (asked before the window opens, so it never flashes and vanishes). Once per version: the machine-local lastSeenWhatsNewVersion is recorded before the window opens, so a second window or a crash cannot show it twice. A fresh install records its version and opens nothing, the onboarding being its welcome; whether a launch is a returning user's is read with the first prefs load, so skipping the onboarding within the window's 1.2 s delay does not count. A downgrade records and opens nothing. Desktop only: the phones announce through their stores, and a self-hosted web app updates with its server. whatsNewOpen joins VimNav's skip list and the command palette's overlay flags. Without that, j moved the sidebar under the open window, and the palette could hand focus back to the editor behind it. Checked in the built app over CDP: the palette path (7/7), the first launch after an update on a build made as 2.68.0 (8/8: a true fresh install opens nothing, a returning user gets the window once, j scrolls it in Vim mode, Enter closes it), and a build made as 2.68.1, which has no entry (the Cloud card and the pointer to the notes). --- packages/app-core/src/App.tsx | 20 +++ packages/app-core/src/components/Editor.tsx | 4 + packages/app-core/src/components/VimNav.tsx | 1 + .../app-core/src/components/WhatsNewModal.tsx | 112 +++++++++++++++ .../src/lib/command-palette-mode.test.ts | 3 +- .../app-core/src/lib/command-palette-mode.ts | 3 +- packages/app-core/src/lib/commands.ts | 8 ++ packages/app-core/src/lib/help.ts | 6 + packages/app-core/src/lib/whats-new.test.ts | 111 +++++++++++++++ packages/app-core/src/lib/whats-new.ts | 133 ++++++++++++++++++ packages/app-core/src/store.ts | 85 ++++++++++- .../app-core/src/whats-new-launch.test.ts | 92 ++++++++++++ 12 files changed, 573 insertions(+), 5 deletions(-) create mode 100644 packages/app-core/src/components/WhatsNewModal.tsx create mode 100644 packages/app-core/src/lib/whats-new.test.ts create mode 100644 packages/app-core/src/lib/whats-new.ts create mode 100644 packages/app-core/src/whats-new-launch.test.ts diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index e2f6ad029..75ba6f2b5 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -223,6 +223,12 @@ const SettingsModal = lazy(async () => { return { default: module.SettingsModal } }) +const WhatsNewModal = lazy(() => import('./components/WhatsNewModal')) + +/** How long after launch What's New waits, so it opens over a settled + * window rather than racing the vault and the restored tabs. */ +const WHATS_NEW_DELAY_MS = 1200 + const EmptyVault = lazy(async () => { const module = await import('./components/EmptyVault') return { default: module.EmptyVault } @@ -387,6 +393,7 @@ function App(): JSX.Element { const unifiedSidebar = useStore((s) => s.unifiedSidebar) const settingsOpen = useStore((s) => s.settingsOpen) const setSettingsOpen = useStore((s) => s.setSettingsOpen) + const whatsNewOpen = useStore((s) => s.whatsNewOpen) const themeId = useStore((s) => s.themeId) const themeFamily = useStore((s) => s.themeFamily) const themeMode = useStore((s) => s.themeMode) @@ -421,6 +428,14 @@ function App(): JSX.Element { void init() }, [init]) + // The first launch after an update shows what it brought (lib/whats-new.ts). + useEffect(() => { + const timer = window.setTimeout(() => { + void useStore.getState().showWhatsNewAfterUpdate() + }, WHATS_NEW_DELAY_MS) + return () => window.clearTimeout(timer) + }, []) + useEffect(() => { if (!vault) return undefined ensureCloudAutoSyncStarted(vault.root) @@ -1247,6 +1262,11 @@ function App(): JSX.Element { )} + {whatsNewOpen && ( + + + + )} diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index 2c38c052e..e5c33f544 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -781,6 +781,10 @@ function registerVimCommands(): void { }, ); + Vim.defineEx("whatsnew", "whatsnew", () => { + void useStore.getState().openWhatsNew(); + }); + Vim.defineEx("daily", "daily", () => { void useStore.getState().openTodayDailyNote(); }); diff --git a/packages/app-core/src/components/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx index 9db242441..08d31f0fd 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -428,6 +428,7 @@ export function VimNav(): JSX.Element | null { state.searchOpen || state.vaultTextSearchOpen || state.settingsOpen || + state.whatsNewOpen || state.commandPaletteOpen || state.bufferPaletteOpen ) return diff --git a/packages/app-core/src/components/WhatsNewModal.tsx b/packages/app-core/src/components/WhatsNewModal.tsx new file mode 100644 index 000000000..0799651ed --- /dev/null +++ b/packages/app-core/src/components/WhatsNewModal.tsx @@ -0,0 +1,112 @@ +import { useRef, type KeyboardEvent as ReactKeyboardEvent } from 'react' +import { useStore } from '../store' +import { requestSettingsTarget } from '../lib/settings-navigation' +import { CLOUD_ANNOUNCEMENT, RELEASE_NOTES_URL, type WhatsNewEntry } from '../lib/whats-new' +import { Button, Modal } from './ui' + +const TITLE_ID = 'whats-new-title' +const SCROLL_STEP = 56 + +function openExternal(url: string): void { + window.open(url, '_blank', 'noopener,noreferrer') +} + +function CloudCard({ onSetUp }: { onSetUp: () => void }): JSX.Element { + return ( +
+
{CLOUD_ANNOUNCEMENT.title}
+

{CLOUD_ANNOUNCEMENT.body}

+
+ + +
+
+ ) +} + +function Highlights({ entry, showVersion }: { entry: WhatsNewEntry; showVersion: boolean }): JSX.Element { + return ( +
+
+ {showVersion ? `New in ${entry.version}` : 'In this update'} +
+
    + {entry.highlights.map((item) => ( +
  • +
    {item.title}
    +
    {item.body}
    +
  • + ))} +
+
+ ) +} + +/** + * What's New: opened by the first launch after every update, and from the + * palette and `:whatsnew` (lib/whats-new.ts holds the entries and the rules). + * Escape, Enter on Close, or the backdrop close it. The arrows scroll it, and + * j / k in Vim mode. + */ +export default function WhatsNewModal(): JSX.Element | null { + const view = useStore((s) => s.whatsNew) + const close = useStore((s) => s.closeWhatsNew) + const vimMode = useStore((s) => s.vimMode) + const closeRef = useRef(null) + const bodyRef = useRef(null) + if (!view) return null + + const setUpCloud = (): void => { + close() + requestSettingsTarget('cloud') + useStore.getState().setSettingsOpen(true) + } + + const onKeyDown = (e: ReactKeyboardEvent): void => { + if (e.metaKey || e.ctrlKey || e.altKey) return + const down = e.key === 'ArrowDown' || (vimMode && e.key === 'j') + const up = e.key === 'ArrowUp' || (vimMode && e.key === 'k') + if (!down && !up) return + e.preventDefault() + bodyRef.current?.scrollBy({ top: down ? SCROLL_STEP : -SCROLL_STEP }) + } + + return ( + +
+ +
+ {view.showCloud && } + {view.entries.length > 0 ? ( + view.entries.map((entry) => ( + 1} /> + )) + ) : ( +

+ What changed in {view.version} is in the full release notes. +

+ )} +
+ + + + +
+
+ ) +} diff --git a/packages/app-core/src/lib/command-palette-mode.test.ts b/packages/app-core/src/lib/command-palette-mode.test.ts index 5b38dd9f7..111c99b99 100644 --- a/packages/app-core/src/lib/command-palette-mode.test.ts +++ b/packages/app-core/src/lib/command-palette-mode.test.ts @@ -32,7 +32,8 @@ describe('shouldRefocusEditorAfterCommand', () => { bufferPaletteOpen: false, outlinePaletteOpen: false, templatePaletteOpen: false, - embedDrawingPaletteOpen: false + embedDrawingPaletteOpen: false, + whatsNewOpen: false } it('hands focus to the editor when a command lands on it', () => { diff --git a/packages/app-core/src/lib/command-palette-mode.ts b/packages/app-core/src/lib/command-palette-mode.ts index 5be44379b..449ec218f 100644 --- a/packages/app-core/src/lib/command-palette-mode.ts +++ b/packages/app-core/src/lib/command-palette-mode.ts @@ -29,7 +29,8 @@ export const COMMAND_OVERLAY_FLAGS = [ 'bufferPaletteOpen', 'outlinePaletteOpen', 'templatePaletteOpen', - 'embedDrawingPaletteOpen' + 'embedDrawingPaletteOpen', + 'whatsNewOpen' ] as const export type CommandOverlayFlag = (typeof COMMAND_OVERLAY_FLAGS)[number] diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 23a515b3d..84d9eb99b 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -850,6 +850,14 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma keywords: 'release download changelog latest', run: () => openExternal(APP_RELEASES_URL) }, + { + id: 'app.whatsNew', + title: 'What’s New in ZenNotes', + category: 'App', + keywords: "whats what's new release notes changelog update announcement", + when: () => window.zen.getAppInfo().runtime === 'desktop', + run: () => void getState().openWhatsNew() + }, { id: 'app.report-issue', title: 'Report an Issue', diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 1596588fc..ea76ba43c 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -848,6 +848,11 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ summary: 'Show version details for a bug report', detail: 'Print the ZenNotes version, operating system and architecture, the Electron or browser engine, how this copy was installed (AppImage, deb, package manager, macOS app bundle, and so on), and the remote server version when connected to one. `:version copy` (or `:version!`) also puts the lines on the clipboard. The same details, with a Copy button, sit in Settings → About.' }, + { + command: ':whatsnew', + summary: 'Show what this version brought', + detail: 'Open the What’s New window for this version of ZenNotes: its highlights and any announcement, with a link to the full release notes. The window also opens by itself the first time ZenNotes starts after an update, once per version. Desktop app.' + }, { command: ':daily', summary: "Open today's daily note", @@ -1229,6 +1234,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ items: [ { label: 'App identity', detail: 'See the ZenNotes app icon, current version, and a short description of the app as a keyboard-first markdown workflow with Vim motions and plain local files.' }, { label: 'Version details', detail: 'Under the version, a Version details block lists what a bug report needs: operating system and architecture, the Electron or browser engine, how this copy was installed, and the remote server version when connected to one. Copy details puts the block on the clipboard. In Vim mode, `:version` prints the same lines and `:version copy` copies them.' }, + { label: 'What’s New', detail: 'The first time ZenNotes starts after an update, a window shows what the version brought, once: its highlights, any announcement, and a link to the full release notes. Escape or Close dismisses it. Open it again any time with What’s New in ZenNotes in the command palette, or `:whatsnew` in Vim mode.' }, { label: 'Updates and releases', detail: 'Check for updates, download a newer build, install and relaunch, or jump straight to the latest GitHub release from inside the app. AUR and tarball installs get the check and the notice only; the package manager does the install.' }, { label: 'Website, community, and issue links', detail: 'The app now exposes direct links to the ZenNotes website, Discord, GitHub repository, and issue tracker so support paths stay discoverable.' }, { label: 'Configuration file', detail: 'Your preferences (theme, editor, Vim, keymaps, fonts, search backend, and more) are mirrored to a plain-text `config.toml` so you can sync them across machines with git, stow, or chezmoi. It lives at `$XDG_CONFIG_HOME/zennotes/config.toml` (`~/.config/zennotes/config.toml` on macOS and Linux, `%APPDATA%\\zennotes\\config.toml` on Windows), or wherever `$ZENNOTES_CONFIG_DIR` points. The file is self-documenting: every available setting is listed with its allowed values, and every keymap action is listed with its default binding (commented out: uncomment a line and edit it to remap, or set it to `""` to remove the key entirely), so you can discover and change anything without opening the app. That list reads the same on every OS: where macOS has its own default, the line notes it, as in `# "tabs.select1" = "Alt+1" # Go to tab 1 (macOS: "Ctrl+1")`, so a file synced between a Mac and a Linux or Windows machine changes only when you change a setting. Settings → About has Reveal and Copy-path buttons. Existing setups are written out automatically the first time you launch this version, and edits to the file, by hand or via a synced dotfile, apply live without a restart. Machine-specific layout (window size, pane widths, collapsed folders) stays local so the file does not churn.' }, diff --git a/packages/app-core/src/lib/whats-new.test.ts b/packages/app-core/src/lib/whats-new.test.ts new file mode 100644 index 000000000..74525c5ed --- /dev/null +++ b/packages/app-core/src/lib/whats-new.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import { + CLOUD_ANNOUNCEMENT, + compareVersions, + RELEASE_NOTES_URL, + WHATS_NEW, + whatsNewAtLaunch, + whatsNewBetween, + whatsNewOnDemand, + type WhatsNewEntry +} from './whats-new' + +const entries: WhatsNewEntry[] = [ + { version: '2.68.0', highlights: [{ title: 'a', body: 'a' }] }, + { version: '2.70.0', highlights: [{ title: 'c', body: 'c' }] }, + { version: '2.69.0', highlights: [{ title: 'b', body: 'b' }] } +] +const versions = (list: WhatsNewEntry[]): string[] => list.map((e) => e.version) + +describe('compareVersions', () => { + it('orders dotted versions numerically, a prerelease below its release', () => { + expect(compareVersions('2.68.0', '2.9.0')).toBe(1) + expect(compareVersions('v2.68.0', '2.68.0')).toBe(0) + expect(compareVersions('2.68.0-beta.1', '2.68.0')).toBe(-1) + }) +}) + +describe('whatsNewAtLaunch', () => { + it('shows a fresh install nothing, but records the version', () => { + expect(whatsNewAtLaunch({ lastSeen: null, current: '2.69.0', returningUser: false, entries })).toEqual({ + updated: false, + entries: [], + record: true + }) + }) + + it('opens for someone coming from a version without the window, with everything up to now', () => { + const result = whatsNewAtLaunch({ lastSeen: null, current: '2.69.0', returningUser: true, entries }) + expect(result.updated).toBe(true) + expect(versions(result.entries)).toEqual(['2.69.0', '2.68.0']) + expect(result.record).toBe(true) + }) + + it('opens with the entries since the version last seen, newest first', () => { + const result = whatsNewAtLaunch({ lastSeen: '2.68.0', current: '2.70.0', returningUser: true, entries }) + expect(result.updated).toBe(true) + expect(versions(result.entries)).toEqual(['2.70.0', '2.69.0']) + }) + + it('opens after every update, a version with no entry included', () => { + expect(whatsNewAtLaunch({ lastSeen: '2.70.0', current: '2.70.1', returningUser: true, entries })).toEqual({ + updated: true, + entries: [], + record: true + }) + }) + + it('does nothing on a relaunch of the same version', () => { + expect(whatsNewAtLaunch({ lastSeen: '2.69.0', current: '2.69.0', returningUser: true, entries })).toEqual({ + updated: false, + entries: [], + record: false + }) + }) + + it('records a downgrade without opening', () => { + expect(whatsNewAtLaunch({ lastSeen: '2.70.0', current: '2.69.0', returningUser: true, entries })).toEqual({ + updated: false, + entries: [], + record: true + }) + }) +}) + +describe('whatsNewOnDemand', () => { + it("opens this version's entry, or the newest below it", () => { + expect(versions(whatsNewOnDemand('2.69.0', entries))).toEqual(['2.69.0']) + expect(versions(whatsNewOnDemand('2.69.4', entries))).toEqual(['2.69.0']) + }) + + it('opens the newest entry for a development build ahead of its notes', () => { + expect(versions(whatsNewOnDemand('2.67.0', entries))).toEqual(['2.70.0']) + }) + + it('reads the entries between two versions', () => { + expect(versions(whatsNewBetween('2.68.0', '2.69.0', entries))).toEqual(['2.69.0']) + }) +}) + +describe('the bundled entries', () => { + it('are unique versions, each with something to say', () => { + const seen = new Set() + for (const entry of WHATS_NEW) { + expect(entry.version).toMatch(/^\d+\.\d+\.\d+$/) + expect(seen.has(entry.version)).toBe(false) + seen.add(entry.version) + expect(entry.highlights.length).toBeGreaterThan(0) + } + }) + + it('follow the house style: no em dash, links over https', () => { + for (const copy of [...WHATS_NEW, CLOUD_ANNOUNCEMENT].map((x) => JSON.stringify(x))) { + expect(copy).not.toContain('\u2014') + } + expect(CLOUD_ANNOUNCEMENT.link.url).toMatch(/^https:\/\//) + }) + + it('send Full release notes to the release on the website', () => { + expect(RELEASE_NOTES_URL('2.68.0')).toBe('https://zennotes.org/releases#v2.68.0') + }) +}) diff --git a/packages/app-core/src/lib/whats-new.ts b/packages/app-core/src/lib/whats-new.ts new file mode 100644 index 000000000..4f149ed91 --- /dev/null +++ b/packages/app-core/src/lib/whats-new.ts @@ -0,0 +1,133 @@ +/** + * What's New: a window on the first launch after every update, and on demand + * from the palette ("What’s New in ZenNotes") and `:whatsnew`. + * + * The highlights are bundled on purpose (Adib, 2026-10-12): the window works + * offline and opens at once, and its copy is reviewed in the release PR like + * the code it describes. Add an entry in the release PR of every release + * worth announcing. An update across several versions shows each entry in + * between, newest first; a version without one still opens the window, with + * the Cloud card and the link to its release notes on the website. + * + * Desktop only: the phones announce through their stores, and a self-hosted + * web app updates with its server. + */ + +export interface WhatsNewEntry { + version: string + highlights: ReadonlyArray<{ title: string; body: string }> +} + +/** What the window shows. */ +export interface WhatsNewView { + /** The version the window is about: the one just installed. */ + version: string + entries: WhatsNewEntry[] + /** The Cloud card, left out for someone already on Cloud. */ + showCloud: boolean +} + +/** The ZenNotes Cloud card: a fixture of every What's New, not one release's + * news (Adib, 2026-10-12). */ +export const CLOUD_ANNOUNCEMENT = { + title: 'ZenNotes Cloud', + body: 'Keep your vaults in sync on every device, backed up every day, and publish notes to the web.', + settingsLabel: 'Set up ZenNotes Cloud', + link: { label: 'Plans and pricing', url: 'https://zennotes.org/pricing' } +} as const + +export const WHATS_NEW: readonly WhatsNewEntry[] = [ + { + version: '2.68.0', + highlights: [ + { + title: 'Review an assistant’s edits', + body: 'When Claude Code, Codex or another assistant changes a note through MCP, review each change in the note itself and keep or undo it.' + }, + { + title: 'Windows on ARM', + body: 'A native build for Snapdragon and other ARM PCs, so ZenNotes no longer runs through emulation there.' + }, + { + title: 'Links on Windows', + body: 'zennotes:// links open ZenNotes on Windows, and connecting ZenNotes Cloud finishes there.' + }, + { + title: 'Headings stay headings', + body: 'Tab no longer turns a heading into a code block.' + }, + { + title: 'Table widths in Preview', + body: 'Column widths you set in the editor carry into Preview, and a wide table scrolls instead of squeezing its columns.' + } + ] + } +] + +/** The version's entry on the website's releases page (an anchor per release). */ +export const RELEASE_NOTES_URL = (version: string): string => `https://zennotes.org/releases#v${version}` + +/** Dotted numeric parts compared left to right; a prerelease suffix + * (`2.68.0-beta.1`) sorts below its release. */ +export function compareVersions(a: string, b: string): number { + const split = (v: string): { parts: number[]; pre: string } => { + const [core, ...rest] = v.replace(/^v/, '').split('-') + return { parts: core.split('.').map((p) => Number.parseInt(p, 10) || 0), pre: rest.join('-') } + } + const x = split(a) + const y = split(b) + for (let i = 0; i < Math.max(x.parts.length, y.parts.length); i += 1) { + const d = (x.parts[i] ?? 0) - (y.parts[i] ?? 0) + if (d !== 0) return d < 0 ? -1 : 1 + } + if (x.pre === y.pre) return 0 + if (!x.pre) return 1 + if (!y.pre) return -1 + return x.pre < y.pre ? -1 : 1 +} + +/** The entries newer than `after` (all when null) up to `current`, newest first. */ +export function whatsNewBetween( + after: string | null, + current: string, + entries: readonly WhatsNewEntry[] = WHATS_NEW +): WhatsNewEntry[] { + return entries + .filter( + (e) => + compareVersions(e.version, current) <= 0 && + (after === null || compareVersions(e.version, after) > 0) + ) + .sort((a, b) => compareVersions(b.version, a.version)) +} + +/** + * What the first launch of `current` does. `updated` opens the window. A fresh + * install sees nothing (the onboarding is its welcome) but records the + * version. A returning user who has never seen the window, because they come + * from a version without it, gets every entry up to `current`; otherwise the + * entries since the version they last saw. A downgrade shows nothing and + * records the version, so the way back up opens the window again. + */ +export function whatsNewAtLaunch(input: { + lastSeen: string | null + current: string + returningUser: boolean + entries?: readonly WhatsNewEntry[] +}): { updated: boolean; entries: WhatsNewEntry[]; record: boolean } { + const { lastSeen, current, returningUser, entries = WHATS_NEW } = input + const none = { updated: false, entries: [] as WhatsNewEntry[] } + if (lastSeen !== null && compareVersions(lastSeen, current) === 0) return { ...none, record: false } + if (lastSeen === null && !returningUser) return { ...none, record: true } + if (lastSeen !== null && compareVersions(lastSeen, current) > 0) return { ...none, record: true } + return { updated: true, entries: whatsNewBetween(lastSeen, current, entries), record: true } +} + +/** The palette's entries: this version's, or the newest at or below it, or + * (a development build ahead of its notes) the newest there is. */ +export function whatsNewOnDemand(current: string, entries: readonly WhatsNewEntry[] = WHATS_NEW): WhatsNewEntry[] { + const upToNow = whatsNewBetween(null, current, entries) + if (upToNow.length) return upToNow.slice(0, 1) + const newest = [...entries].sort((a, b) => compareVersions(b.version, a.version))[0] + return newest ? [newest] : [] +} diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 42e36ac10..bb4fe4651 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -260,6 +260,7 @@ import { normalizeIgnoredKeys } from './lib/ignored-keys' import { normalizeApplicationSchemes } from '@shared/application-links' import { normalizeEditorTabSize } from './lib/editor-tab-size' import { recentNoteToggleTarget } from './lib/recent-note-toggle' +import { whatsNewAtLaunch, whatsNewOnDemand, type WhatsNewView } from './lib/whats-new' import type { NoteSortOrder } from './lib/note-order' export type { NoteSortOrder } from './lib/note-order' @@ -771,6 +772,10 @@ interface Prefs { kanbanFolderRoot: string /** True once the user has dismissed the first-run onboarding wizard. */ hasCompletedOnboarding: boolean + /** The version whose What’s New this device last showed, or on a fresh + * install the version it started on (lib/whats-new.ts). Machine-local, not + * portable: every copy of the app updates on its own. */ + lastSeenWhatsNewVersion: string | null } export type TasksViewMode = 'list' | 'calendar' | 'kanban' @@ -1184,7 +1189,8 @@ export const DEFAULT_PREFS: Prefs = { kanbanCardOrder: {}, kanbanStatuses: [], kanbanFolderRoot: '', - hasCompletedOnboarding: false + hasCompletedOnboarding: false, + lastSeenWhatsNewVersion: null } /** Coerce any loaded prefs blob into a valid Prefs object, dropping * anything unknown (e.g. tokyo-night left over from earlier versions). */ @@ -1539,7 +1545,11 @@ function normalizePrefs(p: Partial): Prefs { hasCompletedOnboarding: typeof p.hasCompletedOnboarding === 'boolean' ? p.hasCompletedOnboarding - : DEFAULT_PREFS.hasCompletedOnboarding + : DEFAULT_PREFS.hasCompletedOnboarding, + lastSeenWhatsNewVersion: + typeof p.lastSeenWhatsNewVersion === 'string' && p.lastSeenWhatsNewVersion + ? p.lastSeenWhatsNewVersion + : DEFAULT_PREFS.lastSeenWhatsNewVersion } } // --- Portable config file integration (desktop) ----------------------------- @@ -1549,6 +1559,10 @@ function normalizePrefs(p: Partial): Prefs { // and the web fallback. `getConfigSync()` returns null on web (and when the // bridge is absent, e.g. tests) — we then behave exactly as before. let cachedInitialPrefs: Prefs | null = null +/** Whether this launch began as a returning user's, read with the first + * prefs load: What's New must not greet a fresh install whose user skips + * the onboarding before the window's check runs. */ +let onboardedAtLaunch: boolean | null = null // True when a config file is available on this platform (desktop). Gates // whether savePrefs mirrors changes out to the file. let configFileEnabled = false @@ -1613,6 +1627,7 @@ function loadPrefs(): Prefs { } cachedInitialPrefs = hadLocalStorage || configFileHadContent ? normalized : DEFAULT_PREFS + onboardedAtLaunch ??= cachedInitialPrefs.hasCompletedOnboarding return cachedInitialPrefs } @@ -1634,6 +1649,25 @@ function pushPortableConfig(p: Prefs): void { }, CONFIG_PUSH_DEBOUNCE_MS) } +/** What's New belongs to the desktop app: the phones announce through their + * stores, and a self-hosted web app updates with its server. */ +function isDesktopHost(): boolean { + if (typeof window === 'undefined' || !window.zen) return false + const info = window.zen.getAppInfo() + return (info.hostKind ?? info.runtime) === 'desktop' +} + +/** Whether ZenNotes Cloud is connected, for leaving the Cloud card out of + * What's New. Asked before the window opens, so the card never flashes and + * vanishes; an answer that never comes counts as not connected. */ +async function cloudConnected(): Promise { + try { + return (await window.zen.getCloudAccountStatus?.())?.state === 'connected' + } catch { + return false + } +} + function savePrefs(p: Prefs): void { try { localStorage.setItem(PREFS_KEY, JSON.stringify(p)) @@ -2441,6 +2475,7 @@ function collectPrefs(s: { /** Folder board root (#730): group by the children of this folder; '' = each note's own folder. */ kanbanFolderRoot: string hasCompletedOnboarding: boolean + lastSeenWhatsNewVersion: string | null }): Prefs { return { vimMode: s.vimMode, @@ -2548,7 +2583,8 @@ function collectPrefs(s: { kanbanCardOrder: s.kanbanCardOrder, kanbanStatuses: s.kanbanStatuses, kanbanFolderRoot: s.kanbanFolderRoot, - hasCompletedOnboarding: s.hasCompletedOnboarding + hasCompletedOnboarding: s.hasCompletedOnboarding, + lastSeenWhatsNewVersion: s.lastSeenWhatsNewVersion } } @@ -3201,6 +3237,10 @@ interface Store { kanbanFolderRoot: string /** True once the user has finished or skipped the first-run onboarding. */ hasCompletedOnboarding: boolean + lastSeenWhatsNewVersion: string | null + whatsNewOpen: boolean + /** What the What’s New window shows while it is open. */ + whatsNew: WhatsNewView | null /** ISO YYYY-MM-DD currently selected in the Calendar view. null = today. */ tasksCalendarSelectedDate: string | null /** First-of-month anchor (ISO YYYY-MM-01) for the Calendar view's grid. */ @@ -3787,6 +3827,12 @@ interface Store { }) => Promise /** Mark the first-run onboarding as complete (or skipped). Persists. */ completeOnboarding: () => void + /** First launch of a version: record it, and after an update open What’s + * New. Desktop only. */ + showWhatsNewAfterUpdate: () => Promise + /** The palette's What’s New in ZenNotes. */ + openWhatsNew: () => Promise + closeWhatsNew: () => void /** Re-open the first-run onboarding wizard. Persists. */ restartOnboarding: () => void setFocusedPanel: (panel: Panel | null) => void @@ -5898,6 +5944,9 @@ export const useStore = create((set, get) => { kanbanStatuses: loadPrefs().kanbanStatuses, kanbanFolderRoot: loadPrefs().kanbanFolderRoot, hasCompletedOnboarding: loadPrefs().hasCompletedOnboarding, + lastSeenWhatsNewVersion: loadPrefs().lastSeenWhatsNewVersion, + whatsNewOpen: false, + whatsNew: null, vaultTasks: [], customThemes: [], customCodeLanguages: [], @@ -9984,6 +10033,36 @@ export const useStore = create((set, get) => { set({ hasCompletedOnboarding: true }) savePrefs(collectPrefs(get())) }, + showWhatsNewAfterUpdate: async () => { + if (!isDesktopHost()) return + const current = window.zen.getAppInfo().version + const { updated, entries, record } = whatsNewAtLaunch({ + lastSeen: get().lastSeenWhatsNewVersion, + current, + returningUser: onboardedAtLaunch ?? get().hasCompletedOnboarding + }) + // Recorded before the window opens, so a second window starting with + // this one, or a crash with it open, does not show it twice. + if (record) { + set({ lastSeenWhatsNewVersion: current }) + savePrefs(collectPrefs(get())) + } + if (!updated) return + const showCloud = !(await cloudConnected()) + if (!entries.length && !showCloud) return + set({ whatsNewOpen: true, whatsNew: { version: current, entries, showCloud } }) + }, + openWhatsNew: async () => { + if (!isDesktopHost()) return + const current = window.zen.getAppInfo().version + const entries = whatsNewOnDemand(current) + const showCloud = !(await cloudConnected()) + set({ + whatsNewOpen: true, + whatsNew: { version: entries[0]?.version ?? current, entries, showCloud } + }) + }, + closeWhatsNew: () => set({ whatsNewOpen: false, whatsNew: null }), restartOnboarding: () => { set({ hasCompletedOnboarding: false, settingsOpen: false }) savePrefs(collectPrefs(get())) diff --git a/packages/app-core/src/whats-new-launch.test.ts b/packages/app-core/src/whats-new-launch.test.ts new file mode 100644 index 000000000..6df940551 --- /dev/null +++ b/packages/app-core/src/whats-new-launch.test.ts @@ -0,0 +1,92 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// The first launch of a version, through the store: what it records, and +// what What's New shows (lib/whats-new.ts holds the rules). + +beforeEach(() => { + vi.resetModules() + localStorage.clear() +}) + +async function launch(opts: { + version: string + prefs?: Record + cloud?: 'connected' | 'disconnected' + hostKind?: 'desktop' | 'ios' +}) { + if (opts.prefs) localStorage.setItem('zen:prefs:v2', JSON.stringify(opts.prefs)) + const bridge = { + getCapabilities: () => ({}), + getAppInfo: () => ({ + version: opts.version, + runtime: opts.hostKind === 'ios' ? 'web' : 'desktop', + hostKind: opts.hostKind ?? 'desktop' + }), + getCloudAccountStatus: async () => ({ state: opts.cloud ?? 'disconnected', account: null }) + } + Object.defineProperty(window, 'zen', { configurable: true, value: bridge }) + const { useStore } = await import('./store') + await useStore.getState().showWhatsNewAfterUpdate() + const state = useStore.getState() + const saved = JSON.parse(localStorage.getItem('zen:prefs:v2') ?? '{}') + return { state, saved } +} + +const returning = { hasCompletedOnboarding: true } + +describe('the first launch of a version', () => { + it('opens nothing on a fresh install, and records the version', async () => { + const { state, saved } = await launch({ version: '2.68.0' }) + expect(state.whatsNewOpen).toBe(false) + expect(saved.lastSeenWhatsNewVersion).toBe('2.68.0') + }) + + it('opens What’s New with the Cloud card after an update', async () => { + const { state } = await launch({ + version: '2.68.0', + prefs: { ...returning, lastSeenWhatsNewVersion: '2.67.0' } + }) + expect(state.whatsNewOpen).toBe(true) + expect(state.whatsNew?.version).toBe('2.68.0') + expect(state.whatsNew?.entries.map((e) => e.version)).toEqual(['2.68.0']) + expect(state.whatsNew?.showCloud).toBe(true) + }) + + it('leaves the Cloud card out for someone on Cloud', async () => { + const { state } = await launch({ + version: '2.68.0', + prefs: { ...returning, lastSeenWhatsNewVersion: '2.67.0' }, + cloud: 'connected' + }) + expect(state.whatsNew?.showCloud).toBe(false) + expect(state.whatsNew?.entries.length).toBe(1) + }) + + it('opens nothing for someone on Cloud after an update with no highlights', async () => { + const { state } = await launch({ + version: '2.68.1', + prefs: { ...returning, lastSeenWhatsNewVersion: '2.68.0' }, + cloud: 'connected' + }) + expect(state.whatsNewOpen).toBe(false) + }) + + it('does nothing on a relaunch of the same version', async () => { + const { state } = await launch({ + version: '2.68.0', + prefs: { ...returning, lastSeenWhatsNewVersion: '2.68.0' } + }) + expect(state.whatsNewOpen).toBe(false) + }) + + it('stays out of the phones entirely', async () => { + const { state, saved } = await launch({ + version: '1.24.0', + prefs: { ...returning, lastSeenWhatsNewVersion: '1.23.0' }, + hostKind: 'ios' + }) + expect(state.whatsNewOpen).toBe(false) + expect(saved.lastSeenWhatsNewVersion).toBe('1.23.0') + }) +}) From d21f2edfcb0a3f64b8f590433560a7bd9a43efa6 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 12 Oct 2026 17:55:33 -0500 Subject: [PATCH 09/14] Feat(app): an anonymous count of installs and updates, with a switch in Settings Nothing told us how many copies of ZenNotes are in use: GitHub's download counts mix in package builders and bots. Now the desktop app tells zennotes.org once when it is installed and once per update, at the same first launch of a version that opens What's New: POST https://zennotes.org/api/v1/app/usage {"event":"update","version":"2.68.0","platform":"macos","arch":"arm64","install":"dmg"} That is the whole message. event is install or update; platform is macos, windows or linux; arch is x64 or arm64; install is the format (dmg, mas, nsis, portable, store, appimage, deb, rpm, pacman, managed or unknown), read through the updater's own detection so the two never disagree. No identifier, no cookies (credentials omit), a bare user agent, and an Accept-Language of *: a capture of Electron's net.fetch on the wire showed Chromium adding the system language otherwise. The site keeps daily totals only (the website change is separate). It is on by default. Settings > About > Count this copy anonymously turns it off, and the portable pref travels as [privacy] usage_count in config.toml. The main process refuses development builds, ZEN_PERF and CI runs, other architectures and prerelease versions, and sends each event at most once per run whatever the renderer asks; a send that fails is dropped, not retried. The phones and the web app send nothing. Tests: usage-count.test.ts (the body and every refusal, once per event, the request's headers) and the store's launch tests (one count per install and per update; none with the switch off, on a relaunch, after a downgrade, or on the phones). --- apps/desktop/src/main/app-config.ts | 7 +- apps/desktop/src/main/index.ts | 2 + apps/desktop/src/main/updater.ts | 22 +-- apps/desktop/src/main/usage-count.test.ts | 98 +++++++++++++ apps/desktop/src/main/usage-count.ts | 137 ++++++++++++++++++ apps/desktop/src/preload/index.ts | 1 + .../app-core/src/components/SettingsModal.tsx | 32 ++++ packages/app-core/src/lib/help.ts | 1 + packages/app-core/src/lib/whats-new.test.ts | 15 +- packages/app-core/src/lib/whats-new.ts | 15 +- packages/app-core/src/store.ts | 23 ++- .../app-core/src/whats-new-launch.test.ts | 38 +++-- packages/bridge-contract/src/app-config.ts | 1 + packages/bridge-contract/src/bridge.ts | 5 + packages/bridge-contract/src/ipc.ts | 1 + packages/shared-domain/src/app-config.ts | 4 + 16 files changed, 368 insertions(+), 34 deletions(-) create mode 100644 apps/desktop/src/main/usage-count.test.ts create mode 100644 apps/desktop/src/main/usage-count.ts diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index 26bd5aa6d..d92db534b 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -39,7 +39,7 @@ interface ScalarFieldMap { } // Order sections are emitted in. Keeps the file stable across rewrites. -const SECTION_ORDER = ['vim', 'search', 'editor', 'appearance', 'typography', 'view', 'mcp'] as const +const SECTION_ORDER = ['vim', 'search', 'editor', 'appearance', 'typography', 'view', 'mcp', 'privacy'] as const // Scalar (string / number / boolean) portable prefs → [section].key (snake_case). const SCALAR_FIELDS: Partial> = { @@ -122,6 +122,11 @@ const SCALAR_FIELDS: Partial> = { tomlKey: 'review_agent_edits', comment: 'keep what a note said before an assistant edits it through MCP, so you can review the change' }, + usageCount: { + section: 'privacy', + tomlKey: 'usage_count', + comment: 'send an anonymous count when ZenNotes installs or updates: version, OS, architecture, install kind; no identifier' + }, markdownSnippets: { section: 'editor', tomlKey: 'markdown_snippets', diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 5bda4baf0..981299321 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -269,6 +269,7 @@ import { getRaycastExtensionStatus, installRaycastExtension, } from "./raycast-integration"; +import { sendUsageCount } from "./usage-count"; import { checkForAppUpdates, describeInstall, @@ -3391,6 +3392,7 @@ function registerIpc(): void { }); handle(IPC.APP_UPDATER_GET_STATE, () => getAppUpdateState()); handle(IPC.APP_UPDATER_CHECK, async () => await checkForAppUpdates()); + handle(IPC.APP_USAGE_COUNT, async (_e, event: unknown) => await sendUsageCount(event)); handle(IPC.APP_UPDATER_CHECK_WITH_UI, async () => { await runMenuUpdateCheck(); }); diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index 64a57ca03..45eb289aa 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -905,15 +905,7 @@ export function describeInstall(): { os: string; install: string } { mas: Boolean(process.mas), windowsStore: Boolean(process.windowsStore), portableExecutableDir: process.env.PORTABLE_EXECUTABLE_DIR, - linuxFormat: () => - linuxUpdaterFormat({ - isAppImage: Boolean(process.env.APPIMAGE), - isOfficialSystemPackage: isOfficialLinuxSystemPackage( - process.resourcesPath, - existsSync(join(process.resourcesPath, 'package-type')) - ), - osRelease - }) + linuxFormat: () => currentLinuxFormat(osRelease) }) } catch { install = 'unknown' @@ -921,6 +913,18 @@ export function describeInstall(): { os: string; install: string } { return { os, install } } +/** This copy's Linux install format, as the updater acts on it. */ +export function currentLinuxFormat(osRelease: string | null = readOsReleaseOrNull()): LinuxPackageFormat { + return linuxUpdaterFormat({ + isAppImage: Boolean(process.env.APPIMAGE), + isOfficialSystemPackage: isOfficialLinuxSystemPackage( + process.resourcesPath, + existsSync(join(process.resourcesPath, 'package-type')) + ), + osRelease + }) +} + function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'` } diff --git a/apps/desktop/src/main/usage-count.test.ts b/apps/desktop/src/main/usage-count.test.ts new file mode 100644 index 000000000..c963fbbe3 --- /dev/null +++ b/apps/desktop/src/main/usage-count.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ app: { getVersion: () => '2.68.0', isPackaged: true }, net: { fetch: vi.fn() } })) +vi.mock('./updater', () => ({ currentLinuxFormat: () => 'appimage' })) + +import { createUsageCounter, USAGE_COUNT_URL, usageCountBody, usageInstallKind, type UsageCount } from './usage-count' + +const base = { + event: 'update' as unknown, + version: '2.68.0', + platform: 'darwin' as NodeJS.Platform, + arch: 'arm64', + install: 'dmg' as const, + isPackaged: true, + env: {} as NodeJS.ProcessEnv +} + +describe('usageInstallKind', () => { + const kind = (platform: NodeJS.Platform, extra: Partial[0]> = {}) => + usageInstallKind({ + platform, + mas: false, + windowsStore: false, + portableExecutableDir: undefined, + linuxFormat: () => 'pacman', + ...extra + }) + + it('names each install the way the server expects', () => { + expect(kind('darwin')).toBe('dmg') + expect(kind('darwin', { mas: true })).toBe('mas') + expect(kind('win32')).toBe('nsis') + expect(kind('win32', { portableExecutableDir: 'C:\\\\Tools' })).toBe('portable') + expect(kind('win32', { windowsStore: true })).toBe('store') + expect(kind('linux')).toBe('pacman') + expect(kind('freebsd')).toBe('unknown') + }) +}) + +describe('usageCountBody', () => { + it('carries the event, version, OS, architecture and install kind, and nothing else', () => { + expect(usageCountBody(base)).toEqual({ + event: 'update', + version: '2.68.0', + platform: 'macos', + arch: 'arm64', + install: 'dmg' + }) + expect(usageCountBody({ ...base, platform: 'win32', arch: 'x64', install: 'nsis' })?.platform).toBe('windows') + }) + + it('counts no development build, test run or CI run', () => { + expect(usageCountBody({ ...base, isPackaged: false })).toBeNull() + expect(usageCountBody({ ...base, env: { ZEN_PERF: '1' } })).toBeNull() + expect(usageCountBody({ ...base, env: { CI: 'true' } })).toBeNull() + }) + + it('refuses what the server would refuse', () => { + expect(usageCountBody({ ...base, event: 'launch' })).toBeNull() + expect(usageCountBody({ ...base, arch: 'ia32' })).toBeNull() + expect(usageCountBody({ ...base, platform: 'freebsd' })).toBeNull() + expect(usageCountBody({ ...base, version: '2.68.0-beta.1' })).toBeNull() + }) +}) + +describe('createUsageCounter', () => { + const body = (event: unknown): UsageCount | null => usageCountBody({ ...base, event }) + + it('sends one POST with the JSON body and a bare user agent', async () => { + const fetch = vi.fn().mockResolvedValue({ ok: true }) + await createUsageCounter({ fetch, body })('update') + expect(fetch).toHaveBeenCalledTimes(1) + const [url, init] = fetch.mock.calls[0] + expect(url).toBe(USAGE_COUNT_URL) + expect(init.method).toBe('POST') + expect(init.credentials).toBe('omit') + expect(init.headers['user-agent']).toBe('ZenNotes') + expect(init.headers['accept-language']).toBe('*') + expect(JSON.parse(init.body)).toEqual({ event: 'update', version: '2.68.0', platform: 'macos', arch: 'arm64', install: 'dmg' }) + }) + + it('sends each event at most once per run, whatever the renderer asks', async () => { + const fetch = vi.fn().mockResolvedValue({ ok: true }) + const send = createUsageCounter({ fetch, body }) + await send('update') + await send('update') + await send('install') + expect(fetch.mock.calls.map(([, init]) => JSON.parse(init.body).event)).toEqual(['update', 'install']) + }) + + it('sends nothing for a refused event, and drops a failed send quietly', async () => { + const fetch = vi.fn().mockRejectedValue(new Error('offline')) + const send = createUsageCounter({ fetch, body }) + await send('launch') + expect(fetch).not.toHaveBeenCalled() + await expect(send('update')).resolves.toBeUndefined() + }) +}) diff --git a/apps/desktop/src/main/usage-count.ts b/apps/desktop/src/main/usage-count.ts new file mode 100644 index 000000000..d91a3abbe --- /dev/null +++ b/apps/desktop/src/main/usage-count.ts @@ -0,0 +1,137 @@ +/** + * The anonymous usage count (Adib, 2026-10-12: on by default, a switch in + * Settings > About, no identifier). Once per fresh install and once per + * update, the renderer asks for one message to zennotes.org: the event, the + * version, the operating system, the architecture and how the copy was + * installed. Nothing else: no identifier, nothing about notes, vaults or the + * account, a bare user agent. The server keeps daily totals only. + * + * The renderer decides when (lib/whats-new.ts, after the version it recorded) + * and whether (the setting); this side decides what, and refuses anything + * that is not a real install of a release: a development build, a test run, + * an event it does not know. + */ +import { app, net } from 'electron' +import { currentLinuxFormat, type LinuxPackageFormat } from './updater' + +export const USAGE_COUNT_URL = 'https://zennotes.org/api/v1/app/usage' + +export type UsageCountEvent = 'install' | 'update' +export type UsageInstallKind = + | 'dmg' + | 'mas' + | 'nsis' + | 'portable' + | 'store' + | 'appimage' + | 'deb' + | 'rpm' + | 'pacman' + | 'managed' + | 'unknown' + +export interface UsageCount { + event: UsageCountEvent + version: string + platform: 'macos' | 'windows' | 'linux' + arch: 'x64' | 'arm64' + install: UsageInstallKind +} + +/** How this copy was installed, in the server's vocabulary. */ +export function usageInstallKind(input: { + platform: NodeJS.Platform + mas: boolean + windowsStore: boolean + portableExecutableDir: string | undefined + linuxFormat: () => LinuxPackageFormat +}): UsageInstallKind { + switch (input.platform) { + case 'darwin': + return input.mas ? 'mas' : 'dmg' + case 'win32': + if (input.windowsStore) return 'store' + return input.portableExecutableDir ? 'portable' : 'nsis' + case 'linux': + return input.linuxFormat() + default: + return 'unknown' + } +} + +/** The message, or null for a copy that is not counted: a development build, + * a test run (ZEN_PERF, CI), or anything the server would refuse. */ +export function usageCountBody(input: { + event: unknown + version: string + platform: NodeJS.Platform + arch: string + install: UsageInstallKind + isPackaged: boolean + env: NodeJS.ProcessEnv +}): UsageCount | null { + const { event, version, arch, install } = input + if (event !== 'install' && event !== 'update') return null + if (!input.isPackaged || input.env.ZEN_PERF === '1' || input.env.CI) return null + const platform = ( + { darwin: 'macos', win32: 'windows', linux: 'linux' } as Partial> + )[input.platform] + if (!platform) return null + if (arch !== 'x64' && arch !== 'arm64') return null + if (!/^\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(version)) return null + return { event, version, platform, arch, install } +} + +type Fetch = (url: string, init: RequestInit) => Promise + +/** The whole request: no cookies (the app's session may hold zennotes.org + * ones from a Cloud page), a bare user agent, and an Accept-Language that + * names no language (Chromium otherwise adds the system's, e.g. en-US). On + * the wire everything but the five fields is the same for every copy. */ +export const USAGE_COUNT_REQUEST = (body: UsageCount): RequestInit => ({ + method: 'POST', + credentials: 'omit', + cache: 'no-store', + headers: { 'content-type': 'application/json', 'user-agent': 'ZenNotes', 'accept-language': '*' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10_000) +}) + +/** A sender that sends each event at most once per run of the app, whatever + * the renderer asks: a renderer bug must not turn the count into a stream. */ +export function createUsageCounter(deps: { + fetch: Fetch + body: (event: unknown) => UsageCount | null +}): (event: unknown) => Promise { + const sent = new Set() + return async (event) => { + const body = deps.body(event) + if (!body || sent.has(body.event)) return + sent.add(body.event) + try { + await deps.fetch(USAGE_COUNT_URL, USAGE_COUNT_REQUEST(body)) + } catch { + // A count that cannot be sent is dropped: it is a tally, not a record. + } + } +} + +export const sendUsageCount = createUsageCounter({ + fetch: (url, init) => net.fetch(url, init), + body: (event) => + usageCountBody({ + event, + version: app.getVersion(), + platform: process.platform, + arch: process.arch, + install: usageInstallKind({ + platform: process.platform, + mas: Boolean(process.mas), + windowsStore: Boolean(process.windowsStore), + portableExecutableDir: process.env.PORTABLE_EXECUTABLE_DIR, + linuxFormat: () => currentLinuxFormat() + }), + isPackaged: app.isPackaged, + env: process.env + }) +}) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index b8a36e174..25c1f7757 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -260,6 +260,7 @@ const api: ZenBridge = { resetAppZoom: (): Promise => ipcRenderer.invoke(IPC.APP_ZOOM_RESET), getAppUpdateState: (): Promise => ipcRenderer.invoke(IPC.APP_UPDATER_GET_STATE), checkForAppUpdates: (): Promise => ipcRenderer.invoke(IPC.APP_UPDATER_CHECK), + sendUsageCount: (event: 'install' | 'update'): Promise => ipcRenderer.invoke(IPC.APP_USAGE_COUNT, event), checkForAppUpdatesWithUi: (): Promise => ipcRenderer.invoke(IPC.APP_UPDATER_CHECK_WITH_UI), downloadAppUpdate: (): Promise => ipcRenderer.invoke(IPC.APP_UPDATER_DOWNLOAD), diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 4c752a5c2..294379ce2 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -5668,6 +5668,22 @@ export function SettingsModal(): JSX.Element { description: "Company and product details.", keywords: ["company", "lumary", "logo"], }, + { + id: "usage-count", + title: "Count this copy anonymously", + description: + "Once per install and per update, tell zennotes.org the version, system and install type, with no identifier.", + keywords: [ + "privacy", + "anonymous", + "count", + "usage", + "telemetry", + "analytics", + "statistics", + ], + available: appInfo.runtime === "desktop", + }, { id: "config-file", title: "Configuration file", @@ -5743,6 +5759,7 @@ export function SettingsModal(): JSX.Element { + {appInfo.runtime === "desktop" && } {appInfo.runtime === "desktop" && } ), @@ -8977,6 +8994,21 @@ function McpSettings(): JSX.Element { ); } +function UsageCountSetting(): JSX.Element { + const usageCount = useStore((s) => s.usageCount); + const setUsageCount = useStore((s) => s.setUsageCount); + return ( +
+ +
+ ); +} + function AgentEditReviewSetting(): JSX.Element { const reviewAgentEdits = useStore((s) => s.reviewAgentEdits); const setReviewAgentEdits = useStore((s) => s.setReviewAgentEdits); diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index ea76ba43c..098ea1bc5 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -1235,6 +1235,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ { label: 'App identity', detail: 'See the ZenNotes app icon, current version, and a short description of the app as a keyboard-first markdown workflow with Vim motions and plain local files.' }, { label: 'Version details', detail: 'Under the version, a Version details block lists what a bug report needs: operating system and architecture, the Electron or browser engine, how this copy was installed, and the remote server version when connected to one. Copy details puts the block on the clipboard. In Vim mode, `:version` prints the same lines and `:version copy` copies them.' }, { label: 'What’s New', detail: 'The first time ZenNotes starts after an update, a window shows what the version brought, once: its highlights, any announcement, and a link to the full release notes. Escape or Close dismisses it. Open it again any time with What’s New in ZenNotes in the command palette, or `:whatsnew` in Vim mode.' }, + { label: 'Count this copy anonymously', detail: 'Once when ZenNotes is installed and once per update, the desktop app sends zennotes.org the version, operating system, architecture and install type, so we know how many copies are in use. It carries no identifier and nothing about your notes, vaults or account, and only daily totals are kept. On by default; turn it off here to send nothing. Set in config.toml as `[privacy] usage_count`.' }, { label: 'Updates and releases', detail: 'Check for updates, download a newer build, install and relaunch, or jump straight to the latest GitHub release from inside the app. AUR and tarball installs get the check and the notice only; the package manager does the install.' }, { label: 'Website, community, and issue links', detail: 'The app now exposes direct links to the ZenNotes website, Discord, GitHub repository, and issue tracker so support paths stay discoverable.' }, { label: 'Configuration file', detail: 'Your preferences (theme, editor, Vim, keymaps, fonts, search backend, and more) are mirrored to a plain-text `config.toml` so you can sync them across machines with git, stow, or chezmoi. It lives at `$XDG_CONFIG_HOME/zennotes/config.toml` (`~/.config/zennotes/config.toml` on macOS and Linux, `%APPDATA%\\zennotes\\config.toml` on Windows), or wherever `$ZENNOTES_CONFIG_DIR` points. The file is self-documenting: every available setting is listed with its allowed values, and every keymap action is listed with its default binding (commented out: uncomment a line and edit it to remap, or set it to `""` to remove the key entirely), so you can discover and change anything without opening the app. That list reads the same on every OS: where macOS has its own default, the line notes it, as in `# "tabs.select1" = "Alt+1" # Go to tab 1 (macOS: "Ctrl+1")`, so a file synced between a Mac and a Linux or Windows machine changes only when you change a setting. Settings → About has Reveal and Copy-path buttons. Existing setups are written out automatically the first time you launch this version, and edits to the file, by hand or via a synced dotfile, apply live without a restart. Machine-specific layout (window size, pane widths, collapsed folders) stays local so the file does not churn.' }, diff --git a/packages/app-core/src/lib/whats-new.test.ts b/packages/app-core/src/lib/whats-new.test.ts index 74525c5ed..0ca270527 100644 --- a/packages/app-core/src/lib/whats-new.test.ts +++ b/packages/app-core/src/lib/whats-new.test.ts @@ -30,7 +30,8 @@ describe('whatsNewAtLaunch', () => { expect(whatsNewAtLaunch({ lastSeen: null, current: '2.69.0', returningUser: false, entries })).toEqual({ updated: false, entries: [], - record: true + record: true, + count: 'install' }) }) @@ -39,6 +40,7 @@ describe('whatsNewAtLaunch', () => { expect(result.updated).toBe(true) expect(versions(result.entries)).toEqual(['2.69.0', '2.68.0']) expect(result.record).toBe(true) + expect(result.count).toBe('update') }) it('opens with the entries since the version last seen, newest first', () => { @@ -51,7 +53,8 @@ describe('whatsNewAtLaunch', () => { expect(whatsNewAtLaunch({ lastSeen: '2.70.0', current: '2.70.1', returningUser: true, entries })).toEqual({ updated: true, entries: [], - record: true + record: true, + count: 'update' }) }) @@ -59,15 +62,17 @@ describe('whatsNewAtLaunch', () => { expect(whatsNewAtLaunch({ lastSeen: '2.69.0', current: '2.69.0', returningUser: true, entries })).toEqual({ updated: false, entries: [], - record: false + record: false, + count: null }) }) - it('records a downgrade without opening', () => { + it('records a downgrade without opening or counting', () => { expect(whatsNewAtLaunch({ lastSeen: '2.70.0', current: '2.69.0', returningUser: true, entries })).toEqual({ updated: false, entries: [], - record: true + record: true, + count: null }) }) }) diff --git a/packages/app-core/src/lib/whats-new.ts b/packages/app-core/src/lib/whats-new.ts index 4f149ed91..c7c13efbe 100644 --- a/packages/app-core/src/lib/whats-new.ts +++ b/packages/app-core/src/lib/whats-new.ts @@ -13,6 +13,8 @@ * web app updates with its server. */ +export type UsageCountEvent = 'install' | 'update' + export interface WhatsNewEntry { version: string highlights: ReadonlyArray<{ title: string; body: string }> @@ -108,19 +110,22 @@ export function whatsNewBetween( * from a version without it, gets every entry up to `current`; otherwise the * entries since the version they last saw. A downgrade shows nothing and * records the version, so the way back up opens the window again. + * + * `count` is the event for the anonymous usage count (Settings > About): once + * per fresh install, once per update, nothing for a relaunch or a downgrade. */ export function whatsNewAtLaunch(input: { lastSeen: string | null current: string returningUser: boolean entries?: readonly WhatsNewEntry[] -}): { updated: boolean; entries: WhatsNewEntry[]; record: boolean } { +}): { updated: boolean; entries: WhatsNewEntry[]; record: boolean; count: UsageCountEvent | null } { const { lastSeen, current, returningUser, entries = WHATS_NEW } = input const none = { updated: false, entries: [] as WhatsNewEntry[] } - if (lastSeen !== null && compareVersions(lastSeen, current) === 0) return { ...none, record: false } - if (lastSeen === null && !returningUser) return { ...none, record: true } - if (lastSeen !== null && compareVersions(lastSeen, current) > 0) return { ...none, record: true } - return { updated: true, entries: whatsNewBetween(lastSeen, current, entries), record: true } + if (lastSeen !== null && compareVersions(lastSeen, current) === 0) return { ...none, record: false, count: null } + if (lastSeen === null && !returningUser) return { ...none, record: true, count: 'install' } + if (lastSeen !== null && compareVersions(lastSeen, current) > 0) return { ...none, record: true, count: null } + return { updated: true, entries: whatsNewBetween(lastSeen, current, entries), record: true, count: 'update' } } /** The palette's entries: this version's, or the newest at or below it, or diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index bb4fe4651..ceb43c5fb 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -610,6 +610,8 @@ interface Prefs { /** Keep what a note said before an assistant edits it through MCP, so the * edit can be reviewed. The MCP server reads it from config.toml. */ reviewAgentEdits: boolean + /** Send the anonymous install/update count (desktop). Portable. */ + usageCount: boolean /** Auto-close markdown delimiters while typing: `**`+Space → `**|**`, * ```` ``` ````+Enter expands a fenced block. Off restores plain typing. */ markdownSnippets: boolean @@ -1109,6 +1111,7 @@ export const DEFAULT_PREFS: Prefs = { defaultPaneMode: 'edit', syncTitleHeadingOnRename: true, reviewAgentEdits: true, + usageCount: true, markdownSnippets: true, textReplacementsEnabled: true, textReplacements: { '->': '→' }, @@ -1311,6 +1314,7 @@ function normalizePrefs(p: Partial): Prefs { : DEFAULT_PREFS.syncTitleHeadingOnRename, reviewAgentEdits: typeof p.reviewAgentEdits === 'boolean' ? p.reviewAgentEdits : DEFAULT_PREFS.reviewAgentEdits, + usageCount: typeof p.usageCount === 'boolean' ? p.usageCount : DEFAULT_PREFS.usageCount, markdownSnippets: typeof p.markdownSnippets === 'boolean' ? p.markdownSnippets @@ -2398,6 +2402,7 @@ function collectPrefs(s: { defaultPaneMode: PaneMode syncTitleHeadingOnRename: boolean reviewAgentEdits: boolean + usageCount: boolean markdownSnippets: boolean textReplacementsEnabled: boolean textReplacements: TextReplacements @@ -2511,6 +2516,7 @@ function collectPrefs(s: { defaultPaneMode: s.defaultPaneMode, syncTitleHeadingOnRename: s.syncTitleHeadingOnRename, reviewAgentEdits: s.reviewAgentEdits, + usageCount: s.usageCount, markdownSnippets: s.markdownSnippets, textReplacementsEnabled: s.textReplacementsEnabled, textReplacements: s.textReplacements, @@ -3049,6 +3055,8 @@ interface Store { syncTitleHeadingOnRename: boolean /** Record assistant edits made through MCP for review. Persisted (portable). */ reviewAgentEdits: boolean + /** Send the anonymous install/update count (desktop). Persisted (portable). */ + usageCount: boolean /** Auto-close markdown delimiters while typing. Persisted. */ markdownSnippets: boolean textReplacementsEnabled: boolean @@ -3658,6 +3666,7 @@ interface Store { setDefaultPaneMode: (mode: PaneMode) => void setSyncTitleHeadingOnRename: (on: boolean) => void setReviewAgentEdits: (on: boolean) => void + setUsageCount: (on: boolean) => void setMarkdownSnippets: (on: boolean) => void setTextReplacementsEnabled: (on: boolean) => void setTextReplacements: (replacements: TextReplacements) => void @@ -3827,8 +3836,8 @@ interface Store { }) => Promise /** Mark the first-run onboarding as complete (or skipped). Persists. */ completeOnboarding: () => void - /** First launch of a version: record it, and after an update open What’s - * New. Desktop only. */ + /** First launch of a version: record it, send the anonymous count (when + * Settings allows it), and after an update open What’s New. Desktop only. */ showWhatsNewAfterUpdate: () => Promise /** The palette's What’s New in ZenNotes. */ openWhatsNew: () => Promise @@ -5867,6 +5876,7 @@ export const useStore = create((set, get) => { defaultPaneMode: loadPrefs().defaultPaneMode, syncTitleHeadingOnRename: loadPrefs().syncTitleHeadingOnRename, reviewAgentEdits: loadPrefs().reviewAgentEdits, + usageCount: loadPrefs().usageCount, markdownSnippets: loadPrefs().markdownSnippets, textReplacementsEnabled: loadPrefs().textReplacementsEnabled, textReplacements: loadPrefs().textReplacements, @@ -9040,6 +9050,10 @@ export const useStore = create((set, get) => { set({ reviewAgentEdits: on }) savePrefs(collectPrefs(get())) }, + setUsageCount: (on) => { + set({ usageCount: on }) + savePrefs(collectPrefs(get())) + }, setMarkdownSnippets: (on) => { set({ markdownSnippets: on }) savePrefs(collectPrefs(get())) @@ -10036,7 +10050,7 @@ export const useStore = create((set, get) => { showWhatsNewAfterUpdate: async () => { if (!isDesktopHost()) return const current = window.zen.getAppInfo().version - const { updated, entries, record } = whatsNewAtLaunch({ + const { updated, entries, record, count } = whatsNewAtLaunch({ lastSeen: get().lastSeenWhatsNewVersion, current, returningUser: onboardedAtLaunch ?? get().hasCompletedOnboarding @@ -10047,6 +10061,9 @@ export const useStore = create((set, get) => { set({ lastSeenWhatsNewVersion: current }) savePrefs(collectPrefs(get())) } + // One anonymous count per fresh install and per update, never per launch + // (the recorded version above is what makes it once). + if (count && get().usageCount) void window.zen.sendUsageCount?.(count) if (!updated) return const showCloud = !(await cloudConnected()) if (!entries.length && !showCloud) return diff --git a/packages/app-core/src/whats-new-launch.test.ts b/packages/app-core/src/whats-new-launch.test.ts index 6df940551..2619ea7a7 100644 --- a/packages/app-core/src/whats-new-launch.test.ts +++ b/packages/app-core/src/whats-new-launch.test.ts @@ -1,8 +1,8 @@ // @vitest-environment jsdom import { beforeEach, describe, expect, it, vi } from 'vitest' -// The first launch of a version, through the store: what it records, and -// what What's New shows (lib/whats-new.ts holds the rules). +// The first launch of a version, through the store: what it records, what it +// counts, and what What's New shows (lib/whats-new.ts holds the rules). beforeEach(() => { vi.resetModules() @@ -16,6 +16,7 @@ async function launch(opts: { hostKind?: 'desktop' | 'ios' }) { if (opts.prefs) localStorage.setItem('zen:prefs:v2', JSON.stringify(opts.prefs)) + const sendUsageCount = vi.fn(async () => {}) const bridge = { getCapabilities: () => ({}), getAppInfo: () => ({ @@ -23,36 +24,48 @@ async function launch(opts: { runtime: opts.hostKind === 'ios' ? 'web' : 'desktop', hostKind: opts.hostKind ?? 'desktop' }), - getCloudAccountStatus: async () => ({ state: opts.cloud ?? 'disconnected', account: null }) + getCloudAccountStatus: async () => ({ state: opts.cloud ?? 'disconnected', account: null }), + sendUsageCount } Object.defineProperty(window, 'zen', { configurable: true, value: bridge }) const { useStore } = await import('./store') await useStore.getState().showWhatsNewAfterUpdate() const state = useStore.getState() const saved = JSON.parse(localStorage.getItem('zen:prefs:v2') ?? '{}') - return { state, saved } + return { state, saved, sendUsageCount } } const returning = { hasCompletedOnboarding: true } describe('the first launch of a version', () => { - it('opens nothing on a fresh install, and records the version', async () => { - const { state, saved } = await launch({ version: '2.68.0' }) + it('counts a fresh install once and opens nothing', async () => { + const { state, saved, sendUsageCount } = await launch({ version: '2.68.0' }) + expect(sendUsageCount.mock.calls).toEqual([['install']]) expect(state.whatsNewOpen).toBe(false) expect(saved.lastSeenWhatsNewVersion).toBe('2.68.0') }) - it('opens What’s New with the Cloud card after an update', async () => { - const { state } = await launch({ + it('counts an update and opens What’s New with the Cloud card', async () => { + const { state, sendUsageCount } = await launch({ version: '2.68.0', prefs: { ...returning, lastSeenWhatsNewVersion: '2.67.0' } }) + expect(sendUsageCount.mock.calls).toEqual([['update']]) expect(state.whatsNewOpen).toBe(true) expect(state.whatsNew?.version).toBe('2.68.0') expect(state.whatsNew?.entries.map((e) => e.version)).toEqual(['2.68.0']) expect(state.whatsNew?.showCloud).toBe(true) }) + it('sends nothing with the switch off, and still opens the window', async () => { + const { state, sendUsageCount } = await launch({ + version: '2.68.0', + prefs: { ...returning, lastSeenWhatsNewVersion: '2.67.0', usageCount: false } + }) + expect(sendUsageCount).not.toHaveBeenCalled() + expect(state.whatsNewOpen).toBe(true) + }) + it('leaves the Cloud card out for someone on Cloud', async () => { const { state } = await launch({ version: '2.68.0', @@ -64,28 +77,31 @@ describe('the first launch of a version', () => { }) it('opens nothing for someone on Cloud after an update with no highlights', async () => { - const { state } = await launch({ + const { state, sendUsageCount } = await launch({ version: '2.68.1', prefs: { ...returning, lastSeenWhatsNewVersion: '2.68.0' }, cloud: 'connected' }) + expect(sendUsageCount.mock.calls).toEqual([['update']]) expect(state.whatsNewOpen).toBe(false) }) it('does nothing on a relaunch of the same version', async () => { - const { state } = await launch({ + const { state, sendUsageCount } = await launch({ version: '2.68.0', prefs: { ...returning, lastSeenWhatsNewVersion: '2.68.0' } }) + expect(sendUsageCount).not.toHaveBeenCalled() expect(state.whatsNewOpen).toBe(false) }) it('stays out of the phones entirely', async () => { - const { state, saved } = await launch({ + const { state, saved, sendUsageCount } = await launch({ version: '1.24.0', prefs: { ...returning, lastSeenWhatsNewVersion: '1.23.0' }, hostKind: 'ios' }) + expect(sendUsageCount).not.toHaveBeenCalled() expect(state.whatsNewOpen).toBe(false) expect(saved.lastSeenWhatsNewVersion).toBe('1.23.0') }) diff --git a/packages/bridge-contract/src/app-config.ts b/packages/bridge-contract/src/app-config.ts index d072623ad..4a8936a4b 100644 --- a/packages/bridge-contract/src/app-config.ts +++ b/packages/bridge-contract/src/app-config.ts @@ -39,6 +39,7 @@ export const PORTABLE_PREF_KEYS = [ 'defaultPaneMode', 'syncTitleHeadingOnRename', 'reviewAgentEdits', + 'usageCount', 'markdownSnippets', 'textReplacementsEnabled', 'textReplacements', diff --git a/packages/bridge-contract/src/bridge.ts b/packages/bridge-contract/src/bridge.ts index d64f0e980..d6dd26b53 100644 --- a/packages/bridge-contract/src/bridge.ts +++ b/packages/bridge-contract/src/bridge.ts @@ -167,6 +167,11 @@ export interface ZenBridge { resetAppZoom(): Promise getAppUpdateState(): Promise checkForAppUpdates(): Promise + /** The desktop app's anonymous install/update count: one message to + * zennotes.org with the event, version, OS, architecture and install kind, + * no identifier. The caller checks the setting; the host refuses + * development builds and test runs, and sends each event once per run. */ + sendUsageCount?(event: 'install' | 'update'): Promise checkForAppUpdatesWithUi(): Promise downloadAppUpdate(): Promise installAppUpdate(): Promise diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index 34dce51ce..5c73f0cc1 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -111,6 +111,7 @@ export const IPC = { APP_ZOOM_RESET: 'app:zoom-reset', APP_UPDATER_GET_STATE: 'app-updater:get-state', APP_UPDATER_CHECK: 'app-updater:check', + APP_USAGE_COUNT: 'app:usage-count', APP_UPDATER_CHECK_WITH_UI: 'app-updater:check-with-ui', APP_UPDATER_DOWNLOAD: 'app-updater:download', APP_UPDATER_INSTALL: 'app-updater:install', diff --git a/packages/shared-domain/src/app-config.ts b/packages/shared-domain/src/app-config.ts index f1754c2d2..bce4fa4c9 100644 --- a/packages/shared-domain/src/app-config.ts +++ b/packages/shared-domain/src/app-config.ts @@ -106,6 +106,10 @@ export const PORTABLE_DEFAULTS: Record = { // edit can be reviewed (docs/ideas/agent-edit-review.md). The MCP server // reads it from config.toml, without the app running. reviewAgentEdits: true, + // The desktop app's anonymous count of installs and updates: version, OS, + // architecture and install kind, with no identifier (lib/whats-new.ts sends + // it with the first launch of each version). Settings > About turns it off. + usageCount: true, markdownSnippets: true, textReplacementsEnabled: true, textReplacements: { '->': '→' }, From aa24d748054927750159343afd4e115675701067 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 12 Oct 2026 18:35:34 -0500 Subject: [PATCH 10/14] Copy(app): the app's own text reads without long dashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The long dash (U+2014) was everywhere a user reads: Settings, the palette, the in-app manual, toasts, onboarding, the welcome note, the comments ZenNotes writes into config.toml, the zn CLI's help, the MCP server's instructions and tool descriptions (which assistants show), and all through the demo tour. It reads as machine-written. Each of the 325 was rewritten by hand in its sentence: a colon before a definition or a list, parentheses for an aside, a comma, or a new sentence. Label pairs take the middle dot the app already uses ("Theme preview · ↵ to keep"). A cell or title that was only the dash now says what it means: None, Untitled, Checking… or Loading…, and the slash menu's Divider icon is a drawn line. Names change in two places. The demo tour's notes are "00 Start Here" and so on instead of "00 Start Here", with every wikilink, alias and the tour graph updated (a new test resolves each link against the notes the tour ships). A vault seeded by an older version, or by a self-hosted server that still writes the old names, keeps working: Regenerate replaces the old files instead of writing a second tour, Remove deletes them, and the palette still finds the tour. That match is exact on the old separator because both paths delete files. And the Meeting Notes and 1:1 templates title new notes "Meeting 2026-10-12" rather than with the dash; existing notes keep their names. Not touched: code comments, test names, and text inside third-party libraries (Excalidraw's tooltips and translations, Mermaid's errors, KaTeX). The self-hosted server's own demo tour copy changes with its next release. --- apps/desktop/src/cli/commands/tasks.ts | 2 +- apps/desktop/src/cli/help.ts | 2 +- apps/desktop/src/main/app-config.test.ts | 2 +- apps/desktop/src/main/app-config.ts | 28 +++---- apps/desktop/src/main/custom-themes.ts | 4 +- apps/desktop/src/main/overrides.ts | 6 +- apps/desktop/src/main/vault.test.ts | 37 +++++++++ apps/desktop/src/main/vault.ts | 48 ++++++++--- apps/desktop/src/mcp/instructions.ts | 28 +++---- apps/desktop/src/mcp/server.ts | 12 +-- apps/desktop/src/renderer/export-window.tsx | 6 +- .../app-core/src/components/CalendarPanel.tsx | 6 +- .../src/components/CommandPalette.tsx | 8 +- .../src/components/DatabaseBoardView.tsx | 2 +- .../src/components/DatabaseTableView.tsx | 8 +- packages/app-core/src/components/Editor.tsx | 2 +- .../app-core/src/components/EditorPane.tsx | 2 +- .../app-core/src/components/EmptyVault.tsx | 2 +- .../src/components/OnboardingWizard.tsx | 10 +-- .../app-core/src/components/OutlinePanel.tsx | 2 +- .../src/components/QuickCaptureApp.tsx | 6 +- .../app-core/src/components/SettingsModal.tsx | 38 ++++----- packages/app-core/src/components/Sidebar.tsx | 2 +- packages/app-core/src/components/TagView.tsx | 4 +- .../app-core/src/components/TasksCalendar.tsx | 2 +- packages/app-core/src/components/VimNav.tsx | 2 +- packages/app-core/src/lib/cm-live-preview.ts | 2 +- .../app-core/src/lib/cm-slash-commands.ts | 2 +- .../app-core/src/lib/cm-template-variables.ts | 2 +- packages/app-core/src/lib/commands.ts | 17 ++-- packages/app-core/src/lib/demo-tour.test.ts | 74 +++++++++++++++++ .../app-core/src/lib/diagram-renderers.ts | 2 +- packages/app-core/src/lib/embed-renderers.ts | 2 +- packages/app-core/src/lib/help.ts | 82 +++++++++---------- packages/app-core/src/lib/keymaps.ts | 8 +- packages/app-core/src/lib/local-assets.ts | 2 +- packages/app-core/src/lib/move-note.ts | 4 +- packages/app-core/src/lib/transclusion.ts | 2 +- packages/app-core/src/store.ts | 2 +- .../shared-domain/src/builtin-templates.ts | 8 +- packages/shared-domain/src/custom-themes.ts | 2 +- packages/shared-domain/src/demo-tour-data.ts | 68 +++++++-------- packages/shared-domain/src/demo-tour.ts | 20 ++++- 43 files changed, 363 insertions(+), 207 deletions(-) create mode 100644 packages/app-core/src/lib/demo-tour.test.ts diff --git a/apps/desktop/src/cli/commands/tasks.ts b/apps/desktop/src/cli/commands/tasks.ts index 52455e260..05776d326 100644 --- a/apps/desktop/src/cli/commands/tasks.ts +++ b/apps/desktop/src/cli/commands/tasks.ts @@ -58,7 +58,7 @@ export async function cmdTaskToggle(vault: VaultBackend, args: ParsedArgs): Prom const next = await vault.toggleTask(id) if (next == null) { throw new Error( - `Task ${id} no longer exists at that location — the file may have changed. Run \`zn task list\` again.` + `Task ${id} no longer exists at that location (the file may have changed). Run \`zn task list\` again.` ) } if (getBool(args, 'json')) { diff --git a/apps/desktop/src/cli/help.ts b/apps/desktop/src/cli/help.ts index 7ef82e0e8..a102c5a48 100644 --- a/apps/desktop/src/cli/help.ts +++ b/apps/desktop/src/cli/help.ts @@ -94,7 +94,7 @@ const SECTIONS: Array<{ heading: string; rows: CommandRow[] }> = [ { name: 'list', description: 'List notes, most recent first', flags: '--folder --tag --limit --json' }, { name: 'read ', description: 'Print a note body to stdout', flags: '--meta --json' }, { name: 'create', description: 'Create a new note. Body from --body or stdin', flags: '--title --folder inbox|quick|archive --subpath

--tag --body "..."|-' }, - { name: 'write ', description: 'Replace a note body. Destructive — prefer append', flags: '--body "..."|-' }, + { name: 'write ', description: 'Replace a note body. Destructive, so prefer append', flags: '--body "..."|-' }, { name: 'append ', description: 'Append text to the end of a note', flags: '--body "..."|-' }, { name: 'prepend ', description: 'Insert text at the top, after frontmatter', flags: '--body "..."|-' }, { name: 'rename ', description: 'Rename a note (filename only)', flags: '--to ' }, diff --git a/apps/desktop/src/main/app-config.test.ts b/apps/desktop/src/main/app-config.test.ts index df0bb6817..db54a75a3 100644 --- a/apps/desktop/src/main/app-config.test.ts +++ b/apps/desktop/src/main/app-config.test.ts @@ -197,7 +197,7 @@ describe('TOML serialization', () => { ) expect(text).toContain('[vim]') expect(text).toContain( - 'wrapped_line_motions = "display" # display | logical — how $, I, A and dependent operators treat soft-wrapped lines' + 'wrapped_line_motions = "display" # display | logical: how $, I, A and dependent operators treat soft-wrapped lines' ) expect(text).toContain('[view]') // Keymaps: every action listed as a commented, grouped default reference. diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index d92db534b..6e8753766 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -44,7 +44,7 @@ const SECTION_ORDER = ['vim', 'search', 'editor', 'appearance', 'typography', 'v // Scalar (string / number / boolean) portable prefs → [section].key (snake_case). const SCALAR_FIELDS: Partial> = { // vim - vimMode: { section: 'vim', tomlKey: 'enabled', comment: 'true | false — CodeMirror Vim bindings' }, + vimMode: { section: 'vim', tomlKey: 'enabled', comment: 'true | false: CodeMirror Vim bindings' }, vimInsertEscape: { section: 'vim', tomlKey: 'insert_escape', @@ -63,7 +63,7 @@ const SCALAR_FIELDS: Partial> = { vimWrappedLineMotions: { section: 'vim', tomlKey: 'wrapped_line_motions', - comment: 'display | logical — how $, I, A and dependent operators treat soft-wrapped lines' + comment: 'display | logical: how $, I, A and dependent operators treat soft-wrapped lines' }, whichKeyHints: { section: 'vim', @@ -182,7 +182,7 @@ const SCALAR_FIELDS: Partial> = { editorScrollOff: { section: 'editor', tomlKey: 'scroll_off', - comment: 'vim scrolloff — lines kept above/below the cursor (0 = off)' + comment: 'vim scrolloff: lines kept above/below the cursor (0 = off)' }, timeFormat: { section: 'editor', @@ -202,7 +202,7 @@ const SCALAR_FIELDS: Partial> = { completedTaskStyle: { section: 'editor', tomlKey: 'completed_task_style', - comment: 'none | strikethrough | gray | gray-strikethrough — style checked-task text' + comment: 'none | strikethrough | gray | gray-strikethrough: style checked-task text' }, mathRenderer: { section: 'editor', @@ -212,7 +212,7 @@ const SCALAR_FIELDS: Partial> = { typstTagPreambles: { section: 'editor', tomlKey: 'typst_tag_preambles', - comment: 'true | false — prepend Typst definitions from notes in a `typst` folder, chosen by a note\'s tags' + comment: 'true | false: prepend Typst definitions from notes in a `typst` folder, chosen by a note\'s tags' }, harperEnabled: { section: 'editor', @@ -232,7 +232,7 @@ const SCALAR_FIELDS: Partial> = { defaultPaneMode: { section: 'editor', tomlKey: 'default_view_mode', - comment: 'edit | split | preview — the mode a note opens in before it has a remembered one' + comment: 'edit | split | preview: the mode a note opens in before it has a remembered one' }, keepViewModeAcrossNotes: { section: 'editor', @@ -273,7 +273,7 @@ const SCALAR_FIELDS: Partial> = { pdfExportUseTheme: { section: 'appearance', tomlKey: 'pdf_export_use_theme', - comment: 'true | false — export PDFs using the current theme instead of a clean light print theme' + comment: 'true | false: export PDFs using the current theme instead of a clean light print theme' }, themeFamily: { section: 'appearance', @@ -448,12 +448,12 @@ const LIST_FIELDS: Partial> = { kanbanStatuses: { section: 'view', tomlKey: 'kanban_statuses', - comment: 'custom-status Kanban columns, in order — e.g. ["backlog", "in_progress", "review", "done"]' + comment: 'custom-status Kanban columns in order, e.g. ["backlog", "in_progress", "review", "done"]' }, hiddenWorkflowPresets: { section: 'view', tomlKey: 'hidden_workflow_presets', - comment: 'built-in workflow recipes hidden from the gallery, by id — e.g. ["reading-log"]' + comment: 'built-in workflow recipe ids hidden from the gallery, e.g. ["reading-log"]' } } @@ -472,7 +472,7 @@ const MAP_TABLE_FIELDS: Partial> = { keymapOverrides: { table: 'keymaps', comment: [ - 'Keymap overrides — only list the bindings you want to change.', + 'Keymap overrides. Only list the bindings you want to change.', 'Set a binding to "" to remove the key entirely.', 'Find the full list of action IDs in Settings → Keymaps.' ], @@ -490,7 +490,7 @@ const MAP_TABLE_FIELDS: Partial> = { }, enabledOverrides: { table: 'overrides', - comment: ['Enabled CSS overrides — list the filenames you want active.'], + comment: ['Enabled CSS overrides. List the filenames you want active.'], example: '"focus.css" = "on"' }, themeTweaks: { @@ -530,10 +530,10 @@ const FILE_HEADER = `# ZenNotes configuration # # Holds your portable preferences (theme, editor, vim, keymaps, …) so you can # sync them across machines with git, stow, chezmoi, and friends. Managed by -# ZenNotes but safe to hand-edit — changes apply live, no restart needed. +# ZenNotes but safe to hand-edit: changes apply live, no restart needed. # # Every available option is listed below with its current value; the inline -# comment shows the allowed values. Edit a value to customize it — ZenNotes +# comment shows the allowed values. Edit a value to customize it. ZenNotes # keeps this list complete, so a removed option reappears with its default. # Note: comments you add yourself may be dropped when the app rewrites the file. @@ -835,7 +835,7 @@ async function readConfigFile(): Promise<{ text: string; portable: AppConfigPort const { portable } = deserializeConfig(text) return { text, portable } } catch (err) { - console.warn('Failed to parse config.toml — keeping last good config', err) + console.warn('Failed to parse config.toml, keeping last good config', err) return null } } diff --git a/apps/desktop/src/main/custom-themes.ts b/apps/desktop/src/main/custom-themes.ts index 6c3a73190..b033c7bd4 100644 --- a/apps/desktop/src/main/custom-themes.ts +++ b/apps/desktop/src/main/custom-themes.ts @@ -83,7 +83,7 @@ function softPaperManifest(): string { name: 'Soft Paper', author: 'Nick Milo (port)', version: '1.0.0', - description: 'A warm, paper-like theme — light + dark.', + description: 'A warm, paper-like theme (light + dark).', modes: 'both', preview: { light: '#1a7da4', dark: '#85c1dc' } }, @@ -126,7 +126,7 @@ my-theme/ \`\`\` \`modes\` is \`"light"\`, \`"dark"\`, or \`"both"\`. \`preview\` colors are only used for -the swatch on the Settings card. A missing manifest is fine — the theme is then +the swatch on the Settings card. A missing manifest is fine: the theme is then named after its folder and assumed to support both modes. ## theme.css diff --git a/apps/desktop/src/main/overrides.ts b/apps/desktop/src/main/overrides.ts index ed29525dc..2d6a1e3fe 100644 --- a/apps/desktop/src/main/overrides.ts +++ b/apps/desktop/src/main/overrides.ts @@ -29,9 +29,9 @@ function isSafeName(name: unknown): name is string { ) } -const EXAMPLE = `/* ZenNotes override — a cookbook. Toggle it on in Settings → Appearance → +const EXAMPLE = `/* ZenNotes override: a cookbook. Toggle it on in Settings → Appearance → * Overrides, then uncomment a recipe below to use it. As shipped, this file - * changes nothing — every recipe is commented out. + * changes nothing: every recipe is commented out. * * Overrides layer on top of whichever theme is active. Target :root[data-theme] * so your rule wins over both a built-in theme's :root[data-theme="…"] block and @@ -70,7 +70,7 @@ const EXAMPLE = `/* ZenNotes override — a cookbook. Toggle it on in Settings } */ -/* Any other CSS works too — use the "Developer tools" button in Settings → +/* Any other CSS works too. Use the "Developer tools" button in Settings → * Appearance → Overrides to inspect an element, find its class, then style it. * Fonts and text size live in Settings → Typography, so set those there. */ ` diff --git a/apps/desktop/src/main/vault.test.ts b/apps/desktop/src/main/vault.test.ts index 33a62bdd6..5f92a3c94 100644 --- a/apps/desktop/src/main/vault.test.ts +++ b/apps/desktop/src/main/vault.test.ts @@ -17,6 +17,7 @@ import { ensureVaultLayout, folderForRelativePath, forgetLocalVault, + generateDemoTour, getVaultSettings, importFiles, importPastedImage, @@ -38,6 +39,7 @@ import { renameLocalVault, deleteFolder, readNoteComments, + removeDemoTour, writeNoteComments, restoreDeletedAsset, restoreFromTrash, @@ -1305,6 +1307,41 @@ describe('listNotes asset embeds (#185 usage)', () => { }) }) +describe('demo tour under its older note names', () => { + // Built from its code point so this file never contains the character itself. + const LONG_DASH = String.fromCharCode(0x2014) + const olderStart = `inbox/demo/00 ${LONG_DASH} Start Here.md` + const olderMath = `inbox/demo/05b ${LONG_DASH} Math Diagrams.md` + const ownNote = 'inbox/demo/My own note.md' + + async function vaultWithOlderTour(): Promise { + const root = await makeTempDir('zennotes-demo-tour-') + await ensureVaultLayout(root) + await mkdir(path.join(root, 'inbox', 'demo'), { recursive: true }) + for (const rel of [olderStart, olderMath, ownNote]) { + await writeFile(path.join(root, rel), 'older\n') + } + return root + } + + it('regenerating replaces the older files instead of writing a second tour', async () => { + const root = await vaultWithOlderTour() + const result = await generateDemoTour(root) + const names = await fsPromises.readdir(path.join(root, 'inbox', 'demo')) + expect(names.filter((name) => name.includes(LONG_DASH))).toEqual([]) + expect(names).toContain('00 Start Here.md') + expect(names).toContain('My own note.md') + expect(result.notePaths).toContain('inbox/demo/00 Start Here.md') + }) + + it('removing deletes the older files too and reports them', async () => { + const root = await vaultWithOlderTour() + const result = await removeDemoTour(root) + expect(result.notePaths).toEqual(expect.arrayContaining([olderStart, olderMath])) + expect(await fsPromises.readdir(path.join(root, 'inbox', 'demo'))).toEqual(['My own note.md']) + }) +}) + describe('archive / trash round-trips', () => { async function makeVaultWithNestedNote(): Promise<{ root: string }> { const root = await makeTempDir('zennotes-folder-moves-') diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index b23062fff..f7d64f354 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -60,7 +60,7 @@ import { VaultChangeEvent, VaultInfo } from '@shared/ipc' -import { DEMO_TOUR_DIR } from '@shared/demo-tour' +import { currentDemoTourPath, DEMO_TOUR_DIR } from '@shared/demo-tour' import { normalizeNoteComments } from '@shared/note-comments' import { FRONTMATTER_BLOCK_RE, frontmatterTags } from '@shared/frontmatter' import { rewriteAssetReferences } from '@shared/asset-link-rename' @@ -4251,9 +4251,29 @@ async function removeDirIfEmpty(abs: string): Promise { } } +/** Tour notes still on disk under their older names (see currentDemoTourPath). */ +async function olderDemoTourNotePaths(root: string): Promise { + const current = new Set(DEMO_TOUR_NOTES.map((note) => note.path)) + let names: string[] + try { + names = await fs.readdir(resolveSafe(root, DEMO_TOUR_DIR)) + } catch { + return [] + } + return names + .map((name) => `${DEMO_TOUR_DIR}/${name}`) + .filter((rel) => rel !== currentDemoTourPath(rel) && current.has(currentDemoTourPath(rel))) +} + export async function generateDemoTour(root: string): Promise { await ensureVaultLayout(root) + // Regenerating replaces a tour seeded under the older names rather than + // writing a second copy beside it. + for (const rel of await olderDemoTourNotePaths(root)) { + await removeFileIfExists(resolveSafe(root, rel)) + } + for (const note of DEMO_TOUR_NOTES) { const abs = resolveSafe(root, note.path) await fs.mkdir(path.dirname(abs), { recursive: true }) @@ -4275,8 +4295,12 @@ export async function generateDemoTour(root: string): Promise { - for (const note of DEMO_TOUR_NOTES) { - await removeFileIfExists(resolveSafe(root, note.path)) + const notePaths = [ + ...DEMO_TOUR_NOTES.map((note) => note.path), + ...(await olderDemoTourNotePaths(root)) + ] + for (const rel of notePaths) { + await removeFileIfExists(resolveSafe(root, rel)) } for (const asset of DEMO_TOUR_ASSETS) { @@ -4288,7 +4312,7 @@ export async function removeDemoTour(root: string): Promise invalidateNoteMetaCache(root) invalidateVaultTextSearchCache(root) return { - notePaths: DEMO_TOUR_NOTES.map((note) => note.path), + notePaths, assetPaths: DEMO_TOUR_ASSETS.map((asset) => asset.path) } } @@ -4495,17 +4519,17 @@ export function absolutePath(root: string, rel: string): string { const WELCOME_NOTE = `# Welcome to ZenNotes -ZenNotes is a **file-based** markdown notes app made for focus and deep work. Every note is a plain \`.md\` file in your vault — yours to keep, sync, and version however you like. +ZenNotes is a **file-based** markdown notes app made for focus and deep work. Every note is a plain \`.md\` file in your vault, yours to keep, sync, and version however you like. ## What you get -- **GitHub-flavored markdown** — tables, task lists, footnotes, strikethrough -- **Wiki links** — jump between notes with [[double brackets]] -- **Tags** — write a hashtag like \`#project\` in any note and it appears in the sidebar -- **Math** — inline like $e^{i\\pi}+1=0$ or as blocks -- **Callouts** — Obsidian-style \`> [!note]\` blocks -- **Mermaid diagrams** — code-fenced \`\`\`mermaid blocks render inline -- **Full-text search** — press \`Space s t\` in Vim mode, or run **Search Text in Vault** from the command palette +- **GitHub-flavored markdown**: tables, task lists, footnotes, strikethrough +- **Wiki links**: jump between notes with [[double brackets]] +- **Tags**: write a hashtag like \`#project\` in any note and it appears in the sidebar +- **Math**: inline like $e^{i\\pi}+1=0$ or as blocks +- **Callouts**: Obsidian-style \`> [!note]\` blocks +- **Mermaid diagrams**: code-fenced \`\`\`mermaid blocks render inline +- **Full-text search**: press \`Space s t\` in Vim mode, or run **Search Text in Vault** from the command palette ## Try it diff --git a/apps/desktop/src/mcp/instructions.ts b/apps/desktop/src/mcp/instructions.ts index 10a41e8dd..cc2f0d310 100644 --- a/apps/desktop/src/mcp/instructions.ts +++ b/apps/desktop/src/mcp/instructions.ts @@ -8,7 +8,7 @@ * connected \u2014 without being told how. */ -export const MCP_SERVER_INSTRUCTIONS = `You are connected to a user's ZenNotes vault \u2014 plain .md files on +export const MCP_SERVER_INSTRUCTIONS = `You are connected to a user's ZenNotes vault: plain .md files on disk, rendered live with KaTeX, TikZ, function-plot, JSXGraph, and Mermaid. Treat the vault like a shared filesystem. Prompts will be short; you must infer the right note shape, voice, and visuals from @@ -55,10 +55,10 @@ the subject itself. ZenNotes vaults run in one of two modes: -- \`primaryNotesLocation: inbox\` \u2014 notes for the conceptual inbox +- \`primaryNotesLocation: inbox\`: notes for the conceptual inbox area live under \`/inbox/\` (paths look like \`inbox/MyNote.md\`). -- \`primaryNotesLocation: root\` \u2014 Obsidian-style. Notes for the +- \`primaryNotesLocation: root\`: Obsidian-style. Notes for the conceptual inbox area live directly at the vault root (paths look like \`MyNote.md\`, no \`inbox/\` prefix). @@ -67,7 +67,7 @@ mode this vault uses. It also says where the vault lives: a folder on this machine, or a self-hosted ZenNotes server the desktop app is connected to (the tools behave the same either way; paths are what the server reports). The folder enum in tool arguments (\`inbox\`, -\`quick\`, \`archive\`, \`trash\`) stays the same in both modes \u2014 only +\`quick\`, \`archive\`, \`trash\`) stays the same in both modes; only the on-disk shape differs. The \`path\` returned by every tool already reflects the mode; use it verbatim and you never have to think about this again. @@ -135,7 +135,7 @@ handbook, a wiki, a trip plan with multiple destinations): order (\`01 - Vectors.md\`, \`02 - Vector Spaces.md\`). - Create a **map / index note first** (\`00 - Course Map.md\` or \`README.md\`). Update it with a \`[[wikilink]]\` as each new note is - created \u2014 append_to_note is perfect for this. + created (append_to_note is perfect for this). - Keep **heading structure, section names, voice, and level of detail identical** across sibling notes. If module 1 has "Intuition / Definition / Examples / Exercises / Related", every module does. @@ -151,7 +151,7 @@ or \`$$\u2026$$\`. Not \`R^n\`, not \`x_1\`, not \`|v|\`, not \`A^T\`, not **Diagrams \u2192 native renderers. ASCII art is banned.** A drawing made of \`/ \\ | - + * ^ . o > < \u2192 \u2190 \u2191 \u2193 \u2022\` or -box-drawing characters \u2014 fenced or not \u2014 is a bug. If you +box-drawing characters, fenced or not, is a bug. If you catch yourself about to make one, delete it and emit a \`\`\`tikz (or \`\`\`function-plot / \`\`\`jsxgraph / \`\`\`mermaid) block instead. Triggers that mean "use TikZ, not ASCII": axes, origin, point at, @@ -160,12 +160,12 @@ span, plane, angle, triangle, unit circle, subspace, tangent, normal, perpendicular, before/after, tree, graph, stack, heap, ring, lattice. Pick the renderer by intent: -- \`\`\`tikz \u2014 vectors, geometric figures, commutative diagrams, +- \`\`\`tikz: vectors, geometric figures, commutative diagrams, math pictures. Default for anything you'd draw in LaTeX. -- \`\`\`function-plot \u2014 Cartesian plots, parametric curves, 2D +- \`\`\`function-plot: Cartesian plots, parametric curves, 2D vectors over axes. -- \`\`\`jsxgraph \u2014 interactive / draggable constructions. -- \`\`\`mermaid \u2014 flow, sequence, class, state, ER, mindmap, +- \`\`\`jsxgraph: interactive / draggable constructions. +- \`\`\`mermaid: flow, sequence, class, state, ER, mindmap, Gantt. NOT for coordinate geometry. Fence body rules: @@ -178,7 +178,7 @@ Fence body rules: **Wikilinks \u2192 aggressive.** First mention of a concept, person, project, or term that has (or deserves) its own note becomes a -\`[[wikilink]]\` \u2014 not bold, not italics. Before writing, run +\`[[wikilink]]\`, not bold, not italics. Before writing, run list_notes / search_by_title. After writing, rescan for wikilink candidates. Non-trivial notes end with a \`## Related\` section of 2\u20136 wikilinks. Notes with zero inbound + zero outbound links are @@ -203,13 +203,13 @@ starts it expanded; use \`-\` for long blocks a reader opens on demand. - read_note before overwriting. Always. - Surgical edits: append_to_note, prepend_to_note, replace_in_note. write_note only for full rewrites the user asked for. -- create_note (not write_note) for new notes \u2014 it sanitizes +- create_note (not write_note) for new notes: it sanitizes filenames and avoids collisions. - Preserve frontmatter and unknown content verbatim. - Deletion: move_to_trash first, empty_trash only with explicit confirmation. - Before rename_note, run backlinks. -- Task ids from list_tasks (\`path#index\`) are stable \u2014 pass +- Task ids from list_tasks (\`path#index\`) are stable, so pass them to toggle_task. - list_tasks omits notes opted out of Tasks (frontmatter \`tasks: false\`/\`note\`, excluded folders). Pass @@ -299,7 +299,7 @@ flowchart LR B -- no --> D[Do Y] \`\`\` -Adjust these \u2014 don't invent ASCII replacements. +Adjust these. Don't invent ASCII replacements. ## Intent mapping diff --git a/apps/desktop/src/mcp/server.ts b/apps/desktop/src/mcp/server.ts index 588e561dd..29189d8dc 100644 --- a/apps/desktop/src/mcp/server.ts +++ b/apps/desktop/src/mcp/server.ts @@ -187,7 +187,7 @@ const TOOLS: ToolDef[] = [ schema: { name: 'list_notes', description: - "List notes in the vault with metadata (title, folder, tags, wikilinks, excerpt, timestamps). Optional filters narrow by folder, tag, or wikilink target. Use this before editing to pick the right note. Trashed notes are included only when folder='trash' is explicit. The returned `path` field is the canonical reference — pass it back verbatim to read_note / write_note / move_note. In `primaryNotesLocation: root` vaults, inbox notes have a path WITHOUT an `inbox/` prefix (e.g. `MyNote.md`); never add one. Each note also carries `link`, a clickable zennotes:// URL that opens it in the app; use it verbatim when rendering note titles as markdown links for the user.", + "List notes in the vault with metadata (title, folder, tags, wikilinks, excerpt, timestamps). Optional filters narrow by folder, tag, or wikilink target. Use this before editing to pick the right note. Trashed notes are included only when folder='trash' is explicit. The returned `path` field is the canonical reference: pass it back verbatim to read_note / write_note / move_note. In `primaryNotesLocation: root` vaults, inbox notes have a path WITHOUT an `inbox/` prefix (e.g. `MyNote.md`); never add one. Each note also carries `link`, a clickable zennotes:// URL that opens it in the app; use it verbatim when rendering note titles as markdown links for the user.", inputSchema: { type: 'object', properties: { @@ -263,7 +263,7 @@ const TOOLS: ToolDef[] = [ schema: { name: 'read_note', description: - 'Read the full body and metadata of a single note. Always read before you write. Use the path verbatim from list_notes / search_* / create_note — never invent or prefix it.', + 'Read the full body and metadata of a single note. Always read before you write. Use the path verbatim from list_notes / search_* / create_note. Never invent or prefix it.', inputSchema: { type: 'object', properties: { @@ -285,7 +285,7 @@ const TOOLS: ToolDef[] = [ schema: { name: 'write_note', description: - 'Overwrite the full body of an existing note. Destructive — prefer append_to_note / replace_in_note for incremental edits. Keep frontmatter intact unless the user asked you to change it.', + 'Overwrite the full body of an existing note. Destructive, so prefer append_to_note / replace_in_note for incremental edits. Keep frontmatter intact unless the user asked you to change it.', inputSchema: { type: 'object', properties: { @@ -306,7 +306,7 @@ const TOOLS: ToolDef[] = [ schema: { name: 'create_note', description: - 'Create a new note. Always picks a non-conflicting filename by appending a counter if needed. Use the `path` from the response to refer to the note in any follow-up tool calls — DO NOT reconstruct it from `folder + title`. In root-mode vaults, `folder: "inbox"` means the note lands directly at the vault root with a path like "MyNote.md" (no inbox/ prefix).', + 'Create a new note. Always picks a non-conflicting filename by appending a counter if needed. Use the `path` from the response to refer to the note in any follow-up tool calls. DO NOT reconstruct it from `folder + title`. In root-mode vaults, `folder: "inbox"` means the note lands directly at the vault root with a path like "MyNote.md" (no inbox/ prefix).', inputSchema: { type: 'object', properties: { @@ -424,7 +424,7 @@ const TOOLS: ToolDef[] = [ schema: { name: 'empty_trash', description: - 'Permanently delete every note in trash/. Confirm with the user before calling — this is irreversible.', + 'Permanently delete every note in trash/. Confirm with the user before calling: this is irreversible.', inputSchema: { type: 'object', properties: { @@ -651,7 +651,7 @@ const TOOLS: ToolDef[] = [ schema: { name: 'backlinks', description: - 'Return every note that links to the given note via [[wikilink]]. Run this before renaming a note — the rename will orphan these links.', + 'Return every note that links to the given note via [[wikilink]]. Run this before renaming a note: the rename will orphan these links.', inputSchema: { type: 'object', properties: { diff --git a/apps/desktop/src/renderer/export-window.tsx b/apps/desktop/src/renderer/export-window.tsx index 529302acc..b8fa0b11b 100644 --- a/apps/desktop/src/renderer/export-window.tsx +++ b/apps/desktop/src/renderer/export-window.tsx @@ -399,7 +399,7 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element { } /* Keep tall (portrait) images within the printable page height so they scale down proportionally instead of overflowing the page and being - clipped at the page boundary — a single can't paginate (#231). + clipped at the page boundary, since a single can't paginate (#231). Letter page height is 11in - 2 * 0.7in margins = 9.6in; cap a touch under that to leave space for the image frame and caption. */ .export-note-shell img { @@ -413,8 +413,8 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element { In export that stretches even a tiny image to the full content width, and the max-height above then blows it up to a full page (#256, a regression surfaced by #231). Here, size embeds to the image's - intrinsic dimensions instead — only ever scaling DOWN to fit the - content width or the page height — and shrink the frame/caption to + intrinsic dimensions instead (only ever scaling DOWN to fit the + content width or the page height) and shrink the frame/caption to hug it. The .prose-zen prefix beats the shared (.prose-zen img-embed) rule on specificity regardless of stylesheet order. */ .export-note-shell .prose-zen .local-image-embed { diff --git a/packages/app-core/src/components/CalendarPanel.tsx b/packages/app-core/src/components/CalendarPanel.tsx index 580be1722..eb39be2dc 100644 --- a/packages/app-core/src/components/CalendarPanel.tsx +++ b/packages/app-core/src/components/CalendarPanel.tsx @@ -840,7 +840,7 @@ export function CalendarPanel({ }} onClick={() => void openTaskAt(task)} onContextMenu={(e) => openTaskMenu(e, task)} - title={`${task.content} — drag to a day to reschedule, right-click for more`} + title={`${task.content} · drag to a day to reschedule, right-click for more`} className={[ 'flex w-full cursor-grab items-center gap-2 rounded px-1.5 py-1 text-left text-xs text-ink-700 transition-colors active:cursor-grabbing', opts?.isActive @@ -1034,7 +1034,7 @@ export function CalendarPanel({ if (dragged) openDropMenu(e.clientX, e.clientY, dragged, iso, day) }} disabled={!dailyEnabled} - title={`${iso} — click to view, double-click to open`} + title={`${iso} · click to view, double-click to open`} className={[ 'relative flex flex-col items-center rounded py-1 text-xs leading-tight transition-colors', !dailyEnabled @@ -1082,7 +1082,7 @@ export function CalendarPanel({

{grabbedTask && (
- Moving “{grabbedTask.content || 'task'}” — h/j/k/l pick a day · Enter place · Esc cancel + Moving “{grabbedTask.content || 'task'}”: h/j/k/l pick a day · Enter place · Esc cancel
)}
diff --git a/packages/app-core/src/components/CommandPalette.tsx b/packages/app-core/src/components/CommandPalette.tsx index 333a6c92c..8745d6aa2 100644 --- a/packages/app-core/src/components/CommandPalette.tsx +++ b/packages/app-core/src/components/CommandPalette.tsx @@ -382,12 +382,12 @@ export function CommandPalette(): JSX.Element { {mode === 'theme' - ? 'Theme preview — ↵ to keep, esc to revert' + ? 'Theme preview · ↵ to keep, esc to revert' : mode === 'workflow' - ? 'Run workflow — ↵ to run, esc to return' + ? 'Run workflow · ↵ to run, esc to return' : vaultAction === 'new-window' - ? 'Open vault in new window — ↵ to open, esc to return' - : 'Switch vault — ↵ to open, esc to return'} + ? 'Open vault in new window · ↵ to open, esc to return' + : 'Switch vault · ↵ to open, esc to return'}
)} diff --git a/packages/app-core/src/components/DatabaseBoardView.tsx b/packages/app-core/src/components/DatabaseBoardView.tsx index 68b1d96c2..fab75984d 100644 --- a/packages/app-core/src/components/DatabaseBoardView.tsx +++ b/packages/app-core/src/components/DatabaseBoardView.tsx @@ -137,7 +137,7 @@ export function DatabaseBoardView({ csvPath, doc, view }: Props): JSX.Element { >
- {titleField ? row.cells[titleField.id] || '—' : '—'} + {titleField ? row.cells[titleField.id] || 'Untitled' : 'Untitled'}
{selected.length === 0 ? ( - — + None ) : ( selected.map((v) => ( {field.optionsSource ? draft - ? 'No matches — Enter adds it as an option.' + ? 'No matches. Enter adds it as an option.' : 'Type to search notes or add an option.' - : 'No options yet — add one below.'} + : 'No options yet. Add one below.'}
)}
@@ -1347,7 +1347,7 @@ function NoteCell({ field, value, editing, onStartEdit, onEndEdit, onCommit }: C const chips = (
{targets.length === 0 ? ( - — + None ) : ( targets.map((target) => { const note = resolveWikilinkTarget(notes, target) diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index e5c33f544..c2912347f 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -1585,7 +1585,7 @@ function renderWildmenu( // they should type more to filter rather than keep tabbing forever. if (matches.length > slice.length) { const more = document.createElement("div"); - more.textContent = `+${matches.length - slice.length} more — type to filter`; + more.textContent = `+${matches.length - slice.length} more · type to filter`; more.style.cssText = "padding: 4px 8px 2px; font-size: 10px; opacity: 0.6; color: rgb(var(--z-fg))"; menu.list.appendChild(more); diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index cd2771b09..31b1fdf09 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -3698,7 +3698,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { onDoubleClick={() => { if (tab.preview) promoteTabInPane(paneId, tab.path) }} - title={tab.preview ? `${tab.title} — preview (double-click to keep open)` : undefined} + title={tab.preview ? `${tab.title} · preview (double-click to keep open)` : undefined} className="flex min-w-0 flex-1 items-center gap-1.5 truncate px-1.5 text-left" > {tab.isTasks && ( diff --git a/packages/app-core/src/components/EmptyVault.tsx b/packages/app-core/src/components/EmptyVault.tsx index 984761588..d51b00d08 100644 --- a/packages/app-core/src/components/EmptyVault.tsx +++ b/packages/app-core/src/components/EmptyVault.tsx @@ -103,7 +103,7 @@ export function EmptyVault(): JSX.Element {

{isServerVaultSetup ? 'Choose the vault directory on the server running ZenNotes. The normal self-hosted path is `make up`, which serves the browser app and server together.' - : 'Choose a folder on your computer to use as your vault. ZenNotes will store your notes there as plain markdown files — yours to keep, back up, and sync any way you like.'} + : 'Choose a folder on your computer to use as your vault. ZenNotes will store your notes there as plain markdown files, yours to keep, back up, and sync any way you like.'}

{isServerVaultSetup && (

diff --git a/packages/app-core/src/components/OnboardingWizard.tsx b/packages/app-core/src/components/OnboardingWizard.tsx index 41d779c78..a426c8fff 100644 --- a/packages/app-core/src/components/OnboardingWizard.tsx +++ b/packages/app-core/src/components/OnboardingWizard.tsx @@ -327,7 +327,7 @@ function StepRail({ }} disabled={!reachable || active} aria-current={active ? 'step' : undefined} - aria-label={`${STEP_LABELS[step]} — step ${i + 1} of ${steps.length}`} + aria-label={`${STEP_LABELS[step]}, step ${i + 1} of ${steps.length}`} className={[ 'group flex flex-1 flex-col items-stretch gap-1.5 rounded-md p-1 text-left transition-colors', reachable && !active @@ -481,7 +481,7 @@ function VimStep({

@@ -820,7 +820,7 @@ function LayoutStep({
@@ -931,7 +931,7 @@ function DoneStep({ subtitle={ hasVault ? "Here are a few shortcuts to get you started. You can revisit any of this from Settings." - : 'You skipped the vault step — open the welcome screen any time to pick one.' + : 'You skipped the vault step. Open the welcome screen any time to pick one.' } /> diff --git a/packages/app-core/src/components/OutlinePanel.tsx b/packages/app-core/src/components/OutlinePanel.tsx index 4707bd122..834d7c94d 100644 --- a/packages/app-core/src/components/OutlinePanel.tsx +++ b/packages/app-core/src/components/OutlinePanel.tsx @@ -81,7 +81,7 @@ export function OutlinePanel({ note, activeLine, onJump, fitWidth }: Props): JSX
{items.length === 0 - ? 'No headings yet — add `#` to build an outline.' + ? 'No headings yet. Add `#` to build an outline.' : `${items.length} heading${items.length === 1 ? '' : 's'}`}
{items.length > 0 && ( diff --git a/packages/app-core/src/components/QuickCaptureApp.tsx b/packages/app-core/src/components/QuickCaptureApp.tsx index 711c5e0d5..95b082e3b 100644 --- a/packages/app-core/src/components/QuickCaptureApp.tsx +++ b/packages/app-core/src/components/QuickCaptureApp.tsx @@ -368,7 +368,7 @@ export function QuickCaptureApp(): JSX.Element { if (!view) return false const plan = planQuickCaptureSave(mode, view.state.doc.toString()) if (plan.op === 'noop') { - if (!opts.silent) setError('Nothing to save yet — start writing.') + if (!opts.silent) setError('Nothing to save yet. Start writing.') return false } setSubmitting(true) @@ -706,7 +706,7 @@ export function QuickCaptureApp(): JSX.Element {