diff --git a/web/src/routes/[username]/[workspace]/[collection]/[slug]/+page.svelte b/web/src/routes/[username]/[workspace]/[collection]/[slug]/+page.svelte index e6b57e8d..ac68dc2a 100644 --- a/web/src/routes/[username]/[workspace]/[collection]/[slug]/+page.svelte +++ b/web/src/routes/[username]/[workspace]/[collection]/[slug]/+page.svelte @@ -4,6 +4,7 @@ import { api } from '$lib/api/client'; import { collectionStore } from '$lib/stores/collections.svelte'; import { syncService } from '$lib/services/sync.svelte'; + import { sseService } from '$lib/services/sse.svelte'; import Editor from '$lib/components/editor/Editor.svelte'; import EditorBubbleMenu from '$lib/components/editor/EditorBubbleMenu.svelte'; import EditorLinkPopover from '$lib/components/editor/EditorLinkPopover.svelte'; @@ -146,6 +147,7 @@ // Sync coordinator — refresh item data on tab resume let unsubscribeSync: (() => void) | null = null; + let unsubscribeSSE: (() => void) | null = null; let unsubscribeBeforePrint: (() => void) | null = null; // Print header/footer state (PLAN-620 / TASK-623). Initialized on mount @@ -170,40 +172,133 @@ window.addEventListener('beforeprint', handler); unsubscribeBeforePrint = () => window.removeEventListener('beforeprint', handler); + // Live SSE updates for THIS item's title / fields / archive state. + // Mirrors the onSync handler below — same edit-conflict guards + // (saveStatus / editingTitle) and same content-preservation + // pattern (the editor owns content; replacing it would clobber + // in-flight edits). The detail page previously had no live + // subscription, so title/field changes from another client (or + // session) didn't propagate until a manual refresh — TASK-1243. + // + // Comments, reactions, timeline events, and child-item updates + // already have their own subscriptions inside CommentThread.svelte, + // ItemTimeline.svelte, and ChildItems.svelte respectively — we + // only handle item_updated / item_archived / item_restored for + // the parent item itself here. + // + // KNOWN LIMITATION: live content-sync is intentionally NOT handled. + // Replacing item.content while the editor is mounted would clobber + // the user's in-flight document. A proper fix needs editor-dirty- + // state integration; tracked separately. + unsubscribeSSE = sseService.onItemEvent(async (event) => { + if (!item || event.item_id !== item.id) return; + + // Archive is destructive and must NOT be gated by the + // edit-conflict guard below — a user editing a since-archived + // item should be redirected immediately. Their in-flight save + // will fail against the archived row, and silently keeping + // them on a non-existent item is worse than discarding the + // edit. Per Codex review round 2. + if (event.type === 'item_archived') { + goto(`/${username}/${wsSlug}/${collSlug}`); + return; + } + + // Non-destructive updates: skip if the user is actively + // editing the title or has a pending content save in flight. + // They'll catch up on the next idle event (and the + // syncService onTabResume path also covers anything missed). + if (saveStatus === 'saving' || editingTitle) return; + + // Capture the item this event was scoped to *before* awaiting. + // Otherwise a navigation that completes during the in-flight + // request would let the resolved fetch clobber the new item + // (TASK-754-style race guard, mirrored from loadData()). + const reqItemId = item.id; + const reqWsSlug = wsSlug; + const reqItemSlug = itemSlug; + + switch (event.type) { + case 'item_updated': { + try { + const updated = await api.items.get(reqWsSlug, reqItemSlug); + // Bail if the user navigated away before this resolved. + if (!item || item.id !== reqItemId) return; + item = { ...updated, content: item.content }; + const links = await api.links.list(reqWsSlug, updated.slug).catch(() => []); + if (!item || item.id !== reqItemId) return; + itemLinks = links; + } catch { + // Ignore — will catch up on next event + } + break; + } + case 'item_restored': { + try { + const updated = await api.items.get(reqWsSlug, reqItemSlug); + if (!item || item.id !== reqItemId) return; + item = { ...updated, content: item.content }; + } catch { + // Ignore — will catch up on next event + } + break; + } + } + }); + unsubscribeSync = syncService.onSync(async (result) => { if (!wsSlug || !itemSlug || !item) return; - // Don't refresh if the user is actively editing - if (saveStatus === 'saving' || editingTitle) return; if (result.type === 'caught_up') return; + // Deletion is destructive and must run even if the user is + // editing — same reasoning as the SSE handler's archive case. + // Check this BEFORE the edit-conflict guard so a deleted item + // doesn't sit there gated by an in-flight save. + if (result.type === 'incremental' && result.changes.deleted.includes(item.id)) { + goto(`/${username}/${wsSlug}/${collSlug}`); + return; + } + + // Don't refresh non-destructive updates if the user is actively editing + if (saveStatus === 'saving' || editingTitle) return; + + // Capture the item this sync was scoped to *before* awaiting. + // Same race guard as the SSE handler above and loadData() — + // a navigation that completes mid-flight must not let a stale + // resolution clobber the newly-loaded item. + const reqItemId = item.id; + const reqWsSlug = wsSlug; + const reqItemSlug = itemSlug; + if (result.type === 'incremental') { // Check if our item is in the changed set - const updated = result.changes.updated.find(i => i.id === item!.id); + const updated = result.changes.updated.find(i => i.id === reqItemId); if (updated) { // Merge server state without disrupting the editor + if (!item || item.id !== reqItemId) return; item = { ...updated, - content: item!.content + content: item.content }; - itemLinks = await api.links.list(wsSlug, updated.slug).catch(() => []); + const links = await api.links.list(reqWsSlug, updated.slug).catch(() => []); + if (!item || item.id !== reqItemId) return; + itemLinks = links; } - // Check if our item was deleted - if (result.changes.deleted.includes(item!.id)) { - // Item was deleted — navigate back to collection - goto(`/${username}/${wsSlug}/${collSlug}`); -} return; } // Full refresh fallback try { - const updated = await api.items.get(wsSlug, itemSlug); + const updated = await api.items.get(reqWsSlug, reqItemSlug); + if (!item || item.id !== reqItemId) return; item = { ...updated, - content: item!.content + content: item.content }; - itemLinks = await api.links.list(wsSlug, updated.slug).catch(() => []); + const links = await api.links.list(reqWsSlug, updated.slug).catch(() => []); + if (!item || item.id !== reqItemId) return; + itemLinks = links; syncService.markSynced(); // Advance cursor now that reload succeeded } catch { // Ignore — will catch up on next event @@ -213,6 +308,7 @@ onDestroy(() => { unsubscribeSync?.(); + unsubscribeSSE?.(); unsubscribeBeforePrint?.(); editorStore.resetForDoc(); collectionStore.setActiveItem(null);