From acd9f3ff1be48eb71e4781ebc40adb70d0f079c2 Mon Sep 17 00:00:00 2001 From: xarmian Date: Sun, 19 Jul 2026 17:21:42 -0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20retain-alive=20master=20freeze=20?= =?UTF-8?q?=E2=80=94=20peeking=20prop=20+=20complete=20mutation-path=20aud?= =?UTF-8?q?it=20(TASK-2172)=20(#977)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN-2154 Phase 2 (Architecture E, D2/R6/R9/R11/R12), per Dave's HT-2176 Option A. Adds a `peeking` prop to `ItemDetail` so the full-page master goes read-only + ALIVE while a detail pane peeks beside it — retain-alive: the collab PROVIDER/Y.Doc is never torn down/flushed. `peeking` defaults false → every existing caller byte-identical (no host passes peeking={true} until TASK-2174). Invariant (HT-2176 Option A): the freeze blocks only the INITIATION of a NEW edit while peeking; a save/action the user STARTED before the pane opened completes normally. Acceptance: "no NEW user-originated edit can be INITIATED while peeking" (not "zero dispatch"). Core freeze (blocks NEW-action initiation): `mutationsEnabled = canEdit && !peeking` (unit-tested `computeMutationsEnabled`) gates title / fields / tags / assign / move / delete / relationship-add; ChildItems add/link + reorder INITIATION; timeline comment compose + edit forms + upload; version-restore initiation; archived restore; star (`!peeking`); Share + the whole quick-actions menu (`!peeking`). The rich Editor gets `editable={!peeking}` + `peeking` in the `{#key}` (remounts re-bound to the SAME live Y.Doc, shedding BlockDragHandle — D2's mechanism); bubble/link popovers gated. Pre-pane in-flight SAVES/ACTIONS complete (Option A) — no suppression: - FIELD: `updateField` has NO peeking recheck; a value typed before the pane opened fires its 500ms FieldEditor debounce and saves. NEW field input is blocked at the UI (`FieldEditor readonly={!mutationsEnabled}`). - TAG / RAW / COLLAB debounced saves complete (drain/save callbacks un-gated). - CHILD REORDER: gated at INITIATION (`dragDisabled: !canEdit || frozen`), NOT finalization — a drag already in progress when the pane opens persists fully (removed the `frozen` reject from `handleFinalize`). Documented deliberate exceptions/edges: - Rich⇄Markdown mode FLIP is refused while peeking (it would teardown/recreate the collab provider — retain-alive/D2); the CONTENT still flushes, only the flip is deferred. Commented at both toggle handlers. - BUG-2177 (accepted, tracked, sev=low): the D2 `{#key}` editor remount destroys the initiating view, so an in-flight EDITOR-BOUND action (attachment upload, rotate/crop, source-refresh, timeline-composer upload) is orphaned — bails gracefully via `view.isDestroyed`/identity checks (no crash, no committed- content loss). Referenced at every bail site. Confirmed no teardown: `createCollabFlusher`'s save is only `api.items.flushCollabContent`; `provider.destroy` lives solely in the `{#key}`/forceRefreshNonce $effect cleanup. R12 complete editor-DOM-handler audit — every custom handler that dispatches a mutation is editable-gated (AttachmentUpload paste/drop/command; AttachmentImage rotate/crop/toolbar; code-block + table clipboard CUT/PASTE — copy read-only; htmlBlock edit-mode). These also close the same latent holes for view-only viewers (already-broken mutations) — a bug fix, not a working-flow regression. R9 clear-if-owner activeItem via `activeItemOwnedId` (byte-identical on a cross-collection load failure) + re-assert on un-peek; R11 master title effect yields while peeking. Timeline freeze via a `frozen` prop threaded ItemTimeline → TimelineCommentCard / TimelineVersionCard. Tests: `computeMutationsEnabled` truth table + FreezeProbe gate probe + a FieldSaveProbe mount test proving a pre-pane debounced field save completes after the field goes read-only. Running-app mutation-silence e2e is TASK-2175 (F). Not a Y.Doc node-spec change — SCHEMA_VERSION/DefaultSchemaVersion unchanged. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra --- web/src/lib/components/ChildItems.svelte | 52 ++- .../lib/components/common/ContentError.svelte | 13 +- .../components/common/QuickActionsMenu.svelte | 7 +- web/src/lib/components/editor/Editor.svelte | 17 +- .../lib/components/editor/attachment-image.ts | 33 ++ .../components/editor/attachment-upload.ts | 39 +++ .../lib/components/items/ItemDetail.svelte | 322 ++++++++++++++++-- .../items/masterFreeze/FieldSaveProbe.svelte | 22 ++ .../items/masterFreeze/FreezeProbe.svelte | 101 ++++++ .../masterFreeze/masterFreeze.svelte.test.ts | 216 ++++++++++++ .../lib/components/items/mutationGate.test.ts | 34 ++ web/src/lib/components/items/mutationGate.ts | 22 ++ .../components/timeline/ItemTimeline.svelte | 32 +- .../timeline/TimelineCommentCard.svelte | 21 +- .../timeline/TimelineVersionCard.svelte | 15 +- 15 files changed, 885 insertions(+), 61 deletions(-) create mode 100644 web/src/lib/components/items/masterFreeze/FieldSaveProbe.svelte create mode 100644 web/src/lib/components/items/masterFreeze/FreezeProbe.svelte create mode 100644 web/src/lib/components/items/masterFreeze/masterFreeze.svelte.test.ts create mode 100644 web/src/lib/components/items/mutationGate.test.ts create mode 100644 web/src/lib/components/items/mutationGate.ts diff --git a/web/src/lib/components/ChildItems.svelte b/web/src/lib/components/ChildItems.svelte index 7bf6acaf..3cc612dc 100644 --- a/web/src/lib/components/ChildItems.svelte +++ b/web/src/lib/components/ChildItems.svelte @@ -37,6 +37,18 @@ * proxy gate (svelte-dnd-action limitation, same as TASK-1106). */ canEdit?: boolean; + /** + * PLAN-2154 Phase 2 / D2 / R12 (TASK-2172): master-freeze. When the + * full-page host peeks a pane beside this item's ItemDetail, the master + * passes `frozen={true}` so ALL ChildItems mutations (add/link-child + + * reorder) freeze while child-row NAVIGATION stays live. Kept SEPARATE + * from `canEdit` on purpose: add/link-child authorize off independent + * target-collection / source-child capabilities (NOT the parent's edit + * permission), so folding the freeze into `canEdit` would wrongly strip + * add-child from a non-peeking user who lacks parent-edit but has those + * capabilities. Defaults false → byte-identical for existing callers. + */ + frozen?: boolean; /** * Instance-scoped shadow of the OWNING ItemDetail's own dirty/ * lastSaveTime (PLAN-2154 Phase 0 / R4, TASK-2156). ChildItems used to @@ -60,7 +72,7 @@ onOpenTarget?: (target: PaneTarget) => void; } - let { wsSlug, username = '', itemSlug, itemId, parentFields, terminalStatuses, onChildrenChange, canEdit = true, selfDirty = false, selfLastSaveTime = 0, onOpenTarget }: Props = $props(); + let { wsSlug, username = '', itemSlug, itemId, parentFields, terminalStatuses, onChildrenChange, canEdit = true, frozen = false, selfDirty = false, selfLastSaveTime = 0, onOpenTarget }: Props = $props(); const defaultTerminal = ['done', 'completed', 'resolved', 'cancelled', 'rejected', 'wontfix', 'fixed', 'implemented', 'archived', 'disabled', 'deprecated']; const terminal = $derived(terminalStatuses ?? defaultTerminal); @@ -142,12 +154,23 @@ groupData[status] = e.detail.items; isDragging = false; + // HT-2176 Option A (TASK-2172): gate reorder INITIATION, not finalization. + // A NEW drag can't START while frozen (`dragDisabled: !canEdit || frozen` + // on the dnd zone), but a drag already IN PROGRESS when the pane opens must + // finalize and persist — so do NOT reject on `frozen` here. `!canEdit` + // stays (the zone gate mirror; a non-editor never reaches finalize anyway). + if (!canEdit) return; + const updates = groupData[status] .filter((i: any) => !i[SHADOW_ITEM_MARKER_PROPERTY_NAME]) .map((item, index) => ({ id: item.id, sort_order: index })); try { for (const { id, sort_order } of updates) { + // HT-2176 Option A (TASK-2172): NO per-PATCH freeze recheck. The + // reorder was INITIATED before peeking (the top guard blocks a NEW + // one); breaking mid-loop would persist it only partially, leaving + // inconsistent sort_orders. Let the initiated reorder finish. await api.items.update(wsSlug, id, { sort_order }); } } catch (e) { @@ -162,6 +185,9 @@ // persists the changed rows via the same per-child update loop the drag // path uses. A canonical reload (SSE/sync) settles it afterward. async function reorderChild(status: string, child: Item, dir: ReorderDirection) { + // Freeze guard (TASK-2172 / R14): mirror handleFinalize. The kebab is + // hidden while `!canEdit || frozen`; this drops a straggler invocation. + if (!canEdit || frozen) return; const grp = (groupData[status] ?? []).filter( (i: any) => !i[SHADOW_ITEM_MARKER_PROPERTY_NAME] ); @@ -174,6 +200,8 @@ const updates = reorderGroup(grp, child.id, dir); try { for (const u of updates) { + // Option A (TASK-2172): no per-PATCH freeze recheck — a reorder + // initiated pre-pane finishes fully (see handleFinalize). await api.items.update(wsSlug, u.item.id, { sort_order: u.sort_order }); } } catch (e) { @@ -331,8 +359,11 @@ ); }); - // Entry "+ Add child" shows iff at least one mode is usable. - let showAddChild = $derived(createTabEnabled || canLinkExisting); + // Entry "+ Add child" shows iff at least one mode is usable — gated on the + // INDEPENDENT target-collection / source-child capabilities (NOT the parent's + // `canEdit`), so the freeze must not touch that logic. `&& !frozen` layers the + // master-freeze on top without changing any non-peeking behavior (TASK-2172). + let showAddChild = $derived((createTabEnabled || canLinkExisting) && !frozen); // ── Form state ───────────────────────────────────────────────────────── let addOpen = $state(false); @@ -468,6 +499,9 @@ } async function submitCreate() { + // Freeze guard (TASK-2172): the form is hidden while frozen, but drop a + // straggler (e.g. an Enter keydown mid-freeze) so no child is created. + if (frozen) return; const title = createTitle.trim(); const collSlug = createCollSlug; if (!title || !collSlug || creating) return; @@ -578,7 +612,8 @@ async function confirmLink() { const cand = confirmCandidate; - if (!cand || linking) return; + // Freeze guard (TASK-2172): mirror submitCreate — no reparent while frozen. + if (!cand || linking || frozen) return; // DR-6b: capture identity BEFORE the await. const reqWs = wsSlug; const reqSlug = itemSlug; @@ -632,7 +667,10 @@ - {#if addOpen} + + {#if addOpen && !frozen}
+ {#if onRetry} + + {/if}