diff --git a/.changeset/bundle-parcel-watcher-into-cli.md b/.changeset/bundle-parcel-watcher-into-cli.md
new file mode 100644
index 000000000..f13907748
--- /dev/null
+++ b/.changeset/bundle-parcel-watcher-into-cli.md
@@ -0,0 +1,5 @@
+---
+"@inkeep/open-knowledge": patch
+---
+
+Restored the native `@parcel/watcher` file watcher in the packaged desktop app (inkeep/open-knowledge#760). The app runs `ok start` from a bundled CLI whose module resolver never reaches the app's own `@parcel/watcher` copy, so at server boot `import('@parcel/watcher')` failed with "Cannot find package" and the watcher silently fell back to chokidar. The macOS build now stages `@parcel/watcher` — its wrapper, the runtime dependencies it loads (`picomatch`, `is-glob`, `is-extglob`, `detect-libc`), and the per-architecture native binary — onto the CLI's own module path, so the fast native watcher loads as intended. Combined with the chokidar-fallback fix in the same release, external edits to files in subfolders are reflected in the graph, backlinks, and dead-link views without a restart. The staging resolves each dependency exactly as the wrapper loads it (the workspace carries two `picomatch` majors, so a naive copy could have shipped the wrong one), and a packaging test verifies the staged tree is CLI-resolvable without needing a full app build.
diff --git a/.changeset/link-suggest-by-name.md b/.changeset/link-suggest-by-name.md
new file mode 100644
index 000000000..6bf400b90
--- /dev/null
+++ b/.changeset/link-suggest-by-name.md
@@ -0,0 +1,7 @@
+---
+"@inkeep/open-knowledge": patch
+---
+
+Link-target suggestions now match by name as you type, like the command palette.
+
+When editing a link target (the "Edit markdown link" dialog, the wiki-link panel, and the bubble-menu link popover all share the same field), typing a bare page name such as `install` now opens the suggestion dropdown and surfaces the matching page — a leading `/` is no longer required. Typing or pasting an external URL, or a bare `#anchor`, still does not pop a page list. The empty/browse list also keeps real content pages ahead of dot-directory files (`.github/`, `.changeset/`) so they no longer fill the list on projects rooted at a repository.
diff --git a/.changeset/pull-only-sync-mode.md b/.changeset/pull-only-sync-mode.md
new file mode 100644
index 000000000..7effab3a7
--- /dev/null
+++ b/.changeset/pull-only-sync-mode.md
@@ -0,0 +1,22 @@
+---
+"@inkeep/open-knowledge": minor
+---
+
+Add Follow — a one-directional sync mode for people who follow a knowledge base they can read but not push to (share-link recipients and read-only followers).
+
+Previously, opening a repository you lacked push access to left your copy silently frozen: the sync onboarding prompt was suppressed, no sync config was written, and the engine parked itself the moment a push was denied. Follow fixes that dead end.
+
+What it does:
+
+- Keeps your local copy current automatically by fast-forwarding to the latest on GitHub, without ever pushing or committing on your behalf.
+- Lets your own uncommitted edits ride safely on top of incoming updates. Non-overlapping changes combine automatically; edits to the same lines the upstream changed surface in the familiar conflict view, where you choose keep-mine or take-theirs. Your branch always tracks the upstream tip and never forks.
+- Is always opt-in. Push-denied users are now offered Follow at first open with plain-language copy ("updates flow in from GitHub; your edits stay on this computer"), and anyone can choose Off / Follow / Full in Settings. A project owner can commit `autoSync.default: follow` so a fresh clone starts following.
+
+Also included:
+
+- Full-sync users who lose push access now get a "Switch to Follow" action on the sync-paused notice instead of a silent stall; switching keeps any unshared local commits as a local overlay.
+- The sync status badge stays visible for a followed project and shows a distinct following state (updating / up to date / conflict / offline).
+- Anonymous followers of public repositories poll more gently than signed-in followers.
+- A one-shot "Sync" pull that reports its outcome, for surfaces that need an on-demand refresh.
+
+Configuration note: the sync preference is now stored as a single `autoSync.mode` (`off` / `follow` / `full`). Existing `autoSync.enabled` settings keep working, and choosing a mode clears the legacy flag so the two can't disagree across versions. An app that predates `autoSync.mode` then reads the config as unanswered and re-prompts rather than acting on a stale toggle, so it never pushes for a mode you didn't consent to there.
diff --git a/.github/workflows/desktop-build-win-linux.yml b/.github/workflows/desktop-build-win-linux.yml
index 8c0c3f390..2690561ee 100644
--- a/.github/workflows/desktop-build-win-linux.yml
+++ b/.github/workflows/desktop-build-win-linux.yml
@@ -193,6 +193,17 @@ jobs:
working-directory: packages/desktop
run: node scripts/prepare-platform-natives.mjs
+ # Stage @parcel/watcher onto the bundled CLI's module path. This workflow
+ # invokes electron-builder directly (not the build:win/build:linux npm
+ # scripts, which DO run this), so the step must be explicit here or the
+ # packaged app ships without @parcel/watcher on the CLI path and the
+ # server degrades to chokidar (#760). Stages only the host-arch binary
+ # today; the other-arch artifact uses the (subfolder-correct) chokidar
+ # fallback — see scripts/stage-parcel-watcher.mjs.
+ - name: Stage @parcel/watcher for the bundled CLI
+ working-directory: packages/desktop
+ run: node scripts/stage-parcel-watcher.mjs
+
- name: Build desktop main/preload/renderer
working-directory: packages/desktop
run: pnpm run build:desktop
@@ -277,6 +288,17 @@ jobs:
working-directory: packages/desktop
run: node scripts/prepare-platform-natives.mjs
+ # Stage @parcel/watcher onto the bundled CLI's module path. This workflow
+ # invokes electron-builder directly (not the build:win/build:linux npm
+ # scripts, which DO run this), so the step must be explicit here or the
+ # packaged app ships without @parcel/watcher on the CLI path and the
+ # server degrades to chokidar (#760). Stages only the host-arch binary
+ # today; the other-arch artifact uses the (subfolder-correct) chokidar
+ # fallback — see scripts/stage-parcel-watcher.mjs.
+ - name: Stage @parcel/watcher for the bundled CLI
+ working-directory: packages/desktop
+ run: node scripts/stage-parcel-watcher.mjs
+
- name: Build desktop main/preload/renderer
working-directory: packages/desktop
run: pnpm run build:desktop
diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml
index 5744602ab..1e78aa5f1 100644
--- a/.github/workflows/desktop-build.yml
+++ b/.github/workflows/desktop-build.yml
@@ -222,6 +222,15 @@ jobs:
working-directory: packages/desktop
run: node scripts/prepare-universal.mjs
+ # Stage @parcel/watcher onto the bundled CLI's module path. This workflow
+ # invokes electron-builder directly (not the build:mac npm script, which
+ # DOES run this), so the step must be explicit here or the smoke/QA DMG
+ # ships without @parcel/watcher on the CLI path and the server degrades to
+ # chokidar (#760).
+ - name: Stage @parcel/watcher for the bundled CLI
+ working-directory: packages/desktop
+ run: node scripts/stage-parcel-watcher.mjs
+
# Step B: package via electron-builder. Kept as a separate runner step
# from step A (rather than chained via && in a single shell). The split
# was originally forced by a packaging bug in the previous script runner:
diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml
index 80119b61d..5498ae8d6 100644
--- a/.github/workflows/desktop-release.yml
+++ b/.github/workflows/desktop-release.yml
@@ -341,6 +341,15 @@ jobs:
working-directory: packages/desktop
run: node scripts/prepare-universal.mjs
+ # Stage @parcel/watcher onto the bundled CLI's module path. This workflow
+ # invokes electron-builder directly (not the build:mac npm script, which
+ # DOES run this), so the step must be explicit here or the published DMG
+ # ships without @parcel/watcher on the CLI path and the server degrades to
+ # chokidar (#760).
+ - name: Stage @parcel/watcher for the bundled CLI
+ working-directory: packages/desktop
+ run: node scripts/stage-parcel-watcher.mjs
+
- name: Build + sign + notarize + publish DMG/ZIP
# electron-builder picks up CSC_LINK / CSC_KEY_PASSWORD from env +
# signs. afterSign.mjs picks up APPLE_* + notarizes + staples +
diff --git a/docs/content/features/github-sync.mdx b/docs/content/features/github-sync.mdx
index 254ba3c1f..09f3648d6 100644
--- a/docs/content/features/github-sync.mdx
+++ b/docs/content/features/github-sync.mdx
@@ -3,10 +3,18 @@ title: GitHub sync
icon: custom/GitHub
description: Clone repos from GitHub, auto-sync changes with your team, and resolve conflicts.
---
-OpenKnowledge can connect a project to a GitHub remote repository and keep it synced over time. When GitHub sync is enabled, OpenKnowledge actively pulls commits from the remote and pushes commits back.
+
+import { Cloud } from 'lucide-react';
+
+OpenKnowledge can connect a project to a GitHub remote repository and keep it synced over time. When GitHub sync is enabled, OpenKnowledge keeps your copy current with the remote.
+
+There are two modes for GitHub sync:
+
+- **Full** pulls commits from the remote and pushes your commits back.
+- **Follow** fetches updates from the remote and never pushes or commits on your behalf.
- Only enable GitHub sync for repositories where you are comfortable with OpenKnowledge writing commits to the remote history. If you are worried about automated commits cluttering your Git history, do not enable sync for that repository.
+ Only enable full GitHub sync for repositories where you are comfortable with OpenKnowledge writing commits to the remote history. If you are worried about automated commits cluttering your Git history, do not enable full sync for that repository.
## What GitHub sync is for
@@ -27,7 +35,7 @@ These steps use the desktop app; from a terminal, [`ok clone`](/docs/reference/c
### Open the clone dialog
- From the Navigator window, click **Clone from GitHub**. You can open the navigator window from the editor by clicking on your project name in the bottom left and then clicking **Switch project**.
+ From the Navigator window, click **Clone from GitHub**. You can open the Navigator window from the editor by clicking on your project name in the bottom left and then clicking **Switch project**.
@@ -57,19 +65,30 @@ This will open a dialog where you can choose an owner and repository name. You c
## Enable GitHub sync
-When you open a freshly cloned project, you will be prompted to enable sync. You can also toggle it from the project settings page at any time.
+When you open a freshly cloned project, you will be prompted to enable sync. If you have full access to the repository, you will be prompted to enable **Full** sync. If you have read only access, you will be prompted to enable **Follow** sync. You can change the sync mode from the sync popover (the icon) or from **Settings → Sync** at any time.
+
+If you want to set a default sync setting for future collaborators, go to the project settings page and navigate to the **Sync** → **Shared default** section. This writes [`autoSync.default`](/docs/reference/configuration) — one of `off`, `follow`, or `full` — to the project's committed `.ok/config.yml`, pre-answering the enable-sync prompt for everyone who clones the repo; each machine's own auto-sync choice overrides it.
-While sync is active, OpenKnowledge:
+### Full
+
+With full sync active, OpenKnowledge:
- Fetches commits from the remote
- Commits your edits locally using the configured Git identity
- Pushes those commits back to the remote so collaborators see your changes
-If your Git identity isn't set, sync still commits under a default "OpenKnowledge" author; the sync status indicator reminds you to set one so teammates see your name.
+If your Git identity isn't set, OpenKnowledge commits under a default "OpenKnowledge" author; the sync status indicator reminds you to set one so teammates see your name.
Pulls can overwrite uncommitted local file changes, so commit or discard work-in-progress before you enable sync.
-If you want to set a default auto-sync setting for future collaborators, go to the project settings page and navigate to the **Sync** -> **Shared default** section. This writes [`autoSync.default`](/docs/reference/configuration) to the project's committed `.ok/config.yml`, pre-answering the enable-sync prompt for everyone who clones the repo; each machine's own auto-sync choice overrides it.
+### Follow
+
+With follow sync active, OpenKnowledge:
+
+- Fetches commits from the remote
+- Never commits or pushes your edits to the remote
+
+Your own edits stay on this computer. You can keep editing a followed project: when an update arrives, non-overlapping changes combine automatically, and an edit to the same lines the remote changed surfaces in the [conflict view](#resolving-a-conflict) where you choose which side to keep. Your branch always tracks the remote tip and never forks.
## Authentication
@@ -104,7 +123,7 @@ A few situations stop sync from completing successfully. In each case the sync s
### Conflicts and pending changes
- **Unresolved conflicts.** If even one file has a conflict between your edits and your team's, sync waits. You can keep working on every other doc; only the conflicted files are frozen until you choose a side from the diff view. See [Resolving a conflict](#resolving-a-conflict) above.
-- **Local changes would be overwritten by an incoming update.** A teammate's update is ready to pull, but a file it touches has uncommitted local changes. Doc edits are committed automatically before merging, so this usually means other tracked files, like configs. Sync pauses rather than clobber your work; commit, stash, or discard those changes from a terminal and sync resumes.
+- **Local changes would be overwritten by an incoming update.** A teammate's update is ready to pull, but a file it touches has uncommitted local changes. Doc edits are committed automatically before merging, so this usually means other tracked files, like configs. Sync pauses rather than clobbering your work; commit, stash, or discard those changes from a terminal and sync resumes.
- **Edits made outside the app aren't ready yet.** If you (or another tool) edited a file directly on disk while sync wanted to merge, OpenKnowledge waits until those external edits settle before merging. Usually this clears in a second or two on its own.
### Project isn't on a normal branch
@@ -115,7 +134,7 @@ A few situations stop sync from completing successfully. In each case the sync s
### GitHub-side blockers
- **Authentication errors.** Your stored credentials expired, were revoked, or don't have access to this repository anymore. Reconnect from the sync indicator (or from **Settings → Account**) to clear the error.
-- **You don't have permission to push to this repo.** Someone shared a project backed by a GitHub repo where your account isn't a collaborator (or has read-only access on a private repo). OpenKnowledge checks this up front: instead of prompting you to enable sync that would just fail, the toggle is disabled with an inline "You don't have permission to push to this repo" message in both the sync indicator popover and **Settings → Sync**. If you had sync enabled on a different repo and switch to one you can't push to, OpenKnowledge pauses sync in-memory rather than mutating your preference; your `Auto-sync: on` setting is preserved for the next repo you have full access to. Ask the project owner for write access, then click sync to re-check.
+- **You don't have permission to push to this repo.** Someone shared a project backed by a GitHub repo where your account isn't a collaborator (or has read-only access on a private repo). OpenKnowledge checks this up front and offers **Follow** instead of full sync.
- **Protected branches and rejected pushes.** If the target branch requires reviews, signed commits, or status checks, GitHub rejects the push. OpenKnowledge turns sync off automatically so it stops retrying, and it stays off until you turn it back on from the sync indicator or **Settings → Sync**. Switch to a branch you can push to, or use a pull request for the protected branch instead.
### Temporary problems
diff --git a/docs/content/features/share.mdx b/docs/content/features/share.mdx
index 957df9262..a172ad524 100644
--- a/docs/content/features/share.mdx
+++ b/docs/content/features/share.mdx
@@ -66,6 +66,6 @@ Even on the right branch, a doc or folder can be renamed or deleted after a link
### Receiving without push permission
-If the shared repo is one your GitHub account can't push to (you're not a collaborator on a public repo, or you have read-only access on a private one), OpenKnowledge skips the usual "Enable auto-sync?" prompt. Sync is also disabled for the same reason in **Settings → Sync**. If the project owner grants you write access later, push permission is re-checked the next time the project's server starts. See [GitHub sync: GitHub-side blockers](/docs/features/github-sync#github-side-blockers) for the full set of permission shapes the sync UI handles.
+If the shared repo is one your GitHub account can't push to (you're not a collaborator on a public repo, or you have read-only access on a private one), OpenKnowledge offers **[Follow](/docs/features/github-sync#follow)** at first open instead of the full "Enable auto-sync?" prompt: your copy keeps up to date by pulling the latest from GitHub automatically, while your own edits stay on your computer. You can keep editing — an incoming update combines with your non-overlapping changes automatically, and an edit to the same lines surfaces as a conflict to resolve.
See [GitHub sync](/docs/features/github-sync) for how to automatically sync changes to GitHub, and [Timeline and recovery](/docs/features/timeline-and-recovery) for per-doc history once the doc is open.
diff --git a/docs/content/reference/configuration.mdx b/docs/content/reference/configuration.mdx
index 90ede825c..ac31b5871 100644
--- a/docs/content/reference/configuration.mdx
+++ b/docs/content/reference/configuration.mdx
@@ -8,7 +8,7 @@ OpenKnowledge reads YAML config from three places:
- **Project**: `./.ok/config.yml` (this project, committed to git)
- **User**: `~/.ok/global.yml` (every project on your machine)
-- **Project-local**: `./.ok/local/config.yml` (this project on this machine; gitignored). Holds per-machine-per-project preferences such as `autoSync.enabled`. Maintained by the editor (auto-sync onboarding modal, sync popover, Settings pane Sync section); you don't normally hand-edit it.
+- **Project-local**: `./.ok/local/config.yml` (this project on this machine; gitignored). Holds per-machine-per-project preferences such as `autoSync.mode`. Maintained by the editor (auto-sync onboarding modal, sync popover, Settings pane Sync section); you don't normally hand-edit it.
All files are optional; defaults cover everything.
@@ -36,8 +36,9 @@ A key set in a file more specific than its scope (a user-scope key in `.ok/confi
| `contentRules.plugins.markdownlint.enabled` | boolean | `false` | project | Enable the [markdownlint](/docs/advanced/content-rules/markdownlint) content-rules plugin for this project (shared via git). Off by default — turn it on in **Settings → This project → Plugins**. The rules themselves live in your native `.markdownlint.*` file, not here. |
| `appearance.theme` | `"light" \| "dark" \| "system"` | (unset) | user | Editor theme. |
| `appearance.sidebar.showHiddenFiles` | boolean | `false` | project-local | Show files whose path segments start with `.`. The sidebar otherwise lists every file on disk under the content directory; tooling internals (`.git/`, `.ok/`, `node_modules/`) stay hidden regardless. Toggled from the sidebar's right-click menu or the macOS **View → Show Hidden Files** item. |
-| `autoSync.enabled` | `boolean \| null` | `null` | project-local | Per-machine git auto-sync toggle. `null` means "unanswered"; the editor's onboarding modal triggers on first remote-detected open. |
-| `autoSync.default` | `boolean \| null` | `null` | project | Committed seed for each machine's first-open auto-sync choice: `true` = on, `false` = off, `null` = ask. Lets a maintainer pre-answer the onboarding prompt for everyone who clones; a per-machine `autoSync.enabled` overrides it. |
+| `autoSync.mode` | `"off" \| "follow" \| "full" \| null` | `null` | project-local | Per-machine sync mode for this project: `off` (no sync), `follow` (one-directional — pull remote changes, never push your own; the earlier value `pull` is accepted as an alias), `full` (bidirectional pull and push). `null` means "unanswered"; the editor's onboarding modal triggers on first remote-detected open. Supersedes `autoSync.enabled`. |
+| `autoSync.enabled` | `boolean \| null` | `null` | project-local | Legacy per-machine auto-sync toggle, superseded by `autoSync.mode`. Read only when `mode` is absent (`true` = full, `false` = off). `null` means "unanswered". |
+| `autoSync.default` | `"off" \| "follow" \| "full" \| boolean \| null` | `null` | project | Committed seed for each machine's first-open sync mode: `off` / `follow` / `full`, or the legacy boolean (`true` = full, `false` = off). `null` = ask. Lets a maintainer pre-answer the onboarding prompt for everyone who clones; a per-machine `autoSync.mode` overrides it. Compatibility note: a committed string value (`off` / `follow` / `full`) is rejected by app versions released before `autoSync.mode` existed, resetting that machine to config defaults until it updates (it never silently syncs). The legacy boolean seed (`true` / `false`) stays readable by older apps — commit `follow` only once collaborators are on a current version. |
| `editor.wordWrap` | boolean | `true` | user | Soft-wrap long lines in the source-mode CodeMirror editor. A personal preference, not project-shared. Toggle from the Settings pane. |
| `appearance.preview.autoOpen` | boolean | `true` | user | Whether the agent should open or refresh the OpenKnowledge preview when it edits a doc through the MCP. Default `true` lets the agent route the preview by host capability: the host's in-app browser (Cursor preview pane, Codex's built-in browser, Claude Code Desktop) when one exists, the system browser otherwise. Set `false` to keep the agent's hands off your preview window. This is useful when you're already viewing the doc in OK Desktop, a browser tab on a second display, a non-default browser, or any flow where your extensions / accessibility tooling only work in your own browser. The agent then surfaces the URL on request but does not navigate. The change takes effect on the next preview-related tool call. Toggle from **Settings → Preferences → "Open preview when agent edits"**. |
| `terminal.enabled` | `boolean \| null` | `null` | project-local | Opt-out for the in-app terminal (a real OS shell at full user privilege). On by default; set `false` to disable it for this project on this machine. |
diff --git a/packages/app/src/components/AutoSyncEnableWarning.tsx b/packages/app/src/components/AutoSyncEnableWarning.tsx
index 4e854cbdc..988e7f032 100644
--- a/packages/app/src/components/AutoSyncEnableWarning.tsx
+++ b/packages/app/src/components/AutoSyncEnableWarning.tsx
@@ -1,8 +1,37 @@
import { Trans } from '@lingui/react/macro';
-import { ArrowRightLeft, Eye, GitCommitVertical } from 'lucide-react';
+import {
+ ArrowDownToLine,
+ ArrowRightLeft,
+ Eye,
+ GitCommitVertical,
+ GitMerge,
+ Laptop,
+} from 'lucide-react';
+import type { ReactNode } from 'react';
+import type { AutoSyncOnboardingVariant } from '@/components/auto-sync-onboarding-gate';
import { DialogDescription, DialogTitle } from '@/components/ui/dialog';
-export function AutoSyncEnableDialogIntro() {
+interface SyncVariantProps {
+ /** 'full' = bidirectional sync copy (default); 'pull' = one-directional. */
+ variant?: AutoSyncOnboardingVariant;
+}
+
+export function AutoSyncEnableDialogIntro({ variant = 'full' }: SyncVariantProps) {
+ if (variant === 'follow') {
+ return (
+ <>
+
+ Enable Follow?
+
+
+
+ Follow fetches updates from your remote git repository and fast-forwards your copy
+ automatically. Your local edits stay on this computer and are never pushed.
+
+
+ >
+ );
+ }
return (
<>
@@ -18,7 +47,7 @@ export function AutoSyncEnableDialogIntro() {
);
}
-export function AutoSyncEnableWarning() {
+export function AutoSyncEnableWarning({ variant = 'full' }: SyncVariantProps) {
return (
@@ -28,48 +57,104 @@ export function AutoSyncEnableWarning() {
Heads up
- Pulls may overwrite uncommitted edits in your local files.
-
-
-
-
+ }
+ title={Uncommitted changes}
+ body={Pulls may overwrite uncommitted edits in your local files.}
+ />
+
-
-
- Commits happen automatically
-
-
-
- OpenKnowledge will create commits and push them to your remote automatically. If you
- do not want automatic commits in your git history, you should not enable auto-sync.
-
-
-
-
-
-
-
-
- Shared repositories
-
-
- Collaborators see your in-progress edits as soon as they sync.
-
-
-
+ }
+ title={Commits happen automatically}
+ body={
+
+ OpenKnowledge will create commits and push them to your remote automatically. If you do
+ not want automatic commits in your git history, you should not enable auto-sync.
+
+ }
+ />
+ }
+ title={Shared repositories}
+ body={Collaborators see your in-progress edits as soon as they sync.}
+ />
+ >
+ );
+}
+
+function PullOnlyBullets() {
+ return (
+ <>
+
+ }
+ title={Updates flow in}
+ body={New changes from your remote appear in your copy automatically.}
+ />
+
+ }
+ title={Your edits stay on this computer}
+ body={
+
+ Follow never pushes or commits your local changes. They stay only on this machine.
+
+ }
+ />
+
+ }
+ title={Overlapping edits}
+ body={
+
+ If an update arrives for something you are editing, you choose which version to keep,
+ like a merge conflict.
+
+ }
+ />
+ >
+ );
+}
+
+function WarningBullet({
+ icon,
+ title,
+ body,
+}: {
+ icon: ReactNode;
+ title: ReactNode;
+ body: ReactNode;
+}) {
+ return (
+
+ {icon}
+
+
{title}
+
{body}
);
diff --git a/packages/app/src/components/AutoSyncOnboardingDialog.dom.test.tsx b/packages/app/src/components/AutoSyncOnboardingDialog.dom.test.tsx
index a387b2112..0238051c5 100644
--- a/packages/app/src/components/AutoSyncOnboardingDialog.dom.test.tsx
+++ b/packages/app/src/components/AutoSyncOnboardingDialog.dom.test.tsx
@@ -1,11 +1,13 @@
+import type { SyncMode } from '@inkeep/open-knowledge-core';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactNode } from 'react';
import { afterEach, describe, expect, test, vi } from 'vitest';
+import type { AutoSyncOnboardingVariant } from './auto-sync-onboarding-gate.ts';
-type SyncWriter = (enabled: boolean) => { ok: true } | { ok: false; error: string };
+type ModeWriter = (mode: SyncMode) => { ok: true } | { ok: false; error: string };
-let writer: SyncWriter | null = null;
+let writer: ModeWriter | null = null;
const toastErrors: string[] = [];
import * as actualLinguiMacro from '@lingui/react/macro';
@@ -28,12 +30,15 @@ vi.doMock('sonner', () => ({
}));
vi.doMock('@/hooks/use-enable-sync-with-confirm', () => ({
- useSyncEnabledWriter: () => writer,
+ useSyncModeWriter: () => writer,
}));
-async function renderDialog(onResolved: () => void = () => {}) {
+async function renderDialog(
+ variant: AutoSyncOnboardingVariant = 'full',
+ onResolved: () => void = () => {},
+) {
const { AutoSyncOnboardingDialog } = await import('./AutoSyncOnboardingDialog');
- render();
+ render();
}
describe('AutoSyncOnboardingDialog runtime behavior', () => {
@@ -48,9 +53,9 @@ describe('AutoSyncOnboardingDialog runtime behavior', () => {
expect(typeof mod.AutoSyncOnboardingDialog).toBe('function');
});
- test('renders stable primary and secondary choices without a close affordance', async () => {
+ test('full variant renders the bidirectional prompt without a close affordance', async () => {
writer = () => ({ ok: true });
- await renderDialog();
+ await renderDialog('full');
expect(screen.getByRole('button', { name: 'Enable auto-sync' })).not.toBeNull();
expect(screen.getByRole('button', { name: 'Keep disabled' })).not.toBeNull();
@@ -58,45 +63,61 @@ describe('AutoSyncOnboardingDialog runtime behavior', () => {
expect(screen.getByRole('note').textContent).toContain('Heads up');
});
- test('disables both choices until the project-local sync writer is ready', async () => {
- writer = null;
- await renderDialog();
+ test('pull variant explains one-directional sync and that edits stay local', async () => {
+ writer = () => ({ ok: true });
+ await renderDialog('follow');
- expect(
- (screen.getByRole('button', { name: 'Enable auto-sync' }) as HTMLButtonElement).disabled,
- ).toBe(true);
- expect(
- (screen.getByRole('button', { name: 'Keep disabled' }) as HTMLButtonElement).disabled,
- ).toBe(true);
+ expect(screen.getByRole('button', { name: 'Enable Follow' })).not.toBeNull();
+ const note = screen.getByRole('note').textContent ?? '';
+ expect(note).toContain('Updates flow in');
+ expect(note).toContain('stay only on this machine');
});
- test('persists enable and disable choices through the shared writer', async () => {
- const writerCalls: boolean[] = [];
- const resolvedCalls: string[] = [];
- writer = (enabled) => {
- writerCalls.push(enabled);
+ test('persists the resolved mode per variant through the writer', async () => {
+ const modeWrites: SyncMode[] = [];
+ writer = (mode) => {
+ modeWrites.push(mode);
return { ok: true };
};
- await renderDialog(() => resolvedCalls.push('resolved'));
+ await renderDialog('full');
await userEvent.click(screen.getByRole('button', { name: 'Enable auto-sync' }));
- expect(writerCalls).toEqual([true]);
- expect(resolvedCalls).toEqual(['resolved']);
+ expect(modeWrites).toEqual(['full']);
cleanup();
- writerCalls.length = 0;
- resolvedCalls.length = 0;
- await renderDialog(() => resolvedCalls.push('resolved'));
+ modeWrites.length = 0;
+ await renderDialog('follow');
+ await userEvent.click(screen.getByRole('button', { name: 'Enable Follow' }));
+ expect(modeWrites).toEqual(['follow']);
+ });
+
+ test('Keep disabled writes mode off in either variant', async () => {
+ const modeWrites: SyncMode[] = [];
+ writer = (mode) => {
+ modeWrites.push(mode);
+ return { ok: true };
+ };
+ await renderDialog('follow');
await userEvent.click(screen.getByRole('button', { name: 'Keep disabled' }));
+ expect(modeWrites).toEqual(['off']);
+ });
+
+ test('disables both choices until the project-local sync writer is ready', async () => {
+ writer = null;
+ await renderDialog('follow');
- expect(writerCalls).toEqual([false]);
- expect(resolvedCalls).toEqual(['resolved']);
+ expect(
+ (screen.getByRole('button', { name: 'Enable Follow' }) as HTMLButtonElement).disabled,
+ ).toBe(true);
+ expect(
+ (screen.getByRole('button', { name: 'Keep disabled' }) as HTMLButtonElement).disabled,
+ ).toBe(true);
});
test('surfaces writer failures without resolving the dialog', async () => {
const resolvedCalls: string[] = [];
writer = () => ({ ok: false, error: 'binding unavailable' });
- await renderDialog(() => resolvedCalls.push('resolved'));
+ await renderDialog('full', () => resolvedCalls.push('resolved'));
await userEvent.click(screen.getByRole('button', { name: 'Enable auto-sync' }));
@@ -107,7 +128,7 @@ describe('AutoSyncOnboardingDialog runtime behavior', () => {
test('Escape does not resolve the non-dismissible prompt', async () => {
const resolvedCalls: string[] = [];
writer = () => ({ ok: true });
- await renderDialog(() => resolvedCalls.push('resolved'));
+ await renderDialog('full', () => resolvedCalls.push('resolved'));
await userEvent.keyboard('{Escape}');
await waitFor(() => {
diff --git a/packages/app/src/components/AutoSyncOnboardingDialog.tsx b/packages/app/src/components/AutoSyncOnboardingDialog.tsx
index 695f68a6c..0318cdbbb 100644
--- a/packages/app/src/components/AutoSyncOnboardingDialog.tsx
+++ b/packages/app/src/components/AutoSyncOnboardingDialog.tsx
@@ -1,11 +1,13 @@
/**
- * AutoSyncOnboardingDialog — first-run prompt explaining git auto-sync.
+ * AutoSyncOnboardingDialog — first-run prompt explaining git sync.
*
- * Shown once per project when the sync engine reports a remote exists AND
- * the project-local config field `autoSync.enabled` has not been set
- * (`=== null`). Both buttons write through the project-local ConfigBinding
- * so the choice flows down the standard Y.Text → persistence-hook →
- * file-watcher → SyncEngine pipeline.
+ * Shown once per project when the sync engine reports a remote exists AND this
+ * machine has not chosen a sync mode. `resolveAutoSyncOnboarding` decides
+ * whether to show it and which `variant` to pass: 'full' (bidirectional, the
+ * push-capable prompt) or 'pull' (one-directional, for a push-denied follower).
+ * Both buttons write `autoSync.mode` through the project-local ConfigBinding so
+ * the choice flows down the standard Y.Text → persistence-hook → file-watcher →
+ * SyncEngine pipeline.
*/
import { Trans, useLingui } from '@lingui/react/macro';
import { toast } from 'sonner';
@@ -13,6 +15,7 @@ import {
AutoSyncEnableDialogIntro,
AutoSyncEnableWarning,
} from '@/components/AutoSyncEnableWarning';
+import type { AutoSyncOnboardingVariant } from '@/components/auto-sync-onboarding-gate';
import { Button } from '@/components/ui/button';
import {
DialogBody,
@@ -21,23 +24,28 @@ import {
DialogHeader,
Dialog as DialogRoot,
} from '@/components/ui/dialog';
-import { useSyncEnabledWriter } from '@/hooks/use-enable-sync-with-confirm';
+import { useSyncModeWriter } from '@/hooks/use-enable-sync-with-confirm';
interface AutoSyncOnboardingDialogProps {
open: boolean;
+ variant: AutoSyncOnboardingVariant;
onResolved: () => void;
}
-export function AutoSyncOnboardingDialog({ open, onResolved }: AutoSyncOnboardingDialogProps) {
+export function AutoSyncOnboardingDialog({
+ open,
+ variant,
+ onResolved,
+}: AutoSyncOnboardingDialogProps) {
const { t } = useLingui();
- const writer = useSyncEnabledWriter();
+ const writer = useSyncModeWriter();
function persistChoice(enabled: boolean): void {
if (writer === null) {
toast.error(t`Sync settings not yet loaded — try again in a moment`);
return;
}
- const result = writer(enabled);
+ const result = writer(enabled ? (variant === 'follow' ? 'follow' : 'full') : 'off');
if (!result.ok) {
const detail = result.error;
toast.error(
@@ -60,14 +68,14 @@ export function AutoSyncOnboardingDialog({ open, onResolved }: AutoSyncOnboardin
>
-
+
-
+
- You can turn this on later in Settings → Sync.
+ You can change this later in Settings → Sync.
@@ -82,7 +90,7 @@ export function AutoSyncOnboardingDialog({ open, onResolved }: AutoSyncOnboardin
Keep disabled
diff --git a/packages/app/src/components/EditorPane.dom.test.tsx b/packages/app/src/components/EditorPane.dom.test.tsx
index c220fdaf1..ccc17f229 100644
--- a/packages/app/src/components/EditorPane.dom.test.tsx
+++ b/packages/app/src/components/EditorPane.dom.test.tsx
@@ -68,11 +68,12 @@ let projectLocalSynced = false;
let projectSynced = false;
let projectLocalConfig: { autoSync?: { enabled?: boolean | null } } | null = null;
let projectConfig: { autoSync?: { default?: boolean | null } } | null = null;
+let pushPermissionCheckStatus: 'allowed' | 'denied' | 'unknown' | undefined = 'allowed';
vi.doMock('@/hooks/use-git-sync-status', () => ({
useGitSyncStatus: () => ({
hasRemote,
- pushPermission: { checkStatus: 'allowed' },
+ pushPermission: { checkStatus: pushPermissionCheckStatus },
}),
}));
@@ -163,11 +164,20 @@ vi.doMock('@/editor/components/TagDialog', () => ({
}));
vi.doMock('./AutoSyncOnboardingDialog', () => ({
- AutoSyncOnboardingDialog: ({ open, onResolved }: { open: boolean; onResolved: () => void }) => (
+ AutoSyncOnboardingDialog: ({
+ open,
+ variant,
+ onResolved,
+ }: {
+ open: boolean;
+ variant: string;
+ onResolved: () => void;
+ }) => (
diff --git a/packages/app/src/components/SyncStatusBadge.dom.test.tsx b/packages/app/src/components/SyncStatusBadge.dom.test.tsx
index 33fbc98fb..77db0bc3c 100644
--- a/packages/app/src/components/SyncStatusBadge.dom.test.tsx
+++ b/packages/app/src/components/SyncStatusBadge.dom.test.tsx
@@ -26,7 +26,13 @@ vi.doMock('@lingui/react/macro', () => ({
let status: GitSyncStatus | null = null;
let fetchError: 'network' | 'server' | null = null;
-let projectLocalConfig: { autoSync?: { enabled?: boolean | null } } | null = {
+let projectLocalConfig: {
+ autoSync?: {
+ enabled?: boolean | null;
+ mode?: 'off' | 'follow' | 'full' | null;
+ resumeMode?: 'follow' | 'full';
+ };
+} | null = {
autoSync: { enabled: false },
};
let projectLocalSynced = true;
@@ -183,6 +189,58 @@ describe('SyncStatusBadge helper behavior', () => {
);
expect(shouldOfferSignInAgain(undefined)).toBe(false);
});
+
+ test('a pull-only toggle stays enabled even when push is denied', async () => {
+ const { shouldDisableSyncSwitch } = await import('./SyncStatusBadge');
+
+ // Pull-only never pushes, so a denied probe is irrelevant to its toggle.
+ expect(shouldDisableSyncSwitch(true, 'denied', 'follow')).toBe(false);
+ // Off/full still gate on denial — enabling there reaches push-requiring full sync.
+ expect(shouldDisableSyncSwitch(true, 'denied', 'off')).toBe(true);
+ expect(shouldDisableSyncSwitch(true, 'denied', 'full')).toBe(true);
+ // Cold start (project-local config not yet synced) disables regardless of mode.
+ expect(shouldDisableSyncSwitch(false, 'allowed', 'follow')).toBe(true);
+ });
+
+ test('displayState promotes a pull-only idle-with-conflicts project to conflict', async () => {
+ const { displayState } = await import('./SyncStatusBadge');
+
+ expect(
+ displayState({ ...baseStatus, syncMode: 'follow', state: 'idle', conflictCount: 1 }),
+ ).toBe('conflict');
+ // No conflicts, or full sync, leaves the state untouched (full sets 'conflict' itself).
+ expect(
+ displayState({ ...baseStatus, syncMode: 'follow', state: 'idle', conflictCount: 0 }),
+ ).toBe('idle');
+ expect(displayState({ ...baseStatus, syncMode: 'full', state: 'idle', conflictCount: 1 })).toBe(
+ 'idle',
+ );
+ expect(
+ displayState({ ...baseStatus, syncMode: 'follow', state: 'pulling', conflictCount: 1 }),
+ ).toBe('pulling');
+ });
+
+ test('tooltipLabel frames a following project as up to date, never "Sync off"', async () => {
+ const { tooltipLabel } = await import('./SyncStatusBadge');
+ const following = { ...baseStatus, syncMode: 'follow' as const };
+
+ expect(tooltipLabel({ ...following, state: 'idle', behind: 0 })).toBe('Up to date');
+ expect(tooltipLabel({ ...following, state: 'idle', behind: 3 })).toBe('3 behind');
+ expect(tooltipLabel({ ...following, state: 'pulling' })).toBe('Updating');
+ expect(tooltipLabel({ ...following, state: 'idle', conflictCount: 2 })).toBe('2 conflicts');
+ // Even a following payload that arrives with syncEnabled false is never "Sync off".
+ expect(tooltipLabel({ ...following, state: 'idle', syncEnabled: false })).toBe('Up to date');
+ // Full sync is unchanged.
+ expect(tooltipLabel({ ...baseStatus, state: 'idle' })).toBe('Synced');
+ expect(tooltipLabel({ ...baseStatus, state: 'idle', syncEnabled: false })).toBe('Sync off');
+ });
+
+ test('formatPausedReason explains a pull-only divergence in plain language', async () => {
+ const { formatPausedReason } = await import('./SyncStatusBadge');
+ expect(formatPausedReason('diverged-local-commits')).toBe(
+ 'Local commits are keeping this copy from updating',
+ );
+ });
});
describe('SyncStatusBadge runtime behavior', () => {
@@ -234,6 +292,24 @@ describe('SyncStatusBadge runtime behavior', () => {
expect(screen.getByRole('button', { name: /Sync status:/ })).toBeTruthy();
});
+ test('stays visible while a resume is in flight (config active, server still disabled)', async () => {
+ // Resuming from paused flips the local config mode 'off'→active optimistically
+ // (so `paused` clears) while the server status still lags at disabled/off. The
+ // badge must stay mounted in that window — unmounting closes the popover and
+ // flickers the icon (the user toggles on, the popover vanishes, then returns).
+ projectLocalConfig = { autoSync: { mode: 'follow' } };
+ status = {
+ ...baseStatus,
+ state: 'disabled',
+ syncEnabled: false,
+ syncMode: 'off',
+ pausedReason: undefined,
+ } as GitSyncStatus;
+ await renderBadge();
+
+ expect(screen.getByRole('button', { name: /Sync status:/ })).toBeTruthy();
+ });
+
test('paused disabled state opens details explaining why sync stopped', async () => {
status = {
...baseStatus,
@@ -247,13 +323,13 @@ describe('SyncStatusBadge runtime behavior', () => {
expect(screen.getByText('Protected branch — cannot push')).toBeTruthy();
});
- test('popover switch checked state reads local config, not server syncEnabled', async () => {
+ test('popover switch is on (Pause sync) when a mode is active', async () => {
status = { ...baseStatus, state: 'idle', syncEnabled: false };
projectLocalConfig = { autoSync: { enabled: true } };
await renderBadge();
await openPopover();
- const toggle = screen.getByRole('switch', { name: 'Disable sync' });
+ const toggle = screen.getByRole('switch', { name: 'Pause sync' });
expect(toggle.getAttribute('aria-checked')).toBe('true');
});
@@ -264,20 +340,182 @@ describe('SyncStatusBadge runtime behavior', () => {
await renderBadge();
await openPopover();
- const toggle = screen.getByRole('switch', { name: 'Enable sync' }) as HTMLButtonElement;
+ const toggle = screen.getByRole('switch', { name: 'Resume sync' }) as HTMLButtonElement;
expect(toggle.disabled).toBe(true);
});
- test('off to on opens confirmation before patching project-local config', async () => {
+ test('resuming from off opens confirmation before patching (defaults to full)', async () => {
status = { ...baseStatus, state: 'idle', syncEnabled: false };
projectLocalConfig = { autoSync: { enabled: false } };
await renderBadge();
await openPopover();
- await userEvent.click(screen.getByRole('switch', { name: 'Enable sync' }));
+ await userEvent.click(screen.getByRole('switch', { name: 'Resume sync' }));
expect(patches).toEqual([]);
+ // Resuming a never-enabled project defaults to full sync (which pushes), so
+ // it confirms; the write clears the resume memory.
await userEvent.click(screen.getByRole('button', { name: 'Enable auto-sync' }));
- expect(patches).toEqual([{ autoSync: { enabled: true } }]);
+ expect(patches).toEqual([{ autoSync: { mode: 'full', enabled: null, resumeMode: null } }]);
+ });
+
+ test.each([
+ ['idle', { state: 'idle' }, 'Sync status: Up to date'],
+ ['pulling', { state: 'pulling' }, 'Sync status: Updating'],
+ ['fetching', { state: 'fetching' }, 'Sync status: Checking for updates'],
+ ['offline', { state: 'offline' }, 'Sync status: Offline'],
+ ['auth-error', { state: 'auth-error' }, 'Sync status: Reconnect required'],
+ ] as const)('pull-only %s renders a distinct following badge', async (_label, override, ariaName) => {
+ status = { ...baseStatus, syncMode: 'follow', ...override } as GitSyncStatus;
+ await renderBadge();
+
+ expect(screen.getByRole('button', { name: ariaName })).toBeTruthy();
+ });
+
+ test('pull-only conflict surfaces on the badge even though the engine stays idle', async () => {
+ // Pull-only holds the engine idle while a same-line collision waits in the
+ // ledger; the badge promotes conflictCount to the conflict rendering.
+ status = {
+ ...baseStatus,
+ syncMode: 'follow',
+ state: 'idle',
+ conflictCount: 1,
+ } as GitSyncStatus;
+ await renderBadge();
+
+ expect(screen.getByRole('button', { name: 'Sync status: Conflict' })).toBeTruthy();
+ expect(screen.getByText('1')).toBeTruthy();
+ });
+
+ test('pull-only stays visible even in a disabled-without-reason payload', async () => {
+ // The engine never parks a pull-only project here, but the hide rule must
+ // exempt following projects so one is never silently hidden.
+ status = {
+ ...baseStatus,
+ syncMode: 'follow',
+ state: 'disabled',
+ pausedReason: undefined,
+ } as GitSyncStatus;
+ await renderBadge();
+
+ expect(screen.getByRole('button', { name: /Sync status:/ })).toBeTruthy();
+ });
+
+ test('following popover shows the follow state, not "sync off", with a Sync action', async () => {
+ status = { ...baseStatus, syncMode: 'follow', state: 'idle' } as GitSyncStatus;
+ projectLocalConfig = { autoSync: { mode: 'follow' } };
+ await renderBadge();
+ await openPopover();
+
+ const toggle = screen.getByRole('switch', { name: 'Pause sync' });
+ expect(toggle.getAttribute('aria-checked')).toBe('true');
+ expect(screen.queryByText(/Sync is off/)).toBeNull();
+ expect(screen.getByRole('button', { name: 'Sync' })).toBeTruthy();
+ });
+
+ test('following popover keeps the Switch reachable when push is denied', async () => {
+ status = {
+ ...baseStatus,
+ syncMode: 'follow',
+ state: 'idle',
+ pushPermission: { checkStatus: 'denied' },
+ } as GitSyncStatus;
+ projectLocalConfig = { autoSync: { mode: 'follow' } };
+ await renderBadge();
+ await openPopover();
+
+ const toggle = screen.getByRole('switch', { name: 'Pause sync' }) as HTMLButtonElement;
+ expect(toggle.disabled).toBe(false);
+ });
+
+ test('following popover states the mode instead of the push-permission verdict', async () => {
+ status = {
+ ...baseStatus,
+ syncMode: 'follow',
+ state: 'idle',
+ pushPermission: { checkStatus: 'denied', deniedReason: 'no-collaborator' },
+ } as GitSyncStatus;
+ projectLocalConfig = { autoSync: { mode: 'follow' } };
+ await renderBadge();
+ await openPopover();
+
+ expect(screen.getByTestId('sync-popover-mode-line').textContent).toContain('Follow');
+ expect(screen.queryByText(/don't have permission to push/)).toBeNull();
+ // A genuine read-only user cannot choose Full, so the mode toggle is hidden.
+ expect(screen.queryByTestId('sync-popover-mode-toggle')).toBeNull();
+ });
+
+ test('pull-only popover suppresses the signed-out reconnect line (push-framed)', async () => {
+ status = {
+ ...baseStatus,
+ syncMode: 'follow',
+ state: 'idle',
+ pushPermission: { checkStatus: 'denied', deniedReason: 'not-authenticated' },
+ } as GitSyncStatus;
+ projectLocalConfig = { autoSync: { mode: 'follow' } };
+ await renderBadge();
+ await openPopover();
+
+ expect(screen.queryByText(/signed out — sign in to resume syncing/)).toBeNull();
+ expect(screen.getByTestId('sync-popover-mode-line')).toBeTruthy();
+ });
+
+ test('a paused (was-enabled) project stays visible and shows the paused popover', async () => {
+ // Engine reports a paused project as disabled; the config (resumeMode set)
+ // keeps the badge visible and drives the paused rendering.
+ status = {
+ ...baseStatus,
+ state: 'disabled',
+ syncMode: 'off',
+ pausedReason: undefined,
+ syncEnabled: false,
+ } as GitSyncStatus;
+ projectLocalConfig = { autoSync: { mode: 'off', resumeMode: 'full' } };
+ await renderBadge();
+
+ // Not hidden (the disabled-hide rule exempts a paused project).
+ expect(screen.getByRole('button', { name: 'Sync status: Sync paused' })).toBeTruthy();
+ await openPopover();
+
+ expect(screen.getByTestId('sync-popover-paused-line')).toBeTruthy();
+ // Switch is off (resume), and a manual Sync is still offered (ever enabled).
+ expect(screen.getByRole('switch', { name: 'Resume sync' }).getAttribute('aria-checked')).toBe(
+ 'false',
+ );
+ expect(screen.getByRole('button', { name: 'Sync' })).toBeTruthy();
+ // The mode is still editable while paused (push-capable user).
+ expect(screen.getByTestId('sync-popover-mode-toggle')).toBeTruthy();
+ });
+
+ test('changing the mode while paused only updates the resume memory (no sync)', async () => {
+ status = {
+ ...baseStatus,
+ state: 'disabled',
+ syncMode: 'off',
+ syncEnabled: false,
+ } as GitSyncStatus;
+ projectLocalConfig = { autoSync: { mode: 'off', resumeMode: 'full' } };
+ await renderBadge();
+ await openPopover();
+
+ // Flip Full → Follow while paused: writes resumeMode only, mode stays off,
+ // no confirmation (nothing syncs until the Switch is turned on).
+ await userEvent.click(screen.getByTestId('sync-popover-mode-follow'));
+ expect(patches).toEqual([{ autoSync: { resumeMode: 'follow' } }]);
+ });
+
+ test('the manual Sync action is hidden for a never-enabled project', async () => {
+ // Off with no resume memory = never enabled → no manual Sync affordance.
+ status = {
+ ...baseStatus,
+ state: 'dormant',
+ syncMode: 'off',
+ syncEnabled: false,
+ } as GitSyncStatus;
+ projectLocalConfig = { autoSync: { mode: 'off' } };
+ await renderBadge();
+ await openPopover();
+
+ expect(screen.queryByRole('button', { name: 'Sync' })).toBeNull();
});
});
diff --git a/packages/app/src/components/SyncStatusBadge.tsx b/packages/app/src/components/SyncStatusBadge.tsx
index 88f06b699..fce99831b 100644
--- a/packages/app/src/components/SyncStatusBadge.tsx
+++ b/packages/app/src/components/SyncStatusBadge.tsx
@@ -7,7 +7,14 @@
* Click opens a popover with last-sync details and action buttons.
*/
-import type { PushPermissionWire, SyncErrorCode } from '@inkeep/open-knowledge-core';
+import {
+ isSyncActiveMode,
+ isSyncPaused,
+ type PushPermissionWire,
+ resolveLocalAutoSyncMode,
+ type SyncErrorCode,
+ type SyncMode,
+} from '@inkeep/open-knowledge-core';
import { plural, t } from '@lingui/core/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import {
@@ -16,14 +23,12 @@ import {
Cloud,
CloudOff,
LogIn,
+ Pause,
RefreshCw,
UserCog,
} from 'lucide-react';
import { useConflicts } from '@/hooks/use-conflicts';
-import {
- useEnableSyncWithConfirm,
- useSyncEnabledWriter,
-} from '@/hooks/use-enable-sync-with-confirm';
+import { useBadgeSyncControls } from '@/hooks/use-enable-sync-with-confirm';
import type { GitSyncStatus } from '@/hooks/use-git-sync-status';
import { useGitSyncStatusDetailed } from '@/hooks/use-git-sync-status';
import { useConfigContext } from '@/lib/config-provider';
@@ -33,6 +38,7 @@ import { EnableSyncConfirmDialog } from './EnableSyncConfirmDialog';
import { Button } from './ui/button';
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
import { Switch } from './ui/switch';
+import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
// ── helpers ──────────────────────────────────────────────────────────────────
@@ -42,9 +48,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
* stream drives the visible state, so a rejected trigger (offline / server
* down) needs no UI handling — but it gets a breadcrumb rather than being
* swallowed silently, so a "Sync now did nothing" report is triageable.
+ *
+ * A following (pull-only) project uses the `pull` op so the trigger runs the
+ * one-directional cycle; full sync uses `sync` (fetch + merge + push).
*/
-function triggerSyncFromBadge(): void {
- triggerSync('sync').catch((err) => {
+function triggerSyncFromBadge(op: 'sync' | 'pull' = 'sync'): void {
+ triggerSync(op).catch((err) => {
console.warn(
'[sync-badge] manual sync trigger failed',
err instanceof Error ? err.message : err,
@@ -67,15 +76,41 @@ function formatRelative(iso: string | null): string {
return new Date(iso).toLocaleDateString();
}
+// ── mode-aware state derivation ───────────────────────────────────────────────
+
+/** True when the project follows upstream one-directionally (pull-only). */
+function isFollowingMode(status: GitSyncStatus): boolean {
+ return status.syncMode === 'follow';
+}
+
+/**
+ * The state the badge renders. A pull-only engine deliberately holds `idle`
+ * while a same-line collision waits in the conflict ledger — that keeps the rest
+ * of the repo fast-forwarding, but a following project must still surface the
+ * collision, so promote it to `conflict` on the badge. Full sync sets the
+ * `conflict` state directly, so this never changes what it renders.
+ */
+export function displayState(status: GitSyncStatus): GitSyncStatus['state'] {
+ if (isFollowingMode(status) && status.state === 'idle' && status.conflictCount > 0) {
+ return 'conflict';
+ }
+ return status.state;
+}
+
// ── inner: icon + color per state ────────────────────────────────────────────
interface BadgeIconProps {
status: GitSyncStatus;
+ /** True when the user paused a previously-enabled project (config-driven). */
+ paused?: boolean;
}
-function BadgeIcon({ status }: BadgeIconProps) {
+function BadgeIcon({ status, paused }: BadgeIconProps) {
const cls = 'size-3.5';
- switch (status.state) {
+ // Paused is a config state the engine's `state` can't express (it reads as
+ // disabled/dormant), so it wins the icon.
+ if (paused) return ;
+ switch (displayState(status)) {
case 'dormant':
// Available: remote exists but sync not yet enabled
return ;
@@ -104,9 +139,11 @@ function BadgeIcon({ status }: BadgeIconProps) {
}
function badgeLabel(status: GitSyncStatus): string {
- switch (status.state) {
+ switch (displayState(status)) {
case 'idle':
- if (status.ahead > 0) return `↑${status.ahead}`;
+ // A following project never pushes, so "ahead" is not an actionable
+ // signal — only surface how far behind upstream the copy is.
+ if (!isFollowingMode(status) && status.ahead > 0) return `↑${status.ahead}`;
if (status.behind > 0) return `↓${status.behind}`;
return '';
case 'fetching':
@@ -126,16 +163,18 @@ function badgeLabel(status: GitSyncStatus): string {
// ── popover content ───────────────────────────────────────────────────────────
-function stateLabel(state: GitSyncStatus['state']): string {
+function stateLabel(state: GitSyncStatus['state'], following = false): string {
switch (state) {
case 'dormant':
return t`No git remote`;
case 'idle':
- return t`Synced`;
+ // A following project tracks upstream one-directionally, so "Synced"
+ // (which implies a two-way exchange) would overstate what happened.
+ return following ? t`Up to date` : t`Synced`;
case 'fetching':
- return t`Fetching`;
+ return following ? t`Checking for updates` : t`Fetching`;
case 'pulling':
- return t`Pulling`;
+ return following ? t`Updating` : t`Pulling`;
case 'pushing':
return t`Pushing`;
case 'conflict':
@@ -161,6 +200,10 @@ export function formatPausedReason(reason: string): string {
return t`Resolve conflict in your terminal`;
case 'detached-head':
return t`Detached HEAD — checkout a branch to resume`;
+ case 'diverged-local-commits':
+ // Pull-only reaches this when local commits sit ahead of origin: it never
+ // pushes or merges to reconcile them, so updates stall until they're gone.
+ return t`Local commits are keeping this copy from updating`;
case 'auth-error':
return t`Reconnect required`;
case 'protected-branch':
@@ -393,16 +436,37 @@ export function shouldOfferSignInAgain(pushPermission: PushPermissionWire | unde
export function shouldDisableSyncSwitch(
projectLocalSynced: boolean | undefined,
pushPermissionCheckStatus: 'allowed' | 'denied' | 'unknown' | undefined,
+ currentMode?: SyncMode,
): boolean {
if (!projectLocalSynced) return true;
+ // A following (pull-only) project never pushes, so a denied push probe is
+ // irrelevant to it — the toggle (whose only action is turning sync off) stays
+ // enabled. A denied probe still disables an off/full project, where enabling
+ // reaches the push-requiring full mode.
+ if (currentMode === 'follow') return false;
if (pushPermissionCheckStatus === 'denied') return true;
return false;
}
-function tooltipLabel(status: GitSyncStatus): string {
- if (!status.syncEnabled) return t`Sync off`;
- if (status.state === 'idle') {
+export function tooltipLabel(status: GitSyncStatus, paused = false): string {
+ if (paused) return t`Sync paused`;
+ const following = isFollowingMode(status);
+ // A following project is always on, so it never reads as "Sync off" — that
+ // label is reserved for a genuinely disabled (mode off) project.
+ if (!status.syncEnabled && !following) return t`Sync off`;
+ const state = displayState(status);
+ if (state === 'conflict' && status.conflictCount > 0) {
+ const { conflictCount } = status;
+ return plural(conflictCount, { one: '# conflict', other: '# conflicts' });
+ }
+ if (state === 'idle') {
const { ahead, behind } = status;
+ if (following) {
+ // "ahead" is not actionable for a project that never pushes; only how far
+ // behind upstream it is (before a pull completes) is worth surfacing.
+ if (behind > 0) return t`${behind} behind`;
+ return t`Up to date`;
+ }
if (ahead > 0 && behind > 0) {
return t`${ahead} ahead, ${behind} behind`;
}
@@ -410,11 +474,7 @@ function tooltipLabel(status: GitSyncStatus): string {
if (behind > 0) return t`${behind} behind`;
return t`Synced`;
}
- if (status.state === 'conflict' && status.conflictCount > 0) {
- const { conflictCount } = status;
- return plural(conflictCount, { one: '# conflict', other: '# conflicts' });
- }
- return stateLabel(status.state);
+ return stateLabel(state, following);
}
interface PopoverBodyProps {
@@ -425,112 +485,163 @@ interface PopoverBodyProps {
function PopoverBody({ status, onSignIn, onSetIdentity }: PopoverBodyProps) {
const { t } = useLingui();
- const { ahead, behind, conflictCount } = status;
+ const { behind, conflictCount } = status;
const { projectLocalConfig, projectLocalSynced } = useConfigContext();
- const enabled = projectLocalConfig?.autoSync?.enabled ?? false;
+ const autoSync = projectLocalConfig?.autoSync;
+ const {
+ active,
+ paused,
+ everEnabled,
+ toggleMode,
+ confirmOpen,
+ setConfirmOpen,
+ pendingMode,
+ strandedCommitCount,
+ onToggleActive,
+ onModeSelect,
+ onConfirm,
+ } = useBadgeSyncControls(autoSync, status.ahead);
+ const localMode = resolveLocalAutoSyncMode(autoSync) ?? 'off';
+ // `following` (status-driven) is the ACTIVE pull state; the paused branch is
+ // config-driven since the engine reports a paused project as disabled.
+ const following = isFollowingMode(status);
+ const state = displayState(status);
const lastSyncedRelative = formatRelative(status.lastSyncUtc);
- const writer = useSyncEnabledWriter();
- const { confirmOpen, setConfirmOpen, onToggleRequest, onConfirm } =
- useEnableSyncWithConfirm(writer);
+ // The Full/Follow control appears only for a user who can actually choose —
+ // a genuine read-only collaborator can only follow, so it's hidden for them
+ // (a signed-out user may gain push after auth, so they keep it).
+ const genuineReadOnly =
+ status.pushPermission?.checkStatus === 'denied' &&
+ status.pushPermission.deniedReason !== 'not-authenticated';
+ const canChooseMode = !genuineReadOnly;
// The "Review conflicts" affordance navigates to the first conflicted file
// (so the editor-area DiffViewBoundary mounts via the lifecycle observer).
- // There is no side-sheet to open — the sidebar Conflicts section is the
- // project-level list, the editor-area DiffView is the resolution surface.
const { conflicts } = useConflicts();
const firstConflict = conflicts[0] ?? null;
+ const showConflictButton = !paused && state === 'conflict' && firstConflict !== null;
+ const showAuthButton = !paused && state === 'auth-error';
+ const showSyncButton =
+ everEnabled && !showConflictButton && !showAuthButton && (paused || state !== 'dormant');
+ // A manual sync from a full-active project pushes too; every other case
+ // (following, or paused) pulls only.
+ const manualSyncOp: 'sync' | 'pull' = active && localMode === 'full' ? 'sync' : 'pull';
+
return (
- ))}
- {shouldOfferReconnect(status.pushPermission) ? (
- // Signed-out denial (no credential resolved) — reconnecting resumes
- // sync, so offer it here. Takes precedence over the button-less
- // `pausedReason`/`denied` branches below, which is exactly the stuck
- // state (sync was enabled, then the credential went away). The genuine
- // read-only-collaborator denial keeps the button-less copy.
-
-
- You're signed out — sign in to resume syncing.
-
- ) : shouldOfferSignInAgain(status.pushPermission) ? (
- // Probe-401 branch: surface a "Sign in again" affordance without
- // disabling sync — the probe couldn't reach a verdict, so the user's
- // existing sync preference is preserved while they re-authenticate.
- // The button reuses `onSignIn`, the same handler wired for the
- // `auth-error` state below.
-
-
- Your GitHub session expired — sign in again to verify push access.
-
- Sync paused — resolve conflicts to resume.
+ {following ? (
+ // A follower keeps fast-forwarding the rest of the repo while a single
+ // document waits on resolution, so it is never globally "paused".
+ A document has a conflict — resolve it to keep it up to date.
+ ) : (
+ Sync paused — resolve conflicts to resume.
+ )}
+ {showSyncButton && (
+ // One label for every mode ("Sync"); the op pushes only when full
+ // and active, and pulls otherwise (following, or a paused one-shot).
+
+ )}
+ {showAuthButton && (
+
+ )}
+ {showConflictButton && firstConflict && (
+
+ )}
+
+
+ )}
);
}
@@ -649,6 +783,17 @@ interface SyncStatusBadgeProps {
export function SyncStatusBadge({ onSignIn, onSetIdentity }: SyncStatusBadgeProps = {}) {
const { t } = useLingui();
const { status, fetchError } = useGitSyncStatusDetailed();
+ const { projectLocalConfig } = useConfigContext();
+ // Paused = the user turned off a previously-enabled project. It's config-only
+ // (the engine reports it as disabled/dormant), so the badge reads it here to
+ // stay visible and render the paused icon/label.
+ const paused = isSyncPaused(projectLocalConfig?.autoSync);
+ // The local config is the source of truth for user intent; the server status
+ // lags a resume by a beat. When the config already resolves to an active mode
+ // (follow/full) but the engine still reports the pre-resume `disabled`, keep
+ // the badge visible so the resume doesn't unmount it — unmounting closes the
+ // popover and flickers the icon until the status catches up.
+ const localWantsSync = isSyncActiveMode(resolveLocalAutoSyncMode(projectLocalConfig?.autoSync));
// Surface a lightweight connectivity warning when the server has been
// reachable before (we have a prior status) but the last refresh failed.
@@ -692,11 +837,24 @@ export function SyncStatusBadge({ onSignIn, onSetIdentity }: SyncStatusBadgeProp
// protected-branch) so the user can see *why* sync stopped — without it,
// the only signal would be a missing badge. Manual disable clears
// `pausedReason`; auto-disable sets it. (Unsafe states like auth-error /
- // conflict / offline already render — they need attention.)
- if (status.state === 'disabled' && !status.pausedReason) return null;
+ // conflict / offline already render — they need attention.) A following
+ // (pull-only) project is always on, so it must always show its state.
+ // A paused project (config: was enabled, now off) stays visible so the user
+ // isn't stranded in Settings to resume — the engine reports it as disabled.
+ if (
+ status.state === 'disabled' &&
+ !status.pausedReason &&
+ status.syncMode !== 'follow' &&
+ !paused &&
+ !localWantsSync
+ ) {
+ return null;
+ }
- const label = badgeLabel(status);
- const syncStateLabel = stateLabel(status.state);
+ const label = paused ? '' : badgeLabel(status);
+ const syncStateLabel = paused
+ ? t`Sync paused`
+ : stateLabel(displayState(status), isFollowingMode(status));
const showIdentityDot = Boolean(status.identityUnresolved);
return (
@@ -714,7 +872,7 @@ export function SyncStatusBadge({ onSignIn, onSetIdentity }: SyncStatusBadgeProp
: t`Sync status: ${syncStateLabel}`
}
>
-
+
{label && (
{label}
@@ -729,9 +887,9 @@ export function SyncStatusBadge({ onSignIn, onSetIdentity }: SyncStatusBadgeProp
- {tooltipLabel(status)}
+ {tooltipLabel(status, paused)}
-
+
diff --git a/packages/app/src/components/auto-sync-onboarding-gate.test.ts b/packages/app/src/components/auto-sync-onboarding-gate.test.ts
index 54b75f8c9..160cfefec 100644
--- a/packages/app/src/components/auto-sync-onboarding-gate.test.ts
+++ b/packages/app/src/components/auto-sync-onboarding-gate.test.ts
@@ -1,110 +1,122 @@
import { describe, expect, test } from 'vitest';
import {
type AutoSyncOnboardingGateInputs,
- shouldShowAutoSyncOnboarding,
+ resolveAutoSyncOnboarding,
} from './auto-sync-onboarding-gate.ts';
-// Baseline = every condition aligned so the modal SHOWS. Each test flips one
-// input and asserts the gate's response, keeping every condition on its own
-// independently verifiable row.
+// Baseline = every condition aligned so the modal SHOWS the full-sync variant
+// (probe allowed). Each test flips one input and asserts the gate's response,
+// keeping every condition on its own independently verifiable row.
const SHOWING: AutoSyncOnboardingGateInputs = {
autoSyncOnboardingDismissed: false,
hasRemote: true,
projectLocalSynced: true,
projectSynced: true,
- projectLocalConfig: { autoSync: { enabled: null } },
+ projectLocalConfig: { autoSync: { mode: null, enabled: null } },
projectConfig: { autoSync: { default: null } },
pushPermissionCheckStatus: 'allowed',
};
-describe('shouldShowAutoSyncOnboarding', () => {
- test('shows when every condition is aligned (unanswered machine, no committed default)', () => {
- expect(shouldShowAutoSyncOnboarding(SHOWING)).toBe(true);
+describe('resolveAutoSyncOnboarding', () => {
+ test('shows the full-sync variant when aligned and the probe allows pushing', () => {
+ expect(resolveAutoSyncOnboarding(SHOWING)).toBe('full');
});
test('hidden once dismissed this session', () => {
- expect(shouldShowAutoSyncOnboarding({ ...SHOWING, autoSyncOnboardingDismissed: true })).toBe(
- false,
- );
+ expect(resolveAutoSyncOnboarding({ ...SHOWING, autoSyncOnboardingDismissed: true })).toBeNull();
});
test('hidden without a git remote', () => {
- expect(shouldShowAutoSyncOnboarding({ ...SHOWING, hasRemote: false })).toBe(false);
- expect(shouldShowAutoSyncOnboarding({ ...SHOWING, hasRemote: undefined })).toBe(false);
+ expect(resolveAutoSyncOnboarding({ ...SHOWING, hasRemote: false })).toBeNull();
+ expect(resolveAutoSyncOnboarding({ ...SHOWING, hasRemote: undefined })).toBeNull();
});
test('hidden until the project-local binding has synced (flash-free)', () => {
- expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectLocalSynced: false })).toBe(false);
- expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectLocalSynced: undefined })).toBe(false);
+ expect(resolveAutoSyncOnboarding({ ...SHOWING, projectLocalSynced: false })).toBeNull();
+ expect(resolveAutoSyncOnboarding({ ...SHOWING, projectLocalSynced: undefined })).toBeNull();
});
test('hidden until the committed project binding has synced (flash-free)', () => {
- // Without the projectSynced guard, a project shipping default:false would
- // briefly read the schema default (null) and flash the modal open.
- expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectSynced: false })).toBe(false);
- expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectSynced: undefined })).toBe(false);
+ // Without the projectSynced guard, a project shipping a committed default
+ // would briefly read the schema default (null) and flash the modal open.
+ expect(resolveAutoSyncOnboarding({ ...SHOWING, projectSynced: false })).toBeNull();
+ expect(resolveAutoSyncOnboarding({ ...SHOWING, projectSynced: undefined })).toBeNull();
});
test('hidden until project-local config hydrates', () => {
- expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectLocalConfig: null })).toBe(false);
+ expect(resolveAutoSyncOnboarding({ ...SHOWING, projectLocalConfig: null })).toBeNull();
+ });
+
+ test('hidden once this machine has answered via mode (off/pull/full)', () => {
+ for (const mode of ['off', 'follow', 'full'] as const) {
+ expect(
+ resolveAutoSyncOnboarding({
+ ...SHOWING,
+ projectLocalConfig: { autoSync: { mode } },
+ }),
+ ).toBeNull();
+ }
});
- test('hidden once this machine has answered (enabled true or false)', () => {
+ test('hidden once this machine has answered via the legacy enabled boolean', () => {
expect(
- shouldShowAutoSyncOnboarding({
+ resolveAutoSyncOnboarding({
...SHOWING,
projectLocalConfig: { autoSync: { enabled: true } },
}),
- ).toBe(false);
+ ).toBeNull();
expect(
- shouldShowAutoSyncOnboarding({
+ resolveAutoSyncOnboarding({
...SHOWING,
projectLocalConfig: { autoSync: { enabled: false } },
}),
- ).toBe(false);
+ ).toBeNull();
});
- test('suppressed when the maintainer committed autoSync.default: false', () => {
+ test('suppressed when the maintainer committed a mode-valued default', () => {
+ for (const def of ['off', 'follow', 'full'] as const) {
+ expect(
+ resolveAutoSyncOnboarding({
+ ...SHOWING,
+ projectConfig: { autoSync: { default: def } },
+ }),
+ ).toBeNull();
+ }
+ });
+
+ test('suppressed when the maintainer committed a legacy boolean default', () => {
expect(
- shouldShowAutoSyncOnboarding({
+ resolveAutoSyncOnboarding({
...SHOWING,
projectConfig: { autoSync: { default: false } },
}),
- ).toBe(false);
- });
-
- test('suppressed when the maintainer committed autoSync.default: true', () => {
+ ).toBeNull();
expect(
- shouldShowAutoSyncOnboarding({
+ resolveAutoSyncOnboarding({
...SHOWING,
projectConfig: { autoSync: { default: true } },
}),
- ).toBe(false);
+ ).toBeNull();
});
test('still asks when committed config is absent or default is null/absent', () => {
- // projectConfig === null (committed doc empty) → no seed → ask.
- expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectConfig: null })).toBe(true);
- // autoSync present but default absent → no seed → ask.
- expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectConfig: { autoSync: {} } })).toBe(
- true,
- );
- // autoSync absent entirely → no seed → ask.
- expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectConfig: {} })).toBe(true);
+ expect(resolveAutoSyncOnboarding({ ...SHOWING, projectConfig: null })).toBe('full');
+ expect(resolveAutoSyncOnboarding({ ...SHOWING, projectConfig: { autoSync: {} } })).toBe('full');
+ expect(resolveAutoSyncOnboarding({ ...SHOWING, projectConfig: {} })).toBe('full');
});
- test('hidden when the push-permission probe denied or is still pending', () => {
- expect(shouldShowAutoSyncOnboarding({ ...SHOWING, pushPermissionCheckStatus: 'denied' })).toBe(
- false,
- );
- expect(shouldShowAutoSyncOnboarding({ ...SHOWING, pushPermissionCheckStatus: undefined })).toBe(
- false,
+ test('forks to the follow variant when the push probe is denied', () => {
+ expect(resolveAutoSyncOnboarding({ ...SHOWING, pushPermissionCheckStatus: 'denied' })).toBe(
+ 'follow',
);
});
- test('shows on probe unknown (graceful degradation)', () => {
- expect(shouldShowAutoSyncOnboarding({ ...SHOWING, pushPermissionCheckStatus: 'unknown' })).toBe(
- true,
- );
+ test('suppressed while the push probe is unknown or still pending', () => {
+ expect(
+ resolveAutoSyncOnboarding({ ...SHOWING, pushPermissionCheckStatus: 'unknown' }),
+ ).toBeNull();
+ expect(
+ resolveAutoSyncOnboarding({ ...SHOWING, pushPermissionCheckStatus: undefined }),
+ ).toBeNull();
});
});
diff --git a/packages/app/src/components/auto-sync-onboarding-gate.ts b/packages/app/src/components/auto-sync-onboarding-gate.ts
index 0a0a4dd10..52ba550a7 100644
--- a/packages/app/src/components/auto-sync-onboarding-gate.ts
+++ b/packages/app/src/components/auto-sync-onboarding-gate.ts
@@ -1,7 +1,8 @@
/**
* Pure decision function for the AutoSync onboarding modal.
*
- * The dialog opens once per machine per project when:
+ * Returns which prompt variant to show, or `null` to stay hidden. The dialog
+ * opens once per machine per project when every gate condition aligns:
* 1. The user has not yet dismissed it this session.
* 2. A git remote exists for the project (`hasRemote === true`).
* 3. The project-local CRDT binding has synced from disk
@@ -11,29 +12,39 @@
* 4. The committed project binding has synced (`projectSynced === true`) —
* the same flash-free guard for the committed `autoSync.default` read in
* condition 7. Until the committed doc lands, `default` reads as the
- * schema default (`null`), so a project that ships `default: false` would
- * flash the modal open and then close once the real value arrives.
+ * schema default (`null`), so a project that ships a default would flash
+ * the modal open and then close once the real value arrives.
* 5. The local config is hydrated (`projectLocalConfig !== null`).
- * 6. The user hasn't answered the autoSync prompt yet
- * (`autoSync.enabled === null`). The schema's `nullable().default(null)`
- * makes `null` the canonical "unanswered" sentinel — never `undefined`.
+ * 6. This machine hasn't answered yet — the resolved local mode is `null`.
+ * `resolveLocalAutoSyncMode` reads `autoSync.mode` and falls back to the
+ * legacy `autoSync.enabled` boolean, so a machine that answered under
+ * either shape is treated as answered.
* 7. The maintainer has NOT committed an `autoSync.default` seed
- * (`projectConfig.autoSync.default` is `null`/absent). A committed `true`
- * or `false` pre-answers the prompt for everyone who clones the project,
- * so the modal is suppressed; only a null/absent default still asks.
- * 8. The push-permission probe HAS resolved AND did not return `'denied'`.
- * The gate requires the probe to settle first — `undefined` (probe
- * still pending) keeps the dialog hidden so we don't flash it open
- * and then close it the moment the probe returns `denied` (the
- * common case on share-linked clones of someone else's repo).
- * `'unknown'` (probe failed) still passes — graceful degradation
- * preserves the read+write user's onboarding ask when the probe
- * can't reach a verdict.
+ * (`modeFromCommittedDefault(default)` is `null`). A committed default
+ * (off/pull/full, or the legacy boolean) pre-answers the prompt for
+ * everyone who clones the project, so the modal is suppressed; only a
+ * null/absent default still asks.
+ * 8. The push-permission probe HAS resolved to a forking verdict. `allowed`
+ * returns the full-sync variant; `denied` (incl. the anonymous no-network
+ * short-circuit, the common case on share-linked clones) returns the
+ * pull-only variant. `unknown` (probe failed) and `undefined` (probe
+ * pending) both suppress: showing a full-sync prompt to a maybe-denied
+ * user promises pushes we can't keep, and Settings stays available for a
+ * later opt-in. The pending suppression is also the flash-free guard.
*
* Extracted from EditorPane into a pure function so each input contributes
* to an independently testable truth table. The cheapest checks come first
* to short-circuit before the more expensive reads.
*/
+import {
+ modeFromCommittedDefault,
+ resolveLocalAutoSyncMode,
+ type StoredSyncMode,
+} from '@inkeep/open-knowledge-core';
+
+/** Which onboarding prompt to show; `follow` explains one-directional sync. */
+export type AutoSyncOnboardingVariant = 'full' | 'follow';
+
export interface AutoSyncOnboardingGateInputs {
/** Local React state — has the user already dismissed this session? */
autoSyncOnboardingDismissed: boolean;
@@ -44,32 +55,34 @@ export interface AutoSyncOnboardingGateInputs {
/** CRDT lifecycle: has the committed project config doc finished its first sync? */
projectSynced: boolean | undefined;
/** Project-local config — null until the binding hydrates. */
- projectLocalConfig: { autoSync?: { enabled: boolean | null } | null } | null;
+ projectLocalConfig: {
+ autoSync?: { mode?: StoredSyncMode | null; enabled?: boolean | null } | null;
+ } | null;
/** Committed project config — carries the maintainer's autoSync.default seed. */
- projectConfig: { autoSync?: { default?: boolean | null } | null } | null;
+ projectConfig: { autoSync?: { default?: boolean | StoredSyncMode | null } | null } | null;
/** Push-permission probe outcome. */
pushPermissionCheckStatus: 'allowed' | 'denied' | 'unknown' | undefined;
}
-export function shouldShowAutoSyncOnboarding(inputs: AutoSyncOnboardingGateInputs): boolean {
- return (
+export function resolveAutoSyncOnboarding(
+ inputs: AutoSyncOnboardingGateInputs,
+): AutoSyncOnboardingVariant | null {
+ const aligned =
!inputs.autoSyncOnboardingDismissed &&
inputs.hasRemote === true &&
inputs.projectLocalSynced === true &&
inputs.projectSynced === true &&
inputs.projectLocalConfig !== null &&
- inputs.projectLocalConfig.autoSync?.enabled === null &&
- // A committed autoSync.default (true OR false) pre-answers the prompt for
- // everyone who clones the project — suppress the modal whenever the
- // maintainer set one; only a null/absent default falls through to the ask.
- // Gated on projectSynced above so this compares against the real committed
- // value, not the schema default during the cold-start sync window.
- (inputs.projectConfig?.autoSync?.default ?? null) === null &&
- // Probe must have resolved AND not be 'denied' — `undefined` (pending)
- // would flash the dialog and then close on the probe's `denied` return.
- // `'unknown'` passes for graceful degradation when the probe can't
- // reach a verdict (network failure, rate-limit, etc.).
- (inputs.pushPermissionCheckStatus === 'allowed' ||
- inputs.pushPermissionCheckStatus === 'unknown')
- );
+ resolveLocalAutoSyncMode(inputs.projectLocalConfig.autoSync ?? undefined) === null &&
+ modeFromCommittedDefault(inputs.projectConfig?.autoSync?.default) === null;
+ if (!aligned) return null;
+
+ switch (inputs.pushPermissionCheckStatus) {
+ case 'allowed':
+ return 'full';
+ case 'denied':
+ return 'follow';
+ default:
+ return null;
+ }
}
diff --git a/packages/app/src/components/bottom-composer-gate.ts b/packages/app/src/components/bottom-composer-gate.ts
index a9acdf69d..4c400c665 100644
--- a/packages/app/src/components/bottom-composer-gate.ts
+++ b/packages/app/src/components/bottom-composer-gate.ts
@@ -23,7 +23,7 @@
*
* Extracted from EditorArea into a pure function so each input contributes to
* an independently testable truth table — mirrors `shouldPaintOverlay` and
- * `shouldShowAutoSyncOnboarding`.
+ * `resolveAutoSyncOnboarding`.
*/
export interface BottomComposerGateInputs {
/** Whether the docked terminal is currently visible. */
diff --git a/packages/app/src/components/settings/SettingsDialogBody.sections.dom.test.tsx b/packages/app/src/components/settings/SettingsDialogBody.sections.dom.test.tsx
index 30bdd11f8..421f7d828 100644
--- a/packages/app/src/components/settings/SettingsDialogBody.sections.dom.test.tsx
+++ b/packages/app/src/components/settings/SettingsDialogBody.sections.dom.test.tsx
@@ -13,11 +13,15 @@ type SyncStatus = {
unknownError?: string;
};
syncEnabled?: boolean;
+ syncMode?: 'off' | 'follow' | 'full';
+ ahead?: number;
remote?: { label: string; webUrl: string | null } | null;
} | null;
let syncStatus: SyncStatus = null;
-let projectLocalConfig: { autoSync?: { enabled?: boolean } } | null = {
+let projectLocalConfig: {
+ autoSync?: { enabled?: boolean; mode?: 'off' | 'follow' | 'full' };
+} | null = {
autoSync: { enabled: true },
};
let projectLocalSynced = true;
@@ -33,8 +37,9 @@ let projectBinding: {
patch: (patch: unknown) => { ok: true } | { ok: false; error: unknown };
} | null = null;
let projectBindingPatchCalls: unknown[] = [];
-let syncWriterCalls: boolean[] = [];
-let syncDefaultWriterCalls: Array = [];
+let syncModeWriterCalls: string[] = [];
+let syncModeSelectCalls: string[] = [];
+let syncDefaultWriterCalls: Array = [];
let okignoreProps: Array<{ binding: unknown; synced: boolean }> = [];
let installDialogProps: Array<{
open: boolean;
@@ -264,36 +269,42 @@ vi.doMock('@/lib/config-provider', () => ({
}),
}));
+type ModeWriterFn = (mode: string) => { ok: true };
vi.doMock('@/hooks/use-enable-sync-with-confirm', () => ({
- useSyncEnabledWriter: () => ({
- write: (enabled: boolean) => {
- syncWriterCalls.push(enabled);
- return true;
- },
- }),
- useSyncDefaultWriter: () => (next: boolean | null) => {
+ useSyncModeWriter: (): ModeWriterFn => (mode: string) => {
+ syncModeWriterCalls.push(mode);
+ return { ok: true };
+ },
+ useSyncDefaultWriter: () => (next: boolean | string | null) => {
syncDefaultWriterCalls.push(next);
return { ok: true };
},
- useEnableSyncWithConfirm: (writer: { write: (enabled: boolean) => boolean }) => {
+ // Thin recorder mirroring the real hook's gating so the section's wiring is
+ // exercised (deep confirm-flow behavior is covered against the real hook in
+ // SettingsDialogBody.sync-mode.dom.test.tsx and the hook's own test).
+ useSyncModeSelection: (writer: ModeWriterFn, currentMode: string) => {
const [confirmOpen, setConfirmOpen] = useState(false);
+ const [pendingMode, setPendingMode] = useState<'follow' | 'full' | null>(null);
return {
confirmOpen,
setConfirmOpen,
- onToggleRequest: (enabled: boolean) => {
- if (enabled) {
- setConfirmOpen(true);
+ pendingMode,
+ onModeSelect: (next: string) => {
+ syncModeSelectCalls.push(next);
+ if (next === currentMode) return;
+ if (next === 'off') {
+ writer('off');
return;
}
- writer.write(false);
+ setPendingMode(next as 'follow' | 'full');
+ setConfirmOpen(true);
},
onConfirm: () => {
- writer.write(true);
+ if (pendingMode) writer(pendingMode);
setConfirmOpen(false);
},
};
},
- EnableSyncConfirmDialog: () => null,
}));
vi.doMock('@/components/EnableSyncConfirmDialog', () => ({
@@ -351,7 +362,8 @@ describe('SettingsDialogBody section runtime dispatch', () => {
return { ok: true };
},
};
- syncWriterCalls = [];
+ syncModeWriterCalls = [];
+ syncModeSelectCalls = [];
syncDefaultWriterCalls = [];
okignoreProps = [];
installDialogProps = [];
@@ -518,22 +530,24 @@ describe('SettingsDialogBody section runtime dispatch', () => {
);
});
- test('sync section reads checked state from project-local config and keeps the writer/confirm path', async () => {
+ test('sync section reflects the resolved mode and wires the three-way control', async () => {
syncStatus = {
- state: 'enabled',
+ state: 'idle',
hasRemote: true,
- syncEnabled: false,
+ syncEnabled: true,
+ syncMode: 'full',
remote: {
label: 'inkeep/open-knowledge',
webUrl: 'https://github.com/inkeep/open-knowledge',
},
};
+ // Legacy `enabled: true` derives to full mode.
projectLocalConfig = { autoSync: { enabled: true } };
await renderBody({ activeId: 'sync' });
- const toggle = screen.getByTestId('settings-sync-toggle');
- expect(toggle.getAttribute('aria-checked')).toBe('true');
+ const modeToggle = screen.getByTestId('settings-sync-mode-toggle');
+ expect(modeToggle.getAttribute('data-value')).toBe('full');
expect(screen.getByTestId('settings-sync-remote-link').getAttribute('href')).toBe(
'https://github.com/inkeep/open-knowledge',
);
@@ -541,23 +555,32 @@ describe('SettingsDialogBody section runtime dispatch', () => {
'noopener noreferrer',
);
- fireEvent.click(toggle);
- expect(syncWriterCalls).toEqual([false]);
+ // Selecting Off is the safe direction — commits immediately, no confirm.
+ fireEvent.click(screen.getByTestId('settings-sync-mode-off'));
+ expect(syncModeSelectCalls).toEqual(['off']);
+ expect(syncModeWriterCalls).toEqual(['off']);
cleanup();
syncStatus = {
- state: 'enabled',
+ state: 'idle',
hasRemote: true,
syncEnabled: true,
+ syncMode: 'follow',
remote: { label: 'ssh://git.example/repo.git', webUrl: null },
};
- projectLocalConfig = { autoSync: { enabled: false } };
+ projectLocalConfig = { autoSync: { mode: 'follow' } };
projectLocalSynced = false;
await renderBody({ activeId: 'sync' });
- expect(screen.getByTestId('settings-sync-toggle').getAttribute('aria-checked')).toBe('false');
- expect(screen.getByTestId('settings-sync-toggle').hasAttribute('disabled')).toBe(true);
+ // Explicit mode wins; the control is disabled until the project-local config
+ // has hydrated (cold-start guard).
+ expect(screen.getByTestId('settings-sync-mode-toggle').getAttribute('data-value')).toBe(
+ 'follow',
+ );
+ expect(screen.getByTestId('settings-sync-mode-toggle').getAttribute('data-disabled')).toBe(
+ 'true',
+ );
expect(screen.getByTestId('settings-sync-remote-label').textContent).toBe(
'ssh://git.example/repo.git',
);
@@ -584,13 +607,17 @@ describe('SettingsDialogBody section runtime dispatch', () => {
'off',
);
- // "On by default" writes the committed seed `true`.
- fireEvent.click(screen.getByTestId('settings-sync-default-on'));
+ // "Full by default" writes the legacy boolean seed `true` (older builds honor it).
+ fireEvent.click(screen.getByTestId('settings-sync-default-full'));
expect(syncDefaultWriterCalls).toEqual([true]);
- // "Ask each person" clears the committed seed (writes null → RFC 7396 delete).
+ // "Pull-only by default" writes the widened mode string.
+ fireEvent.click(screen.getByTestId('settings-sync-default-follow'));
+ expect(syncDefaultWriterCalls).toEqual([true, 'follow']);
+
+ // "None" clears the committed seed (writes null → RFC 7396 delete).
fireEvent.click(screen.getByTestId('settings-sync-default-ask'));
- expect(syncDefaultWriterCalls).toEqual([true, null]);
+ expect(syncDefaultWriterCalls).toEqual([true, 'follow', null]);
});
test('committed default control is disabled until the committed config has synced', async () => {
@@ -616,31 +643,60 @@ describe('SettingsDialogBody section runtime dispatch', () => {
);
});
- test('sync section disables the toggle with denied-specific accessible copy when push permission is denied', async () => {
+ test('sync section keeps the mode control enabled and points a denied receiver at pull-only', async () => {
syncStatus = {
state: 'idle',
hasRemote: true,
syncEnabled: false,
+ syncMode: 'off',
pushPermission: { checkStatus: 'denied', deniedReason: 'no-collaborator' },
remote: {
label: 'inkeep/open-knowledge',
webUrl: 'https://github.com/inkeep/open-knowledge',
},
};
- projectLocalConfig = { autoSync: { enabled: false } };
+ projectLocalConfig = { autoSync: { mode: 'off' } };
projectLocalSynced = true;
await renderBody({ activeId: 'sync' });
- const toggle = screen.getByTestId('settings-sync-toggle') as HTMLButtonElement;
- expect(toggle.disabled).toBe(true);
- expect(toggle.getAttribute('aria-label')).toBe(
- "Sync disabled — you don't have permission to push",
+ // A denied probe no longer disables the control — pull-only never pushes, so
+ // the receiver must be able to reach it.
+ expect(screen.getByTestId('settings-sync-mode-toggle').getAttribute('data-disabled')).toBe(
+ 'false',
);
- expect(screen.getByTestId('settings-sync-body').textContent).toContain(
- "you don't have permission to push",
+ expect(screen.getByTestId('settings-sync-denied-hint').textContent).toContain(
+ "You don't have permission to push",
);
- expect(screen.queryByTestId('settings-sync-reason')).toBeNull();
+ // Off mode is not paused; the switch-to-pull affordance is full-only.
+ expect(screen.queryByTestId('settings-sync-switch-follow')).toBeNull();
+ });
+
+ test('sync section offers Switch to pull-only when full sync is paused on a denied push probe', async () => {
+ syncStatus = {
+ state: 'disabled',
+ hasRemote: true,
+ syncEnabled: true,
+ syncMode: 'full',
+ pausedReason: 'no-push-permission',
+ ahead: 2,
+ remote: {
+ label: 'inkeep/open-knowledge',
+ webUrl: 'https://github.com/inkeep/open-knowledge',
+ },
+ };
+ projectLocalConfig = { autoSync: { mode: 'full' } };
+ projectLocalSynced = true;
+
+ await renderBody({ activeId: 'sync' });
+
+ expect(screen.getByTestId('settings-sync-switch-follow')).not.toBeNull();
+ // The action routes through the mode selector toward pull-only.
+ fireEvent.click(screen.getByTestId('settings-sync-switch-follow-action'));
+ expect(syncModeSelectCalls).toEqual(['follow']);
+ // Opening the confirm, not an immediate write (consent gate).
+ expect(syncModeWriterCalls).toEqual([]);
+ expect(screen.getByTestId('sync-confirm-dialog').getAttribute('data-open')).toBe('true');
});
test('sync section renders shared paused-reason copy for non-permission pause reasons', async () => {
diff --git a/packages/app/src/components/settings/SettingsDialogBody.sync-mode.dom.test.tsx b/packages/app/src/components/settings/SettingsDialogBody.sync-mode.dom.test.tsx
new file mode 100644
index 000000000..e13b44fac
--- /dev/null
+++ b/packages/app/src/components/settings/SettingsDialogBody.sync-mode.dom.test.tsx
@@ -0,0 +1,330 @@
+/**
+ * Integration coverage for the three-way sync-mode control in the Settings Sync
+ * section. Unlike the sibling sections test (which stubs the sync hooks), this
+ * renders the REAL `useSyncModeSelection` / `useSyncModeWriter` hooks and the
+ * REAL `EnableSyncConfirmDialog`, mocking only the config-binding boundary, the
+ * status feed, and leaf UI primitives. It proves the actual wiring: select a
+ * mode -> the consent dialog opens with the right copy -> confirming patches
+ * `autoSync.mode` on the project-local binding.
+ */
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { createContext, type ReactNode, use } from 'react';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import { renderLinguiTemplate } from '@/test-utils/lingui-mock';
+
+type SyncStatus = {
+ state: string;
+ hasRemote: boolean;
+ pausedReason?: string;
+ pushPermission?: { checkStatus: 'allowed' | 'denied' | 'unknown'; deniedReason?: string };
+ syncEnabled?: boolean;
+ syncMode?: 'off' | 'follow' | 'full';
+ ahead?: number;
+ remote?: { label: string; webUrl: string | null } | null;
+} | null;
+
+let syncStatus: SyncStatus = null;
+let projectLocalConfig: {
+ autoSync?: { enabled?: boolean; mode?: 'off' | 'follow' | 'full' };
+} | null = null;
+let projectConfig: {
+ autoSync?: { default?: boolean | string | null };
+ content: { attachmentFolderPath: string };
+} | null = null;
+let projectLocalSynced = true;
+let projectSynced = true;
+let localPatchCalls: unknown[] = [];
+const projectLocalBinding: {
+ patch: (patch: unknown) => { ok: true } | { ok: false; error: unknown };
+} = {
+ patch: (patch: unknown) => {
+ localPatchCalls.push(patch);
+ return { ok: true };
+ },
+};
+
+import * as actualLinguiMacro from '@lingui/react/macro';
+
+vi.doMock('@lingui/react/macro', () => ({
+ ...actualLinguiMacro,
+ Plural: ({ value, one, other }: { value: number; one: string; other: string }) => (
+ <>{(value === 1 ? one : other).replace('#', String(value))}>
+ ),
+ Trans: ({ children }: { children?: ReactNode }) => <>{children}>,
+ useLingui: () => ({ t: renderLinguiTemplate }),
+}));
+
+vi.doMock('@lingui/core/macro', () => ({
+ ...actualLinguiMacro,
+ msg: renderLinguiTemplate,
+ plural: (value: number, options: { one: string; other: string }) =>
+ (value === 1 ? options.one : options.other).replace('#', String(value)),
+ t: renderLinguiTemplate,
+}));
+
+const toastErrors: string[] = [];
+vi.doMock('sonner', () => ({ toast: { error: (m: string) => toastErrors.push(m) } }));
+
+vi.doMock('@/components/ui/button', () => ({
+ Button: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
+
+ ),
+}));
+
+vi.doMock('@/components/ui/collapsible', () => ({
+ Collapsible: ({ children }: { children?: ReactNode }) =>
+ ),
+ TooltipTrigger: ({ children }: { children?: ReactNode }) => <>{children}>,
+}));
+
+vi.doMock('@/components/PublishToGitHubDialog', () => ({
+ PublishToGitHubDialog: () => null,
+}));
+vi.doMock('@/components/AuthModal', () => ({ AuthModal: () => null }));
+vi.doMock('@/components/InstallInClaudeDesktopDialog', () => ({
+ InstallInClaudeDesktopDialog: () => null,
+}));
+vi.doMock('./OkignoreSection', () => ({ OkignoreSection: () => null }));
+vi.doMock('./ProjectTemplatesSection', () => ({ ProjectTemplatesSection: () => null }));
+
+vi.doMock('@/hooks/use-git-sync-status', () => ({
+ useGitSyncStatus: () => syncStatus,
+ useGitSyncStatusDetailed: () => ({ status: syncStatus, fetchError: null }),
+}));
+
+vi.doMock('@/lib/config-provider', () => ({
+ useConfigContext: () => ({
+ projectBinding: projectLocalBinding,
+ projectConfig,
+ projectLocalConfig,
+ projectLocalBinding,
+ projectLocalSynced,
+ projectSynced,
+ }),
+}));
+
+// Import the component AFTER the doMock calls so it picks up the mocked deps
+// while keeping the REAL sync hooks + REAL EnableSyncConfirmDialog. RTL itself
+// depends on none of the mocked modules, so it stays a static top-level import
+// (the Tier-3 filename contract requires it).
+async function renderSyncSection() {
+ const { SettingsDialogBody } = await import('./SettingsDialogBody');
+ render(
+ ,
+ );
+}
+
+describe('Settings Sync section — three-way mode control (real hooks + dialog)', () => {
+ beforeEach(() => {
+ cleanup();
+ syncStatus = {
+ state: 'idle',
+ hasRemote: true,
+ syncEnabled: false,
+ syncMode: 'off',
+ ahead: 0,
+ remote: {
+ label: 'inkeep/open-knowledge',
+ webUrl: 'https://github.com/inkeep/open-knowledge',
+ },
+ };
+ projectLocalConfig = { autoSync: { mode: 'off' } };
+ projectConfig = { autoSync: { default: null }, content: { attachmentFolderPath: './' } };
+ projectLocalSynced = true;
+ projectSynced = true;
+ localPatchCalls = [];
+ toastErrors.length = 0;
+ });
+
+ test('selecting Pull-only opens the one-directional confirm and patches mode on confirm', async () => {
+ await renderSyncSection();
+
+ fireEvent.click(screen.getByTestId('settings-sync-mode-follow'));
+
+ // The real pull-variant confirmation renders; no write yet.
+ expect(screen.getByRole('button', { name: 'Enable Follow' })).not.toBeNull();
+ expect(screen.getByRole('note').textContent ?? '').toContain('Updates flow in');
+ expect(localPatchCalls).toEqual([]);
+
+ fireEvent.click(screen.getByRole('button', { name: 'Enable Follow' }));
+ expect(localPatchCalls).toEqual([{ autoSync: { mode: 'follow', enabled: null } }]);
+ });
+
+ test('selecting Full opens the bidirectional confirm and patches full on confirm', async () => {
+ await renderSyncSection();
+
+ fireEvent.click(screen.getByTestId('settings-sync-mode-full'));
+
+ expect(screen.getByRole('button', { name: 'Enable auto-sync' })).not.toBeNull();
+ expect(screen.getByRole('note').textContent ?? '').toContain('Commits happen automatically');
+
+ fireEvent.click(screen.getByRole('button', { name: 'Enable auto-sync' }));
+ expect(localPatchCalls).toEqual([{ autoSync: { mode: 'full', enabled: null } }]);
+ });
+
+ test('selecting Off writes immediately with no confirmation', async () => {
+ projectLocalConfig = { autoSync: { mode: 'full' } };
+ syncStatus = { ...syncStatus, syncMode: 'full', syncEnabled: true } as SyncStatus;
+
+ await renderSyncSection();
+
+ fireEvent.click(screen.getByTestId('settings-sync-mode-off'));
+
+ expect(localPatchCalls).toEqual([{ autoSync: { mode: 'off', enabled: null } }]);
+ // No confirmation dialog for the safe direction.
+ expect(screen.queryByRole('button', { name: /Enable/ })).toBeNull();
+ });
+
+ test('Switch to pull-only from a paused full sync discloses stranded commits then patches pull', async () => {
+ projectLocalConfig = { autoSync: { mode: 'full' } };
+ syncStatus = {
+ state: 'disabled',
+ hasRemote: true,
+ syncEnabled: true,
+ syncMode: 'full',
+ pausedReason: 'no-push-permission',
+ ahead: 2,
+ remote: {
+ label: 'inkeep/open-knowledge',
+ webUrl: 'https://github.com/inkeep/open-knowledge',
+ },
+ };
+
+ await renderSyncSection();
+
+ fireEvent.click(screen.getByTestId('settings-sync-switch-follow-action'));
+
+ // Pull-variant confirm with the stranded-commit disclosure sourced from ahead.
+ expect(
+ screen.getByText("You have 2 changes you haven't shared. They will stay on this computer."),
+ ).not.toBeNull();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Enable Follow' }));
+ expect(localPatchCalls).toEqual([{ autoSync: { mode: 'follow', enabled: null } }]);
+ });
+
+ test('a genuine read-only denial disables Full but keeps Off and Pull-only reachable', async () => {
+ syncStatus = {
+ ...syncStatus,
+ pushPermission: { checkStatus: 'denied', deniedReason: 'no-collaborator' },
+ } as SyncStatus;
+
+ await renderSyncSection();
+
+ expect((screen.getByTestId('settings-sync-mode-full') as HTMLButtonElement).disabled).toBe(
+ true,
+ );
+ expect((screen.getByTestId('settings-sync-mode-off') as HTMLButtonElement).disabled).toBe(
+ false,
+ );
+ expect((screen.getByTestId('settings-sync-mode-follow') as HTMLButtonElement).disabled).toBe(
+ false,
+ );
+ // The disabled Full item carries a tooltip explaining the read-only denial.
+ expect(screen.getByTestId('settings-sync-mode-full-tip').textContent ?? '').toContain(
+ "You don't have permission to push to this repo",
+ );
+ });
+
+ test('a signed-out denial keeps Full enabled (push access is unknowable until sign-in)', async () => {
+ syncStatus = {
+ ...syncStatus,
+ pushPermission: { checkStatus: 'denied', deniedReason: 'not-authenticated' },
+ } as SyncStatus;
+
+ await renderSyncSection();
+
+ expect((screen.getByTestId('settings-sync-mode-full') as HTMLButtonElement).disabled).toBe(
+ false,
+ );
+ });
+});
diff --git a/packages/app/src/components/settings/SyncSection.tsx b/packages/app/src/components/settings/SyncSection.tsx
index 3ead62020..176a37580 100644
--- a/packages/app/src/components/settings/SyncSection.tsx
+++ b/packages/app/src/components/settings/SyncSection.tsx
@@ -1,13 +1,20 @@
/**
- * Sync section — surface the git auto-sync toggle in Settings so users
- * have a deliberate path to re-enable when the header badge is hidden
- * (state === 'disabled' hides the badge).
+ * Sync section — the Settings home for the three-way sync mode
+ * (off / pull-only / full) plus the committed shared default, so users have a
+ * deliberate path to change modes even when the header badge is hidden
+ * (state === 'disabled' hides the badge for non-following projects).
*
- * The toggle writes through the project-local ConfigBinding so the choice
- * lands in `/.ok/local/config.yml`; the file watcher then drives
- * the SyncEngine to match.
+ * The mode control writes through the project-local ConfigBinding so the
+ * choice lands in `/.ok/local/config.yml`; the file watcher then
+ * drives the SyncEngine to match.
*/
+import {
+ isSyncMode,
+ modeFromCommittedDefault,
+ resolveLocalAutoSyncMode,
+ type SyncMode,
+} from '@inkeep/open-knowledge-core';
import { Trans, useLingui } from '@lingui/react/macro';
import { ArrowUpRight, ChevronRight } from 'lucide-react';
import { useState } from 'react';
@@ -17,27 +24,25 @@ import { EnableSyncConfirmDialog } from '@/components/EnableSyncConfirmDialog';
import { PublishToGitHubDialog } from '@/components/PublishToGitHubDialog';
import {
formatPausedReason,
- shouldDisableSyncSwitch,
shouldOfferReconnect,
shouldOfferSignInAgain,
} from '@/components/SyncStatusBadge';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
-import { Switch } from '@/components/ui/switch';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import {
- useEnableSyncWithConfirm,
useSyncDefaultWriter,
- useSyncEnabledWriter,
+ useSyncModeSelection,
+ useSyncModeWriter,
} from '@/hooks/use-enable-sync-with-confirm';
import { useGitSyncStatus } from '@/hooks/use-git-sync-status';
import { useConfigContext } from '@/lib/config-provider';
-// The selected committed-default option uses the app's primary blue (the same
-// token as the Button default variant), not the muted ToggleGroup default, so
-// the active stance reads as clearly chosen and matches the accent used
-// elsewhere in the app.
-const COMMITTED_DEFAULT_SELECTED_CLASS =
+// Selected toggle items use the app's primary blue (the same token as the
+// Button default variant), not the muted ToggleGroup default, so the active
+// stance reads as clearly chosen and matches the accent used elsewhere.
+const SYNC_SELECTED_TOGGLE_CLASS =
'data-[state=on]:border-primary data-[state=on]:bg-primary data-[state=on]:text-primary-foreground data-[state=on]:hover:bg-primary/90';
export function SyncSection() {
@@ -45,10 +50,14 @@ export function SyncSection() {
const status = useGitSyncStatus();
const { projectConfig, projectLocalConfig, projectLocalSynced, projectSynced } =
useConfigContext();
- const writer = useSyncEnabledWriter();
+ const modeWriter = useSyncModeWriter();
const defaultWriter = useSyncDefaultWriter();
- const { confirmOpen, setConfirmOpen, onToggleRequest, onConfirm } =
- useEnableSyncWithConfirm(writer);
+ // Per-machine mode: an explicit `autoSync.mode` wins, else derive from the
+ // legacy `enabled` boolean; never-answered resolves to off for display (the
+ // committed shared default has its own control below).
+ const localMode = resolveLocalAutoSyncMode(projectLocalConfig?.autoSync) ?? 'off';
+ const { confirmOpen, setConfirmOpen, pendingMode, onModeSelect, onConfirm } =
+ useSyncModeSelection(modeWriter, localMode);
const [publishOpen, setPublishOpen] = useState(false);
// Local AuthModal control for the Sign-in-again affordance surfaced when
// the probe returns 401. The editor header has its own AuthModal — settings
@@ -119,47 +128,82 @@ export function SyncSection() {
);
}
- // Read user intent from the synchronous local CRDT preference (the same
- // binding `useSyncEnabledWriter` writes to). Don't read from the server's
- // engine-state projection — that round-trips through ~2 s persistence
- // debounce + chokidar settle + 100 ms CC1 debounce, making the Switch
- // appear to lag every click.
- const enabled = projectLocalConfig?.autoSync?.enabled ?? false;
- // Mirrors the SyncStatusBadge popover so both surfaces gate identically.
- // Disable on cold start OR on a denied probe; never disable on
- // undefined / unknown / pending (preserves read+write parity).
- const disabledControl = shouldDisableSyncSwitch(
- projectLocalSynced,
- status?.pushPermission?.checkStatus,
- );
- // Whether the body line should carry the no-permission copy inline (instead
- // of the standard "your edits stay local" string + a redundant paragraph
- // underneath). Fires for both the probe-`denied` path AND the in-memory
- // pause path (autoSync was already enabled when probe came back denied —
- // engine sets `pausedReason='no-push-permission'`).
+ // Cold-start guard only: disable the control until the project-local config
+ // has hydrated. Unlike the old boolean toggle, a denied probe does NOT disable
+ // it — pull-only never pushes, so a push-denied receiver must still be able to
+ // select it (the whole point of the mode).
+ const modeControlDisabled = !projectLocalSynced;
+ // Full sync paused (or would pause) because the push probe came back denied.
+ // Only `full` cares about push permission — `pull`/`off` never push.
const isPushDenied =
status?.pushPermission?.checkStatus === 'denied' ||
status?.pausedReason === 'no-push-permission';
- const sectionMessage =
- isPushDenied || !status?.pausedReason ? null : formatPausedReason(status.pausedReason);
+ // Signed-out denial ('denied/not-authenticated') — signing back in restores
+ // the full sync the user already consented to, so it takes precedence over
+ // the switch-to-pull-only offer (that one is for genuinely revoked access).
+ const offerReconnect = shouldOfferReconnect(status?.pushPermission);
+ const showReconnect = localMode === 'full' && isPushDenied && offerReconnect;
+ const showSwitchToPullOnly = localMode === 'full' && isPushDenied && !offerReconnect;
+ // Full sync would immediately fail-and-pause for a genuine read-only user, so
+ // don't offer it. Signed-out denial is excluded — that user may well have push
+ // access once they authenticate, so Full stays reachable for them.
+ const genuineReadOnlyDenied =
+ status?.pushPermission?.checkStatus === 'denied' &&
+ status.pushPermission.deniedReason !== 'not-authenticated';
+ // A non-permission pause reason (protected-branch, dirty-tree, …) — reachable
+ // only under full sync. Suppressed when the switch-to-pull-only affordance
+ // already explains a paused full-sync engine.
+ const pausedNotice =
+ showSwitchToPullOnly || isPushDenied || !status?.pausedReason
+ ? null
+ : formatPausedReason(status.pausedReason);
+
+ function onModeChange(next: string) {
+ // Radix single ToggleGroup emits '' when the active item is re-pressed
+ // (deselect) — ignore so there is always exactly one selected mode.
+ if (!isSyncMode(next)) return;
+ onModeSelect(next);
+ }
// Committed project default (`autoSync.default`) — the maintainer-facing,
- // git-shared seed for everyone's first open. true/false/null map to the three
- // ToggleGroup options; `null` (ask) is the absence of a committed seed.
- const committedDefault = projectConfig?.autoSync?.default ?? null;
- const committedDefaultValue =
- committedDefault === true ? 'on' : committedDefault === false ? 'off' : 'ask';
+ // git-shared seed for everyone's first open. Widened to the mode vocabulary so
+ // a maintainer can ship a pull-only default; `modeFromCommittedDefault` reads
+ // both the mode strings and the legacy boolean seed, `null` (ask) = no seed.
+ const committedDefaultValue = modeFromCommittedDefault(projectConfig?.autoSync?.default) ?? 'ask';
function onCommittedDefaultChange(next: string) {
// Radix single ToggleGroup emits '' when the active item is re-pressed
// (deselect) — ignore it so there is always exactly one committed stance.
- if (next !== 'ask' && next !== 'on' && next !== 'off') return;
+ if (next !== 'ask' && !isSyncMode(next)) return;
if (defaultWriter === null) {
toast.error(t`Sync settings not yet loaded — try again in a moment`);
return;
}
- // 'ask' writes null, which clears the committed key (RFC 7396 merge-patch) →
- // unanswered machines see the onboarding prompt again.
- const value = next === 'on' ? true : next === 'off' ? false : null;
+ // 'ask' clears the committed key (RFC 7396 merge-patch) → unanswered machines
+ // see the onboarding prompt again. off/full stay legacy booleans so an older
+ // OK build still honors them verbatim; 'pull' has no legacy equivalent, so it
+ // is written as the mode string (older builds safely re-prompt on it).
+ // Exhaustive per value: this writes committed (git-shared) config, so a
+ // future mode must make a deliberate serialization choice here rather than
+ // silently falling through to one arm.
+ let value: boolean | SyncMode | null;
+ switch (next) {
+ case 'ask':
+ value = null;
+ break;
+ case 'off':
+ value = false;
+ break;
+ case 'full':
+ value = true;
+ break;
+ case 'follow':
+ value = 'follow';
+ break;
+ default: {
+ const exhaustive: never = next;
+ throw new Error(`unhandled committed default: ${String(exhaustive)}`);
+ }
+ }
const result = defaultWriter(value);
if (!result.ok) {
const detail = result.error;
@@ -175,40 +219,27 @@ export function SyncSection() {
- Auto-sync pushes/pulls commits to your git remote on intervals and on save. Toggling on
- requires confirmation.
+ Keep this project in sync with your git remote. Follow fetches updates without pushing;
+ full sync pushes your commits too. Turning sync on requires confirmation.
-
+
-
+
+ Git sync
+
- {isPushDenied ? (
- // Probe denied (or engine paused in-memory because autoSync was
- // already on when probe denied). Replace the standard body copy
- // with the permission-specific message — the redundant
- // sectionMessage paragraph below is suppressed in this case.
- // "Paused", not "off": the user's preference is still on (the
- // toggle shows it), sync is just blocked. Signed-out vs genuine
- // read-only get different remedies.
- shouldOfferReconnect(status?.pushPermission) ? (
- Auto-sync is paused — sign in to resume.
- ) : (
-
- Auto-sync is paused — you don't have permission to push to this repo.
-
- )
- ) : enabled ? (
+ {localMode === 'full' ? (
+ Full sync — your commits push and remote changes pull automatically.
+ ) : localMode === 'follow' ? (
- Auto-sync is on — your commits push and remote changes pull on intervals.
+ Follow — updates flow in from your remote; your edits stay on this computer.
) : (
- Auto-sync is off — your edits stay local until you commit and push manually.
+ Sync is off — your edits stay on this computer until you commit and push manually.
)}
-
+
+
+ Off
+
+
+ Follow
+
+ {genuineReadOnlyDenied ? (
+
+
+ {/* A disabled button emits no pointer events, so the tooltip
+ hangs off a wrapper span that still receives hover — the
+ only way to surface why Full is greyed out. Keyboard users
+ get the same reason from the read-only hint text below. */}
+
+
+ Full
+
+
+
+
+ You don't have permission to push to this repo
+
+
+ ) : (
+
+ Full
+
+ )}
+
- {sectionMessage !== null && (
+ {showReconnect && (
+ // "Paused", not "off": the preference is still full sync, it's just
+ // blocked by a signed-out session — reconnecting resumes it. Mirrors
+ // the popover's reconnect affordance.
+
+
+ Auto-sync is paused — sign in to resume.
+
+
+
+ )}
+ {showSwitchToPullOnly && (
+
+
+
+ Auto-sync is paused — you don't have permission to push to this repo. Switch to
+ Follow to keep receiving updates.
+
+
+
+
+ )}
+ {!showSwitchToPullOnly && !showReconnect && isPushDenied && localMode !== 'follow' && (
+ // Push-denied and not yet following: point the receiver at pull-only,
+ // which the mode control above already offers. Suppressed for the
+ // signed-out shape — permission is unknowable until they sign in.
+
+
+ You don't have permission to push to this repo. Follow can still keep your copy up to
+ date.
+
+
)}
- {shouldOfferReconnect(status?.pushPermission) && (
- // Signed-out denial ('denied/not-authenticated') — reconnecting
- // resumes sync (the body copy above reads "sign in to resume"), so
- // surface the button. Mirrors the popover's reconnect affordance.
-
- {/* Default size (not xs) to match the None/On/Off toggle row above:
- both resolve to h-8 / px-2.5 / text-sm. */}
-
-
- )}
-
+
Shared default
- Set the auto-sync default for users opening this project for the first time. This
- setting is committed to your repository.
+ Set the sync default for users opening this project for the first time. This setting
+ is committed to your repository.
@@ -311,36 +423,45 @@ export function SyncSection() {
value={committedDefaultValue}
onValueChange={onCommittedDefaultChange}
disabled={!projectSynced}
- aria-label={t`Shared auto-sync default`}
+ aria-labelledby="settings-sync-default-label"
data-testid="settings-sync-default-toggle"
>
None
-
- On
- Off
+
+ Follow
+
+
+ Full
+
{
expect(screen.getByRole('listbox', { name: 'Path suggestions' })).toBeDefined();
expect(screen.getByRole('option', { name: '/docs/install Page' })).toBeDefined();
+ // Full path is exposed as a hover tooltip so a truncated row is still readable.
+ expect(screen.getByTitle('/docs/install')).toBeDefined();
});
test('opens project path suggestions when slash input has focus', () => {
@@ -77,6 +79,51 @@ describe('LinkPathSuggestionInput', () => {
expect(screen.getByRole('option', { name: '/guides/intro Page' })).toBeDefined();
});
+ test('opens suggestions for a bare name and lists the matching page', () => {
+ render();
+
+ fireEvent.focus(screen.getByRole('combobox', { name: 'Link target' }));
+
+ expect(screen.getByRole('listbox', { name: 'Path suggestions' })).toBeDefined();
+ expect(screen.getByRole('option', { name: '/docs/install Page' })).toBeDefined();
+ });
+
+ test('does not open the panel while typing an external URL', () => {
+ render();
+
+ fireEvent.focus(screen.getByRole('combobox', { name: 'Link target' }));
+
+ expect(screen.queryByRole('listbox', { name: 'Path suggestions' })).toBeNull();
+ });
+
+ test('does not open the panel for a protocol-relative or scheme URL', () => {
+ render();
+
+ fireEvent.focus(screen.getByRole('combobox', { name: 'Link target' }));
+
+ expect(screen.queryByRole('listbox', { name: 'Path suggestions' })).toBeNull();
+ });
+
+ test('does not open the panel for a bare in-doc anchor', () => {
+ render();
+
+ fireEvent.focus(screen.getByRole('combobox', { name: 'Link target' }));
+
+ expect(screen.queryByRole('listbox', { name: 'Path suggestions' })).toBeNull();
+ });
+
+ test('stays silent for a bare name with no match — no empty-state flash', () => {
+ // A scheme-less value that matches nothing (e.g. a URL-in-progress like
+ // example.com) must not pop the "No matching paths" empty-state. Only an
+ // empty browse or an explicit leading slash surfaces that state.
+ render();
+
+ fireEvent.focus(screen.getByRole('combobox', { name: 'Link target' }));
+
+ expect(screen.queryByRole('listbox', { name: 'Path suggestions' })).toBeNull();
+ expect(screen.queryByRole('status')).toBeNull();
+ });
+
test('shows an empty state for slash input with no matching paths', () => {
render();
diff --git a/packages/app/src/editor/link-path-suggestions.test.ts b/packages/app/src/editor/link-path-suggestions.test.ts
index 9c49ac8fe..a3fdf5189 100644
--- a/packages/app/src/editor/link-path-suggestions.test.ts
+++ b/packages/app/src/editor/link-path-suggestions.test.ts
@@ -15,12 +15,24 @@ describe('buildLinkPathSuggestions', () => {
const folderPaths = new Set(['docs', 'guides', 'notes']);
const assetPaths = new Set(['assets/logo.png', 'guides/demo.mov']);
- test('does not suggest paths until the value starts with a single slash', () => {
- expect(buildLinkPathSuggestions({ value: 'guides', pages, folderPaths })).toEqual([]);
+ test('matches a bare name query without requiring a leading slash', () => {
+ expect(buildLinkPathSuggestions({ value: 'guides', pages, folderPaths })).toEqual([
+ { kind: 'folder', path: 'guides' },
+ { kind: 'page', path: 'guides/bun' },
+ { kind: 'page', path: 'guides/intro' },
+ ]);
+ expect(buildLinkPathSuggestions({ value: 'install', pages, folderPaths })).toEqual([
+ { kind: 'page', path: 'docs/install' },
+ ]);
+ });
+
+ test('returns nothing for a bare query that matches no path', () => {
+ expect(buildLinkPathSuggestions({ value: 'zzz-nope', pages, folderPaths })).toEqual([]);
+ // URL-shaped values match no path; suppression of the panel for real URLs
+ // is the component's job, not this pure matcher's.
expect(buildLinkPathSuggestions({ value: 'https://example.com', pages, folderPaths })).toEqual(
[],
);
- expect(buildLinkPathSuggestions({ value: '//example.com', pages, folderPaths })).toEqual([]);
});
test('suggests matching existing page and folder paths after slash input', () => {
@@ -61,14 +73,36 @@ describe('buildLinkPathSuggestions', () => {
});
test('ranks basename substring matches before full-path-only substring matches', () => {
+ const ranked = [
+ { kind: 'page', path: 'notes/api' },
+ { kind: 'page', path: 'guides/api/reference' },
+ ];
+ // Same ranking whether the query carries a leading slash or not — the query
+ // is normalized before scoring, so both forms hit the identical code path.
expect(
buildLinkPathSuggestions({
value: '/api',
pages: new Set(['guides/api/reference', 'notes/api']),
}),
- ).toEqual([
- { kind: 'page', path: 'notes/api' },
- { kind: 'page', path: 'guides/api/reference' },
- ]);
+ ).toEqual(ranked);
+ expect(
+ buildLinkPathSuggestions({
+ value: 'api',
+ pages: new Set(['guides/api/reference', 'notes/api']),
+ }),
+ ).toEqual(ranked);
+ });
+
+ test('browse ordering keeps content pages ahead of dot-directory files', () => {
+ const mixed = new Set(['.github/ci', '.changeset/note', 'docs/install', 'guides/intro']);
+ const browsed = buildLinkPathSuggestions({ value: '', pages: mixed }).map((s) => s.path);
+ // Every non-dot page appears before any dot-directory page.
+ const firstDotIndex = browsed.findIndex((p) => p.split('/')[0]?.startsWith('.'));
+ const lastNonDotIndex = browsed.reduce(
+ (last, p, i) => (p.split('/')[0]?.startsWith('.') ? last : i),
+ -1,
+ );
+ expect(lastNonDotIndex).toBeLessThan(firstDotIndex);
+ expect(browsed.slice(0, 2)).toEqual(['docs/install', 'guides/intro']);
});
});
diff --git a/packages/app/src/editor/link-path-suggestions.tsx b/packages/app/src/editor/link-path-suggestions.tsx
index b749281d0..ffa01c483 100644
--- a/packages/app/src/editor/link-path-suggestions.tsx
+++ b/packages/app/src/editor/link-path-suggestions.tsx
@@ -1,4 +1,5 @@
import { autoUpdate, computePosition, flip, offset, shift, size } from '@floating-ui/dom';
+import { isExternalHref } from '@inkeep/open-knowledge-core';
import { useLingui } from '@lingui/react/macro';
import {
type ComponentProps,
@@ -114,25 +115,45 @@ export function LinkPathSuggestionInput({
const panelRef = useRef(null);
const listId = useId();
- const emptyTriggered = value.trim() === '';
- const suggestionValue = emptyTriggered ? '/' : value;
- const suggestions = buildLinkPathSuggestions({
- value: suggestionValue,
- pages,
- folderPaths,
- assetPaths,
- includeAssets,
- });
+ const trimmedValue = value.trim();
+ const emptyTriggered = trimmedValue === '';
+ const slashTriggered = isSlashPathSuggestionValue(value);
+ // Suggest for any internal path target — a bare name matches by basename like
+ // the command palette, not just a leading-slash path. Suppress for an
+ // external URL or a bare in-doc anchor, which are valid targets that must not
+ // pop a page list.
+ const pathTargetTriggered = !isExternalHref(trimmedValue) && !trimmedValue.startsWith('#');
+ const suggestionTriggered = emptyTriggered || pathTargetTriggered;
+ const suggestionValue = emptyTriggered ? '' : value;
+ // Only score the page list when a suggestion could actually show — avoids
+ // re-scoring the whole KB on every keystroke of a URL, and keeps URL-shaped
+ // input out of the matcher.
+ const suggestions = suggestionTriggered
+ ? buildLinkPathSuggestions({
+ value: suggestionValue,
+ pages,
+ folderPaths,
+ assetPaths,
+ includeAssets,
+ })
+ : [];
const suggestionsKey = suggestions
.map((suggestion) => `${suggestion.kind}:${suggestion.path}`)
.join('\u0000');
- const slashTriggered = isSlashPathSuggestionValue(value);
- const suggestionTriggered = emptyTriggered || slashTriggered;
const showSuggestionOptions = suggestions.length > 0;
const showSuggestions =
focused && !dismissed && suggestionTriggered && (showSuggestionOptions || loading);
+ // The "no matching paths" empty-state is louder than a silent field, so only
+ // surface it on a strong path signal — an empty browse or an explicit leading
+ // slash. A bare name that matches nothing stays silent, so typing/pasting a
+ // scheme-less URL (e.g. example.com) into the dual-purpose field never flashes
+ // an empty page list.
const showNoMatches =
- focused && !dismissed && suggestionTriggered && !loading && !showSuggestionOptions;
+ focused &&
+ !dismissed &&
+ (emptyTriggered || slashTriggered) &&
+ !loading &&
+ !showSuggestionOptions;
const showSuggestionPanel = showSuggestions || showNoMatches;
const activeIndex = Math.min(highlightedIndex, Math.max(suggestions.length - 1, 0));
const activeId = showSuggestionOptions ? `${listId}-option-${activeIndex}` : undefined;
@@ -297,7 +318,9 @@ export function LinkPathSuggestionInput({
onClick={() => selectSuggestion(suggestion)}
>
{suggestionIcon(suggestion)}
- /{suggestion.path}
+
+ /{suggestion.path}
+ {kindLabel}
);
diff --git a/packages/app/src/hooks/use-enable-sync-with-confirm.dom.test.tsx b/packages/app/src/hooks/use-enable-sync-with-confirm.dom.test.tsx
index f2422dafd..58308d58f 100644
--- a/packages/app/src/hooks/use-enable-sync-with-confirm.dom.test.tsx
+++ b/packages/app/src/hooks/use-enable-sync-with-confirm.dom.test.tsx
@@ -1,3 +1,4 @@
+import type { SyncMode } from '@inkeep/open-knowledge-core';
import * as actualLinguiMacro from '@lingui/react/macro';
import { act, cleanup, render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
@@ -60,7 +61,18 @@ function WriterProbe({ children: _children }: { children?: ReactNode }) {
return