diff --git a/frontend/src/lib/utils/blockConflictMerge.spec.ts b/frontend/src/lib/utils/blockConflictMerge.spec.ts index e9bd1aad..e10b245d 100644 --- a/frontend/src/lib/utils/blockConflictMerge.spec.ts +++ b/frontend/src/lib/utils/blockConflictMerge.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { mergeBlockOnConflict } from './blockConflictMerge'; +import { BlockConflictResolvedError, mergeBlockOnConflict } from './blockConflictMerge'; import type { PersonMention, TranscriptionBlockData } from '$lib/types'; const baseBlock: TranscriptionBlockData = { @@ -85,6 +85,21 @@ describe('mergeBlockOnConflict', () => { expect(merged.version).toBe(42); }); + it('carries server-only mention array through when local has none', () => { + const merged = mergeBlockOnConflict({ + serverBlock: { + ...baseBlock, + mentionedPersons: [ + { personId: 'p-aug', displayName: 'Augusta Raddatz' }, + { personId: 'p-anna', displayName: 'Anna Schmidt' } + ] + }, + localText: 'x', + localMentions: [] + }); + expect(merged.mentionedPersons).toHaveLength(2); + }); + it('carries other server fields (sortOrder, reviewed, updatedAt) forward', () => { const merged = mergeBlockOnConflict({ serverBlock: { @@ -101,3 +116,13 @@ describe('mergeBlockOnConflict', () => { expect(merged.updatedAt).toBe('2026-04-29T10:00:00Z'); }); }); + +describe('BlockConflictResolvedError', () => { + it('is an Error with code = CONFLICT_RESOLVED', () => { + const err = new BlockConflictResolvedError('block-1'); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('CONFLICT_RESOLVED'); + expect(err.name).toBe('BlockConflictResolvedError'); + expect(err.message).toContain('block-1'); + }); +}); diff --git a/frontend/src/lib/utils/blockConflictMerge.ts b/frontend/src/lib/utils/blockConflictMerge.ts index af37f9a3..c3a00039 100644 --- a/frontend/src/lib/utils/blockConflictMerge.ts +++ b/frontend/src/lib/utils/blockConflictMerge.ts @@ -1,5 +1,21 @@ import type { PersonMention, TranscriptionBlockData } from '$lib/types'; +/** + * Sentinel thrown by saveBlock after a 409 rename-mid-edit has been merged + * into local state. Surfaces to the autosave hook as an error (so the UI + * shows the retry indicator), but distinguishable from a genuine network + * failure via the code. + */ +export class BlockConflictResolvedError extends Error { + readonly code = 'CONFLICT_RESOLVED' as const; + constructor(blockId: string) { + super( + `Block ${blockId} was rebased onto the latest server snapshot — retry to save the merged result` + ); + this.name = 'BlockConflictResolvedError'; + } +} + type MergeArgs = { serverBlock: TranscriptionBlockData; localText: string; diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index b07bad86..88540618 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -100,7 +100,8 @@ async function saveBlock( }); if (res.status === 409) { // Rename-mid-edit (B12b): refetch latest, merge so transcriber input survives. - const { mergeBlockOnConflict } = await import('$lib/utils/blockConflictMerge'); + const { mergeBlockOnConflict, BlockConflictResolvedError } = + await import('$lib/utils/blockConflictMerge'); const fresh = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}`); if (fresh.ok) { const serverBlock = await fresh.json(); @@ -111,7 +112,7 @@ async function saveBlock( }); transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? merged : b)); } - throw new Error('Conflict resolved — please save again'); + throw new BlockConflictResolvedError(blockId); } if (!res.ok) throw new Error('Save failed'); const updated = await res.json();