diff --git a/web/src/routes/[username]/[workspace]/[collection]/[slug]/+page.svelte b/web/src/routes/[username]/[workspace]/[collection]/[slug]/+page.svelte
index 1c444e9c..9b219bb5 100644
--- a/web/src/routes/[username]/[workspace]/[collection]/[slug]/+page.svelte
+++ b/web/src/routes/[username]/[workspace]/[collection]/[slug]/+page.svelte
@@ -26,6 +26,7 @@
import QuickActionsMenu from '$lib/components/common/QuickActionsMenu.svelte';
import BottomSheet from '$lib/components/common/BottomSheet.svelte';
import ContentSkeleton from '$lib/components/common/ContentSkeleton.svelte';
+ import ContentError from '$lib/components/common/ContentError.svelte';
import EditCollectionModal from '$lib/components/collections/EditCollectionModal.svelte';
import ShareDialog from '$lib/components/ShareDialog.svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
@@ -579,6 +580,53 @@
if (collabProvider?.synced) hasEverSynced = true;
});
+ // Stuck-connecting timeout — if the provider stays in
+ // `connecting` for >10s without ever syncing, surface the same
+ // ContentError UI as `offline`. Unconditional reset at the top
+ // of each effect run gives every fresh provider its own 10s
+ // grace (covers item navigation, rawMode toggle, force_refresh,
+ // and retry-driven rebuilds — without this, a stuck-connecting
+ // flag from a previous provider would carry over and the new
+ // provider would immediately show error UI). Only the timer
+ // can flip it back to true. Per Codex review round 1.
+ let staleConnecting = $state(false);
+ $effect(() => {
+ if (!collabProvider) return;
+ staleConnecting = false;
+ if (collabProvider.state !== 'connecting' || hasEverSynced) {
+ return;
+ }
+ const t = setTimeout(() => {
+ if (collabProvider?.state === 'connecting' && !hasEverSynced) {
+ staleConnecting = true;
+ }
+ }, 10_000);
+ return () => clearTimeout(t);
+ });
+
+ // Manual recovery from the initial-connect failure modes
+ // (staleConnecting / offline-while-!hasEverSynced). The template
+ // gate restricts retry to cases where `!hasEverSynced`, which
+ // means the current Y.Doc has never received a sync and therefore
+ // cannot hold user edits — tearing down the provider is safe.
+ // (Offline AFTER a successful sync keeps the editor mounted; see
+ // the gate comment in the template for why.)
+ //
+ // Bumps forceRefreshNonce so the collab $effect tears down the
+ // dead provider and rebuilds. We deliberately do NOT refetch
+ // items.content here — the lazy-seed on the new Y.Doc reads from
+ // the already-cached item.content, and the new provider's WS
+ // replay reconciles against canonical server state via the
+ // op-log. If the cursor is below MIN the server sends a real
+ // force_refresh which goes through onForceRefresh (which DOES
+ // refetch — correctly, because the server is the source of truth
+ // in that case). Per Codex review rounds 1 and 2 of TASK-1376.
+ function retryCollabSync() {
+ if (!item) return;
+ staleConnecting = false;
+ forceRefreshNonce += 1;
+ }
+
$effect(() => {
if (!collabKey) return;
const itemId = collabKey;
@@ -1658,7 +1706,11 @@
{#if loading}