Skip to content

v2.6.2 — sync recovery UX, callout fix, protocol versioning - #113

Merged
ddutchie merged 8 commits into
mainfrom
ddutchie/2.6.2-sync-recovery-ux
Aug 5, 2026
Merged

ddutchie merged 8 commits into
mainfrom
ddutchie/2.6.2-sync-recovery-ux

Conversation

@ddutchie

@ddutchie ddutchie commented Aug 4, 2026 •

Copy link
Copy Markdown
Owner

What does this PR do?

The v2.6.2 cycle — a consolidation release that hardens what 2.6.1 shipped and fixes issues found by on-device testing. Five commits, each independently reviewable; two are user-facing fixes surfaced by testing the sync work on real devices, one re-enables a shelved editor feature, one is defensive sync infrastructure, and one is a test-only verification pass.

1. Restore / conflict-copy overlap (14ff77c)

Found on-device. Delete a note on device A, edit it on B before B sees the delete → the delete-won path keeps B's edit as a conflict copy and tombstones the original with its body intact. Both hold the same content, so B was offered a Restore button next to the conflict — and restoring produced two identical notes.

Fix: a new engine predicate hasLivePreservingCopy suppresses the restore offer when a live conflict copy already preserves that content, with a typed preserved-as-copy reason pointing the user to conflict resolution instead. Wired into the single restorability() check so the list and the action can't disagree. A plain peer delete with no competing edit still offers Restore.

2. Inline callout widget re-enabled (07aec36)

Callouts (> [!note]) render inline in the editor's live preview again. They were switched off (CALLOUTS_ENABLED = false) because clicking near or below a callout dropped the cursor several lines off — text after a callout was unselectable. Root cause: createRoot().render() paints async, so CodeMirror measured the widget at ~0px and mis-mapped every line below. The prior fix chased it with requestMeasure after paint, which never fully recovered. This mounts React synchronously with flushSync in toDOM(), so the widget has real height the first time CM measures — correct by construction, not by timing.

3. Sync protocol versioning + stale-peer detection (faed1c5)

The oplog wire format had no version field, so a device inferred a peer's capability from which optional fields happened to be present. Adds OplogEntry.v (stamped at the export boundary; missing = pre-2.6.2 = protocol 1), records the highest version seen per peer in sync_state, and surfaces a "device needs updating" hint (desktop Settings → Sync + mobile Sync screen) when a peer is behind — because a behind peer may not honour deletes. Skew contract documented in docs/plans/sync-lifecycle-hardening.md §6.

4. Agent approval / plan / connector test suite (2eeacee)

The verification pass the roadmap flagged as missing for the v2.5.22 approval UX. 16 tests across PlanApprovalCard, ConnectorToolCard, and tool-risk. To test the approval card's grant-scoping without faking Electron, its inline decision logic was extracted into pure approvalGrantScope() / approvalScopeLabel() helpers and the card rewired to use them — so the tested logic is the shipped logic.

5. Platform-neutral approval copy (128b9d6)

