feat(web): retain-alive master freeze — peeking prop + complete mutation-path audit (TASK-2172) (#977)

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
This commit is contained in:
xarmian
2026-07-19 17:21:42 -04:00
committed by GitHub
parent 04923c1c56
commit acd9f3ff1b
15 changed files with 885 additions and 61 deletions
+45 -7
View File
@@ -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 @@
</div>
</div>
{#if addOpen}
<!-- `&& !frozen` unmounts an already-open add-child form the instant the
parent freezes (TASK-2172): the master-freeze must leave no live mutation
controls, not just block their writes. -->
{#if addOpen && !frozen}
<div class="add-child-form">
<div class="add-child-tabs" role="tablist">
<button
@@ -776,7 +814,7 @@
type: 'child-item',
dropTargetClasses: ['drop-target'],
delayTouchStart: touchDragDelayMs,
dragDisabled: !canEdit
dragDisabled: !canEdit || frozen
}}
onconsider={(e) => handleConsider(status, e)}
onfinalize={(e) => handleFinalize(status, e)}
@@ -806,7 +844,7 @@
</span>
{/if}
</a>
{#if canEdit}
{#if canEdit && !frozen}
<ItemActionsMenu
item={child}
label={child.title}
@@ -2,7 +2,10 @@
interface Props {
title?: string;
detail?: string;
onRetry: () => void;
// Optional: when omitted the retry button is hidden entirely. The
// PLAN-2154 master-freeze (TASK-2172) passes it undefined while peeking so
// the frozen master can't trigger a provider-destroying reload.
onRetry?: () => void;
retryLabel?: string;
}
@@ -20,9 +23,11 @@
{#if detail}
<p class="error-detail">{detail}</p>
{/if}
<button class="retry-btn" onclick={onRetry}>
{retryLabel}
</button>
{#if onRetry}
<button class="retry-btn" onclick={onRetry}>
{retryLabel}
</button>
{/if}
</div>
<style>
@@ -123,6 +123,11 @@
}
async function handleSaveNewAction() {
// Recheck `canEdit` at dispatch time: if it flips false while the create
// form is open (e.g. the PLAN-2154 master-freeze passing canEdit=false
// while peeking, TASK-2172), refuse the api.collections.update. The form
// itself unmounts on the same flip (see `{#if showCreateForm && canEdit}`).
if (!canEdit) return;
const label = newLabel.trim();
const prompt = newPrompt.trim();
if (!label || !prompt || saving) return;
@@ -239,7 +244,7 @@
{/snippet}
{#snippet actionList()}
{#if showCreateForm}
{#if showCreateForm && canEdit}
{@render createForm()}
{:else}
{#each filtered as action (action.label)}
+15 -2
View File
@@ -140,7 +140,12 @@
// Clearing HTML prevents tiptap-markdown from re-decorating the paste target.
event.clipboardData.setData('text/html', '');
if (isCut) {
// Master-freeze / R12 (TASK-2172): the clipboard WRITE (copy) is read-only
// and always allowed, but the CUT deletes content — a Yjs mutation. handleDOMEvents
// fire even when the view is read-only (a peeking master OR a view-only
// viewer), so gate the delete on `view.editable`. A read-only cut degrades
// to a copy (same viewer bug-fix as the attachment gates).
if (isCut && view.editable) {
const tr = state.tr.delete(from, to);
view.dispatch(tr);
}
@@ -236,7 +241,10 @@
event.clipboardData.setData('text/plain', text);
event.clipboardData.setData('text/html', '');
if (isCut) {
// Master-freeze / R12 (TASK-2172): copy is read-only; the cut deletes
// rows/cells (a Yjs mutation) — gate on `view.editable` (see
// writeCodeBlockClipboard). A read-only cut degrades to a copy.
if (isCut && view.editable) {
if (selection instanceof CellSelection && selection.isRowSelection()) {
// Whole-row selection: prefer structural row removal so undo restores
// the rows intact in one step.
@@ -319,6 +327,11 @@
// round-trip fidelity and matches spreadsheet convention.
function readTablePasteClipboard(view: any, event: ClipboardEvent): boolean {
if (!event.clipboardData) return false;
// Master-freeze / R12 (TASK-2172): a TSV paste REPLACES table cells — a Yjs
// mutation. handleDOMEvents fire even when the view is read-only (a peeking
// master OR a view-only viewer), so bail while read-only and let the default
// (contentEditable-blocked) paste path handle it — no cell mutation lands.
if (!view.editable) return false;
const { state } = view;
// Guard 1: anchor must be inside a table.
@@ -315,6 +315,16 @@ export const AttachmentImage = Node.create<AttachmentImageOptions>({
};
const swapNodeUuid = (newId: string): void => {
// Master-freeze / R12 (TASK-2172); ACCEPTED tracked edge BUG-2177:
// runRotate/runCrop gate editability at CLICK time, but the transform
// awaits a network round-trip during which the master can begin peeking
// — remounting the editor (editable=false) via the `{#key peeking}`,
// which destroys THIS NodeView's editor. Re-check right before the
// dispatch: a destroyed view would throw, and a read-only one must not
// receive the Yjs transaction the freeze forbids. The server-side
// transform still ran; only its doc reference is dropped (the tracked
// BUG-2177 tradeoff — no crash, no committed-content loss).
if (editor.isDestroyed || !editor.isEditable) return;
const pos = typeof getPos === 'function' ? getPos() : null;
if (pos == null) return;
// Replace the node's UUID at its current position.
@@ -342,6 +352,12 @@ export const AttachmentImage = Node.create<AttachmentImageOptions>({
};
const runRotate = async (degrees: 90 | 180 | 270): Promise<void> => {
// Master-freeze / R12 (PLAN-2154 / TASK-2172): tiptap's `editable`
// flag does NOT gate this NodeView toolbar, so a read-only editor
// (e.g. a peeking full-page master) could otherwise rotate the
// attachment and dispatch a Yjs transaction. Refuse the transform
// unless the editor is currently editable.
if (!editor.isEditable) return;
// Snapshot uuid at click time. Now that update() keeps the
// NodeView alive, currentUuid can shift while transform is
// in flight (e.g. peer rotates the same image). Without the
@@ -364,6 +380,10 @@ export const AttachmentImage = Node.create<AttachmentImageOptions>({
};
const runCrop = async (): Promise<void> => {
// Master-freeze / R12 (PLAN-2154 / TASK-2172): same editable gate as
// runRotate — a read-only editor must not open the crop modal or
// dispatch the resulting transform.
if (!editor.isEditable) return;
// Snapshot uuid + alt at click time. The crop rect the user
// chooses is bound to the IMAGE-AT-OPEN-TIME, so any drift
// (peer rotated/cropped while the modal is open) must
@@ -390,6 +410,12 @@ export const AttachmentImage = Node.create<AttachmentImageOptions>({
rect = null;
}
if (rect == null) return;
// Master-freeze / R14 (TASK-2172); tracked edge BUG-2177: peeking can
// begin WHILE the crop modal is open. Re-check editability after it
// resolves — before the server-side transform — so a frozen/remounted
// master starts no transform (a crop initiated pre-pane whose editor
// remounts is the accepted BUG-2177 orphan: no crash, no content loss).
if (editor.isDestroyed || !editor.isEditable) return;
// Live node moved to a different uuid while the modal was
// open — the rect doesn't apply. Drop silently rather than
// mis-cropping the new image.
@@ -468,6 +494,13 @@ export const AttachmentImage = Node.create<AttachmentImageOptions>({
},
selectNode() {
wrapper.classList.add('attachment-image-selected');
// Master-freeze / R12 (TASK-2172): don't surface the rotate/crop
// toolbar on a read-only editor — a peeking master OR a view-only
// viewer. Its buttons no-op via the editable gate in
// runRotate/runCrop; hiding it keeps the freeze visually honest.
// Closing this for viewers too is a bug fix (their transforms had
// no provider to sync and 403'd on save), not a working-flow change.
if (!editor.isEditable) return;
const tb = ensureToolbar();
tb.classList.remove('attachment-image-toolbar-hidden');
},
@@ -171,6 +171,22 @@ function startUpload(
opts
.upload(file)
.then((result) => {
// Master-freeze / R12 (TASK-2172); ACCEPTED tracked edge BUG-2177 — the
// bytes land server-side but the reference isn't inserted (no crash, no
// committed-content loss): the view can be torn down or turned
// read-only WHILE this upload is in flight — e.g. a peeking master
// remounts its editor (editable=false) via the `{#key peeking}`,
// destroying THIS view. Never dispatch into a destroyed/read-only view:
// dispatch on a destroyed view throws, and inserting into a frozen one
// is exactly the mutation the freeze forbids. Drop the result (the
// bytes are already stored server-side; the reference is simply not
// inserted) and clean up the placeholder if the same view still lives.
if (view.isDestroyed || !view.editable) {
if (!view.isDestroyed) {
view.dispatch(view.state.tr.setMeta(pluginKey, { remove: id } satisfies UploadAction));
}
return;
}
const state = pluginKey.getState(view.state);
const entry = state?.uploads.get(id);
if (!entry) return; // placeholder gone — drop the upload silently
@@ -181,7 +197,15 @@ function startUpload(
view.dispatch(tr);
})
.catch((err: unknown) => {
// Same teardown/freeze guard as the success path (TASK-2172): a
// destroyed view can't accept the placeholder-cleanup dispatch, and a
// read-only master (peeking) must not surface an upload-error alert for
// an upload it never let the user start. The cleanup dispatch is a
// meta-only transaction (no doc step → no Yjs op), so it's safe to run
// on a live view; only skip it when the view is gone.
if (view.isDestroyed) return;
view.dispatch(view.state.tr.setMeta(pluginKey, { remove: id } satisfies UploadAction));
if (!view.editable) return;
const message = err instanceof Error ? err.message : String(err ?? 'Upload failed');
opts.onError?.(filename, message);
});
@@ -260,6 +284,15 @@ export function attachmentUploadPlugin(opts: AttachmentUploadOptions): Plugin<Up
},
handleDOMEvents: {
paste(view, event) {
// Master-freeze / R12 (PLAN-2154 / TASK-2172): ProseMirror runs
// custom DOM handlers even when the view is NOT editable, so a
// paste onto a read-only editor (a peeking full-page master OR a
// genuine view-only viewer) would otherwise upload + dispatch a
// Yjs insertion. Bail while read-only. NOTE: this also closes the
// same latent hole for view-only viewers, whose attachment
// mutations were already broken (no provider to sync; the content
// PATCH 403s) — a bug fix, not a regression of any working flow.
if (!view.editable) return false;
const files = filesFromPaste(event as ClipboardEvent);
if (files.length === 0) return false; // let other handlers process the paste
event.preventDefault();
@@ -270,6 +303,8 @@ export function attachmentUploadPlugin(opts: AttachmentUploadOptions): Plugin<Up
return true;
},
drop(view, event) {
// Same editable gate as paste (TASK-2172 / R12).
if (!view.editable) return false;
const files = filesFromDrop(event as DragEvent);
if (files.length === 0) return false;
const pos = dropPosition(view, event as DragEvent);
@@ -318,6 +353,10 @@ export const AttachmentUpload = Extension.create<AttachmentUploadOptions>({
uploadAttachments:
(files: File[]) =>
({ view }) => {
// Master-freeze / R12 (TASK-2172): refuse programmatic uploads
// on a read-only editor (the invoking toolbar/slash surfaces are
// already hidden while frozen — defense-in-depth).
if (!view.editable) return false;
if (!files.length) return false;
const pos = view.state.selection.from;
for (const file of files) {
+285 -37
View File
@@ -42,6 +42,7 @@
import { repairDeadItemLastRoute } from '$lib/collections/paneUrlParams';
import { isSamePaneTarget, breadcrumbParentTarget } from '$lib/collections/paneTarget';
import { readPaneState } from '$lib/collections/paneController';
import { computeMutationsEnabled } from './mutationGate';
import { shouldOpenInPane } from '$lib/components/collections/itemCardClick';
import { starredStore } from '$lib/stores/starred.svelte';
import { titleStore } from '$lib/stores/title.svelte';
@@ -86,6 +87,7 @@
collSlug,
ref,
embedded = false,
peeking = false,
onClose,
onGone,
onNavigateAway,
@@ -98,6 +100,14 @@
collSlug: string;
ref: string;
embedded?: boolean;
// PLAN-2154 Phase 2 / D2 (TASK-2172): retain-alive master freeze. When
// the full-page host mounts a detail pane beside this master it sets
// `peeking={!!openItemRef}` (wired in TASK-2174), turning the master
// read-only WITHOUT tearing down its live collab provider. Drives
// `mutationsEnabled` below (gates every mutation surface), the editor's
// `editable`/remount, and the timeline/ChildItems freeze. Defaults false
// so every existing (non-host) caller is byte-identical.
peeking?: boolean;
onClose?: () => void;
onGone?: () => void;
onNavigateAway?: (url: string) => void;
@@ -170,6 +180,17 @@
// counter — not reactive; it only fences async writes.
let loadGeneration = 0;
// R9 teardown ownership (TASK-2172). The onDestroy clear-if-owner compares
// the global `collectionStore.activeItem` against the id THIS instance last
// set active — NOT against the reactive `item`, which a cross-collection load
// reassigns BEFORE it resolves (and may fail to resolve) the collection,
// leaving `item` ahead of `activeItem` and desyncing the compare. Tracking
// the id we actually set active keeps single-instance teardown byte-identical
// (always clears, like the pre-R9 unconditional clear) while still not
// nulling a DIFFERENT instance's activeItem on the full-page host. Plain
// `let` — a handler-only tracker, never reactive.
let activeItemOwnedId: string | null = null;
// Per-switch fetch memoization (TASK-2120). The workspace's members and
// agent roles are workspace-invariant, yet loadData re-fetched them on
// every row-click and every j/k keystroke. Cache them on this persistent
@@ -569,6 +590,15 @@
// archived gate that forces canEdit false — otherwise the Restore CTA
// would render for read-only viewers and just 403 on click (Codex).
let canRestore = $derived(item ? workspaceStore.canEditItem(item) : false);
// PLAN-2154 Phase 2 / D2 / R12 (TASK-2172): the master-freeze gate. Every
// local/user-originated mutation surface below (title / fields / assign /
// move / delete / relationships / ChildItems / raw editor / editor mutation
// UI / timeline) gates on this instead of raw `canEdit`, so a peeking master
// (full-page host with a pane open) is a complete read-only freeze while its
// collab provider stays LIVE. Single source of truth: `computeMutationsEnabled`
// (unit-tested). `peeking` defaults false → `mutationsEnabled === canEdit` for
// every non-host caller (byte-identical).
let mutationsEnabled = $derived(computeMutationsEnabled(canEdit, peeking));
$effect(() => {
if (wsSlug && collSlug && itemSlug) {
loadData();
@@ -585,13 +615,70 @@
// yields while the pane is mounted and reclaims the tab title the instant
// the pane closes and this component unmounts. (Embedded ItemDetail is
// only ever mounted when `?item=` is set, so the two never write at once.)
//
// R11 (TASK-2172): on the full-page host a peeking master and its embedded
// pane are BOTH mounted ItemDetail instances that would write the tab title.
// The pane wins — so the peeking master yields here (mirrors the collection
// page's `!openItemRef` gate). `peeking` defaults false, so the non-host
// full-page view still owns the title exactly as before.
$effect(() => {
if (peeking) return;
titleStore.setPageTitle({
section: null,
item: item ? (formatItemRef(item) || null) : null,
});
});
// PLAN-2154 Phase 2 (TASK-2172): master-freeze TRANSITION effects.
// • Peeking BEGIN (R6): the raw-markdown editor goes read-only, but a save
// it queued just BEFORE the freeze is still sitting in the
// `rawContentSaver` debounce. Drain it synchronously (flushNow) and
// cancel the debounce so no REST `item.content` write lands mid-freeze.
// Also close any open in-place title edit so its textarea can't linger
// editable behind the freeze.
// • Peeking END (R9): the master never re-runs loadData on pane close, so
// it must reclaim the shared `collectionStore.activeItem` the pane's
// clear-if-owner teardown nulled — otherwise layout self-save suppression
// and ChildItems.defaultCollSlug degrade.
// Only `peeking` is a tracked dep — `wasPeeking` is a plain prev-flag (not
// $state, so CONVE-1688 doesn't apply) and `item`/saver reads are untracked.
// For non-host callers `peeking` is constant false, so this runs once at
// mount and does nothing (byte-identical).
let wasPeeking = false;
$effect(() => {
const nowPeeking = !!peeking;
const began = !wasPeeking && nowPeeking;
const ended = wasPeeking && !nowPeeking;
wasPeeking = nowPeeking;
if (began) {
untrack(() => {
// Freeze blocks NEW edits (HT-2176 Option A). Dismiss any open
// mutation surface so no new edit can be COMPLETED while peeking —
// an in-place title edit and the share / edit-collection / move /
// add-relationship menus. A debounced save the user INITIATED before
// the pane opened is deliberately NOT touched here: it completes
// normally (it persists the master's own pre-pane content, doesn't
// collide with the pane, and — confirmed — never tears down the
// collab provider). TASK-2172.
editingTitle = false;
shareDialogOpen = false;
editCollectionOpen = false;
showMoveMenu = false;
showAddLink = false;
});
} else if (ended) {
untrack(() => {
// Un-peek: the master reclaims the shared activeItem (R9). Nothing
// to re-flush — under Option A pre-pane saves already completed on
// their own while peeking; the freeze only ever blocked NEW edits.
if (item) {
collectionStore.setActiveItem(item);
activeItemOwnedId = item.id;
}
});
}
});
// Sync coordinator — refresh item data on tab resume
let unsubscribeSync: (() => void) | null = null;
let unsubscribeSSE: (() => void) | null = null;
@@ -871,7 +958,17 @@
loadGeneration++;
editorStore.resetForDoc();
localDirty = false;
collectionStore.setActiveItem(null);
// R9 (TASK-2172): clear-if-owner. `collectionStore.activeItem` is a
// module singleton shared with the layout (self-save suppression) and
// ChildItems (defaultCollSlug). On the full-page host the docked PANE and
// the master are two ItemDetail instances; the pane's teardown must NOT
// null the master's activeItem. Only clear it when THIS instance still
// owns it — the master's own teardown (page nav) still clears, and its
// un-peek effect re-asserts (below). Pre-host callers own it uniquely, so
// this stays a plain clear for them.
if (collectionStore.activeItem?.id === activeItemOwnedId) {
collectionStore.setActiveItem(null);
}
});
async function loadData() {
@@ -1076,6 +1173,7 @@
collection = collData;
}
collectionStore.setActiveItem(itemData);
activeItemOwnedId = itemData.id;
editorStore.resetForDoc();
localDirty = false;
@@ -1395,6 +1493,11 @@
// in that case). Per Codex review rounds 1 and 2 of TASK-1376.
function retryCollabSync() {
if (!item) return;
// Master-freeze / R14 (TASK-2172): the retry bumps forceRefreshNonce,
// whose $effect cleanup FLUSHES + DESTROYS the provider/Y.Doc — a teardown
// D2 forbids while peeking (retain-alive). Refuse it; the affordance is
// also hidden below (onRetry passed undefined while peeking).
if (peeking) return;
staleConnecting = false;
forceRefreshNonce += 1;
}
@@ -1866,7 +1969,7 @@
});
async function startEditTitle() {
if (!item || !canEdit) return;
if (!item || !mutationsEnabled) return;
titleDraft = item.title;
editingTitle = true;
// Wait for the DOM to render the textarea, then focus + select all
@@ -1913,6 +2016,9 @@
async function saveTitle() {
editingTitle = false;
// Master-freeze guard (TASK-2172): a blur can fire saveTitle after the
// freeze began; drop the write so a peeking master never PATCHes a title.
if (!mutationsEnabled) return;
if (!item || titleDraft.trim() === item.title) return;
// Capture the target item + generation BEFORE the await. A blur-fired
// saveTitle can resolve AFTER the pane switched to another item (click
@@ -1946,6 +2052,13 @@
}
async function updateField(key: string, value: any) {
// HT-2176 Option A (TASK-2172): NO `mutationsEnabled` recheck. NEW field
// input is blocked at the UI (`FieldEditor readonly={!mutationsEnabled}`),
// so this only ever runs for a value the user typed BEFORE the pane opened
// — FieldEditor's 500ms debounce fires the pending onchange after peeking
// began (peeking doesn't re-prop `value`, so its cancel-on-external-change
// $effect leaves the timer armed). That pre-pane save must COMPLETE, not be
// suppressed (mirrors the tag/raw pre-pane saves).
if (!item) return;
const updated = { ...fields, [key]: value };
const payload = JSON.stringify(updated);
@@ -2054,7 +2167,7 @@
const tagSavers = new Map<string, TagSaver>();
function updateTags(newTags: string[]) {
if (!item) return;
if (!item || !mutationsEnabled) return;
const targetItem = item;
const targetWs = wsSlug;
// Optimistic so chips react instantly.
@@ -2086,6 +2199,10 @@
saveStatus = 'saving';
try {
while (saver.pending !== null) {
// HT-2176 Option A (TASK-2172): NO peeking recheck in the drain. NEW
// tag input is blocked at the UI (`TagInput readonly={!mutationsEnabled}`)
// + the `updateTags` guard, so this only drains a tag edit the user
// made BEFORE the pane opened — allowed to complete.
const toSave = saver.pending;
saver.pending = null;
const fresh = await api.items.update(saver.ws, saver.itemId, {
@@ -2209,6 +2326,11 @@
// close the race against concurrent field edits.
const latest = await api.items.get(targetWs, targetItem.id);
if (switchedAway(targetItem, gen)) return;
// HT-2176 Option A (TASK-2172): NO peeking recheck. New imports are
// blocked at the trigger (refreshFromSource / handleImportInserted gate
// on `mutationsEnabled`); this only ever runs as the automatic
// continuation of an import the user STARTED before the pane opened, so
// it completes normally (stamping the master's own pre-pane content).
const latestFields = parseFields(latest);
const merged = {
...latestFields,
@@ -2245,7 +2367,7 @@
// as source-backed if it happened to not have synced yet. Per
// Codex review round 4.
function handleImportInserted(meta: ImportURLResponse, ctx: { wasEmpty: boolean }) {
if (!item) return;
if (!item || !mutationsEnabled) return;
const alreadyStamped =
typeof fields.pad_source_url === 'string' && fields.pad_source_url.length > 0;
if (!ctx.wasEmpty || alreadyStamped) return;
@@ -2261,7 +2383,7 @@
let refreshing = $state(false);
async function refreshFromSource() {
const url = fields.pad_source_url;
if (!url || typeof url !== 'string' || !item || !editorInstance) return;
if (!url || typeof url !== 'string' || !item || !editorInstance || !mutationsEnabled) return;
// Capture the item + editor instance before any awaits. A user
// who confirms the refresh and then navigates to another item
// before the fetch returns would otherwise have their NEW item
@@ -2284,7 +2406,11 @@
const resp = await api.importURL(url);
// Bail if the user navigated to a different item — applying
// the refresh now would target the wrong document AND stamp
// the wrong source URL.
// the wrong source URL. The `editorInstance !== targetEditor` arm
// ALSO covers the ACCEPTED tracked edge BUG-2177 (TASK-2172): a
// source-refresh confirmed pre-pane whose editor is REMOUNTED by the
// peeking `{#key peeking}` — the captured `targetEditor` is stale, so
// we drop the content replacement (no crash, no committed-content loss).
if (switchedAway(targetItem, gen) || editorInstance !== targetEditor) {
return;
}
@@ -2320,7 +2446,7 @@
}
async function updateAssignedUser(userId: string | null) {
if (!item) return;
if (!item || !mutationsEnabled) return;
// Capture target + generation before the await; drop the post-await
// write if the pane switched items mid-request (PLAN-2105 / TASK-2112;
// coordinator P1).
@@ -2346,7 +2472,7 @@
}
async function updateAgentRole(roleId: string | null) {
if (!item) return;
if (!item || !mutationsEnabled) return;
// Capture target + generation before the await; drop the post-await
// write if the pane switched items mid-request (PLAN-2105 / TASK-2112;
// coordinator P1).
@@ -2390,6 +2516,13 @@
// canonical for live state; items.content stays "reasonably
// fresh" for search / share-page / API consumers. Per
// TASK-1260 / PLAN-1248.
//
// Deliberately NOT peeking-gated (TASK-2172 / D2): while peeking the
// editor is `editable=false`, so the USER can't type — this fires only
// for REMOTE collab ops syncing into the still-live Y.Doc. Persisting
// that canonical remote state as a background snapshot is exactly the
// retain-alive posture (the acceptance carves out remote sync); gating
// it here would half-tear-down the provider the freeze must keep whole.
if (collabProvider) {
editorStore.setDirty(true);
localDirty = true;
@@ -2622,6 +2755,11 @@
debounceMs: 1200,
save: (markdown, { keepalive }) => {
if (!item) return;
// HT-2176 Option A (TASK-2172): NO peeking recheck here. NEW raw input
// is blocked at the editor (`readonly={!canEdit || peeking}`), so this
// only ever fires for a debounced save the user INITIATED before the
// pane opened — which is allowed to complete (persists the master's own
// pre-pane content; no provider teardown).
// Capture the item id this PATCH was scoped to so a
// late-arriving response after navigation to a new item
// can't apply the old item's snapshot to the new page
@@ -2703,6 +2841,10 @@
let rawSeedMarkdown = $state<string | null>(null);
function handleRawContentUpdate(markdown: string) {
// Master-freeze guard (TASK-2172 / R6): the raw editor is `readonly` while
// peeking, so this shouldn't fire — but drop any straggler so no queued
// REST `item.content` save is armed behind the freeze.
if (!mutationsEnabled) return;
// Cancel any pending LEGACY (non-collab rich) debounce so it can't
// fire a stale save over this raw edit — preserves the shared-timer
// non-trample the two paths had before TASK-2029 split the raw
@@ -2987,12 +3129,16 @@
// currently shown — otherwise A's restore would render permanently under
// ?item=B (PLAN-2105 / TASK-2112; coordinator). The descendant also
// fences its own onRestore by itemSlug (belt-and-suspenders).
// HT-2176 Option A (TASK-2172): NO peeking guard. NEW restores are blocked
// at the card (hidden while `frozen`); this callback only adopts the result
// of a restore the user CONFIRMED before the pane opened — a completed
// server-side action whose result must land, not be discarded.
if (!item || item.id !== updatedItem.id) return;
item = withInflightTags(updatedItem);
}
async function handleDelete() {
if (!item) return;
if (!item || !mutationsEnabled) return;
// Capture identity before the await. The DELETE targets `targetItem.id`
// (= A), but the post-await feedback must be fenced: if the pane
// switched to B while A's delete was in flight, calling handleGone()
@@ -3022,7 +3168,10 @@
// endpoint can 409 if the slug/invocation_slug was reclaimed while
// archived — surface that message the same way other handlers do. TASK-1829.
async function handleRestore() {
if (!item || restoring) return;
// `canEdit` is forced false for an archived item, so restore gates on
// `canRestore` — freeze it with `!peeking` (not `mutationsEnabled`), the
// same split the render gate uses (TASK-2172).
if (!item || restoring || peeking) return;
// Capture target + generation before the awaits; drop the post-await
// write if the pane switched items mid-request (PLAN-2105 / TASK-2112;
// coordinator "gate all post-await writes").
@@ -3104,7 +3253,7 @@
}
async function handleDeleteLink(linkId?: string) {
if (!linkId || !item) return;
if (!linkId || !item || !mutationsEnabled) return;
// Capture identity BEFORE the awaits. The refresh GET must use the
// captured slug (not the live `itemSlug`, which an A→B→C switch would
// have advanced) and the result must be dropped if we switched away —
@@ -3194,7 +3343,7 @@
}
async function handleCreateLink(target: Item) {
if (!item) return;
if (!item || !mutationsEnabled) return;
// Capture the SOURCE item (the one being edited) + generation before the
// awaits. `target` is the link target chosen from search; `sourceItem`
// is the current item. Use the captured slug for the refresh GET and
@@ -3229,7 +3378,7 @@
}
async function handleMove(targetSlug: string) {
if (!item || moving) return;
if (!item || moving || !mutationsEnabled) return;
moving = true;
showMoveMenu = false;
// Capture FULL route identity (workspace, username, source
@@ -3528,7 +3677,7 @@
{/if}
<span class="archived-hint">It's read-only until restored.</span>
</div>
{#if canRestore}
{#if canRestore && !peeking}
<button class="archived-restore-btn" onclick={handleRestore} disabled={restoring}>
{restoring ? 'Restoring…' : 'Restore'}
</button>
@@ -3551,12 +3700,13 @@
onkeydown={handleTitleKeydown}
oninput={(e) => autoResizeTitle(e.currentTarget)}
></textarea>
{:else if canEdit}
{:else if mutationsEnabled}
<button class="title" onclick={startEditTitle}>
{item.title}
</button>
{:else}
<!-- Read-only title (PLAN-1100 / TASK-1105) — no click-to-edit. -->
<!-- Read-only title (PLAN-1100 / TASK-1105; frozen while peeking,
TASK-2172) — no click-to-edit. -->
<h1 class="title title-readonly">{item.title}</h1>
{/if}
{#if typeof fields.pad_source_url === 'string' && fields.pad_source_url}
@@ -3580,7 +3730,7 @@
Refresh button so the import history is still
discoverable. (Per Codex review round 1.)
-->
{#if canEdit && !rawMode}
{#if mutationsEnabled && !rawMode}
<button
type="button"
class="source-chip"
@@ -3637,15 +3787,24 @@
<!-- Actions -->
<div class="meta-actions">
<!-- Star toggles a REST mutation (starredStore.toggle) and is otherwise
available to viewers too, so it gates on `peeking` ONLY — NOT
`mutationsEnabled` (which folds in canEdit and would wrongly disable
a non-peeking viewer's star, breaking byte-identity). TASK-2172. -->
<button
class="action-btn star-btn"
class:starred={starredStore.isStarred(item.id)}
onclick={() => item && starredStore.toggle(wsSlug, item.slug, item.id)}
disabled={peeking}
onclick={() => { if (peeking || !item) return; starredStore.toggle(wsSlug, item.slug, item.id); }}
title={starredStore.isStarred(item.id) ? 'Unstar' : 'Star'}
>
{starredStore.isStarred(item.id) ? '★' : '☆'}
</button>
{#if collection && (quickActions.length > 0 || isOwner)}
<!-- Quick-actions trigger gated on `!peeking` (TASK-2172): unmounts the
whole menu while peeking, dismissing any open dropdown/create-form
and blocking the owner create/manage collection-schema mutations.
`&& !peeking` → byte-identical when not peeking. -->
{#if collection && (quickActions.length > 0 || isOwner) && !peeking}
<!-- {#key itemSlug}: structural containment (PLAN-2105 / TASK-2112).
Remount this item-scoped menu on every item switch so any
in-flight quick-action continuation is discarded. Keyed on
@@ -3731,7 +3890,7 @@
📎 {backlinksCount}
</button>
{/if}
{#if canEdit}
{#if mutationsEnabled}
<div class="move-wrapper">
<button class="action-btn" onclick={() => { showMoveMenu = !showMoveMenu; }} disabled={moving}>
{moving ? 'Moving...' : 'Move to...'}
@@ -3766,12 +3925,17 @@
{/if}
</div>
{/if}
{#if isOwner}
<!-- Share opens a dialog that dispatches access-grant / share-link REST
mutations; hide the trigger while peeking. `isOwner && !peeking`
(not mutationsEnabled) stays byte-identical for an archived-item
owner when not peeking. Any already-open dialog is dismissed by the
peeking-begin transition (shareDialogOpen=false). TASK-2172. -->
{#if isOwner && !peeking}
<button class="action-btn" onclick={() => { shareDialogOpen = true; }}>
Share
</button>
{/if}
{#if canEdit}
{#if mutationsEnabled}
{#if confirmDelete}
<span class="delete-confirm">
Delete this item?
@@ -3868,7 +4032,7 @@
{field}
value={rawFieldValue}
onchange={(v) => updateField(field.key, v)}
readonly={!canEdit}
readonly={!mutationsEnabled}
/>
</div>
</div>
@@ -3883,7 +4047,7 @@
{tags}
suggestions={tagSuggestions}
onchange={updateTags}
readonly={!canEdit}
readonly={!mutationsEnabled}
/>
</div>
</div>
@@ -3896,7 +4060,7 @@
<div class="field-row">
<span class="field-label">Assigned to</span>
<div class="field-value">
{#if canEdit}
{#if mutationsEnabled}
<select
class="assignment-select"
value={item.assigned_user_id ?? ''}
@@ -3929,7 +4093,7 @@
<div class="field-row">
<span class="field-label">Role</span>
<div class="field-value">
{#if canEdit}
{#if mutationsEnabled}
<select
class="assignment-select"
value={item.agent_role_id ?? ''}
@@ -3963,11 +4127,26 @@
Editor, EditorBubbleMenu, provider, collabKey and SSE stay
persistent across an A→B item switch (the no-{#key} perf premise). -->
<div class="content-panel">
<!-- Master-freeze (TASK-2172): the Rich⇄Markdown toggle is a
provider-LIFECYCLE control — switching to Markdown flushes,
sets rawMode, nulls collabKey and DESTROYS the retained
provider (violating retain-alive). Hide it entirely while
peeking; the onclick guards below are belt-and-suspenders. -->
{#if !peeking}
<div class="editor-mode-toggle">
<button
class="mode-btn"
class:active={!rawMode}
onclick={async () => {
// DELIBERATE exception to HT-2176 Option A (TASK-2172): the
// Rich⇄Markdown mode flip mints/tears-down the collab provider
// (raw mode nulls collabKey → provider.destroy). Option A lets
// pre-pane SAVES complete, but a provider-DESTROYING mode change
// while peeking would violate retain-alive (D2). So the toggle
// is a NEW action blocked here + hidden while peeking; the raw/
// collab CONTENT still flushes below — only the mode FLIP is
// deferred to un-peek.
if (peeking) return;
// Flush any pending raw debounce SYNCHRONOUSLY
// before the collab provider mints; otherwise
// the deferred PATCH fires post-mint and gets
@@ -3985,7 +4164,15 @@
const startItemId = item?.id;
const startGen = loadGeneration;
const ok = await flushRawIfPending();
if (startGen !== loadGeneration || item?.id !== startItemId) return;
// Freeze recheck (TASK-2172 / Option A): the mode toggle is a
// NEW UI action that changes the PROVIDER lifecycle (raw⇄rich
// mints/tears down the collab provider). It's hidden while
// peeking, but a click landed pre-pane can resolve here after
// peeking began — the pending raw SAVE (flushRawIfPending) is
// allowed to complete, but the mode FLIP is blocked so the
// frozen master's provider lifecycle isn't changed. No data
// loss (content saved); the flip is just deferred to un-peek.
if (startGen !== loadGeneration || item?.id !== startItemId || peeking) return;
if (ok) {
rawSeedMarkdown = null;
rawMode = false;
@@ -3997,6 +4184,13 @@
class="mode-btn"
class:active={rawMode}
onclick={async () => {
// DELIBERATE exception to Option A (TASK-2172): see the Rich
// button above — switching to raw nulls collabKey and DESTROYS
// the retained provider, which retain-alive (D2) forbids while
// peeking. The collab CONTENT still flushes below (the pre-pane
// save completes); only the provider-destroying mode FLIP is
// blocked + deferred to un-peek.
if (peeking) return;
// Toggle rich+collab → raw. We need to
// land the live Y.Doc state in
// items.content BEFORE activating raw
@@ -4049,7 +4243,10 @@
// show B a recovery toast, keep looping, or
// fall through to rawMode=true for the wrong
// item (PLAN-2105 / TASK-2112; coordinator).
if (!item || item.id !== itemId || genAtToggle !== loadGeneration) return;
// `|| peeking` (TASK-2172 / R14): peeking can begin
// mid-loop; bail before the NEXT flush dispatch so a
// frozen master issues no further collab PATCH.
if (!item || item.id !== itemId || genAtToggle !== loadGeneration || peeking) return;
if (result === 'failed' || result === 'skipped') {
// 'failed' — PATCH errored;
// runCollabFlush already
@@ -4112,6 +4309,12 @@
if (!item || item.id !== itemId || genAtToggle !== loadGeneration) return;
}
}
// Freeze recheck (TASK-2172): peeking can begin DURING the
// awaited flush loop. Switching to rawMode nulls collabKey
// and DESTROYS the retained provider — a retain-alive
// violation. Bail before the mode flip; the flush already
// landed items.content, so nothing is lost.
if (peeking) return;
// Cancel any pending timer-driven flush
// scheduled by edits during the await
// window — left armed, it would fire
@@ -4124,9 +4327,15 @@
title="Raw markdown editor"
>Markdown</button>
</div>
{/if}
{#if rawMode}
{#key item.id}
<RawMarkdownEditor content={rawSeedMarkdown ?? item.content ?? ''} onUpdate={handleRawContentUpdate} readonly={!canEdit} />
<!-- Master-freeze (TASK-2172, HT-2176 Option A): a peeking
master's raw editor is read-only, so NO NEW raw edit can be
entered. A save the user debounced BEFORE the pane opened
completes normally (it persists the master's own pre-pane
content; not suppressed). -->
<RawMarkdownEditor content={rawSeedMarkdown ?? item.content ?? ''} onUpdate={handleRawContentUpdate} readonly={!canEdit || peeking} />
{/key}
{:else}
<!--
@@ -4184,16 +4393,41 @@
<ContentError
title="Content unavailable"
detail="Could not sync with the server. Reload the editor to try again."
onRetry={retryCollabSync}
onRetry={peeking ? undefined : retryCollabSync}
/>
{:else if collabProvider?.state === 'connecting' && !hasEverSynced}
<ContentSkeleton variant="inline" />
{:else}
{#key `${item.id}:true:${forceRefreshNonce}`}
<!--
Master-freeze (TASK-2154 D2 / TASK-2172): `peeking` is
in the {#key} so a freeze/un-freeze REMOUNTS the editor
re-bound to the SAME live Y.Doc (ydoc prop unchanged —
no teardown of the PROVIDER/Y.Doc, no data loss, the
forceRefreshNonce remount pattern). The remount is
load-bearing: BlockDragHandle is construction-gated on
`editable` (Editor.svelte ~885) — a complex plugin with no
single runtime gate — so a reactive editable flip alone
would leave the drag-reorder mutation hole open.
`editable={!peeking}` also auto-disables the slash menu,
mobile toolbar, and table toolbar. Defaults false →
editable=true, byte-identical for non-host callers.
Tradeoff (HT-2176 Option A): the remount destroys the
editor VIEW, so an editor-bound action in flight at the
instant the pane opens (a paste/drop upload, a rotate/crop
transform, a bubble-menu create, a source refresh) is
interrupted rather than completed. All degrade GRACEFULLY
— the completion paths check `view.isDestroyed`/identity
and drop silently; existing content is never lost (it's in
the retained Y.Doc). This is the accepted cost of closing
the drag-reorder NEW-mutation hole, which the freeze must
do; the alternative (no remount) reopens that hole.
-->
{#key `${item.id}:true:${forceRefreshNonce}:${peeking}`}
<Editor
content={editorContent}
onUpdate={handleContentUpdate}
editable={true}
editable={!peeking}
itemId={item.id}
ydoc={ydoc}
awareness={collabProvider?.awareness}
@@ -4204,7 +4438,9 @@
{/key}
{/if}
{/if}
{#if canEdit}
{#if mutationsEnabled}
<!-- Editor mutation UI — gated on the master-freeze predicate
(TASK-2172): a peeking master shows no bubble/link popover. -->
<EditorBubbleMenu
editor={editorInstance}
{wsSlug}
@@ -4259,7 +4495,7 @@
{#if entry.status}
<span class="link-status">{formatFieldDisplay(entry.status)}</span>
{/if}
{#if entry.linkId && canEdit}
{#if entry.linkId && mutationsEnabled}
<button class="link-delete-btn" title="Remove relationship" onclick={() => handleDeleteLink(entry.linkId)}>×</button>
{/if}
</span>
@@ -4272,8 +4508,9 @@
</div>
{/if}
<!-- Add Relationship — gated on canEdit (PLAN-1100 / TASK-1105). -->
{#if item && canEdit}
<!-- Add Relationship — gated on the master-freeze predicate (PLAN-1100 /
TASK-1105; frozen while peeking, TASK-2172). -->
{#if item && mutationsEnabled}
<div class="add-relationship-section">
{#if !showAddLink}
<button class="add-relationship-btn" onclick={() => { showAddLink = true; }}>
@@ -4330,7 +4567,13 @@
always-mounted SSE guarantee below. -->
{#if item}
<div id="item-children" class="children-anchor">
<ChildItems {wsSlug} {username} {itemSlug} itemId={item.id} parentFields={fields} terminalStatuses={childTerminalStatuses} onChildrenChange={(children) => { if (keyedSlug !== itemSlug) return; handleChildrenChange(children); }} {canEdit} selfDirty={localDirty} selfLastSaveTime={localLastSaveTime} onOpenTarget={paneOpenTarget} />
<!-- ChildItems takes the REAL `canEdit` (reorder authorizes off
parent edit) plus a SEPARATE `frozen={peeking}` (TASK-2172): the
freeze stops add-child + reorder while child-row navigation stays
live, WITHOUT routing add-child's independent capability logic
through the parent's canEdit (that would change non-peeking
behavior — the byte-identity regression the orchestrator flagged). -->
<ChildItems {wsSlug} {username} {itemSlug} itemId={item.id} parentFields={fields} terminalStatuses={childTerminalStatuses} onChildrenChange={(children) => { if (keyedSlug !== itemSlug) return; handleChildrenChange(children); }} {canEdit} frozen={peeking} selfDirty={localDirty} selfLastSaveTime={localLastSaveTime} onOpenTarget={paneOpenTarget} />
</div>
{/if}
@@ -4358,6 +4601,10 @@
<!-- Unified Timeline (comments + activity + versions) -->
<div id="item-timeline" class="timeline-section">
<!-- Timeline freeze (TASK-2172 / R12): `frozen={peeking}` hides the
comment composer, disables reply/reaction/delete, unmounts any
already-open comment/reply edit form (and its CommentEditor direct
upload), and hides version restore on a peeking master. -->
<ItemTimeline
{wsSlug}
{username}
@@ -4367,6 +4614,7 @@
onRestore={handleVersionRestore}
itemId={item.id}
collectionId={item.collection_id}
frozen={peeking}
/>
</div>
{/key}
@@ -0,0 +1,22 @@
<script lang="ts">
// PLAN-2154 Phase 2 / HT-2176 Option A (TASK-2172) — pre-pane field-save probe.
//
// Mounts the REAL `FieldEditor` to prove the Option A invariant for FIELDS: a
// value the user typed BEFORE the pane opened still SAVES (its 500ms debounce
// fires onchange even after the field flips read-only on peeking-begin), while
// a NEW field edit cannot be started once read-only. `peeking` drives the
// FieldEditor's `readonly` exactly as ItemDetail does (`readonly={!mutationsEnabled}`,
// with canEdit=true here). A `<button>` flips peeking so the test can simulate
// the pane opening mid-edit without prop-rerender gymnastics.
import FieldEditor from '$lib/components/fields/FieldEditor.svelte';
import type { FieldDef } from '$lib/types';
let { onchange }: { onchange: (v: any) => void } = $props();
let peeking = $state(false);
const field: FieldDef = { key: 'component', label: 'Component', type: 'text' };
</script>
<button data-testid="begin-peek" onclick={() => (peeking = true)}>peek</button>
<FieldEditor {field} value="" {onchange} readonly={peeking} />
@@ -0,0 +1,101 @@
<script lang="ts">
// PLAN-2154 Phase 2 / D2 / R12 (TASK-2172) — master-freeze wiring probe.
//
// HT-2176 Option A: the freeze blocks the INITIATION of NEW edits while the
// master peeks; a save the user debounced BEFORE the pane opened completes
// normally (no suppression, no data loss, no provider teardown). So this probe
// asserts the NEW-EDIT GATES only — every new-edit surface is disabled/gated
// while peeking, and byte-identical to the canEdit-only baseline when not.
// (That a pre-pane in-flight save is NOT suppressed is a runtime property of
// ItemDetail's saver/flush paths — no recheck blocks them — not assertable in
// this static gate probe.)
//
// The running-app assertion is deferred to TASK-2175 (F) — no host passes
// `peeking={true}` until TASK-2174 (E). The probe imports the SAME
// `computeMutationsEnabled` helper `ItemDetail` uses and renders the CANONICAL
// gate EXPRESSIONS from ItemDetail.svelte, so the two can't drift — same
// pattern as GuardProbe.svelte for the localDirty shadow.
import { computeMutationsEnabled } from '../mutationGate';
let {
canEdit = true,
peeking = false,
canRestore = true,
isOwner = true,
quickActionsPresent = false,
}: {
canEdit?: boolean;
peeking?: boolean;
canRestore?: boolean;
isOwner?: boolean;
quickActionsPresent?: boolean;
} = $props();
// The exact derived from ItemDetail.svelte.
let mutationsEnabled = $derived(computeMutationsEnabled(canEdit, peeking));
</script>
<!-- Scalar gate props threaded to child components (mirror the exact
ItemDetail expressions). -->
<div data-testid="mutationsEnabled">{mutationsEnabled}</div>
<div data-testid="editor-editable">{!peeking}</div>
<div data-testid="raw-readonly">{!canEdit || peeking}</div>
<!-- FieldEditor input is readonly while peeking → NO new field edit can be
started; a value typed BEFORE the pane opened still saves (updateField has
no peeking recheck — its debounce completes). -->
<div data-testid="field-readonly">{!mutationsEnabled}</div>
<!-- ChildItems receives the REAL canEdit (reorder authorizes off parent edit)
plus a SEPARATE frozen — NOT mutationsEnabled — so add-child's independent
capability logic is untouched when not peeking. Timeline mirrors this. -->
<div data-testid="child-canEdit">{canEdit}</div>
<div data-testid="child-frozen">{peeking}</div>
<div data-testid="timeline-frozen">{peeking}</div>
<!-- The Rich⇄Markdown mode toggle is a provider-LIFECYCLE control (switching to
Markdown destroys the retained collab provider), so it hides on `!peeking`
— NOT on `mutationsEnabled` (a genuine read-only viewer keeps the toggle;
only a peeking master must not tear the provider down). -->
{#if !peeking}
<button data-testid="mode-toggle">Rich / Markdown</button>
{/if}
<!-- Mutation UI gated on `mutationsEnabled` — unmounted while peeking. -->
{#if mutationsEnabled}
<button data-testid="delete-btn">Delete</button>
{/if}
{#if mutationsEnabled}
<button data-testid="move-btn">Move to…</button>
{/if}
{#if mutationsEnabled}
<button data-testid="add-relationship-btn">+ Add relationship</button>
{/if}
{#if mutationsEnabled}
<button data-testid="editor-mutation-ui">bubble/link popover</button>
{/if}
<!-- Title: editable click-to-edit vs read-only heading. -->
{#if mutationsEnabled}
<button data-testid="title-editable">Edit title</button>
{:else}
<h1 data-testid="title-readonly">Title</h1>
{/if}
<!-- Archived restore uses the `canRestore && !peeking` split (canEdit is forced
false for archived items, so it can't ride `mutationsEnabled`). -->
{#if canRestore && !peeking}
<button data-testid="archived-restore-btn">Restore</button>
{/if}
<!-- Star gates on `peeking` ONLY (viewers can star; mutationsEnabled would
wrongly disable a non-peeking viewer). -->
<button data-testid="star-btn" disabled={peeking}>Star</button>
<!-- Share + the whole quick-actions menu gate on `!peeking` (the menu unmounts
while peeking, dismissing any open dropdown/create-form). NOT mutationsEnabled
— byte-identical for an archived-item owner. -->
{#if isOwner && !peeking}
<button data-testid="share-btn">Share</button>
{/if}
{#if (quickActionsPresent || isOwner) && !peeking}
<button data-testid="quickactions-menu">Quick actions</button>
{/if}
@@ -0,0 +1,216 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { flushSync, mount, unmount } from 'svelte';
import FreezeProbe from './FreezeProbe.svelte';
import FieldSaveProbe from './FieldSaveProbe.svelte';
// PLAN-2154 Phase 2 / D2 / R12 (TASK-2172) — retain-alive master freeze.
//
// HT-2176 Option A: the freeze blocks the INITIATION of NEW edits while peeking;
// a pre-pane in-flight/debounced save completes on its own (not suppressed, not
// re-flushed on un-peek). These tests therefore assert the NEW-EDIT GATES only —
// (a) every new-edit surface is disabled/gated while peeking, and byte-identical
// to the canEdit-only baseline when not. There are deliberately NO suspend /
// resume / re-flush assertions: nothing is suspended under Option A.
//
// FreezeProbe mounts the CANONICAL freeze gate expressions from ItemDetail,
// backed by the shared `computeMutationsEnabled` helper, so the gate predicate +
// wiring can't drift. The running-app assertion is TASK-2175's — no host passes
// `peeking={true}` until TASK-2174.
function target(): HTMLElement {
return document.body.appendChild(document.createElement('div'));
}
function text(root: HTMLElement, testid: string): string {
return root.querySelector(`[data-testid="${testid}"]`)?.textContent ?? '';
}
function present(root: HTMLElement, testid: string): boolean {
return root.querySelector(`[data-testid="${testid}"]`) != null;
}
function disabled(root: HTMLElement, testid: string): boolean {
return (root.querySelector(`[data-testid="${testid}"]`) as HTMLButtonElement | null)?.disabled ?? false;
}
// Every mutation surface the master-freeze gates behind `mutationsEnabled`.
const MUTATION_SURFACES = [
'delete-btn',
'move-btn',
'add-relationship-btn',
'editor-mutation-ui',
'title-editable',
];
describe('retain-alive master freeze wiring (TASK-2172)', () => {
let root: HTMLElement | null = null;
let instance: ReturnType<typeof mount> | null = null;
function render(props: { canEdit?: boolean; peeking?: boolean; canRestore?: boolean }) {
root = target();
instance = mount(FreezeProbe, { target: root, props });
flushSync();
return root;
}
afterEach(() => {
if (instance) unmount(instance);
root?.remove();
instance = null;
root = null;
});
it('peeking=true freezes EVERY mutation surface even for an editor (canEdit=true)', () => {
const r = render({ canEdit: true, peeking: true });
expect(text(r, 'mutationsEnabled')).toBe('false');
// Editor is read-only and the raw editor is read-only; the field input
// is read-only too (no NEW field edit can be started while peeking).
expect(text(r, 'editor-editable')).toBe('false');
expect(text(r, 'raw-readonly')).toBe('true');
expect(text(r, 'field-readonly')).toBe('true');
// Child + timeline receive the frozen signal (child keeps the REAL
// canEdit — the freeze rides the separate `frozen` prop, not canEdit).
expect(text(r, 'child-canEdit')).toBe('true');
expect(text(r, 'child-frozen')).toBe('true');
expect(text(r, 'timeline-frozen')).toBe('true');
// Every gated mutation control is unmounted.
for (const surface of MUTATION_SURFACES) {
expect(present(r, surface), `${surface} must be frozen`).toBe(false);
}
// Title falls through to the read-only heading, and archived restore hides.
expect(present(r, 'title-readonly')).toBe(true);
expect(present(r, 'archived-restore-btn')).toBe(false);
// The mode toggle (provider-teardown control) is hidden — retain-alive.
expect(present(r, 'mode-toggle')).toBe(false);
// Star disabled; Share + the whole quick-actions menu hidden/dismissed.
expect(disabled(r, 'star-btn')).toBe(true);
expect(present(r, 'share-btn')).toBe(false);
expect(present(r, 'quickactions-menu')).toBe(false);
});
it('peeking=false, canEdit=true keeps every mutation surface live (byte-identical baseline)', () => {
const r = render({ canEdit: true, peeking: false });
expect(text(r, 'mutationsEnabled')).toBe('true');
expect(text(r, 'editor-editable')).toBe('true');
expect(text(r, 'raw-readonly')).toBe('false');
expect(text(r, 'field-readonly')).toBe('false');
expect(text(r, 'child-canEdit')).toBe('true');
expect(text(r, 'child-frozen')).toBe('false');
expect(text(r, 'timeline-frozen')).toBe('false');
for (const surface of MUTATION_SURFACES) {
expect(present(r, surface), `${surface} must be live`).toBe(true);
}
expect(present(r, 'title-readonly')).toBe(false);
expect(present(r, 'archived-restore-btn')).toBe(true);
// A non-peeking master keeps its mode toggle (editor or viewer alike).
expect(present(r, 'mode-toggle')).toBe(true);
// Star enabled; Share + quick-actions menu live.
expect(disabled(r, 'star-btn')).toBe(false);
expect(present(r, 'share-btn')).toBe(true);
expect(present(r, 'quickactions-menu')).toBe(true);
});
it('star stays enabled for a non-peeking VIEWER, and share/quick-actions for a non-peeking archived owner (byte-identity)', () => {
// A viewer (canEdit=false, mutationsEnabled=false) can still star when not
// peeking — star gates on peeking, not mutationsEnabled.
let r = render({ canEdit: false, peeking: false, isOwner: false });
expect(disabled(r, 'star-btn')).toBe(false);
unmount(instance!);
root!.remove();
// An archived-item owner (isOwner=true, canEdit=false) keeps Share + the
// quick-actions menu when not peeking — they gate on `!peeking`, not
// mutationsEnabled (which would fold in the archived canEdit=false).
r = render({ canEdit: false, peeking: false, isOwner: true });
expect(present(r, 'share-btn')).toBe(true);
expect(present(r, 'quickactions-menu')).toBe(true);
// Peeking freezes both regardless.
unmount(instance!);
root!.remove();
r = render({ canEdit: false, peeking: true, isOwner: true });
expect(disabled(r, 'star-btn')).toBe(true);
expect(present(r, 'share-btn')).toBe(false);
expect(present(r, 'quickactions-menu')).toBe(false);
});
it('peeking gate is independent of canEdit — a view-only master already freezes without peeking', () => {
// canEdit=false alone (a genuine read-only viewer) hides the mutation UI;
// `mutationsEnabled` collapses to canEdit when not peeking, so the freeze
// prop changes nothing for that caller.
const r = render({ canEdit: false, peeking: false });
expect(text(r, 'mutationsEnabled')).toBe('false');
expect(text(r, 'editor-editable')).toBe('true'); // still a live (read-only) editor, NOT peeking
// The mode toggle stays for a read-only viewer — it's peeking-gated, not
// mutation-gated (the provider only needs protecting from a peek teardown).
expect(present(r, 'mode-toggle')).toBe(true);
for (const surface of MUTATION_SURFACES) {
expect(present(r, surface)).toBe(false);
}
});
it('archived restore rides `canRestore && !peeking`, not canEdit (archived items force canEdit false)', () => {
// Not peeking: restore shows for a permitted user.
let r = render({ canEdit: false, peeking: false, canRestore: true });
expect(present(r, 'archived-restore-btn')).toBe(true);
unmount(instance!);
root!.remove();
// Peeking: restore hides even though canRestore is true.
r = render({ canEdit: false, peeking: true, canRestore: true });
expect(present(r, 'archived-restore-btn')).toBe(false);
});
});
// HT-2176 Option A / fix #1 (TASK-2172): a FIELD value typed BEFORE the pane
// opened must SAVE. FieldEditor debounces onchange ~500ms; if peeking begins
// before it fires, the field flips read-only (blocking any NEW edit) but the
// pending debounce still fires onchange — and `updateField` no longer rechecks
// `mutationsEnabled`, so the pre-pane value completes. This mounts the REAL
// FieldEditor to lock that behavior in.
describe('pre-pane debounced field save completes under Option A (TASK-2172)', () => {
let root: HTMLElement | null = null;
let instance: ReturnType<typeof mount> | null = null;
afterEach(() => {
if (instance) unmount(instance);
root?.remove();
instance = null;
root = null;
vi.useRealTimers();
});
function mountProbe(onchange: (v: any) => void) {
root = target();
instance = mount(FieldSaveProbe, { target: root, props: { onchange } });
flushSync();
return root;
}
it('a value typed before peeking still fires onchange after the field goes read-only', () => {
vi.useFakeTimers();
const onchange = vi.fn();
const r = mountProbe(onchange);
// Type a value (pre-pane) — arms the 500ms debounce, does NOT fire yet.
const input = r.querySelector<HTMLInputElement>('input.field-input')!;
input.value = 'ui/editor';
input.dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
expect(onchange).not.toHaveBeenCalled();
// Pane opens mid-edit → the field flips read-only (input unmounts, no new
// edit possible) but the armed debounce survives.
r.querySelector<HTMLButtonElement>('[data-testid="begin-peek"]')!.click();
flushSync();
expect(r.querySelector('input.field-input')).toBeNull(); // NEW edit blocked
// The debounce fires → the pre-pane value reaches the parent (→ updateField,
// which no longer suppresses it). The freeze did NOT drop the typed value.
vi.advanceTimersByTime(500);
expect(onchange).toHaveBeenCalledTimes(1);
expect(onchange).toHaveBeenCalledWith('ui/editor');
});
});
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { computeMutationsEnabled } from './mutationGate';
// PLAN-2154 Phase 2 / D2 / R12 (TASK-2172). `computeMutationsEnabled` is the
// single source of truth for the retain-alive master freeze: `ItemDetail`'s
// `mutationsEnabled` derived and its freeze test probe both import it, so the
// gate predicate can't drift between the component and its coverage.
describe('computeMutationsEnabled — master-freeze gate predicate (TASK-2172)', () => {
it('is exactly canEdit while NOT peeking (byte-identical for non-host callers)', () => {
// `peeking` defaults false at every non-host call site, so the freeze
// prop must leave the gate equal to raw `canEdit`.
expect(computeMutationsEnabled(true, false)).toBe(true);
expect(computeMutationsEnabled(false, false)).toBe(false);
});
it('is false while peeking regardless of canEdit (complete freeze)', () => {
// The core freeze invariant: a peeking master enables NO mutation,
// even for an owner/editor who would otherwise have full edit rights.
expect(computeMutationsEnabled(true, true)).toBe(false);
expect(computeMutationsEnabled(false, true)).toBe(false);
});
it('only ever returns true in the single (canEdit && !peeking) corner', () => {
const truthTable: Array<[boolean, boolean, boolean]> = [
[true, false, true],
[true, true, false],
[false, false, false],
[false, true, false],
];
for (const [canEdit, peeking, expected] of truthTable) {
expect(computeMutationsEnabled(canEdit, peeking)).toBe(expected);
}
});
});
@@ -0,0 +1,22 @@
// PLAN-2154 Phase 2 / D2 (TASK-2172): the master-freeze gate predicate.
//
// The full-page item view (`[slug]/+page.svelte`) keeps its `ItemDetail`
// master ALIVE while a detail pane peeks beside it (retain-alive — the collab
// provider is never torn down; no flush, snapshot, or persistence barrier).
// The master goes read-only via a single `peeking` prop. `mutationsEnabled`
// is the derived flag that gates EVERY local/user-originated mutation surface
// on the master: title edit, fields, assignment, move, delete, relationship
// add/remove, ChildItems add-child/reorder, the raw-markdown editor, the
// editor bubble/link popovers, the timeline (comment compose + edit forms +
// version restore), and the AttachmentImage node toolbar (via `editable`).
//
// Factored out as a pure function so the gate is a single, unit-testable
// source of truth shared by `ItemDetail.svelte` and its freeze test probe —
// the two can't drift. Keep it dependency-free.
//
// `peeking` defaults falsy at every non-peeking call site, so
// `mutationsEnabled === canEdit` for every existing (non-host) caller — the
// prop is a pure addition and leaves those callers byte-identical.
export function computeMutationsEnabled(canEdit: boolean, peeking: boolean): boolean {
return canEdit && !peeking;
}
@@ -36,16 +36,29 @@
*/
itemId?: string;
collectionId?: string;
/**
* PLAN-2154 Phase 2 / D2 / R12 (TASK-2172): master-freeze. When the
* full-page host peeks a detail pane beside this item's ItemDetail, the
* master passes `frozen={true}` so its timeline goes fully read-only:
* the composer hides, reply/reaction/delete disable, any already-open
* comment/reply edit form (and its CommentEditor direct-upload) unmounts,
* and version restore hides. Defaults false → byte-identical for every
* existing caller.
*/
frozen?: boolean;
}
let { wsSlug, username = '', itemSlug, currentContent, items = [], onRestore, itemId, collectionId }: Props = $props();
let { wsSlug, username = '', itemSlug, currentContent, items = [], onRestore, itemId, collectionId, frozen = false }: Props = $props();
// Resolve canEditItem reactively; falls to false if itemId/collectionId
// aren't supplied (e.g. an older caller).
// aren't supplied (e.g. an older caller). Folds in the master-freeze gate
// (TASK-2172): while `frozen`, the composer / reply / reaction / delete
// affordances all disable through this single derived.
let canEdit = $derived(
itemId && collectionId
!frozen &&
(itemId && collectionId
? workspaceStore.canEditItem({ id: itemId, collection_id: collectionId })
: false
: false)
);
let entries: TimelineEntry[] = $state([]);
@@ -415,9 +428,14 @@
{/if}
</header>
<!-- Comment compose — gated on canEditItem (PLAN-1100 / TASK-1107).
<!-- Comment compose — gated on canEditItem (PLAN-1100 / TASK-1107) folded
with the master-freeze (`frozen` → canEdit=false, TASK-2172).
Read-only viewers / guests with view-only grants see the timeline
thread but cannot post; the composer is hidden entirely. -->
thread but cannot post; the composer is hidden entirely. Note: an
attachment upload the user STARTED in this composer before the pane
opened is orphaned when the composer unmounts on freeze — the ACCEPTED,
tracked BUG-2177 tradeoff (its upload bails via attachment-upload.ts's
view.isDestroyed check; no crash, no committed-content loss). -->
{#if canEdit}
<div class="compose">
<CommentEditor
@@ -459,6 +477,7 @@
{items}
{currentUserId}
{canEdit}
{frozen}
{isAdmin}
{attachmentResolver}
onDelete={handleDelete}
@@ -476,6 +495,7 @@
{itemSlug}
{currentContent}
{onRestore}
{frozen}
/>
{/if}
</div>
@@ -20,6 +20,15 @@
* don't pass it.
*/
canEdit?: boolean;
/**
* PLAN-2154 Phase 2 / D2 / R12 (TASK-2172): master-freeze. When a peeking
* master's timeline is frozen, the author/admin edit affordance
* (`canEditComment`) is suppressed AND any ALREADY-OPEN comment/reply edit
* form (with its CommentEditor direct-upload) is unmounted — distinct from
* `canEdit`, which gates reply/reaction/delete. Defaults false →
* byte-identical for existing callers.
*/
frozen?: boolean;
/**
* Resolves `pad-attachment:UUID` references in the comment / reply
* bodies to inline images or file chips (IDEA-1650). Optional —
@@ -37,7 +46,7 @@
onRemoveReaction: (commentId: string, emoji: string) => void;
}
let { comment, wsSlug, username = '', items, currentUserId = '', canEdit = true, attachmentResolver, isAdmin = false, onDelete, onReply, onEdit, onReaction, onRemoveReaction }: Props = $props();
let { comment, wsSlug, username = '', items, currentUserId = '', canEdit = true, frozen = false, attachmentResolver, isAdmin = false, onDelete, onReply, onEdit, onReaction, onRemoveReaction }: Props = $props();
let showReplyForm = $state(false);
let submittingReply = $state(false);
@@ -52,6 +61,8 @@
// A null/empty user_id has no provable author → admin-only, matching the
// server's canEditComment.
function canEditComment(c: Comment): boolean {
// Master-freeze (TASK-2172): a peeking master surfaces no edit affordance.
if (frozen) return false;
return isAdmin || (!!c.user_id && c.user_id === currentUserId);
}
@@ -204,7 +215,9 @@
<div class="activity-label">commented on update</div>
{/if}
{#if editing}
{#if editing && !frozen}
<!-- Master-freeze (TASK-2172 / R12): unmount an already-open edit form —
and its CommentEditor direct-upload — the instant the master peeks. -->
<div class="edit-compose">
<CommentEditor
{wsSlug}
@@ -307,7 +320,9 @@
{/if}
</div>
{#if editingReplyId === reply.id}
{#if editingReplyId === reply.id && !frozen}
<!-- Master-freeze (TASK-2172 / R12): same unmount-on-peek as
the comment edit form above. -->
<div class="edit-compose">
<CommentEditor
{wsSlug}
@@ -10,9 +10,16 @@
itemSlug: string;
currentContent: string;
onRestore?: (item: Item) => void;
/**
* PLAN-2154 Phase 2 / D2 / R12 (TASK-2172): master-freeze. The restore
* button is otherwise ungated (server-authorized), so a peeking master
* must hide it to keep the freeze complete. Defaults false →
* byte-identical for existing callers.
*/
frozen?: boolean;
}
let { version, wsSlug, itemSlug, currentContent, onRestore }: Props = $props();
let { version, wsSlug, itemSlug, currentContent, onRestore, frozen = false }: Props = $props();
let expanded = $state(false);
let confirming = $state(false);
@@ -68,6 +75,9 @@
}
async function confirmRestore() {
// Master-freeze guard (TASK-2172): the restore UI is hidden while frozen,
// but drop a straggler click so a peeking master never dispatches restore.
if (frozen) return;
// Capture the item identity before the await so a mid-flight item switch
// (rapid j/k / row-click in the split pane) can't fire onRestore with
// A's restored item into a parent now showing B — nor flip this card's
@@ -140,6 +150,8 @@
{/if}
</div>
<!-- Master-freeze (TASK-2172 / R12): a peeking master hides restore. -->
{#if !frozen}
<div class="restore-area">
{#if confirming}
<div class="confirm-prompt">
@@ -173,6 +185,7 @@
</button>
{/if}
</div>
{/if}
</div>
{/if}
</div>