From c0d25dd275366cad2815dfcd1214cb6cc0be9ec7 Mon Sep 17 00:00:00 2001 From: Vishal Habib Date: Mon, 12 Oct 2026 20:02:57 -0400 Subject: [PATCH 01/19] Fix(tasks): list_tasks and zn task list leave cancelled tasks out of open A [-] cancelled task is abandoned, and isTaskOpen already keeps it off the calendar and Kanban surfaces (#450, #476). The MCP list_tasks "open" filter and the zn task list default/--unchecked filters still returned it next to actionable tasks. Exclude t.cancelled in both; status "all" and --all still list it. --- apps/desktop/src/cli/commands/tasks.test.ts | 66 +++++++++++++++++++++ apps/desktop/src/cli/commands/tasks.ts | 7 ++- apps/desktop/src/mcp/server.test.ts | 36 +++++++++++ apps/desktop/src/mcp/server.ts | 7 ++- 4 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/cli/commands/tasks.test.ts diff --git a/apps/desktop/src/cli/commands/tasks.test.ts b/apps/desktop/src/cli/commands/tasks.test.ts new file mode 100644 index 000000000..e80deb088 --- /dev/null +++ b/apps/desktop/src/cli/commands/tasks.test.ts @@ -0,0 +1,66 @@ +import { promises as fsp } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { createBackend } from '../backend' +import type { ParsedArgs } from '../args' +import { cmdTaskList } from './tasks' + +function makeArgs(flags: Array<[string, string]> = []): ParsedArgs { + const map = new Map() + for (const [k, v] of flags) map.set(k, [...(map.get(k) ?? []), v]) + return { positionals: [], flags: map } +} + +let tmpDir: string +let root: string +let out: string[] + +beforeAll(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'zen-task-cli-')) + root = path.join(tmpDir, 'vault') + for (const d of ['inbox', 'quick', 'archive', 'trash']) { + await fsp.mkdir(path.join(root, d), { recursive: true }) + } + await fsp.writeFile( + path.join(root, 'inbox', 'todo.md'), + '- [ ] Buy milk\n- [-] Cancelled plan\n- [x] Done thing\n' + ) +}) + +afterAll(async () => { + await fsp.rm(tmpDir, { recursive: true, force: true }) +}) + +beforeEach(() => { + out = [] + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + out.push(String(chunk)) + return true + }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +const backend = (): ReturnType => createBackend({ kind: 'local', root }) +const listed = (): string[] => + (JSON.parse(out.join('')) as Array<{ content: string }>).map((t) => t.content) + +describe('zn task list', () => { + it('leaves `[-]` cancelled tasks out of the open list (#450, #476)', async () => { + await cmdTaskList(backend(), makeArgs([['json', 'true']])) + expect(listed()).toEqual(['Buy milk']) + }) + + it('leaves cancelled tasks out of --unchecked', async () => { + await cmdTaskList(backend(), makeArgs([['unchecked', 'true'], ['json', 'true']])) + expect(listed()).toEqual(['Buy milk']) + }) + + it('still lists cancelled tasks with --all', async () => { + await cmdTaskList(backend(), makeArgs([['all', 'true'], ['json', 'true']])) + expect(listed()).toContain('Cancelled plan') + }) +}) diff --git a/apps/desktop/src/cli/commands/tasks.ts b/apps/desktop/src/cli/commands/tasks.ts index 52455e260..bc252d492 100644 --- a/apps/desktop/src/cli/commands/tasks.ts +++ b/apps/desktop/src/cli/commands/tasks.ts @@ -20,9 +20,10 @@ export async function cmdTaskList(vault: VaultBackend, args: ParsedArgs): Promis if (!showAll) { // A `[>]` forwarded record is history of a move; its live copy is in the // destination note and lists on its own, so showing both doubled every - // carried task (#611 review). `--all` still surfaces the records. - if (onlyUnchecked) tasks = tasks.filter((t) => !t.checked && !t.forwarded) - else tasks = tasks.filter((t) => !t.checked && !t.waiting && !t.forwarded) + // carried task (#611 review). A `[-]` cancelled task is abandoned, so it is + // not open either (#450, #476). `--all` still surfaces both. + if (onlyUnchecked) tasks = tasks.filter((t) => !t.checked && !t.forwarded && !t.cancelled) + else tasks = tasks.filter((t) => !t.checked && !t.waiting && !t.forwarded && !t.cancelled) } if (tag) tasks = tasks.filter((t) => t.tags.includes(tag)) diff --git a/apps/desktop/src/mcp/server.test.ts b/apps/desktop/src/mcp/server.test.ts index 3e63d32e0..d1a427335 100644 --- a/apps/desktop/src/mcp/server.test.ts +++ b/apps/desktop/src/mcp/server.test.ts @@ -141,6 +141,42 @@ describe('tools run through the backend', () => { }) }) +describe('list_tasks', () => { + const task = (content: string, extra: Record = {}): unknown => ({ + content, + noteFolder: 'inbox', + checked: false, + waiting: false, + forwarded: false, + cancelled: false, + tags: [], + ...extra + }) + const tasksBackend = backend({ + scanAllTasks: async () => + [ + task('Buy milk', { due: '2026-10-20' }), + task('Cancelled plan', { cancelled: true, due: '2026-10-15' }), + task('Done thing', { checked: true }) + ] as never + }) + const contents = (rows: unknown): string[] => + (rows as Array<{ content: string }>).map((t) => t.content) + + it('leaves `[-]` cancelled tasks out of the default open list (#450, #476)', async () => { + expect(contents(await callTool('list_tasks', {}, tasksBackend))).toEqual(['Buy milk']) + expect( + contents(await callTool('list_tasks', { dueBefore: '2026-10-31' }, tasksBackend)) + ).toEqual(['Buy milk']) + }) + + it('still returns cancelled tasks for status "all"', async () => { + expect(contents(await callTool('list_tasks', { status: 'all' }, tasksBackend))).toContain( + 'Cancelled plan' + ) + }) +}) + describe('describeToolError', () => { it('turns a 401 from the server into the token hint', () => { const text = describeToolError( diff --git a/apps/desktop/src/mcp/server.ts b/apps/desktop/src/mcp/server.ts index 3fed6e0ff..ad703c32f 100644 --- a/apps/desktop/src/mcp/server.ts +++ b/apps/desktop/src/mcp/server.ts @@ -673,7 +673,7 @@ const TOOLS: ToolDef[] = [ type: 'string', enum: ['open', 'done', 'waiting', 'all'], description: - 'open = unchecked & not waiting (default). done = checked. waiting = has @waiting. all = everything.' + 'open = not done, cancelled or waiting (default). done = checked. waiting = has @waiting. all = everything.' }, priority: { type: 'string', @@ -721,7 +721,10 @@ const TOOLS: ToolDef[] = [ if (folder && t.noteFolder !== folder) return false // A `[>]` record's live copy sits in the destination note; listing the // record as open doubled every carried task for agents (#611 review). - if (status === 'open' && (t.checked || t.waiting || t.forwarded)) return false + // A `[-]` cancelled task is abandoned, so it is not open either (#450, #476). + if (status === 'open' && (t.checked || t.waiting || t.forwarded || t.cancelled)) { + return false + } if (status === 'done' && !t.checked) return false if (status === 'waiting' && !t.waiting) return false if (priority && t.priority !== priority) return false From 5ce41c4c025aa00e89f111fbf5d32c585e9d937c Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 12 Oct 2026 21:17:05 -0500 Subject: [PATCH 02/19] Docs(help): the encryption entry names Save archive and the desktop app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manual's end-to-end encryption entry said that **Download** under Backups saves a readable archive of an encrypted vault. No button has that name: the backup row's button reads Save archive. And only the desktop app can do it. Its main process decrypts the service's archive item by item, while both phone apps refuse an encrypted vault's backup and point to the desktop, since that archive holds ciphertext under path ids. help.ts reaches the phones through app-core, so the sentence has to hold there too: it now names the desktop app and says the phones send you to it. The same entry promised that other devices linked to a vault that was just encrypted "say it is now end-to-end encrypted". That is the desktop's message for the service's 410 VAULT_SUPERSEDED answer; the phones do not special-case it and show the service's own text. The entry now says what every device does: it stops syncing the old vault, and you unlock encryption on each one, then open the encrypted vault from Settings → Cloud. Both sentences now match the website's encryption docs, corrected the same way before the launch (ZenNotes/website#74). Checked in the built app over CDP with a throwaway profile: Open Help, search "Encrypt a Cloud vault", and the entry reads the new text. Left alone: the Help view prints **bold** markers literally and runs the entry's two paragraphs together, which 13 entries share; that is its own change. --- packages/app-core/src/lib/help.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 7192bfe41..b2181644f 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -199,7 +199,7 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ { title: 'Encrypt a Cloud vault end to end', body: - 'End-to-end encryption keeps a Cloud vault\'s notes, attachments, file and folder names, and the vault\'s own name unreadable to ZenNotes Cloud: your devices encrypt everything before it is uploaded, with a key only you hold. Settings → Cloud shows **End-to-end encryption** (beta) once it is available for your account. Set it up there: choose a passphrase of at least 12 characters (four or more unrelated words work well), then save the 24-word recovery key it shows and type three of its words back. Either one unlocks encryption on your other devices, once per device. ZenNotes never receives them and cannot reset them; if you lose both, the encrypted Cloud copy cannot be read, but the notes on your devices are untouched.\n\nTo encrypt a new vault, tick **End-to-end encrypt this vault** when you create it. To move an existing one, choose **Encrypt this vault** in Settings → Cloud: ZenNotes makes a new encrypted vault with the same name, uploads this device\'s copy, then deletes the readable Cloud copy along with its version history and backups, so download a backup first if you want one. Other devices linked to the old vault say it is now end-to-end encrypted; unlock encryption on them and open the encrypted vault from Settings → Cloud. Publishing a note from an encrypted vault still makes a readable public copy until you unpublish it. The website cannot export an encrypted vault as a ZIP, but **Download** under Backups saves a readable archive. Change the passphrase, make a new recovery key, or lock this device from the same section; zennotes.org/security lists what ZenNotes can and cannot see.' + 'End-to-end encryption keeps a Cloud vault\'s notes, attachments, file and folder names, and the vault\'s own name unreadable to ZenNotes Cloud: your devices encrypt everything before it is uploaded, with a key only you hold. Settings → Cloud shows **End-to-end encryption** (beta) once it is available for your account. Set it up there: choose a passphrase of at least 12 characters (four or more unrelated words work well), then save the 24-word recovery key it shows and type three of its words back. Either one unlocks encryption on your other devices, once per device. ZenNotes never receives them and cannot reset them; if you lose both, the encrypted Cloud copy cannot be read, but the notes on your devices are untouched.\n\nTo encrypt a new vault, tick **End-to-end encrypt this vault** when you create it. To move an existing one, choose **Encrypt this vault** in Settings → Cloud: ZenNotes makes a new encrypted vault with the same name, uploads this device\'s copy, then deletes the readable Cloud copy along with its version history and backups, so download a backup first if you want one. Other devices linked to the old vault stop syncing it; unlock encryption on each one, then open the encrypted vault from Settings → Cloud. Publishing a note from an encrypted vault still makes a readable public copy until you unpublish it. The website cannot export an encrypted vault as a ZIP. In the desktop app, **Save archive** under Backups saves a readable archive; the phone apps send you there. Change the passphrase, make a new recovery key, or lock this device from the same section; zennotes.org/security lists what ZenNotes can and cannot see.' }, { title: 'Customize the look: themes vs. overrides', From 00c592bbb441d24250d80ef067d57cd0bd44a468 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 12 Oct 2026 21:36:45 -0500 Subject: [PATCH 03/19] Fix(cloud): a vault another device encrypted says so, on phones too When one device moves a readable Cloud vault into an end-to-end encrypted one, the service answers every other device still linked to the old vault with 410 VAULT_SUPERSEDED. Two things went wrong after that. The phones never let go. Both shells sync through shared-domain's CloudSyncHostService, which retired a link only when Cloud said the vault was gone (404). On a 410 it kept the link, so every sync failed again with the service's own text, "Update ZenNotes on this device to keep syncing it", even on a phone that was already up to date and able to open the encrypted vault. The desktop let go but told the wrong story. Its main process retired the link and said the vault is now end-to-end encrypted, but app-core's notice classified any dropped link it could not name as deleted, so the status bar read "Cloud vault deleted" and Settings said the vault had been deleted from ZenNotes Cloud. Now cloud-vault-availability owns the check (a CloudServiceRequestError with 410 and VAULT_SUPERSEDED, which, unlike a 404, always concerns the vault itself and needs no second request) and the message the hosts throw. The host service retires the link on it during a sync and during the background check for remote changes, exactly as it does for a deleted vault, so the phones get the fix from the next core without a line of shell code. The desktop uses the same two pieces instead of its own string, and its probe now handles the 410 as well. app-core gains a third removal reason, "encrypted", read from the 410 when an in-process host keeps it and from the hosts' first sentence when the error crossed IPC as text. The status bar says "Cloud vault now encrypted"; Settings says the vault "was replaced by an end-to-end encrypted Cloud vault" and points the way on: an unlocked device chooses the encrypted vault, a locked one first unlocks under End-to-end encryption, the section right above. A stored notice with the new reason survives a reload like the other two, and the manual's Space r entry says what the status bar can now show; its website mirror waits on website/docs-2.69. Checked: typecheck and the whole test run; the new host tests fail against the old service; the desktop live test against a local Cloud with CLOUD_E2EE=beta passes, its second device getting the real 410; and in the built app over CDP, the status bar and both Settings states read as above. Not done here: a phone run, which needs a core artifact built from this commit. The service's 410 text stays as it is: only apps without this fix show it, and for them updating is the right advice once the fixed versions are in the stores. --- .../src/main/cloud-sync-service.test.ts | 51 ++++++++++++++- apps/desktop/src/main/cloud-sync-service.ts | 18 ++++-- .../src/components/CloudSettings.test.ts | 51 +++++++++++++++ .../app-core/src/components/CloudSettings.tsx | 18 ++++-- .../app-core/src/lib/cloud-auto-sync.test.ts | 59 ++++++++++++++++- packages/app-core/src/lib/cloud-auto-sync.ts | 64 +++++++++++++------ packages/app-core/src/lib/help.ts | 2 +- .../src/cloud-sync-host-service.test.ts | 55 ++++++++++++++++ .../src/cloud-sync-host-service.ts | 17 ++++- .../src/cloud-vault-availability.ts | 17 +++++ 10 files changed, 318 insertions(+), 34 deletions(-) diff --git a/apps/desktop/src/main/cloud-sync-service.test.ts b/apps/desktop/src/main/cloud-sync-service.test.ts index 0ad23bbd1..0f21e4652 100644 --- a/apps/desktop/src/main/cloud-sync-service.test.ts +++ b/apps/desktop/src/main/cloud-sync-service.test.ts @@ -13,6 +13,7 @@ import type { CloudSyncVault } from '@zennotes/bridge-contract/cloud-sync' import { afterEach, describe, expect, it, vi } from 'vitest' +import { CLOUD_VAULT_SUPERSEDED_MESSAGE } from '@zennotes/shared-domain/cloud-vault-availability' import { CloudServiceRequestError, createCloudSyncClient } from './cloud-sync-client' import { DesktopCloudSyncService, type DesktopCloudSyncServiceDependencies } from './cloud-sync-service' @@ -971,7 +972,8 @@ describe('deleted Cloud vault recovery (#791)', () => { ['unauthenticated', new CloudServiceRequestError('Sign in again', 401, 'UNAUTHENTICATED')], ['forbidden', new CloudServiceRequestError('Access denied', 403, 'FORBIDDEN')], ['server failure', new CloudServiceRequestError('Try later', 503, null)], - ['unstructured proxy 404', new CloudServiceRequestError('Not found', 404, null)] + ['unstructured proxy 404', new CloudServiceRequestError('Not found', 404, null)], + ['410 without Cloud\'s code', new CloudServiceRequestError('Gone', 410, null)] ])('keeps the association and cursor after %s', async (_label, failure) => { const { service, client, localRoot, storageDirectory } = await setup([cloudVault()]) const link = await service.link(localRoot, 'vault-1') @@ -1038,3 +1040,50 @@ describe('deleted Cloud vault recovery (#791)', () => { expect(await service.linkedVault(localRoot)).toEqual(replacement) }) }) + +describe('Cloud vault replaced by an encrypted one', () => { + const cloudVault = (): CloudSyncVault => ({ + id: 'vault-1', name: 'Notes', cursor: 0, + created_at: '2026-10-13T12:00:00.000Z', updated_at: '2026-10-13T12:00:00.000Z' + }) + const superseded = () => new CloudServiceRequestError( + 'This vault was replaced by an end-to-end encrypted vault. Update ZenNotes on this device to keep syncing it.', + 410, 'VAULT_SUPERSEDED', { successor_vault_id: 'vault-2' } + ) + const fingerprint = (value: string) => createHash('sha256').update(value).digest('hex') + const savedStatePath = (storage: string, root: string) => path.join( + storage, 'states', fingerprint(path.resolve(root)), fingerprint('https://zennotes.org'), + `${fingerprint('vault-1')}.json` + ) + + it('unlinks on the first 410 and says the vault is now encrypted, leaving local notes alone', async () => { + const { service, client, localRoot, storageDirectory } = await setup([cloudVault()]) + await service.link(localRoot, 'vault-1') + await service.sync(localRoot) + const note = Buffer.from('Unsent local edit 📝\r\n') + await writeFile(path.join(localRoot, 'note.md'), note) + client.changes.mockRejectedValue(superseded()) + client.manifest.mockRejectedValue(superseded()) + + await expect(service.sync(localRoot)).rejects.toThrow(CLOUD_VAULT_SUPERSEDED_MESSAGE) + + expect(await service.linkedVault(localRoot)).toBeNull() + await expect(readFile(savedStatePath(storageDirectory, localRoot))).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await readFile(path.join(localRoot, 'note.md'))).toEqual(note) + expect(client.deleteVault).not.toHaveBeenCalled() + }) + + it('unlinks the same way when the background probe meets the 410 first', async () => { + const { service, client, localRoot } = await setup([cloudVault()]) + await service.link(localRoot, 'vault-1') + await service.sync(localRoot) + client.manifest.mockRejectedValue(superseded()) + + await expect(service.hasRemoteChanges(localRoot)).rejects.toThrow(CLOUD_VAULT_SUPERSEDED_MESSAGE) + + expect(await service.linkedVault(localRoot)).toBeNull() + client.manifest.mockClear() + expect(await service.hasRemoteChanges(localRoot)).toBe(false) + expect(client.manifest).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/main/cloud-sync-service.ts b/apps/desktop/src/main/cloud-sync-service.ts index b1ccec5bf..f446ec617 100644 --- a/apps/desktop/src/main/cloud-sync-service.ts +++ b/apps/desktop/src/main/cloud-sync-service.ts @@ -43,7 +43,14 @@ import { type DesktopPublishAssetFiles, type PublishAssetUploader } from './cloud-publish-assets' -import { CLOUD_VAULT_REMOVED_MESSAGE, confirmCloudVaultMissing, isCloudResourceMissing, sameCloudVaultLink } from '@zennotes/shared-domain/cloud-vault-availability' +import { + CLOUD_VAULT_REMOVED_MESSAGE, + CLOUD_VAULT_SUPERSEDED_MESSAGE, + confirmCloudVaultMissing, + isCloudResourceMissing, + isCloudVaultSuperseded, + sameCloudVaultLink +} from '@zennotes/shared-domain/cloud-vault-availability' import { createDesktopCloudSyncCoordinator, DesktopCloudSyncStateStore } from './cloud-sync-filesystem' import { rewriteBackupArchive } from './cloud-backup-archive' import { @@ -511,6 +518,9 @@ export class DesktopCloudSyncService { }) return manifest.cursor !== state.cursor } catch (error) { + if (isCloudVaultSuperseded(error) && await this.retireMissingLink(localRoot, link)) { + throw new Error(CLOUD_VAULT_SUPERSEDED_MESSAGE) + } if (isCloudResourceMissing(error) && await this.retireMissingLink(localRoot, link)) { throw new Error(CLOUD_VAULT_REMOVED_MESSAGE) } @@ -649,10 +659,8 @@ export class DesktopCloudSyncService { legacy_conflict_copies: result.legacyConflictCopies } } catch (error) { - if (cloudErrorCode(error) === 'VAULT_SUPERSEDED' && await this.retireMissingLink(localRoot, link)) { - throw new Error( - 'This Cloud vault is now end-to-end encrypted. Unlock encryption in Settings, then open the encrypted vault on this device.' - ) + if (isCloudVaultSuperseded(error) && await this.retireMissingLink(localRoot, link)) { + throw new Error(CLOUD_VAULT_SUPERSEDED_MESSAGE) } if (await confirmCloudVaultMissing(client, link.vault_id, error) && await this.retireMissingLink(localRoot, link)) { diff --git a/packages/app-core/src/components/CloudSettings.test.ts b/packages/app-core/src/components/CloudSettings.test.ts index f9d3c104b..f8a1c5cbf 100644 --- a/packages/app-core/src/components/CloudSettings.test.ts +++ b/packages/app-core/src/components/CloudSettings.test.ts @@ -763,6 +763,57 @@ describe("CloudSettings", () => { }); }); + describe("a vault another device encrypted", () => { + const replaced = { + phase: "unlinked" as const, + removedVault: { vaultName: "Notes", reason: "encrypted" as const }, + }; + const renderSettings = () => + act(async () => + root.render( + createElement(CloudSettings, { + localVaultAvailable: true, + localVaultName: "Notes", + }), + ), + ); + + beforeEach(() => { + mocks.getCloudAccountStatus.mockResolvedValue(connected); + mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount); + mocks.getCloudVaultLink.mockResolvedValue(null); + mocks.listCloudVaults.mockResolvedValue([]); + }); + + afterEach(() => { + delete (mocks as Record).getCloudEncryptionStatus; + }); + + it("says the vault was replaced, not deleted, and asks a locked device to unlock first", async () => { + useCloudSyncStatusStore.setState(replaced); + await renderSettings(); + + const notice = host.querySelector("[data-cloud-vault-removed]"); + expect(notice?.textContent).toContain("Cloud vault now encrypted"); + expect(notice?.textContent).toContain( + "“Notes” was replaced by an end-to-end encrypted Cloud vault, so this vault stopped syncing. Your notes on this device are untouched. Unlock this device under End-to-end encryption, then choose the encrypted vault.", + ); + expect(notice?.textContent).not.toContain("deleted"); + }); + + it("points an unlocked device straight at the encrypted vault", async () => { + Object.assign(mocks, { + getCloudEncryptionStatus: vi.fn(async () => ({ available: true, keyset: true, unlocked: true })), + }); + useCloudSyncStatusStore.setState(replaced); + await renderSettings(); + + expect(host.querySelector("[data-cloud-vault-removed]")?.textContent).toContain( + "Choose the encrypted vault to sync it again.", + ); + }); + }); + it("presents capacity rejections as queued uploads instead of reviewable conflicts", async () => { mocks.getCloudAccountStatus.mockResolvedValue(connected); mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount); diff --git a/packages/app-core/src/components/CloudSettings.tsx b/packages/app-core/src/components/CloudSettings.tsx index 33f031786..9772b793d 100644 --- a/packages/app-core/src/components/CloudSettings.tsx +++ b/packages/app-core/src/components/CloudSettings.tsx @@ -1393,6 +1393,7 @@ function CloudVaultPanel({ 0} + encryptionReady={encryptionReady} onDismiss={onDismissRemoval} /> )} @@ -2822,16 +2823,28 @@ function revealCloudVault(vault: HTMLElement): void { function CloudVaultRemovedNotice({ removal, vaultsToChoose, + encryptionReady, onDismiss, }: { removal: CloudVaultRemoval; vaultsToChoose: boolean; + encryptionReady: boolean; onDismiss: () => void; }): JSX.Element { const ref = useRef(null); useEffect(() => { ref.current?.scrollIntoView?.({ block: "nearest" }); }, []); + // A vault replaced by an encrypted one lives on under its old name, which a + // locked device cannot read yet, so unlocking comes before choosing. + const nextStep = + removal.reason === "encrypted" + ? encryptionReady + ? "Choose the encrypted vault to sync it again." + : "Unlock this device under End-to-end encryption, then choose the encrypted vault." + : vaultsToChoose + ? "Choose a cloud vault or start a new one." + : "Create a new cloud vault to sync it again."; return (

- {cloudVaultRemovalMessage(removal)}{" "} - {vaultsToChoose - ? "Choose a cloud vault or start a new one." - : "Create a new cloud vault to sync it again."} + {cloudVaultRemovalMessage(removal)} {nextStep}

+ ); + } else if (state.kind === "shared") { + summary = ( + <> + Assistants can reach {code(state.serverUrl)}: its auth token is + saved in {code(state.credentialsPath)}, where zn reads it too. + + ); + detail = + "Stop sharing removes it from that file. Forgetting the server in Settings \u2192 Vault removes it too, and a new token you save for the server in ZenNotes replaces it."; + action = ( + + ); + } else if (state.kind === "different") { + summary = ( + <> + {code(state.credentialsPath)} holds a different auth token for{" "} + {code(state.serverUrl)}, saved by {code("zn connect")} or before the + server{"\u2019"}s token changed. + + ); + detail = + "If assistants are turned away, replace it with the token ZenNotes connects with. zn reads the same file."; + action = ( + + ); + } else { + summary = <>{state.message}; + detail = + "ZenNotes won\u2019t write to it, so the tokens it holds stay safe."; + action = ( + + ); + } + body = ( +
+
+

{summary}

+

{detail}

+
+
{action}
+
+ ); + } + + return ( +
+ {body} + {error && ( + + Something went wrong: {error} + + )} +
+ ); +} + 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 473871b89..44d27280c 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -1318,7 +1318,7 @@ 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. The Claude desktop app\'s Code tab runs Claude Code, so it reads the **Claude Code (CLI + Desktop Code tab)** entry in Settings → MCP; **Claude Desktop (Chat)** reaches only its Chat tab. Once `zn` is installed from Settings → CLI, Settings → MCP installs configure the clients to launch `zn mcp` directly, so the install path is one stable absolute path that survives app moves; otherwise, a `zn` installed some other way included, they run the server bundled with the app. 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 where both servers look, `zn mcp` and the bundled one, without changing which vault they follow, and the assistant\'s next call uses it), 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`).' + '`zn mcp` starts the ZenNotes MCP server in stdio mode, the same one Claude Code, Claude Desktop, and Codex use under the hood. The Claude desktop app\'s Code tab runs Claude Code, so it reads the **Claude Code (CLI + Desktop Code tab)** entry in Settings → MCP; **Claude Desktop (Chat)** reaches only its Chat tab. Once `zn` is installed from Settings → CLI, Settings → MCP installs configure the clients to launch `zn mcp` directly, so the install path is one stable absolute path that survives app moves; otherwise, a `zn` installed some other way included, they run the server bundled with the app. 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. Settings → MCP → Server token → **Let assistants use it** saves it to `credentials.toml` in the config folder, the file `zn connect` writes and both servers read (`zn mcp` and the bundled one), and the assistant\'s next call uses it. Any program running as you can read that file, so it is your choice: **Stop sharing** removes the token, forgetting the server in Settings → Vault removes it too, and a new token you save for the server replaces it. From a terminal, `zn connect --no-default` saves it the same way without changing which vault `zn` follows, and `ZENNOTES_REMOTE_TOKEN` in the MCP client\'s environment works as well. 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', diff --git a/packages/bridge-contract/src/bridge.ts b/packages/bridge-contract/src/bridge.ts index d6dd26b53..b68371a57 100644 --- a/packages/bridge-contract/src/bridge.ts +++ b/packages/bridge-contract/src/bridge.ts @@ -92,7 +92,8 @@ import type { McpClientId, McpClientStatus, McpInstructionsPayload, - McpServerRuntime + McpServerRuntime, + McpServerTokenState } from './mcp-clients.js' import type { AppConfigPortable } from './app-config.js' import type { ExternalUrlResult } from './application-links.js' @@ -514,6 +515,12 @@ export interface ZenBridge { mcpGetStatuses(): Promise mcpInstall(id: McpClientId): Promise mcpUninstall(id: McpClientId): Promise + /** Whether assistants can use the token of the server the app is on (#830). */ + mcpGetServerTokenState(): Promise + /** Saves the app's token for that server where zn and the MCP servers look. */ + mcpShareServerToken(): Promise + /** Removes it again, only when it is still the app's token. */ + mcpRemoveServerToken(): Promise mcpGetInstructions(): Promise mcpSetInstructions(next: string | null): Promise cliGetStatus(): Promise diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index 5c73f0cc1..5a0b56dbf 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -186,6 +186,9 @@ export const IPC = { MCP_INSTALL: 'mcp:install', MCP_UNINSTALL: 'mcp:uninstall', MCP_RUNTIME: 'mcp:runtime', + MCP_SERVER_TOKEN_STATE: 'mcp:server-token-state', + MCP_SHARE_SERVER_TOKEN: 'mcp:share-server-token', + MCP_REMOVE_SERVER_TOKEN: 'mcp:remove-server-token', MCP_GET_INSTRUCTIONS: 'mcp:get-instructions', MCP_SET_INSTRUCTIONS: 'mcp:set-instructions', CLI_GET_STATUS: 'cli:get-status', diff --git a/packages/bridge-contract/src/mcp-clients.ts b/packages/bridge-contract/src/mcp-clients.ts index 848ac99b9..7f1cb2b7c 100644 --- a/packages/bridge-contract/src/mcp-clients.ts +++ b/packages/bridge-contract/src/mcp-clients.ts @@ -34,6 +34,26 @@ export interface McpServerRuntime { unavailableReason?: string } +/** + * Settings → MCP → Server token (#830): whether assistants can authenticate + * to the ZenNotes Server the app is connected to, through the token file + * `zn connect` writes. The main process works it out from its own config; + * it never carries the token itself. + */ +export type McpServerTokenState = + /** Nothing to share: the vault is on this machine, or the server needs no + * token. */ + | { kind: 'none'; reason: 'local' | 'no-token' } + /** The server needs a token and the file has none for it. */ + | { kind: 'missing'; serverUrl: string; credentialsPath: string } + /** The file holds the token the app connects with. */ + | { kind: 'shared'; serverUrl: string; credentialsPath: string } + /** The file holds another token for this server (`zn connect`, or the + * server's token changed since). */ + | { kind: 'different'; serverUrl: string; credentialsPath: string } + /** The file is not zn's format; ZenNotes will not write to it. */ + | { kind: 'unreadable'; serverUrl: string; credentialsPath: string; message: string } + /** * Shape returned when the renderer asks for the current server-side * instructions. `defaultValue` is the compiled default; `current` is diff --git a/packages/shared-domain/src/mcp-clients.ts b/packages/shared-domain/src/mcp-clients.ts index 6fe4e74eb..fadce0ba7 100644 --- a/packages/shared-domain/src/mcp-clients.ts +++ b/packages/shared-domain/src/mcp-clients.ts @@ -11,6 +11,7 @@ export type { McpClientId, McpClientStatus, McpServerRuntime, + McpServerTokenState, McpInstructionsPayload } from '@zennotes/bridge-contract/mcp-clients' From fb9c6fcc0e5ab5d6e2cccb739af90f9e9e2f8d6b Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 13 Oct 2026 09:06:25 -0500 Subject: [PATCH 10/19] Fix(create form): follow-up to #933, the Enter hint reads the row Enter picks Review follow-up to @uNyanda's footer hints (#933), kept apart so their commit stands as theirs. - The Enter hint counted a highlight whenever the focused field's list had any row, while the Enter handlers pick the row at the highlight. The two only part ways when a list shrinks under the highlight with no typing, which the form allows: it follows the vault's notes, so a sync or another window can drop a tag while it is open. The footer then said "add tag" while Enter created the note. The hint now reads the row exactly the way the handlers do, as the PR's own comment describes, and a new test shrinks the tag list under a highlight. typedTag is already trimmed, so its extra trim goes too. - The new test file seeded the store with vaultSettings: null, which the store's type does not allow. Vitest does not typecheck, so the tests passed while every CI build failed at the app-core typecheck; it seeds DEFAULT_VAULT_SETTINGS now. - Two comments lose their long dashes, per the house style. Checked: app-core typecheck; the form, search-create and palette-nav suites (42 tests, the new one failing on the merged code); and the built app over CDP, reading the footer in Name, Folder (with and without a highlight, and filtered to nothing) and Tags (with a highlight and with invalid text): every state matches the PR. --- .../src/components/SearchCreateForm.test.ts | 21 ++++++++++++++++--- .../src/components/SearchCreateForm.tsx | 16 +++++++------- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/packages/app-core/src/components/SearchCreateForm.test.ts b/packages/app-core/src/components/SearchCreateForm.test.ts index fdd0a1564..9925f99e7 100644 --- a/packages/app-core/src/components/SearchCreateForm.test.ts +++ b/packages/app-core/src/components/SearchCreateForm.test.ts @@ -3,7 +3,7 @@ import { act, createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { NoteMeta } from '@shared/ipc' +import { DEFAULT_VAULT_SETTINGS, type NoteMeta } from '@shared/ipc' import { useStore } from '../store' import type { SearchCreateDraft } from '../lib/search-create' import { SearchCreateForm } from './SearchCreateForm' @@ -50,7 +50,7 @@ describe('SearchCreateForm: footer hints follow the focused field', () => { notes: [note('Alpha', ['alpha', 'beta'])], folders: [], systemFolderLabels: {}, - vaultSettings: null, + vaultSettings: DEFAULT_VAULT_SETTINGS, activeNote: null }) }) @@ -159,6 +159,21 @@ describe('SearchCreateForm: footer hints follow the focused field', () => { expect(hints()).toEqual(['↑↓ pick', '↵ add tag', 'tab next field', 'esc back']) }) + it('reads the row Enter would pick, not just the index, when the list shrinks under it', () => { + render() + // The highlight sits on the second of two vault tags, then the vault loses + // a tag while the form is open (a sync, another window). The index now + // outlives its row, so Enter finds nothing behind it and, with nothing + // typed, creates the note. + focusField('tags') + press('tags', { key: 'ArrowDown' }) + press('tags', { key: 'ArrowDown' }) + act(() => { + useStore.setState({ notes: [note('Alpha', ['alpha'])] }) + }) + expect(hints()).toEqual(['↑↓ pick', '↵ create note', 'tab next field', 'esc back']) + }) + it('still says add tag once the highlight cycles off the last row', () => { render() // Two tags in the vault. The highlight wraps from the last row back to the @@ -174,7 +189,7 @@ describe('SearchCreateForm: footer hints follow the focused field', () => { it('keeps pick when the typed-tag row is all there is, and still says add tag', () => { // A vault with no tags: typing leaves exactly one row, the `Add #…` one. - // Moving the highlight off it does not remove the row, so pick stays true — + // Moving the highlight off it does not remove the row, so pick stays true; // what changes is that Enter now commits the typed text instead. useStore.setState({ notes: [note('Alpha')] }) render() diff --git a/packages/app-core/src/components/SearchCreateForm.tsx b/packages/app-core/src/components/SearchCreateForm.tsx index 6f569346c..78b7ca0c6 100644 --- a/packages/app-core/src/components/SearchCreateForm.tsx +++ b/packages/app-core/src/components/SearchCreateForm.tsx @@ -157,27 +157,29 @@ export function SearchCreateForm({ // field is focusable: the system folders guarantee them in Folder, but a // typed path matching nothing empties the list, and a vault with no tags // leaves Tags empty until something valid is typed. - const hasList = - focused === 'folder' ? folderRows.length > 0 - : focused === 'tags' ? tagRows.length > 0 - : false + const focusedRows: readonly unknown[] = + focused === 'folder' ? folderRows : focused === 'tags' ? tagRows : [] + const hasList = focusedRows.length > 0 // Enter means three different things depending on the field and on whether a // row actually stands behind the highlight, and one word cannot cover them. // The highlight only counts while a row is behind it: typing in Folder // preselects row 0 so Enter picks without an arrow key first (#467), and that - // index outlives the row once the text filters the list down to nothing — + // index outlives the row once the text filters the list down to nothing: // `folderRows[0]` is then `undefined` and Enter falls through to `create()`. + // A list can also shrink under the highlight without any typing (the vault's + // tags change while the form is open), so the hint reads the row exactly the + // way the Enter handlers do, not just whether the index is non-negative. // // In Tags the third case is a typed tag, which commits as a chip rather than // creating anything, so it reads `add tag` too. Typed text that is not a // valid tag leaves Enter with nothing to do at all, and offering it any // label would be a promise it cannot keep, so the hint is dropped and the // reason is left to the status line above. - const rowPicked = hasList && activeRow >= 0 + const rowPicked = activeRow >= 0 && focusedRows[activeRow] !== undefined const inTags = focused === 'tags' const enterLabel = rowPicked ? (inTags ? 'add tag' : 'choose folder') - : inTags && typedTag.trim() ? (normalizeTag(typedTag) ? 'add tag' : null) + : inTags && typedTag ? (normalizeTag(typedTag) ? 'add tag' : null) : 'create note' const create = (): void => { From faa8f873c455f458b6d87069ff97532c737c9d29 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 13 Oct 2026 09:39:47 -0500 Subject: [PATCH 11/19] Fix(bridge): the Server token methods are desktop-only, so the phones still compile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dd0f929b added mcpGetServerTokenState, mcpShareServerToken and mcpRemoveServerToken to ZenBridge as required methods, and gave the web bridge stubs for them. The two phone shells implement ZenBridge as well (src/bridge/mobile-bridge.ts in each), so adopting core 2.69 would have failed their typecheck. It surfaced while adopting a rehearsal core into the Android shell for zennotesandroid#108. The three methods are optional now and marked desktop only, the way cliUpdateGetState and cliUpdateCheck already are, so a bridge with no assistants to hand a token to needs no stub, and the web stubs go. Settings → MCP → Server token reads a missing method as nothing to share and shows its one-line note. Checked: typecheck across every package, and the Android shell's typecheck against a rehearsal core built from this tree; the app-core and desktop suites. --- apps/web/src/bridge/http-bridge.ts | 20 +------------------ .../app-core/src/components/SettingsModal.tsx | 17 ++++++++++++---- packages/bridge-contract/src/bridge.ts | 14 +++++++------ 3 files changed, 22 insertions(+), 29 deletions(-) diff --git a/apps/web/src/bridge/http-bridge.ts b/apps/web/src/bridge/http-bridge.ts index fadb7eca7..72781907a 100644 --- a/apps/web/src/bridge/http-bridge.ts +++ b/apps/web/src/bridge/http-bridge.ts @@ -79,8 +79,7 @@ import type { McpClientId, McpClientStatus, McpInstructionsPayload, - McpServerRuntime, - McpServerTokenState + McpServerRuntime } from '@shared/mcp-clients' import type { CustomCodeLanguageInstallInput, @@ -1366,20 +1365,6 @@ async function mcpUninstall(_id: McpClientId): Promise { return notImplemented('mcpUninstall') } -// The web client is the server's own page: there is no assistant on this -// machine to hand a token to. -async function mcpGetServerTokenState(): Promise { - return { kind: 'none', reason: 'local' } -} - -async function mcpShareServerToken(): Promise { - return notImplemented('mcpShareServerToken') -} - -async function mcpRemoveServerToken(): Promise { - return notImplemented('mcpRemoveServerToken') -} - async function mcpGetInstructions(): Promise { return { defaultValue: '', current: '', isCustom: false, filePath: '' } } @@ -1657,9 +1642,6 @@ export const httpBridge: ZenBridge = { mcpGetStatuses, mcpInstall, mcpUninstall, - mcpGetServerTokenState, - mcpShareServerToken, - mcpRemoveServerToken, mcpGetInstructions, mcpSetInstructions, cliGetStatus, diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 6703a8e22..0f8156a4a 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -9029,6 +9029,8 @@ function UsageCountSetting(): JSX.Element { ); } +const NO_SERVER_TOKEN: McpServerTokenState = { kind: "none", reason: "local" }; + /** * Settings → MCP → Server token (#830): lets assistants use the token of the * ZenNotes Server the app is on, without a terminal. Main reads the server and @@ -9044,7 +9046,8 @@ function McpServerTokenSetting(): JSX.Element { const refresh = useCallback(async (): Promise => { try { - setState(await window.zen.mcpGetServerTokenState()); + // Desktop only: a bridge without it has no assistants to hand a token to. + setState((await window.zen.mcpGetServerTokenState?.()) ?? NO_SERVER_TOKEN); setError(null); } catch (err) { setError((err as Error).message); @@ -9108,7 +9111,9 @@ function McpServerTokenSetting(): JSX.Element { variant="primary" size="sm" disabled={busy} - onClick={() => void run(() => window.zen.mcpShareServerToken())} + onClick={() => + void run(async () => (await window.zen.mcpShareServerToken?.()) ?? NO_SERVER_TOKEN) + } > Let assistants use it @@ -9127,7 +9132,9 @@ function McpServerTokenSetting(): JSX.Element { variant="secondary" size="sm" disabled={busy} - onClick={() => void run(() => window.zen.mcpRemoveServerToken())} + onClick={() => + void run(async () => (await window.zen.mcpRemoveServerToken?.()) ?? NO_SERVER_TOKEN) + } > Stop sharing @@ -9147,7 +9154,9 @@ function McpServerTokenSetting(): JSX.Element { variant="primary" size="sm" disabled={busy} - onClick={() => void run(() => window.zen.mcpShareServerToken())} + onClick={() => + void run(async () => (await window.zen.mcpShareServerToken?.()) ?? NO_SERVER_TOKEN) + } > Replace it diff --git a/packages/bridge-contract/src/bridge.ts b/packages/bridge-contract/src/bridge.ts index b68371a57..f517751a2 100644 --- a/packages/bridge-contract/src/bridge.ts +++ b/packages/bridge-contract/src/bridge.ts @@ -515,12 +515,14 @@ export interface ZenBridge { mcpGetStatuses(): Promise mcpInstall(id: McpClientId): Promise mcpUninstall(id: McpClientId): Promise - /** Whether assistants can use the token of the server the app is on (#830). */ - mcpGetServerTokenState(): Promise - /** Saves the app's token for that server where zn and the MCP servers look. */ - mcpShareServerToken(): Promise - /** Removes it again, only when it is still the app's token. */ - mcpRemoveServerToken(): Promise + /** Desktop only: whether assistants can use the token of the server the app + * is on (#830). Optional so the web and phone bridges need no stub. */ + mcpGetServerTokenState?(): Promise + /** Desktop only: saves the app's token for that server where zn and the MCP + * servers look. */ + mcpShareServerToken?(): Promise + /** Desktop only: removes it again, only when it is still the app's token. */ + mcpRemoveServerToken?(): Promise mcpGetInstructions(): Promise mcpSetInstructions(next: string | null): Promise cliGetStatus(): Promise From f9e219b2c49429d8d0a419d82af6e261879308f8 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 13 Oct 2026 09:39:47 -0500 Subject: [PATCH 12/19] Feat(browse): a phone can start a note in the Browse folder it shows The phones' Browse list offers New folder in every folder but no New note, so a note could only be created at the top and then moved, and New Note in Current Folder means the open note's folder, not the one being browsed (zennotesandroid#108, also filed as #923). The core half is requestCreateBrowseNote(host, directory) in the browse boundary, beside requestCreateBrowseFolder and with the same guards: an existing primary-notes folder ('' is the root), no database folder, no dialog already open, and nothing once the vault or the host has moved on. It then creates the note the way the phones' Note button does, through createAndOpen with the title focused, so there is no prompt: naming the note is the next thing typed either way. The shells add the row and call it once they adopt this core. Checked: typecheck; browse-actions tests for a folder and the root and for every refusal; the app-core package test renders the new boundary call; and end to end on an Android emulator with a rehearsal core: Browse, New folder "Projects", into it, New note, which opened "Untitled" with its title selected and wrote inbox/Projects/Untitled.md. --- packages/app-core/README.md | 1 + packages/app-core/src/browse-actions.test.ts | 28 +++++++++++++++++++- packages/app-core/src/browse.ts | 1 + packages/app-core/src/lib/browse-actions.ts | 23 ++++++++++++++++ tooling/scripts/test-app-core-package.mjs | 1 + 5 files changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/app-core/README.md b/packages/app-core/README.md index 71a2476a4..7d471b59a 100644 --- a/packages/app-core/README.md +++ b/packages/app-core/README.md @@ -223,6 +223,7 @@ host's stable vault token. - `createBrowseDatabase(host, directory?)` creates and opens an untitled database. Omitting the directory uses the configured database location, including the active note's folder. Explicit `''` selects the primary root; any other explicit directory must be an existing ordinary Browse folder. - `requestRenameBrowseDatabase(host, directory)` prompts for a database title and preserves host collision numbering. Names starting with a dot are rejected because vault scanners hide those directories. Case-only renames retain existing host behavior and can receive a numbered suffix on case-insensitive filesystems. - `requestCreateBrowseFolder(host, directory = '')` prompts for a child folder. +- `requestCreateBrowseNote(host, directory = '')` creates a note in an ordinary Browse folder (`''` is the primary root) and opens it with its title focused, without a prompt. A missing folder or a database is refused with `unavailable`. - `requestRenameBrowseFolder(host, directory)` prompts for an ordinary folder's leaf name. - `requestMoveBrowseDirectory(host, directory)` prompts for a new parent for an ordinary folder or an entire database, using the move-note prompt's `inbox[/path]` values. Only existing notes-area folders are offered, never the directory itself, its descendants, or a database. The leaf name, and so a database's `.base` suffix, is kept. A destination that already holds that name is blocked in the prompt, and the host still refuses to overwrite. The store carries open tabs (database tabs included), folder icons and colors, favorites, and manual order to the new path. Pins are host-owned: a host that pins folders re-keys them after `completed`. - `requestDeleteBrowseDirectory(host, directory)` confirms permanent deletion of an ordinary folder or an entire database, with the appropriate warning. diff --git a/packages/app-core/src/browse-actions.test.ts b/packages/app-core/src/browse-actions.test.ts index 3488aaf71..f6b6e47b8 100644 --- a/packages/app-core/src/browse-actions.test.ts +++ b/packages/app-core/src/browse-actions.test.ts @@ -22,6 +22,7 @@ async function setup() { const createDatabase = vi.fn(async () => undefined) const renameDatabase = vi.fn(async () => {}) const refreshAssets = vi.fn(async () => {}) + const createAndOpen = vi.fn(async () => {}) useStore.setState({ vault: { root: '/test', name: 'Test' }, folders: ['Work', 'Work/Nested', 'People.base'].map((subpath) => ({ @@ -34,7 +35,8 @@ async function setup() { deleteFolder: remove, createDatabase, renameDatabase, - refreshAssets + refreshAssets, + createAndOpen }) const host = { isCurrent: () => true } const answer = (value: string | null) => { @@ -58,6 +60,7 @@ async function setup() { createDatabase, renameDatabase, refreshAssets, + createAndOpen, host, answer, confirm @@ -222,6 +225,29 @@ describe('public Browse actions', () => { s.answer(null) expect(await next).toBe('cancelled') }) + // zennotesandroid#108: the phones could only create a note at the top and + // move it, so Browse offers New note in the folder it is showing. + it('creates a note in the Browse directory and opens it on its title, without a prompt', async () => { + const s = await setup() + expect(await s.requestCreateBrowseNote(s.host, 'Work/Nested')).toBe('completed') + expect(s.createAndOpen).toHaveBeenCalledWith('inbox', 'Work/Nested', { focusTitle: true }) + expect(await s.requestCreateBrowseNote(s.host)).toBe('completed') + expect(s.createAndOpen).toHaveBeenLastCalledWith('inbox', '', { focusTitle: true }) + expect(s.getPromptRequest()).toBeNull() + }) + + it('creates no note in a missing folder, a database, or while a dialog is open', async () => { + const s = await setup() + for (const directory of ['Missing', 'People.base', 'People.base/pages']) + expect(await s.requestCreateBrowseNote(s.host, directory)).toBe('unavailable') + expect(await s.requestCreateBrowseNote({ isCurrent: () => false }, 'Work')).toBe('unavailable') + const prompt = s.promptApp({ title: 'Unrelated prompt' }) + expect(await s.requestCreateBrowseNote(s.host, 'Work')).toBe('unavailable') + s.answer(null) + await prompt + expect(s.createAndOpen).not.toHaveBeenCalled() + }) + it('creates an untitled database in the explicit Browse directory without a prompt', async () => { const s = await setup() expect(await s.createBrowseDatabase(s.host, 'Work')).toBe('completed') diff --git a/packages/app-core/src/browse.ts b/packages/app-core/src/browse.ts index 70c7a5bab..9cafb91fc 100644 --- a/packages/app-core/src/browse.ts +++ b/packages/app-core/src/browse.ts @@ -278,6 +278,7 @@ export { createBrowseDatabase, requestRenameBrowseDatabase, requestCreateBrowseFolder, + requestCreateBrowseNote, requestRenameBrowseFolder, requestMoveBrowseDirectory, requestDeleteBrowseDirectory, diff --git a/packages/app-core/src/lib/browse-actions.ts b/packages/app-core/src/lib/browse-actions.ts index 155441765..cdea4e102 100644 --- a/packages/app-core/src/lib/browse-actions.ts +++ b/packages/app-core/src/lib/browse-actions.ts @@ -118,6 +118,29 @@ export async function requestCreateBrowseFolder( } } +/** + * Create a note in a primary-notes directory and open it on its title, the + * way the phones' create row makes one at the top (zennotesandroid#108). An + * empty directory means its root. There is no prompt: the note opens with its + * title focused, so naming it is the next thing typed either way. Database + * folders hold records rather than notes, so they are refused like a folder + * there is. + */ +export async function requestCreateBrowseNote( + host: BrowseActionHost, + directory = '' +): Promise { + if (formDirContaining(directory)) return 'unavailable' + const context = start(host, directory, true) + if (!context) return 'unavailable' + try { + await useStore.getState().createAndOpen('inbox', directory, { focusTitle: true }) + return context.isCurrent() ? 'completed' : 'stale' + } finally { + pending = false + } +} + /** Prompt for an ordinary folder's leaf name. Database renaming uses its own feature. */ export async function requestRenameBrowseFolder( host: BrowseActionHost, diff --git a/tooling/scripts/test-app-core-package.mjs b/tooling/scripts/test-app-core-package.mjs index f89d410df..cdeb64246 100644 --- a/tooling/scripts/test-app-core-package.mjs +++ b/tooling/scripts/test-app-core-package.mjs @@ -193,6 +193,7 @@ function Browse() { + {rows.folders.map(row =>
From 46ee2438c558dd5da423d8e1aed8416f61ace20c Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 13 Oct 2026 09:54:19 -0500 Subject: [PATCH 13/19] Release: align desktop and shared packages at 2.69.0 --- apps/desktop/package.json | 2 +- apps/quicklook/package.json | 2 +- apps/share-viewer/package.json | 2 +- apps/web/package.json | 2 +- package-lock.json | 20 ++++++++++---------- package.json | 2 +- packages/app-core/package.json | 2 +- packages/bridge-contract/package.json | 2 +- packages/shared-domain/package.json | 2 +- packages/shared-ui/package.json | 2 +- 10 files changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c96d48998..138980e37 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.68.0", + "version": "2.69.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/quicklook/package.json b/apps/quicklook/package.json index 77dc09294..b8edcf006 100644 --- a/apps/quicklook/package.json +++ b/apps/quicklook/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/quicklook", "private": true, - "version": "2.68.0", + "version": "2.69.0", "type": "module", "description": "The macOS Quick Look preview for Markdown files: the page it renders and the Swift extension that serves it", "homepage": "https://zennotes.org", diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json index 2c26faa62..988824be8 100644 --- a/apps/share-viewer/package.json +++ b/apps/share-viewer/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/share-viewer", "private": true, - "version": "2.68.0", + "version": "2.69.0", "type": "module", "description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website", "homepage": "https://zennotes.org", diff --git a/apps/web/package.json b/apps/web/package.json index ffc6a6c85..2a8e2640f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.68.0", + "version": "2.69.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/package-lock.json b/package-lock.json index 4362d6997..726507e12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.68.0", + "version": "2.69.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.68.0", + "version": "2.69.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.68.0", + "version": "2.69.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -914,7 +914,7 @@ }, "apps/quicklook": { "name": "@zennotes/quicklook", - "version": "2.68.0", + "version": "2.69.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -987,7 +987,7 @@ }, "apps/share-viewer": { "name": "@zennotes/share-viewer", - "version": "2.68.0", + "version": "2.69.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -1058,7 +1058,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.68.0", + "version": "2.69.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16390,7 +16390,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.68.0", + "version": "2.69.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16478,14 +16478,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.68.0", + "version": "2.69.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.68.0", + "version": "2.69.0", "dependencies": { "@scure/bip39": "2.4.0", "@zennotes/bridge-contract": "*", @@ -16499,7 +16499,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.68.0" + "version": "2.69.0" } } } diff --git a/package.json b/package.json index 2d072538c..4fab0b1a3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.68.0", + "version": "2.69.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 53a1e7224..76680b039 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.68.0", + "version": "2.69.0", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 040189534..50d4b759e 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.68.0", + "version": "2.69.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index 9f88d630d..14cf40ac6 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.68.0", + "version": "2.69.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index fc57368d9..b66e2b216 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-ui", "private": true, - "version": "2.68.0", + "version": "2.69.0", "type": "module", "exports": { ".": "./src/index.ts" From 87c7e216557b9150684658d6f59768e26cf1fb34 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 13 Oct 2026 09:54:19 -0500 Subject: [PATCH 14/19] Docs(whats-new): the 2.69.0 highlights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What's New opens on the first launch after the update with five highlights: Settings → MCP → Server token for a ZenNotes Server that needs a token (#830), the Claude integrations naming the tab they reach, setup skipping the layout step for a server vault, the New note form's footer naming the keys of the focused field (#933), and a Cloud vault another device encrypted reading as encrypted, not deleted. --- packages/app-core/src/lib/whats-new.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/app-core/src/lib/whats-new.ts b/packages/app-core/src/lib/whats-new.ts index c7c13efbe..b4e7cca90 100644 --- a/packages/app-core/src/lib/whats-new.ts +++ b/packages/app-core/src/lib/whats-new.ts @@ -39,6 +39,31 @@ export const CLOUD_ANNOUNCEMENT = { } as const export const WHATS_NEW: readonly WhatsNewEntry[] = [ + { + version: '2.69.0', + highlights: [ + { + title: 'Assistants reach your server', + body: 'Settings → MCP → Server token lets Claude, Codex and other assistants use a ZenNotes Server that needs a token, with no terminal step.' + }, + { + title: 'Which Claude, which tab', + body: 'The MCP integrations say which part of the Claude app they reach: its Code tab uses the Claude Code entry.' + }, + { + title: 'Setup for a server vault', + body: 'Connecting to a ZenNotes Server during setup skips the layout step: the server’s vault already has one.' + }, + { + title: 'Clearer keys for a new note', + body: 'The New note form in search names the keys that work in the field you are in.' + }, + { + title: 'Encrypted vaults say so', + body: 'A Cloud vault that another device encrypted end to end reads as encrypted, not deleted.' + } + ] + }, { version: '2.68.0', highlights: [ From 69342fa3864c0fc27428c3591b908dee384db62a Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 13 Oct 2026 10:14:26 -0500 Subject: [PATCH 15/19] Release(cli): bundle the verified 0.7.1 terminal release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled CLI is the floor a desktop build guarantees, and 0.7.1 is the newest release. It carries this release's two zn changes into a desktop-managed install from the first launch instead of the first daily update check: `zn task list` and `zn mcp`'s `list_tasks` leave cancelled tasks out of the open ones, matching the bundled MCP server since #929, and when a server needs a token, zn's message points to the new Settings → MCP → Server token as well as `zn connect`. The pin is the release's terminal-release.json copied byte for byte after it verified against packaging/release-signing/zn-release-1.pub in ZenNotes/tui (source commit 91bb33b, integration protocol 1, all four archives matching checksums.txt). Staged through `npm run terminal:stage`; the native darwin-arm64 binary reports zn v0.7.1. The in-app manual's CLI card says 0.7.1 and what it changes. --- apps/desktop/terminal-release.json | 20 ++++++++++---------- packages/app-core/src/lib/help.ts | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/desktop/terminal-release.json b/apps/desktop/terminal-release.json index 50bc983e0..e79d48648 100644 --- a/apps/desktop/terminal-release.json +++ b/apps/desktop/terminal-release.json @@ -3,24 +3,24 @@ "release": { "repository": "ZenNotes/tui", "protocol": 1, - "version": "0.7.0", - "commit": "9650f8d6ce0c87c94e11c61719aa5cfeb7ba7b4b", + "version": "0.7.1", + "commit": "91bb33b42a3d156c2e577762b1f7ce1feb02d586", "artifacts": { "darwin-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.0/zn_0.7.0_darwin_arm64.tar.gz", - "sha256": "6272909db88987a6fda33f7e9686067ee15b514892ebda016d66f2b07af6e957" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.1/zn_0.7.1_darwin_arm64.tar.gz", + "sha256": "cb0c90410fcd86972b4e62a24840bd0f5d2d82d403cfc556399e3c735b43651c" }, "darwin-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.0/zn_0.7.0_darwin_amd64.tar.gz", - "sha256": "bd7292fb6b24e81afc54fd5af3958aa4022f224cca3e0cba34208b306de4dd65" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.1/zn_0.7.1_darwin_amd64.tar.gz", + "sha256": "c6df5efe8d18dfa598e1ce8698fdd1a2eccf76892d94533479838cdd9a3e5d37" }, "linux-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.0/zn_0.7.0_linux_arm64.tar.gz", - "sha256": "943ca6a123c8e4a4fdd56f08e2fd8095b614e60ef6cfb0defe4b292b83ab3666" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.1/zn_0.7.1_linux_arm64.tar.gz", + "sha256": "1151c6daed55825e881014873802c32c056dff68797e64ed83451b9fdfe3f0b9" }, "linux-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.0/zn_0.7.0_linux_amd64.tar.gz", - "sha256": "50d794e67125126fd176e40a49a0abc17fd0cd5888ddab609bad0d6af22ccc55" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.1/zn_0.7.1_linux_amd64.tar.gz", + "sha256": "b61d145931c32b417eaf5f8bce379e7f94e327e3b5527f7868dcba3a3a178ac2" } } } diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 44d27280c..76b01367c 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -1261,9 +1261,9 @@ export const HELP_CLI: HelpCard[] = [ 'Builds with the Go terminal tool include `zn tui`. An existing desktop-managed CLI keeps itself up to date (see Updates arrive without a ZenNotes update); keep using the same `zn` commands. Settings shows the installed terminal version and offers Repair if an upgrade needs attention. Desktop-installed commands keep following the desktop vault, while the TUI remembers its own selection. Explicit `--vault` and `--server` flags still win. Set `ZENNOTES_WORKSPACE_SOURCE=terminal` to use the terminal default for a command, or `ZENNOTES_CLI_ENGINE=legacy` to run the previous CLI during the transition. Homebrew and manual installations stay managed by their own installer. A shortcut left behind by a moved Mac app or an old AppImage can be repaired from Settings: review the old target, replacement and backup path before choosing Repair shortcut. Note saves preserve creation dates in small files under `.zennotes/note-metadata` without changing Markdown; keep the `.zennotes` folder with vault backups. Explicit legacy rollback needs the original app resources to remain available.' }, { - title: 'CLI 0.7.0 in desktop 2.68.0', + title: 'CLI 0.7.1 in desktop 2.69.0', body: - 'On macOS and Linux, desktop 2.68.0 bundles CLI 0.7.0. Run `zn` in an interactive terminal or `zn tui` to open the editor. It includes editable settings, restored splits and sessions, comments, attachments, templates, bulk actions, and cross-note yank and paste. Use `:version` in the TUI for a persistent report and press `c` or `y` to copy it. `zn status`, `zn doctor`, `zn config`, and `zn completion` help with setup and scripts; `zn server` manages native local servers, also available through `:servers`. Since 0.6.1, `zn vault list` marks each entry `app` (saved by this desktop app) or `terminal` (saved by zn itself) and says which of them `zn use`, `zn disconnect` and `zn vault remove` act on; every command starts without waiting on terminals that do not answer a background-color query; and the help output lines up. 0.6.2 is the first release with a signed manifest, which is how this app keeps its managed zn current, 0.6.3 reads a line over a lone `-` as a paragraph, as the app does (#898), and 0.7.0’s `zn mcp` records an assistant’s edits for review in this app and no longer lets an assistant write into `.zennotes`. Run `zn update --check` to see the latest CLI release; for a desktop-managed CLI, ZenNotes installs it. The full command reference is at https://zennotes.org/tui/docs.' + 'On macOS and Linux, desktop 2.69.0 bundles CLI 0.7.1. Run `zn` in an interactive terminal or `zn tui` to open the editor. It includes editable settings, restored splits and sessions, comments, attachments, templates, bulk actions, and cross-note yank and paste. Use `:version` in the TUI for a persistent report and press `c` or `y` to copy it. `zn status`, `zn doctor`, `zn config`, and `zn completion` help with setup and scripts; `zn server` manages native local servers, also available through `:servers`. Since 0.6.1, `zn vault list` marks each entry `app` (saved by this desktop app) or `terminal` (saved by zn itself) and says which of them `zn use`, `zn disconnect` and `zn vault remove` act on; every command starts without waiting on terminals that do not answer a background-color query; and the help output lines up. 0.6.2 is the first release with a signed manifest, which is how this app keeps its managed zn current, 0.6.3 reads a line over a lone `-` as a paragraph, as the app does (#898), and 0.7.0’s `zn mcp` records an assistant’s edits for review in this app and no longer lets an assistant write into `.zennotes`. 0.7.1 leaves cancelled tasks out of the open ones in `zn task list` and in `zn mcp`’s `list_tasks` (#929), and when a server needs a token, its message points to Settings → MCP → Server token. Run `zn update --check` to see the latest CLI release; for a desktop-managed CLI, ZenNotes installs it. The full command reference is at https://zennotes.org/tui/docs.' }, { title: 'Updates arrive without a ZenNotes update', From 1290840746b055f5964cb521c404ad002ea5dc2a Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 13 Oct 2026 10:29:37 -0500 Subject: [PATCH 16/19] Fix(keys): app shortcuts no longer act behind What's New, Settings or a palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What's New opens by itself on the first launch after every update, so the keys people press out of habit land on it. None of the app's shortcut handlers knew about it. Mod+P opened a note search underneath it that nobody could see, took the keyboard out of the window, and whatever was typed next went into that hidden search; Escape then closed What's New and revealed it. Mod+N and Shift+Mod+N made a note behind it and moved the keyboard into the new note's title, Shift+Mod+P opened the command palette, and Mod+, opened Settings under it. Found while recording this release's demos, where a whole scripted take ran invisibly behind the window. The capture-phase handler (Mod+P, Mod+N, pane focus and pane modes) now bails for What's New as it already did for Settings and the palettes. The bubble-phase handler returns at once while What's New is up, so no app shortcut acts behind it; its own buttons, Tab and Escape are its own. Asking for Settings is the exception: Mod+, replaces What's New with Settings, and so does Settings… in the macOS menu, which takes the key before the page does. The same check found four shortcuts that acted behind Settings, any palette, a menu or a prompt: Ctrl+Tab (previous note), Alt+Left/Right (note history) and Shift+Mod+T (reopen closed tab) switched the note under the overlay, and Shift+Mod+N made a Quick Note there and pulled the keyboard into its hidden title. The handler's own comment on tab selection names the rule they broke: switching the note under an overlay strands the user on a different note than they left. The overlay check that guarded tab selection and the template pickers now sits at the top of the handler and guards these four as well. Shortcuts that only change the view (sidebar, panels, Zen mode, word wrap, zoom) and the command palette's own toggle are unchanged. Checked in the built app over CDP with throwaway profiles (docs/releases/v2.69.0/whats-new-overlay/). Before: with What's New up, Mod+P opened a hidden search that took the typing, Mod+N wrote inbox/Untitled.md, Shift+Mod+N a Quick Note, Shift+Mod+P and Mod+, opened their dialogs under it; behind Settings, Ctrl+Tab, Alt+Left and Shift+Mod+T changed the active note and Shift+Mod+N wrote a Quick Note. After: none of them acts behind the window or Settings, focus stays where it was, Mod+, replaces What's New with Settings, and with nothing open all four note shortcuts still work. app-core typecheck and suite (3,214) pass. --- packages/app-core/src/App.tsx | 68 +++++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 19 deletions(-) diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 75ba6f2b5..742fa087e 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -480,6 +480,11 @@ function App(): JSX.Element { useEffect(() => { return window.zen.onOpenSettings(() => { + // The macOS menu takes Mod+, before the page does. Asking for Settings + // while What's New is up replaces it, as the shortcut does below, + // instead of opening Settings underneath with the keyboard in it. + const state = useStore.getState() + if (state.whatsNewOpen) state.closeWhatsNew() setSettingsOpen(true) }) }, [setSettingsOpen]) @@ -720,6 +725,40 @@ function App(): JSX.Element { const state = useStore.getState() const overrides = state.keymapOverrides + // What's New opens by itself on the first launch after an update, so + // the keys people press out of habit land on it. No app shortcut acts + // behind it: Mod+N made a note under it and moved the keyboard there, + // Mod+P opened a search nobody could see. Its own buttons, Tab and + // Escape are its own. Asking for Settings replaces it, as the macOS + // menu does. + if (state.whatsNewOpen) { + if (matchesShortcut(e, overrides, 'global.openSettings')) { + e.preventDefault() + state.closeWhatsNew() + state.setSettingsOpen(true) + } + return + } + + // A modal, palette, menu, or Settings (with its keymap recorder) is + // open. Per the house rule for global key handlers, a shortcut that + // switches, reopens or creates the note must not act under it: it + // strands the user on a different note than they left, and a new note + // pulls the keyboard into a title field nobody can see. + const overlayOpen = + state.settingsOpen || + state.searchOpen || + state.vaultTextSearchOpen || + state.commandPaletteOpen || + state.bufferPaletteOpen || + state.templatePaletteOpen || + state.embedDrawingPaletteOpen || + state.outlinePaletteOpen || + !!document.querySelector('[data-ctx-menu]') || + !!document.querySelector('[data-prompt-modal]') || + !!document.querySelector('[data-confirm-modal]') || + !!document.querySelector('[data-cloud-conflict-dialog]') + if (isUndoShortcut(e)) { const assetUndoEntry = state.assetUndoStack.at(-1) if (assetUndoEntry) { @@ -751,6 +790,7 @@ function App(): JSX.Element { } if (matchesShortcut(e, overrides, 'global.newQuickNote')) { // ⇧⌘N — new quick note + if (overlayOpen) return e.preventDefault() const title = resolveQuickNoteTitle( state.notes, @@ -768,23 +808,8 @@ function App(): JSX.Element { } // Alt+1..9 (⌃1..9 on macOS): jump straight to tab N (#497). Position // counts across panes in the same order gt cycles through. Bails while - // a modal, palette, menu, or Settings (with its keymap recorder) is - // open, per the house rule for global key handlers: switching the tab - // under an overlay strands the user on a different note than they left. - const tabSelectBlocked = - state.settingsOpen || - state.searchOpen || - state.vaultTextSearchOpen || - state.commandPaletteOpen || - state.bufferPaletteOpen || - state.templatePaletteOpen || - state.embedDrawingPaletteOpen || - state.outlinePaletteOpen || - document.querySelector('[data-ctx-menu]') || - document.querySelector('[data-prompt-modal]') || - document.querySelector('[data-confirm-modal]') || - document.querySelector('[data-cloud-conflict-dialog]') - if (!tabSelectBlocked) { + // an overlay is open (the rule above). + if (!overlayOpen) { for (let i = 0; i < TAB_SELECT_KEYMAP_IDS.length; i += 1) { const id = TAB_SELECT_KEYMAP_IDS[i] if (!matchesShortcut(e, overrides, id)) continue @@ -800,13 +825,13 @@ function App(): JSX.Element { // The template pickers as plain shortcuts, so they no longer need Vim's // leader (#847). Same overlay rule as tab selection: a picker opened // under Settings or another palette would strand the user behind it. - if (!tabSelectBlocked && matchesShortcut(e, overrides, 'global.newNoteFromTemplate')) { + if (!overlayOpen && matchesShortcut(e, overrides, 'global.newNoteFromTemplate')) { e.preventDefault() state.setTemplatePaletteOpen(true) return } if ( - !tabSelectBlocked && + !overlayOpen && state.activeNote && matchesShortcut(e, overrides, 'global.insertTemplate') ) { @@ -847,16 +872,19 @@ function App(): JSX.Element { isEditableShortcutTarget(e.target) if (!isMacWordMotion && matchesShortcut(e, overrides, 'global.historyBack')) { // Back in note navigation history (works in any mode). + if (overlayOpen) return e.preventDefault() void state.jumpToPreviousNote() return } if (!isMacWordMotion && matchesShortcut(e, overrides, 'global.historyForward')) { + if (overlayOpen) return e.preventDefault() void state.jumpToNextNote() return } if (matchesShortcut(e, overrides, 'global.toggleRecentNote')) { + if (overlayOpen) return e.preventDefault() void state.toggleRecentNote() return @@ -877,6 +905,7 @@ function App(): JSX.Element { return } if (matchesShortcut(e, overrides, 'global.reopenClosedTab')) { + if (overlayOpen) return e.preventDefault() void state.reopenLastClosedTab() return @@ -1016,6 +1045,7 @@ function App(): JSX.Element { } if ( state.settingsOpen || + state.whatsNewOpen || state.searchOpen || state.vaultTextSearchOpen || state.commandPaletteOpen || From 6da71c4384e45fca9369f6d2b85be7d8d3e4deb2 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 13 Oct 2026 10:29:37 -0500 Subject: [PATCH 17/19] Fix(tasks): the calendar's keys no longer change tasks behind a dialog The Tasks calendar listens for keys on the whole window in the capture phase, so it runs before anything else, and unlike the list and the board it never asked whether a dialog was open. With Settings, What's New or a palette over it and the keyboard on one of the dialog's buttons, Space (or x in Vim mode) marked the selected day's task done on disk, Tab cycled the calendar's tasks instead of the dialog's buttons, and in Vim mode dd deleted a task, < and > rescheduled it. It now bails while a modal, menu or Settings is open, with the same isOverlayOrDialogOpen check TasksView and TasksKanban use. The calendar's own right-click and drop menus are menus too, so the calendar already waits for them to close, as the list does. Checked in the built app over CDP: the calendar showing a task due today, Settings open with focus on its Appearance button. Before: Space (Vim off) and x (Vim on) each wrote "- [x] Pay rent" to the note. After: the file is unchanged, and with nothing open both keys still mark the task done. app-core typecheck and suite pass. --- packages/app-core/src/components/TasksCalendar.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/app-core/src/components/TasksCalendar.tsx b/packages/app-core/src/components/TasksCalendar.tsx index b7cf04899..48f737e75 100644 --- a/packages/app-core/src/components/TasksCalendar.tsx +++ b/packages/app-core/src/components/TasksCalendar.tsx @@ -27,6 +27,7 @@ import { CloudTaskConflictIndicator } from './CloudTaskConflictIndicator' import { resolveWeekStartDay } from '../lib/week-start' import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { buildTaskMenuItems } from '../lib/task-context-menu' +import { isOverlayOrDialogOpen } from '../lib/overlay-open' interface Props { tasks: VaultTask[] @@ -354,6 +355,12 @@ export function TasksCalendar({ // sidebar bindings (same trick TasksView's list mode uses). useEffect(() => { const handler = (e: KeyboardEvent): void => { + // A modal, menu or Settings owns the keyboard while open, as in the list + // and the board. This handler runs first (capture phase), so without it + // Space on a button in Settings or What's New marked the day's task + // done behind the dialog, Tab cycled the calendar's tasks instead of + // the dialog's buttons, and in Vim mode `dd` deleted a task. + if (isOverlayOrDialogOpen()) return // While the Vim hint overlay is open it owns the keyboard; yield to it. (#151) if (document.querySelector('[data-vim-hint-overlay]')) return const active = document.activeElement as HTMLElement | null From 803ae29537f2f53a92a88b7e025a3bb505e7e69d Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 13 Oct 2026 10:38:13 -0500 Subject: [PATCH 18/19] Fix(settings): the phones and iPad offer only the settings and keys they have MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two places in Settings listed things a phone or an iPad cannot use. Search. The phone shells hide the sections they have no use for (MCP and CLI everywhere, Keymap on a phone) by their labels in the section list, but Settings search builds its results from app-core's index, which knows nothing of that. Searching "review" on a phone offered "Review agent edits", and a tap opened a section the list hides. Each result now carries its section's id (`data-settings-result-category`), so a shell can hide the results of the sections it hides; the Android and iPhone shells do that with this core. The web client keeps its MCP and CLI pages, which explain that they need the desktop app, so app-core does not hide them itself. Keymap. Settings → Keymap listed every shortcut on every host, so an iPad offered the four keys and four Vim keys of agent edit review, which only the desktop app has, and the Harper keys where Harper is not available. `keymapAvailableOnHost` leaves a shortcut out when the host lacks its feature (`supportsAgentEditReview`, `supportsHarper`); the desktop has both, so its list is unchanged, and the web client loses only the review keys. Checked: app-core typecheck, `keymaps.test.ts` (three new tests: the eight review keys and the Harper keys leave the list where their feature is missing, stay where it is present, and nothing else is filtered). The phone half is verified with the shells against this core. --- .../app-core/src/components/SettingsModal.tsx | 7 ++++++ packages/app-core/src/lib/keymaps.test.ts | 25 +++++++++++++++++++ packages/app-core/src/lib/keymaps.ts | 18 +++++++++++++ 3 files changed, 50 insertions(+) diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 0f8156a4a..e49bca6dc 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -70,6 +70,7 @@ import { getKeymapDisplay, isMacPlatform, isUnboundBinding, + keymapAvailableOnHost, sequenceTokenFromEvent, shortcutBindingFromEvent, UNBOUND_BINDING, @@ -5998,6 +5999,10 @@ export function SettingsModal(): JSX.Element { key={result.id} ref={selected ? selectedResultRef : undefined} type="button" + // The phone shells hide whole sections (MCP, CLI, + // and Keymap on a phone); this lets them hide the + // search results that would open one. + data-settings-result-category={result.category.id} onClick={() => openSearchResult(result)} className={[ "w-full rounded-xl px-3 py-2.5 text-left transition-colors", @@ -6137,9 +6142,11 @@ function KeymapSettings({ const groups = useMemo(() => { const q = query.trim().toLowerCase(); + const capabilities = getZenBridge().getCapabilities(); return getKeymapDefinitionsByGroup() .map((group) => { const items = group.items.filter((definition) => { + if (!keymapAvailableOnHost(definition.id, capabilities)) return false; if ( definition.vimOnly && !vimMode && diff --git a/packages/app-core/src/lib/keymaps.test.ts b/packages/app-core/src/lib/keymaps.test.ts index 4b39d232f..f8a6063ba 100644 --- a/packages/app-core/src/lib/keymaps.test.ts +++ b/packages/app-core/src/lib/keymaps.test.ts @@ -10,6 +10,7 @@ import { getKeymapDisplay, getSequenceTokens, isKeymapUnbound, + keymapAvailableOnHost, labelWithShortcut, matchesSequenceToken, matchesShortcut, @@ -514,3 +515,27 @@ describe('unbound keymaps (an override of "")', () => { expect(labelWithShortcut('Go back', '')).toBe('Go back') }) }) + +describe('keymapAvailableOnHost', () => { + const ids = getKeymapDefinitions().map((definition) => definition.id) + const review = ids.filter((id) => /^(editor|vim)\.review/.test(id)) + const harper = ids.filter((id) => id.startsWith('vim.harper')) + + it('leaves out the review and Harper keys where those features are missing', () => { + expect(review).toHaveLength(8) + expect(harper.length).toBeGreaterThan(0) + for (const id of [...review, ...harper]) expect(keymapAvailableOnHost(id, {})).toBe(false) + }) + + it('keeps each one on a host that has its feature', () => { + for (const id of review) expect(keymapAvailableOnHost(id, { supportsAgentEditReview: true })).toBe(true) + for (const id of harper) expect(keymapAvailableOnHost(id, { supportsHarper: true })).toBe(true) + expect(keymapAvailableOnHost(review[0], { supportsHarper: true })).toBe(false) + }) + + it('keeps every other key everywhere', () => { + const rest = ids.filter((id) => !review.includes(id) && !harper.includes(id)) + expect(rest.length).toBeGreaterThan(50) + for (const id of rest) expect(keymapAvailableOnHost(id, {})).toBe(true) + }) +}) diff --git a/packages/app-core/src/lib/keymaps.ts b/packages/app-core/src/lib/keymaps.ts index 3772ea995..1491dcac7 100644 --- a/packages/app-core/src/lib/keymaps.ts +++ b/packages/app-core/src/lib/keymaps.ts @@ -2030,6 +2030,24 @@ export function describeCurrentBinding( return getKeymapDisplay(overrides, id); } +/** + * Whether a shortcut belongs to a feature this host has. Agent edit review + * needs `supportsAgentEditReview` and Harper needs `supportsHarper`; where + * they are absent (the web client, the phones and iPads) the shortcuts can + * never fire, so Settings → Keymap leaves them out instead of offering keys + * for a review or a grammar check that does not exist there. + */ +export function keymapAvailableOnHost( + id: KeymapId, + capabilities: { supportsAgentEditReview?: boolean; supportsHarper?: boolean }, +): boolean { + if (id.startsWith("editor.review") || id.startsWith("vim.review")) { + return capabilities.supportsAgentEditReview === true; + } + if (id.startsWith("vim.harper")) return capabilities.supportsHarper === true; + return true; +} + export function getKeymapDefinitionsByGroup(): Array<{ group: KeymapGroup; label: string; From d13440764dfa66263714390fe044e2b2747f645d Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 13 Oct 2026 10:38:13 -0500 Subject: [PATCH 19/19] Fix(cloud): an encrypted vault shows its name after unlocking, and phones say where to save its archive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two rough edges of end-to-end encryption in Settings → Cloud, both on the list of follow-ups from the launch. An encrypted vault's real name is sealed; the host opens it only while this device's keys are unlocked and lists the placeholder "Encrypted vault" otherwise. Settings read the vault list once, so unlocking left the placeholder on screen until Settings was opened again. It now reads the list again whenever the device locks or unlocks (not for the first status, which arrives with the first load). The phones cannot open an encrypted vault's backup archive (it holds ciphertext under path ids; the desktop app opens it item by item), and their host refuses the download with "Download backups of encrypted vaults from the ZenNotes desktop app." Settings still offered Save archive there, a button that could only fail. A new capability, `supportsEncryptedBackupArchive`, which the desktop app sets, decides it: without it, an encrypted vault's backups show "To save an archive of this encrypted vault, use Save archive in the ZenNotes desktop app." instead of the button. Browse notes and Restore work on every host and stay. Checked: typecheck across every package; `CloudSettings.test.ts` (74, three new): unlocking through the real dialog reads the list a second time and shows "Work notes" where it said "Encrypted vault", and Save archive is offered for an encrypted vault on the desktop and replaced by the note on a phone. Both new tests fail with their fix removed. --- apps/desktop/src/preload/index.ts | 3 +- .../src/components/CloudSettings.test.ts | 136 ++++++++++++++++++ .../app-core/src/components/CloudSettings.tsx | 62 ++++++-- packages/bridge-contract/src/bridge.ts | 5 + 4 files changed, 196 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index b64ecd922..c4c932a44 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -133,7 +133,8 @@ const DESKTOP_CAPABILITIES: ZenCapabilities = { supportsCustomCodeLanguages: true, supportsWorkflows: true, supportsUndoFile: true, - supportsAgentEditReview: true + supportsAgentEditReview: true, + supportsEncryptedBackupArchive: true } const DESKTOP_APP_INFO: ZenAppInfo = { diff --git a/packages/app-core/src/components/CloudSettings.test.ts b/packages/app-core/src/components/CloudSettings.test.ts index f8a1c5cbf..33248d5ac 100644 --- a/packages/app-core/src/components/CloudSettings.test.ts +++ b/packages/app-core/src/components/CloudSettings.test.ts @@ -460,6 +460,69 @@ describe("CloudSettings", () => { expect(host.textContent).not.toContain("Cursor 7"); }); + it("shows an encrypted vault's name once this device unlocks, without reopening Settings", async () => { + const encrypted = { + id: "vault-e", + cursor: 1, + created_at: "2026-10-12T12:00:00.000Z", + updated_at: "2026-10-12T12:30:00.000Z", + encryption: { + scheme: "zne1" as const, + key_id: "0123456789abcdef0123456789abcdef", + key_version: 1, + vault_key_envelope: "sealed", + name: "sealed-name", + }, + }; + mocks.getCloudAccountStatus.mockResolvedValue(connected); + mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount); + mocks.getCloudVaultLink.mockResolvedValue(null); + mocks.listCloudVaults + .mockResolvedValueOnce([{ ...encrypted, name: "Encrypted vault" }]) + .mockResolvedValue([{ ...encrypted, name: "Work notes" }]); + let unlocked = false; + const bridge = mocks as unknown as Record; + bridge.getCloudEncryptionStatus = vi.fn(async () => ({ available: true, keyset: true, unlocked })); + bridge.unlockCloudEncryption = vi.fn(async () => { + unlocked = true; + }); + try { + await act(async () => + root.render( + createElement(CloudSettings, { + localVaultAvailable: true, + localVaultName: "Notes", + }), + ), + ); + // The vault's placeholder name, not the panel's "Encrypted vaults sync…". + expect(host.textContent).toMatch(/Encrypted vault(?!s)/); + expect(mocks.listCloudVaults).toHaveBeenCalledTimes(1); + + const open = [...host.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Unlock this device", + ); + await act(async () => open!.click()); + const input = document.querySelector('input[type="password"]'); + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "correct horse"); + input!.dispatchEvent(new Event("input", { bubbles: true })); + }); + const unlock = [...document.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Unlock", + ); + await act(async () => unlock!.click()); + + expect(bridge.unlockCloudEncryption).toHaveBeenCalledOnce(); + expect(mocks.listCloudVaults).toHaveBeenCalledTimes(2); + expect(host.textContent).toContain("Work notes"); + expect(host.textContent).not.toMatch(/Encrypted vault(?!s)/); + } finally { + delete bridge.getCloudEncryptionStatus; + delete bridge.unlockCloudEncryption; + } + }); + it("separates unlinking this device from permanently deleting the cloud vault", async () => { mocks.getCloudAccountStatus.mockResolvedValue(connected); mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount); @@ -1983,6 +2046,79 @@ describe("CloudSettings", () => { expect(host.textContent).toContain("Linked to Notes"); }); + it.each([ + ["a phone, which cannot open the archive", {}, false], + ["the desktop app", { supportsEncryptedBackupArchive: true }, true], + ])("offers Save archive for an encrypted vault only where it works: %s", async (_host, capabilities, offered) => { + mocks.getCloudAccountStatus.mockResolvedValue(connected); + mocks.getCloudServiceAccount.mockResolvedValue({ + ...serviceAccount, + features: { + ...serviceAccount.features, + backup: { active: true, limits: { max_snapshots: 30 } }, + }, + }); + mocks.listCloudVaults.mockResolvedValue([ + { + id: "vault-e", + name: "Work notes", + cursor: 3, + created_at: "2026-10-12T12:00:00.000Z", + updated_at: "2026-10-12T12:30:00.000Z", + encryption: { + scheme: "zne1", + key_id: "0123456789abcdef0123456789abcdef", + key_version: 1, + vault_key_envelope: "sealed", + name: "sealed-name", + }, + }, + ]); + mocks.getCloudVaultLink.mockResolvedValue({ + base_url: "https://zennotes.org", + vault_id: "vault-e", + vault_name: "Work notes", + linked_at: "2026-10-12T12:00:00.000Z", + }); + mocks.listCloudBackups.mockResolvedValue([ + { + id: "backup-1", + label: "Before migration", + trigger: "manual", + status: "ready", + cursor: 3, + item_count: 8, + total_bytes: 2048, + archive_bytes: 1024, + expires_at: "2026-11-09T12:00:00.000Z", + created_at: "2026-10-12T12:00:00.000Z", + }, + ]); + const bridge = mocks as unknown as Record; + bridge.getCapabilities = vi.fn(() => capabilities); + try { + await act(async () => + root.render( + createElement(CloudSettings, { + localVaultAvailable: true, + localVaultName: "Notes", + }), + ), + ); + expect(host.textContent).toContain("Before migration"); + const saveArchive = [...host.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Save archive", + ); + expect(!!saveArchive).toBe(offered); + expect(!!host.querySelector("[data-cloud-archive-desktop-only]")).toBe(!offered); + // Browse and Restore work on every host. + expect(host.textContent).toContain("Browse notes"); + expect(host.textContent).toContain("Restore"); + } finally { + delete bridge.getCapabilities; + } + }); + it("creates and safely restores backups for a linked vault", async () => { mocks.getCloudAccountStatus.mockResolvedValue(connected); mocks.getCloudServiceAccount.mockResolvedValue({ diff --git a/packages/app-core/src/components/CloudSettings.tsx b/packages/app-core/src/components/CloudSettings.tsx index 9772b793d..7f9e32887 100644 --- a/packages/app-core/src/components/CloudSettings.tsx +++ b/packages/app-core/src/components/CloudSettings.tsx @@ -94,6 +94,16 @@ const VAULT_SECTION_ACTIONS: ReadonlySet = new Set([ * again, so a burst of runs costs one read. */ const USAGE_READ_DELAY_MS = 3_000; +/** Whether this host can save a readable archive of an encrypted vault's + * backup (the desktop app can; the phones refuse). */ +function hostSavesEncryptedArchives(bridge: ZenBridge): boolean { + try { + return bridge.getCapabilities().supportsEncryptedBackupArchive === true; + } catch { + return false; + } +} + export function CloudSettings({ localVaultAvailable, localVaultName, @@ -181,6 +191,22 @@ export function CloudSettings({ [bridge], ); + // An encrypted vault's real name is sealed, and the host opens it only + // while this device's keys are unlocked; a locked device lists the + // placeholder ("Encrypted vault"). The list was read once, so unlocking + // here left the placeholder until Settings was opened again. Read it again + // whenever the device locks or unlocks (not on the first status, which + // arrives with the first load). + const lastUnlocked = useRef(null); + useEffect(() => { + const unlocked = encryptionStatus ? encryptionStatus.unlocked === true : null; + const before = lastUnlocked.current; + lastUnlocked.current = unlocked; + if (before !== null && unlocked !== null && before !== unlocked) { + void refreshCloudVaults(); + } + }, [encryptionStatus, refreshCloudVaults]); + // The link as last drawn, for a store change that arrives between renders: // deleting here drops the link from state just before it reports the // deletion, and the vault that went is still the drawn one. @@ -896,6 +922,11 @@ export function CloudSettings({ /> vault.id === activeLink.vault_id)?.encryption && + !hostSavesEncryptedArchives(bridge) + } backupIncluded={backupIncluded} backupLabel={backupLabel} backupNotes={backupNotes} @@ -1877,6 +1908,7 @@ function useBackupNotes(bridge: ZenBridge): BackupNotes { function CloudBackupPanel({ action, + archiveDesktopOnly, backupIncluded, backupLabel, backupNotes, @@ -1897,6 +1929,8 @@ function CloudBackupPanel({ onScheduleChange, }: { action: CloudAction; + /** An encrypted vault on a host that cannot open its archive (the phones). */ + archiveDesktopOnly: boolean; backupIncluded: boolean; backupLabel: string; backupNotes: BackupNotes; @@ -1977,6 +2011,14 @@ function CloudBackupPanel({

{backupPolicy || "Create a recovery point from the synced vault."}

+ {archiveDesktopOnly && ( +

+ To save an archive of this encrypted vault, use Save archive in the ZenNotes desktop app. +

+ )}
+ {!archiveDesktopOnly && ( + + )}