The extracted approvalScopeLabel described where a tool reaches as "this Mac" — but Cairn ships on Windows and Linux. Changed to "this device" (the codebase's existing neutral term), with a guard test asserting no scope label matches \bMac\b.

Type of change

  • Bug fix
  • New feature
  • Refactor / code quality
  • Docs / changelog
  • Tests

Screenshots / recording

To attach. UI-visible changes: the re-enabled callout widget in the editor, the "device needs updating" hint (desktop Settings → Sync + mobile Sync screen), and the removed duplicate Restore affordance.

Checklist

  • npm run type-check:all passes — plus npm --prefix mobile run type-check
  • npm run lint passes (mobile lint: 2 pre-existing warnings in token-breakdown.test.ts, untouched)
  • npm test passes — 1,815 passed / 12 pre-existing skips
  • npm run test:e2e passes — 17/17
  • Major user-facing feature → What's New: N/A. These are fixes + defensive infra + tests. Sync features remain out of the launch modal (Device Sync needs the unreleased mobile app), consistent with 2.6.1.
  • No hardcoded colours — CSS variables / theme tokens only
  • No text-[Npx] pixel font classes
  • New IPC handlers wrapped in handle() and return IpcResult<T> — sync:peerProtocols
  • No new DB migrations — OplogEntry.v is a wire-format field stamped at export, not a stored column; peer versions live in the existing sync_state table
  • No new Database construction; sync SQL stays inside the shared engine
  • N/A — no new dependencies, MCP tools, or --external flags

Notes for reviewer

Focus: shared/sync/engine.ts. Two additions — hasLivePreservingCopy (restore suppression) and the protocol-version tracking. Both are additive to reconciliation, not changes to the delete-wins rules that 2.6.1 established.

Two things are unit-green but not fully provable in CI, called out honestly on their cards:

  • Callout cursor fix — jsdom has no layout engine, so the drift regression test exercises the path but can't reproduce the browser-layout race. Confidence is the mechanism (synchronous height) + e2e booting the editor in real Chromium. Wants a manual click-after-callout check on the built app.
  • Stale-peer "behind" classification — unit-tested by simulating a lower peer version, but the true "old build vs new build, hint appears" only proves out after the next protocol bump, since v1 is the only version in the wild. Shipping the mechanism now (while two historical shapes exist) is the point of the card.

Also in this branch: created the two missing Obsidian-import roadmap cards (re-import conflict handling EM6_XaQAlTrH, rollback/undo gADsLmLlctVP) — both were 🔴 Missing in roadmap §4 with no board representation. Card-creation only; not implemented here.

Both platforms: changelogs/v2.6.2.md and mobile/changelogs/v0.1.6.md updated; both sit one patch ahead of their versions (2.6.1 / 0.1.5) so both release gates pass.

Summary by CodeRabbit

  • New Features
    • Live preview now displays callouts as interactive widgets.
    • Sync screens warn when paired devices use outdated protocols that may restore deleted notes.
    • Approval dialogs now show clearer action scope and platform-neutral wording.
    • AI and agent settings support configurable maximum output tokens, including Auto mode.
  • Bug Fixes
    • Prevented duplicate restore prompts when deleted notes are preserved as conflicts.
    • Improved callout sizing and editor positioning.
    • Fixed reasoning-model stalls, empty follow-ups, and truncated responses.
  • Documentation
    • Added release notes and sync compatibility documentation.
  • Tests
    • Expanded coverage for syncing, restore behavior, callouts, approvals, and connector displays.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@ddutchie, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 49 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 44d6f766-944c-40c5-b0cf-2f4a30c84f64

📥 Commits

Reviewing files that changed from the base of the PR and between 4eb9549 and 288bae1.

📒 Files selected for processing (3)
  • electron/ipc/pi-agent.ts
  • shared/models/model-catalog.test.ts
  • shared/models/model-catalog.ts
📝 Walkthrough

Walkthrough

The pull request hardens sync protocol compatibility and restore handling, enables and stabilizes editor callout widgets, centralizes agent approval scope and wording, and adds configurable model output limits. It updates desktop and mobile surfaces, chat providers, tests, caches, and release documentation.

Changes

Sync lifecycle hardening

Layer / File(s) Summary
Protocol metadata and restore eligibility
shared/sync/engine.ts
Oplog entries include protocol versions. SyncEngine tracks peer versions and refuses duplicate restores when a conflict copy preserves the content.
Protocol and restore validation
shared/sync/oplog-guard.ts, shared/sync/engine.test.ts, shared/sync/transport.test.ts
Validation accepts current and legacy versions and rejects malformed versions. Tests cover peer tracking and conflict-copy restore behavior.
Peer protocol API and IPC
electron/preload.ts, electron/sync/..., src/lib/sync-client.ts
Desktop IPC and the shared client expose peer protocol records with fallback handling outside Electron and on query failure.
Stale-peer and restore messaging
src/components/settings/SyncActivityPanel.tsx, mobile/app/sync.tsx, mobile/app/restore.tsx, mobile/src/db/queries.ts
Desktop and mobile screens warn about outdated peers and explain conflict-copy restore refusals.
Sync behavior documentation
docs/plans/sync-lifecycle-hardening.md, changelogs/v2.6.2.md, mobile/changelogs/v0.1.6.md
Documentation describes protocol versioning, stale-device warnings, and revised restore behavior.

Editor callout widgets

Layer / File(s) Summary
Callout widget rendering and measurement
src/lib/livePreview.ts, src/lib/callout-widget.tsx, src/lib/livePreview.component.test.tsx
Live preview enables callout widgets. Widget rendering is synchronous before CodeMirror measurement, with delayed remeasurement after collapsible toggles. Tests cover rendering, source visibility, and document mapping.

Agent approval scope

Layer / File(s) Summary
Approval scope helpers and controls
src/lib/tool-risk.ts, src/components/agent/AgentMessageBubble.tsx
Helpers classify command, session, or unavailable standing grants and provide platform-neutral action labels. Approval actions use the computed scope.
Approval and connector behavior tests
src/lib/tool-risk.test.ts, src/components/agent/PlanApprovalCard.component.test.tsx, src/components/shared/ConnectorToolCard.component.test.tsx
Tests cover approval actions, feedback validation, connector presentation, expansion, and secret-like argument redaction.

Configurable model output limits

Layer / File(s) Summary
Model metadata and output-limit resolution
shared/models/model-catalog.ts, shared/models/model-catalog.test.ts, src/lib/models-dev.ts, mobile/src/chat/models-dev.ts
Model metadata stores advertised output limits. Resolvers normalize positive overrides and omit invalid or uncapped limits. Catalog cache keys use version v2.
Output-limit settings and state
src/components/settings/AISettings.tsx, src/components/settings/AgentSettings.tsx, src/components/settings/shared.tsx, src/store/slices/ui.ts, src/lib/constants.ts
AI and agent settings support automatic or manual output limits, model-specific bounds, suppressed Auto values, and persisted configuration fields.
Chat and agent request propagation
src/components/agent/AgentChatPane.tsx, src/components/chat/chat-panel/index.tsx, electron/ipc/pi-agent.ts, electron/lib/config-cache.ts, electron/lib/pi-agent-loop.ts, electron/lib/tools.ts, src/hooks/useChatStream.ts
Entry points resolve output limits and pass optional maxTokens values through request and cache configuration.
Provider output and replay behavior
electron/lib/chat-loop.ts, electron/lib/chat-subagent-loop.ts, electron/ipc/chat.ts, electron/lib/llm.ts, mobile/src/chat/providers/*
Providers omit empty assistant turns and uncapped requests. Streamed reasoning responses surface captured reasoning when token limits end a response without content or tool calls. Tests cover sendable-message rules.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • ddutchie/cairn#87: Shares sync engine changes for conflict-copy handling and deletion convergence.
  • ddutchie/cairn#104: Shares chat and agent loop changes for reasoning handling and assistant-message replay.
  • ddutchie/cairn#109: Provides related agent approval changes extended by scoped approval helpers.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 56.41% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies major changes in sync recovery, callouts, and protocol versioning.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ddutchie/2.6.2-sync-recovery-ux

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@changelogs/v2.6.2.md`:
- Line 4: The release notes overstate detection of outdated peers relative to
the protocol-version contract. Update the entries at changelogs/v2.6.2.md:4 and
mobile/changelogs/v0.1.6.md:3 to state that the “device needs updating” hint
applies to peers with a lower protocol version, without changing the protocol
version itself.

In `@mobile/app/sync.tsx`:
- Line 145: Update the stale-peer warning row in the relevant sync rendering
component to use styles.conflictRowActive instead of styles.restoreRowActive,
preserving styles.conflictRow and the existing warning behavior.
- Around line 47-52: Update the manual sync flow around requestSync("manual") to
refresh stalePeers after the request settles, reusing the existing
stalePeerCount refresh and error-handling behavior. Ensure the refresh runs for
both successful and failed sync completion without relying on the screen focus
refresh in refresh.

In `@src/components/settings/SyncActivityPanel.tsx`:
- Around line 289-295: Update the empty-state logic in SyncActivityPanel by
calculating behindPeers before the loaded-state check, then use
behindPeers.length instead of stalePeers.length when deciding whether to render
the empty state. Preserve non-stale protocol differences without showing an
empty container, while retaining existing behavior for actual stale peers.

In `@src/lib/livePreview.component.test.tsx`:
- Around line 112-126: Extend the test case around mount and the callout
regression to exercise physical coordinate mapping with browser-capable view
coordinates: target the rendered “target line after callout” below the
.cm-lp-callout using posAtCoords or a mouse click, then assert the resolved or
selected document position matches that line’s position. Keep the existing
lineBlockAt assertion, but ensure the new check validates rendered widget height
rather than only document-position mapping.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d3298084-b33c-47a4-a78c-3bccab104411

📥 Commits

Reviewing files that changed from the base of the PR and between bc6435c and 128b9d6.

📒 Files selected for processing (23)
  • changelogs/v2.6.2.md
  • docs/plans/sync-lifecycle-hardening.md
  • electron/preload.ts
  • electron/sync/desktop-sync.ts
  • electron/sync/sync-handlers.ts
  • mobile/app/restore.tsx
  • mobile/app/sync.tsx
  • mobile/changelogs/v0.1.6.md
  • mobile/src/db/queries.ts
  • shared/sync/engine.test.ts
  • shared/sync/engine.ts
  • shared/sync/oplog-guard.ts
  • shared/sync/transport.test.ts
  • src/components/agent/AgentMessageBubble.tsx
  • src/components/agent/PlanApprovalCard.component.test.tsx
  • src/components/settings/SyncActivityPanel.tsx
  • src/components/shared/ConnectorToolCard.component.test.tsx
  • src/lib/callout-widget.tsx
  • src/lib/livePreview.component.test.tsx
  • src/lib/livePreview.ts
  • src/lib/sync-client.ts
  • src/lib/tool-risk.test.ts
  • src/lib/tool-risk.ts

Comment thread changelogs/v2.6.2.md Outdated
Comment thread mobile/app/sync.tsx
Comment thread mobile/app/sync.tsx Outdated
Comment thread src/components/settings/SyncActivityPanel.tsx
Comment thread src/lib/livePreview.component.test.tsx
The chat, coding-agent, and subagent loops each hardcoded a max_tokens
cap (4096/8192). A reasoning model counts its chain-of-thought against
that budget, so on a hard prompt it could spend the whole cap thinking
and stop with finish_reason:"length" and empty content. That empty
assistant turn, replayed on the next message, made the provider reject
the request: "content or tool_calls must be set" (400).

Verified against the live opencode.ai/zen endpoint: with no cap the
model finishes naturally (finish_reason:"stop", full reasoning +
answer) using far fewer tokens than any cap — a cap only ever truncates
the tail case.

- resolveMaxOutputTokens(userOverride?) -> number | undefined: Auto
  (default) omits max_tokens entirely; a positive manual value is sent.
- Thread maxTokens through chat, pi-agent, and subagent request configs;
  omit the field when unset.
- New 'Max output tokens' control (Auto = no limit + manual override) in
  AI and Agent settings; StepperSettingsRow gains autoSuppressesValue so
  a previously-typed number clears when Auto is re-selected.
- Parse limit.output into ModelInfo.maxOutput for settings guidance; bump
  the models.dev cache key (v2) so existing caches refetch immediately.
- Belt-and-braces: isSendableMessage / assistantTurnIsSendable drop any
  assistant turn with neither content nor tool_calls (desktop chat +
  dispatch history, mobile mapMessage) so a stalled empty turn can never
  poison the next request. Streaming branch surfaces captured reasoning
  if a reply is ever cut off.
…state

- changelogs: describe the 'device needs updating' hint as firing on peers
  reporting a LOWER protocol version (matching listPeerProtocols' behind flag),
  not the local device stamping a newer version.
- mobile sync: stale-peer row uses conflictRowActive (warning) instead of
  restoreRowActive (danger), matching its warning icon/intent; refresh peer +
  restorable counts after a manual sync settles (success or failure) rather than
  relying only on the focus refresh.
- SyncActivityPanel: compute behindPeers before the empty-state check and gate
  the empty state on behindPeers.length, so a non-stale protocol difference no
  longer suppresses the empty state while leaving an empty shell.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@electron/ipc/pi-agent.ts`:
- Line 219: Persist the selected maxTokens value in both agent cache calls:
update electron/ipc/pi-agent.ts lines 200-207 and 294-301 to pass
req.config?.maxTokens to cacheLlmConnection("agent", ...), covering the fallback
reads at lines 219 and 313.

In `@shared/models/model-catalog.ts`:
- Line 280: Update maxOutput handling in the model-catalog mapping and the
limit.output path around the referenced validation logic to accept only positive
numbers, converting zero, negative, and invalid values to null. Reuse a shared
positive-number validator in both paths, and add tests covering zero and
negative inputs.
- Around line 455-457: Update resolveMaxOutputTokens to require userOverride >=
1 before flooring, ensuring fractional values such as 0.1 and 0.999 return
undefined rather than zero; add tests covering both cases while preserving
existing handling of valid positive caps.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ed930b71-819c-408a-a8de-b19010a48bbc

📥 Commits

Reviewing files that changed from the base of the PR and between 128b9d6 and 4eb9549.

📒 Files selected for processing (28)
  • changelogs/v2.6.2.md
  • electron/ipc/chat.ts
  • electron/ipc/pi-agent.ts
  • electron/lib/chat-loop.ts
  • electron/lib/chat-subagent-loop.ts
  • electron/lib/config-cache.ts
  • electron/lib/llm-sendable.test.ts
  • electron/lib/llm.ts
  • electron/lib/pi-agent-loop.ts
  • electron/lib/tools.ts
  • mobile/app/sync.tsx
  • mobile/changelogs/v0.1.6.md
  • mobile/src/chat/models-dev.ts
  • mobile/src/chat/providers/openai.ts
  • mobile/src/chat/providers/types.test.ts
  • mobile/src/chat/providers/types.ts
  • shared/models/model-catalog.test.ts
  • shared/models/model-catalog.ts
  • src/components/agent/AgentChatPane.tsx
  • src/components/chat/chat-panel/index.tsx
  • src/components/settings/AISettings.tsx
  • src/components/settings/AgentSettings.tsx
  • src/components/settings/SyncActivityPanel.tsx
  • src/components/settings/shared.tsx
  • src/hooks/useChatStream.ts
  • src/lib/constants.ts
  • src/lib/models-dev.ts
  • src/store/slices/ui.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • mobile/app/sync.tsx
  • src/components/settings/SyncActivityPanel.tsx

Comment thread electron/ipc/pi-agent.ts
Comment thread shared/models/model-catalog.ts Outdated
Comment thread shared/models/model-catalog.ts
…rsing

- pi-agent: pass req.config.maxTokens into both cacheLlmConnection('agent') calls
  so the fallback reads at the cached-config merge actually recover a user's cap
  on a later turn that omits it (previously always undefined).
- model-catalog: add a shared positiveTokenLimit() validator; use it for
  maxOutput in both the parse and normalize paths so limit.output of 0 (which
  ~180 real models.dev entries have), negative, or non-finite becomes null
  instead of a broken 0 ceiling on the settings stepper.
- resolveMaxOutputTokens: require userOverride >= 1 before flooring, so a
  fractional cap in (0,1) returns undefined instead of Math.floor'ing to 0 and
  sending max_tokens:0.
- tests for zero/negative limit.output, positiveTokenLimit, and fractional
  overrides.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant