Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
152c16b
feat(desktop): merge external changes into unsaved edits three-way
ocavue Oct 7, 2026
8f5c9f0
fix(desktop): keep merge previews honest: invalidate on typing, CAS o…
ocavue Oct 7, 2026
deae21f
fix(desktop): park the marked merge if typing continued during the re…
ocavue Oct 7, 2026
76fec93
Merge remote-tracking branch 'origin/master' into git_P2.3_session_merge
ocavue Oct 7, 2026
20fe455
refactor(desktop): write editor conflicts into the note instead of pa…
ocavue Oct 7, 2026
d67ba00
fix(desktop): keep the dirty buffer until the conflict copy holds it
ocavue Oct 7, 2026
eb6bd2f
fix(desktop): one conflict copy per reconciliation, overwritten only …
ocavue Oct 7, 2026
c00e5c8
Merge remote-tracking branch 'origin/master' into git_P2.3_session_merge
ocavue Oct 7, 2026
f15f848
fix(desktop): clear the save error once a reconciliation lands, widen…
ocavue Oct 7, 2026
b9e1164
fix(desktop): a flush settles the merge it ran into; no adopt without…
ocavue Oct 7, 2026
b294e9d
fix(desktop): a clean merge the editor cannot round-trip is written e…
ocavue Oct 7, 2026
b1e6613
fix(desktop): finish a reconciliation the final flush started, and ru…
ocavue Oct 9, 2026
3cfa1fa
test(desktop): restore mocks after every session test
ocavue Oct 9, 2026
2f73dc7
fix(desktop): a merged title from another device is not this user's r…
ocavue Oct 9, 2026
932c261
fix(desktop): the notice for edits kept beside a note can be acknowle…
ocavue Oct 9, 2026
33b2c19
docs: name the test for a failed conflict copy in S8
ocavue Oct 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions apps/desktop/src/components/note-conflict-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,53 @@ interface NoteConflictBannerProps {
onKeepMine: () => void
/** Resolve by loading the external content (discards the buffer). */
onLoadTheirs: () => void
/**
* Set when a three-way merge exists for the overlap: resolve by keeping
* both sides of every overlapping block, or by opening the marked merge
* for block-by-block review.
*/
onKeepBoth?: (() => void) | undefined
onReview?: (() => void) | undefined
}

/**
* The non-destructive conflict prompt (Plan 05): an external change raced
* unsaved edits, saves are paused, and nothing is written until the user
* picks a side. The two actions map 1:1 onto the note session's
* `keepMine`/`loadTheirs`.
* picks a side. Edits that do not overlap merge silently before this is ever
* shown; the banner appears only for overlapping edits (with Keep both and
* Review) or when no merge was possible (two choices).
*/
export function NoteConflictBanner({
onKeepMine,
onLoadTheirs,
onKeepBoth,
onReview,
}: NoteConflictBannerProps): ReactElement {
const mergeable = onKeepBoth !== undefined && onReview !== undefined
return (
<InlineAlert className="mb-4 flex flex-wrap items-center gap-x-3 gap-y-2">
<span className="min-w-0 flex-1">This note changed on disk while you had unsaved edits.</span>
<div className="flex gap-2">
<span className="min-w-0 flex-1">
{mergeable
? 'This note changed on disk, and the changes overlap your unsaved edits.'
: 'This note changed on disk while you had unsaved edits.'}
</span>
<div className="flex flex-wrap gap-2">
<Button size="xs" variant="outline" onClick={onKeepMine}>
Keep mine
</Button>
<Button size="xs" variant="outline" onClick={onLoadTheirs}>
Load theirs
</Button>
{mergeable ? (
<>
<Button size="xs" variant="outline" onClick={onKeepBoth}>
Keep both
</Button>
<Button size="xs" variant="outline" onClick={onReview}>
Review
</Button>
</>
) : null}
</div>
</InlineAlert>
)
Expand Down
7 changes: 6 additions & 1 deletion apps/desktop/src/components/note-save-alerts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,12 @@ export function NoteSaveAlerts({
</InlineAlert>
) : null}
{document.conflict !== null ? (
<NoteConflictBanner onKeepMine={document.keepMine} onLoadTheirs={document.loadTheirs} />
<NoteConflictBanner
onKeepMine={document.keepMine}
onLoadTheirs={document.loadTheirs}
onKeepBoth={document.mergedPreview === null ? undefined : document.keepBoth}
onReview={document.mergedPreview === null ? undefined : document.review}
/>
) : null}
</>
)
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/editor/document-binding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ function fakeSession(path: string) {
externalChanged: () => {},
flush,
keepMine: () => {},
keepBoth: () => {},
review: () => {},
isDirty: () => false,
isUnpersisted: () => false,
prepareDelete: async () => false,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/editor/move-note.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ function fakeSession(path: string) {
externalChanged: () => {},
flush,
keepMine: () => {},
keepBoth: () => {},
review: () => {},
isDirty: () => false,
isUnpersisted: () => false,
prepareDelete: async () => false,
Expand Down
115 changes: 109 additions & 6 deletions apps/desktop/src/editor/note-session-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
errorMessage,
isAppError,
upsertFrontmatter,
resolveConflictMarkers,
type MergeTextOutcome,
} from '@reflect/core'
import { splitDoc } from './note-session-doc.ts'
import { frontmatterPatchToYaml, type FrontmatterPatch } from './note-session-frontmatter.ts'
Expand Down Expand Up @@ -32,6 +34,7 @@ export function createNoteSession(options: NoteSessionOptions): NoteSession {
let dirty = false
let missing = false
let conflict: string | null = null
let mergedPreview: string | null = null
let error: string | null = null

// Pipeline state (never surfaces).
Expand Down Expand Up @@ -79,6 +82,7 @@ export function createNoteSession(options: NoteSessionOptions): NoteSession {
dirty,
missing,
conflict,
mergedPreview,
error,
}
if (
Expand All @@ -89,6 +93,7 @@ export function createNoteSession(options: NoteSessionOptions): NoteSession {
lastEmitted.dirty === next.dirty &&
lastEmitted.missing === next.missing &&
lastEmitted.conflict === next.conflict &&
lastEmitted.mergedPreview === next.mergedPreview &&
lastEmitted.error === next.error
) {
return
Expand Down Expand Up @@ -182,6 +187,9 @@ export function createNoteSession(options: NoteSessionOptions): NoteSession {
}
buffer = markdown
dirty = header + markdown !== disk
// A parked preview was merged from the buffer as it was; later typing
// would be lost under Keep both or Review, so those options go away.
mergedPreview = null
if (missing && markdown.trim() === '') {
// A still-unwritten note cleared back to nothing (e.g. the seeded
// empty-title template deleted wholesale) stays unwritten: creating an
Expand Down Expand Up @@ -261,17 +269,66 @@ export function createNoteSession(options: NoteSessionOptions): NoteSession {
return
}
if (dirty) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P1] (anchored here; the early return is at line 253 in reconcileFromDisk)

** A final flush refused by CAS drops the buffer: no write, no copy, no error.**

document-binding.ts:84-93 calls target.flush() and then target.dispose() synchronously, so disposed === true before the write runs. Sequence: the user edits note A, another device or a pull writes A before the 800 ms debounce fires, the user navigates away. save() fails CAS → catch at 132 → reconcileFromDisk → reads the new content → returns here. No merge, no keepAside, no emit, and flush() resolves. Reproduced with a probe test (edit, mutate disk, dispose(), advance timers): writes: [], copies: [], disk unchanged.

Master had the same hole but parked the buffer. This PR and docs/git-backup-safety.md S8 claim “the buffer is never left in a state it cannot save from, so no exit path has to rescue it”, and #1451 was closed on that claim, so this needs to hold.

Fix: let a reconciliation that a dispose-flush started run to completion. Make disposed suppress only emit() / applyToEditor, not reconcileFromDisk / mergeExternal / materialize / keepAside (for example a finalizing flag set by dispose() that these guards accept). Regression: “a dispose flush refused by an external change still merges or copies the buffer”.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed, and it had to hold. Fixed in b1e6613 without a new flag: the disposed early returns in reconcileFromDisk, mergeExternal, and materialize are gone, so a reconciliation that the final flush's refused save started runs to completion (merge and save, exact write, or a copy beside the note). What disposed still suppresses is the UI side only: emit() already ignored it, and applyToEditor now does too, since the editor belongs to the next session by then. Two guards replace the old ones: a discarded session (its file is being deleted) still stops at every await, and a disposed session with nothing dirty returns after the read, because it has nothing to rescue.

Tests: a dispose flush refused by an external change still merges the buffer (flush, dispose in the same tick, disk mutated before; the merged content is written) and a dispose flush refused with no merge available keeps the buffer beside the note. docs/git-backup-safety.md S8 names the case and the test.

// Never clobber unsaved edits — park the external content and pause the
// save pipeline (cancel any pending debounce) until the user chooses; a
// save landing now would overwrite "theirs" first.
cancelScheduledSave()
conflict = content
emit()
await mergeExternal(content)
return
}
adoptCleanContent(content)
}

/**
* External content arrived while the buffer has unsaved edits. Merge the
* two three-way over the last content read from disk: disjoint edits (a
* script appending to the daily note while the user types elsewhere, a
* folded bullet) apply silently and keep saving; overlapping edits park
* with the marked merge as a preview. Without a merge capability, or when
* a side already carries markers, the change parks as before. Never
* clobber unsaved edits: nothing is written here, and the save pipeline
* pauses while a conflict is parked.
*/
async function mergeExternal(content: string): Promise<void> {
const ours = header + buffer

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in f15f848: adoptCleanContent and adoptMerged clear error (both are the landing points of a reconciliation), so the failure from the checked save that led there does not linger with nothing left to save. The materialize and copy failure paths still set their own error afterwards. Test: a stale autosave whose content is already on disk ends with dirty: false, error: null.

let merged: MergeTextOutcome | null = null
if (io.mergeText !== undefined) {
try {
merged = await io.mergeText(path, disk, ours, content)
} catch (cause) {
console.error('three-way merge failed; parking the conflict:', cause)
}
Comment on lines +339 to +343

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in f15f848: "a merge that throws keeps the edits beside the note before adopting the external version" scripts setMerge(null), spies console.error (and asserts the message), and checks the copy is made before the external content is applied, with nothing written to the note.

}
if (disposed) {
return
}
// Typing continued while the merge ran: its result no longer covers the
// buffer. Park without it instead of applying a stale merge over newer
// keystrokes.
const current = header + buffer === ours
if (merged?.kind === 'clean' && current) {
adoptMerged(merged.content, content)
return
}
cancelScheduledSave()
conflict = content
mergedPreview = merged?.kind === 'conflicted' && current ? merged.content : null
emit()
}

/** Put `merged` in the editor as the dirty buffer over `onDisk`, and keep saving. */
function adoptMerged(merged: string, onDisk: string): void {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const doc = splitDoc(merged)
header = doc.header
buffer = doc.body
disk = onDisk
conflict = null
mergedPreview = null
dirty = merged !== onDisk
missing = false
emit()
applyToEditor(doc.body)
if (dirty) {
scheduleSave()

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Reviewed by GPT-6.

[P1] Make flush await the clean merge's write, not its debounce timer

If another device changes the file just before Cmd-Q, the exit-facing flush() attempts a checked write against the old disk. That fails, and the save-chain catch awaits reconcileFromDisk(). A clean merge reaches this code, which only schedules a write for 800 ms later. The catch then resolves with dirty === true and error === null; flushOpenDocuments() finishes, and quit-flush.ts can call confirmQuit() before the merged local edits reach disk. The same early completion affects background persistence. On navigation, immediate disposal can instead make reconciliation return at its disposed guard before preserving the buffer.

Please make the persistence boundary drain reconciliation and its resulting checked write before resolving, including teardown, without waiting recursively on the same save chain. Add a regression with stale disk plus a clean merge: awaiting flush() must leave the merged bytes on disk without advancing the debounce timer, and disposal during the deferred merge must retain local edits.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in b9e1164. Two changes: adoptMerged writes the merge now (save(), not the debounce), and flush() settles the whole save chain, looping until no step was appended while it waited, so the write a reconciliation appended inside the refused save's catch lands before the flush resolves. Test: "a flush that runs into an external change lands the clean merge before it resolves" (edit, external write, flush(); the merged content is the last write and the snapshot is clean).

}
}

/** The initial read; with `createIfMissing`, a missing file is an empty note. */
async function readInitial(): Promise<{ content: string; fileMissing: boolean }> {
try {
Expand Down Expand Up @@ -356,6 +413,7 @@ export function createNoteSession(options: NoteSessionOptions): NoteSession {
missing = false
}
conflict = null
mergedPreview = null
dirty = true // force the rewrite even if content drifted equal
emit()
save()
Expand All @@ -367,11 +425,54 @@ export function createNoteSession(options: NoteSessionOptions): NoteSession {
}
const content = conflict
conflict = null
mergedPreview = null
// Same re-gating as the clean-reload path: never load lossy content into a
// live editor whose next save would drop what it can't model.
adoptCleanContent(content)
}

function keepBoth(): void {
if (conflict === null || mergedPreview === null) {
return
}
adoptMerged(resolveConflictMarkers(mergedPreview, 'both'), conflict)
}

function review(): void {
if (conflict === null || mergedPreview === null || io.write === null) {
return
}
// The marked merge becomes the file, exactly as a Git pull leaves a
// conflicted note, and opens protected: the notice resolves it block by
// block, and every side stays recoverable on disk meanwhile. The write
// expects the external version this merge was made from; a newer one on
// disk means the preview is stale, so the conflict stays parked and is
// reconciled afresh. The parked state is only cleared once the write
// lands, and the chain settles either way.
const marked = mergedPreview
const onDisk = conflict
const write = io.write
saveChain = saveChain.then(async () => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P2] Recover the save chain when the review write fails

GPT-6, reviewed SHA 152c16b. When the Review write rejects (for example disk full), conflict and mergedPreview have already been cleared, and this promise has no catch. The session gets neither its normal error state nor a retryable conflict, and saveChain remains rejected. On the next edit, save() appends a then to that rejected chain, so its write is skipped; the catch only reconciles and does not retry the queued write. Keep the conflict until the write succeeds, surface failures, and restore a settled save chain so an ordinary retry can save. Add a failed-review-write regression followed by a successful retry.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 8f5c9f0: the parked state is cleared only after the write lands. On failure the session records error, reconciles, and the chain settles (the rejection is caught inside the step), so the next save runs normally. Test: a failing Review write keeps the conflict with the error, and Keep mine afterwards writes.

try {
await write(path, marked, onDisk)
} catch (cause) {
if (disposed) {
return
}
error = errorMessage(cause)
emit()
await reconcileFromDisk()
return
}
if (!disposed) {
conflict = null
mergedPreview = null
error = null
adoptCleanContent(marked)
}
})
}

function updateFrontmatter(patch: FrontmatterPatch): boolean {
if (disposed || isProtected || status !== 'ready') {
return false
Expand Down Expand Up @@ -548,6 +649,8 @@ export function createNoteSession(options: NoteSessionOptions): NoteSession {
flush,
keepMine,
loadTheirs,
keepBoth,
review,
content: () => header + buffer,
liveContent: () => (status === 'ready' ? header + buffer : null),
isDirty: () => dirty,
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/src/editor/note-session-types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { MergeTextOutcome } from '@reflect/core'
import type { FrontmatterPatch } from './note-session-frontmatter.ts'
import type { RoundTripFidelity } from './roundtrip.ts'

Expand Down Expand Up @@ -28,6 +29,13 @@ export interface NoteSessionSnapshot {
missing: boolean
/** External content waiting on the user's choice (set only when dirty). */
conflict: string | null
/**
* With a parked `conflict`: the three-way merge of the buffer and the
* external content, carrying labeled markers where they overlap. `null`
* when no merge was possible (no merge capability, or a side already
* carried markers), in which case only Keep mine / Load theirs apply.
*/
mergedPreview: string | null
error: string | null
}

Expand All @@ -39,6 +47,7 @@ export const INITIAL_NOTE_SNAPSHOT: NoteSessionSnapshot = {
dirty: false,
missing: false,
conflict: null,
mergedPreview: null,
error: null,
}

Expand All @@ -53,6 +62,14 @@ export interface NoteSessionIo {
write:
| ((path: string, contents: string, expectedContents?: string | null) => Promise<void>)
| null
/**
* Three-way merge of the buffer (`ours`) and external content (`theirs`)
* over the last content read from disk (`base`). Optional: without it an
* external change against a dirty buffer always parks.
*/
mergeText?:
| ((path: string, base: string, ours: string, theirs: string) => Promise<MergeTextOutcome>)
| undefined
}

/** Why {@link NoteSessionOptions.onContent} fired. */
Expand Down Expand Up @@ -134,6 +151,14 @@ export interface NoteSession {
keepMine: () => void
/** Resolve a conflict by loading the external content (discards the buffer). */
loadTheirs: () => void
/** Resolve a conflict by keeping both sides of every overlapping block (needs `mergedPreview`). */
keepBoth: () => void
/**
* Resolve a conflict by writing the marked merge to disk and opening it
* protected, where the conflict notice offers block-by-block choices
* (needs `mergedPreview`).
*/
review: () => void
/** The full current document (frontmatter + buffer), as a save would write it. */
content: () => string
/**
Expand Down
Loading
Loading