fix(web): make the master/pane freeze invisible to the user (BUG-2263) (#987)

On the full-page item host, opening a detail pane froze the non-active
side by DEGRADING its DOM — fields became plaintext, buttons vanished,
the title turned read-only. Under the focus-follows-editing model
(PLAN-2179) the freeze is transient and one-click-reversible, so that
degradation was pure user-visible friction: you'd click a plaintext
field, the click would flip activePane, the field would re-render into a
live control, and you'd have to click again.

The freeze exists ONLY to keep exactly one TYPEABLE collab content editor
(single-owner of the editorStore/activeItem/tab-title singletons). It is
NOT a data-collision barrier: master and pane are always DIFFERENT items,
whose collab state is fully itemID-keyed / instance-local, and most REST
surfaces (fields, title, assign/role, tags, move, delete, share,
relationships, children, comments, reactions, archived restore, star) are
single-item, server-gated, side-independent writes.

So drop the `!peeking` term from those REST surfaces — gate them on
`canEdit` alone (their pre-freeze contract) — and keep `peeking` ONLY on
the content editor and its chrome (rich + raw editors, bubble/link
popover, provider-lifecycle mode toggle + retry). The content editor is
already invisible: the host's pointerdown-capture flips activePane before
the click's caret placement (TASK-2180 no-remount reactive editable), so
one gesture activates the side and lands the edit. Now the whole side is:
click anywhere -> edit it, no visible mode.

Two surfaces are NOT side-independent and stay confined to the active side
(the two documented exceptions, found by Codex review):
 - Version restore REST-writes this item's `items.content` directly, which
   collides with the retained Y.Doc on a peeking side. Kept frozen via a
   new ItemTimeline `restoreFrozen={peeking}` prop; comments/reactions
   (separate REST entities) stay live.
 - The quick-actions "Manage/New" controls rewrite the whole collection
   `settings` from a per-item snapshot (last-write-wins across two items in
   one collection), so they gate on `isOwner && !peeking` and recheck
   canEdit at dispatch; the read-only prompt-copy actions stay visible on
   both sides.

Scope: full-page host only. The collection route never passes peeking, so
`mutationsEnabled === canEdit`, `frozen={peeking}` is inert, and
`restoreFrozen` defaults false there — every change is byte-identical on
that route. `mutationsEnabled` survives but now scopes to content-editor
chrome only.

Tests: rewrote the masterFreeze unit probe + both full-page e2e specs
(host + capstone) to assert the new contract — the frozen side keeps its
editable title/fields/buttons; only its content editor flips
contenteditable=false. The freeze signal moved from `h1.title-readonly`
to the ProseMirror `contenteditable` attribute. Added a runtime-mutation
e2e assertion (a field edit on the frozen master PATCHes the correct item),
a real-QuickActionsMenu integration test, and unit coverage for the two
exceptions.

Two PRE-EXISTING concurrency issues were surfaced by the review (version-
restore gating + collection-settings write-exposure are byte-identical to
main, so this PR neither introduces nor worsens them); filed as BUG-2264
(restore <-> Y.Doc reconciliation) and BUG-2265 (collection-settings
optimistic concurrency).

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
This commit is contained in:
xarmian
2026-07-20 18:55:25 -04:00
committed by GitHub
parent 69b361d7b2
commit 29e49e4c63
9 changed files with 593 additions and 349 deletions
+138 -50
View File
@@ -15,16 +15,16 @@ import type { SuiteFixture } from './fixtures';
* FULL-PAGE host specifically, the three properties that host alone
* introduces or that only it can now demonstrate:
*
* 1. Option-A mutation-SILENCE (the D2 / HT-2176 freeze acceptance). While
* a pane is peeking beside the retain-alive master, NO NEW user edit can
* be INITIATED on the master — the title click-to-edit is gone, field
* inputs are read-only, the comment composer / compose surfaces are
* unmounted, the rich editor is contenteditable=false, and the star /
* Share / Quick-actions affordances are gated. This is Option A — "no NEW
* edit can be INITIATED while peeking", NOT "zero network writes": a
* pre-pane pending save legitimately completes and remote collab sync is
* expected, so we assert the INITIATION surfaces are disabled/absent, not
* the absence of REST/WS traffic. Un-peeking (close) restores every one.
* 1. INVISIBLE freeze (BUG-2263). While a pane is peeking beside the
* retain-alive master, the ONLY thing that changes on the master is that
* its collab CONTENT editor stops being typeable (contenteditable=false) —
* the single collision surface the freeze exists to protect. EVERY other
* surface stays live and editable: the title is still a click-to-edit
* button, field inputs stay editable, the comment composer is present, and
* the star / Share / Quick-actions / Move / Delete / Add-relationship
* affordances are all present. They are side-independent, server-gated REST
* mutations, so the freeze must be transparent to the user there. Un-peeking
* (close) restores the content editor's typeability; nothing else moved.
*
* 2. The bounded TWO-WS cost while peeking (D2's "cost model" note). Opening
* the pane yields at most the master's provider + the pane's provider = 2
@@ -138,12 +138,21 @@ async function seedNoteCollection(
request: APIRequestContext,
namePrefix: string,
itemPrefix: string,
quickActions?: Array<{ label: string; prompt: string }>,
): Promise<{ id: string; slug: string }> {
const name = `${namePrefix} ${Date.now()}`;
const schema = JSON.stringify({ fields: [{ key: 'note', label: 'Note', type: 'text' }] });
const data: Record<string, unknown> = { name, prefix: itemPrefix, schema };
if (quickActions?.length) {
// Seed read-only prompt-copy actions so the quick-actions trigger stays
// visible on the peeking side (the write controls gate separately).
data.settings = JSON.stringify({
quick_actions: quickActions.map((a) => ({ ...a, scope: 'item' })),
});
}
const resp = await request.post(`/api/v1/workspaces/${fixture.workspaceSlug}/collections`, {
headers: authHeaders(fixture),
data: { name, prefix: itemPrefix, schema },
data,
});
if (!resp.ok()) throw new Error(`collection create failed (${resp.status()}): ${await resp.text()}`);
return (await resp.json()) as { id: string; slug: string };
@@ -242,16 +251,16 @@ test.describe('full-page pane host CAPSTONE (PLAN-2154 Phase 2 / TASK-2175)', ()
);
});
// ── 1. Option-A mutation-SILENCE (the D2 / HT-2176 freeze acceptance) ────
// While a pane peeks beside the retain-alive master, NO NEW user edit can be
// INITIATED on the master. We assert the KEY initiation surfaces are
// disabled/absent — NOT the absence of network writes (Option A explicitly
// permits a pre-pane pending save to complete + remote collab sync). This is
// the RUNTIME smoke of the freeze; the exhaustive per-mutation-path audit
// (raw mode, tags, assignment, timeline reply/reaction/version, drag-reorder,
// etc.) is unit-tested in masterFreeze.svelte.test.ts + mutationGate.test.ts.
// Then un-peek (close) and assert the surfaces are restored.
test('peeking freezes the master NEW-edit-initiation surfaces; closing the pane restores them', async ({
// ── 1. INVISIBLE freeze (BUG-2263) ──────────────────────────────────────
// While a pane peeks beside the retain-alive master, the ONLY master surface
// that changes is its content editor's typeability (contenteditable=false) —
// the single collision surface. We assert every OTHER surface (title, field,
// composer, star, Share, Quick-actions, Delete, Move, Add-relationship) stays
// live and editable, i.e. the freeze is transparent to the user. This is the
// RUNTIME smoke of the invisible freeze; the exhaustive per-surface gate audit
// is unit-tested in masterFreeze.svelte.test.ts + mutationGate.test.ts. Then
// un-peek (close) and assert the content editor is typeable again.
test('peeking freezes ONLY the master content editor; every other surface stays live and editable (BUG-2263 invisible freeze)', async ({
page,
fixture,
request,
@@ -316,48 +325,127 @@ test.describe('full-page pane host CAPSTONE (PLAN-2154 Phase 2 / TASK-2175)', ()
// Activate the pane (click its title) → the master becomes the frozen side.
await pane.locator('.title', { hasText: 'FP freeze target' }).click();
// ── Peeking: every NEW-edit-initiation surface is gone/disabled. ──
// Title: click-to-edit button replaced by a non-editable <h1>.
await expect(col.locator('h1.title.title-readonly', { hasText: 'FP freeze master' })).toBeVisible();
await expect(col.locator('button.title', { hasText: 'FP freeze master' })).toHaveCount(0);
// Field: the editable input is gone — rendered as a readonly-display.
await expect(col.locator('input.field-input')).toHaveCount(0);
await expect(col.locator('.field-row', { hasText: 'Note' }).locator('.readonly-display')).toBeVisible();
// Comment composer: unmounted entirely.
await expect(col.locator('.compose')).toHaveCount(0);
// Star: disabled (gated on `peeking`, not unmounted — it stays visible).
await expect(col.locator('button.star-btn')).toBeDisabled();
// Share + Quick-actions triggers: unmounted.
await expect(col.locator('button.action-btn', { hasText: 'Share' })).toHaveCount(0);
await expect(col.locator('button.trigger-btn[title="Quick actions"]')).toHaveCount(0);
// Move-to + Delete: unmounted (gated on mutationsEnabled).
await expect(col.locator('button.action-btn', { hasText: 'Move to' })).toHaveCount(0);
await expect(col.locator('button.delete-btn')).toHaveCount(0);
// Relationship mutation surfaces: the per-link remove + Add opener are gone.
await expect(col.locator('button.link-delete-btn')).toHaveCount(0);
await expect(col.locator('button.add-relationship-btn')).toHaveCount(0);
// Rich editor: retained (still visible — no teardown), but read-only.
await expect(masterEditor).toBeVisible();
// ── Peeking (BUG-2263): the freeze is INVISIBLE. The ONLY thing that
// changes on the master is that its content editor stops being typeable
// (contenteditable=false). Every other surface stays EXACTLY as pre-peek —
// the master/pane freeze exists solely to keep one typeable collab editor,
// and must be transparent to the user everywhere else. ──
await expect(masterEditor).toHaveAttribute('contenteditable', 'false');
// ── Close (un-peek) → every surface is EDITABLE again. ──
await pane.locator('button[aria-label="Close pane"]').click();
await expect(pane).toBeHidden();
// Title: STILL a click-to-edit button (no degraded <h1>).
await expect(col.locator('button.title', { hasText: 'FP freeze master' })).toBeVisible();
await expect(col.locator('h1.title.title-readonly')).toHaveCount(0);
await expect(col.locator('.field-row', { hasText: 'Note' }).locator('input.field-input')).toBeVisible();
// Field: STILL an editable input (no readonly-display swap).
await expect(noteInput).toBeVisible();
await expect(noteInput).toBeEnabled();
await expect(col.locator('.field-row', { hasText: 'Note' }).locator('.readonly-display')).toHaveCount(0);
// Comment composer: still present.
await expect(col.locator('.compose')).toBeVisible();
// Star: still enabled (never gated on peeking anymore).
await expect(col.locator('button.star-btn')).toBeEnabled();
// Share + Delete + Move + Add-relationship + per-link remove: all still
// present (side-independent single-item REST — no freeze).
await expect(col.locator('button.action-btn', { hasText: 'Share' })).toBeVisible();
await expect(col.locator('button.trigger-btn[title="Quick actions"]')).toBeVisible();
await expect(col.locator('button.delete-btn')).toBeVisible();
await expect(col.locator('button.action-btn', { hasText: 'Move to' })).toBeVisible();
await expect(col.locator('button.add-relationship-btn')).toBeVisible();
// The per-link remove is back in the DOM (display:none until row-hover).
await expect(col.locator('button.link-delete-btn')).toHaveCount(1);
// EXCEPTION (Codex P1): the owner quick-actions menu's "Manage/New" controls
// WRITE the whole collection settings from a per-item snapshot (last-write-
// wins across two items in one collection), so they gate on `!peeking`. This
// note collection seeds NO read-only prompt actions, so with the write
// controls gated the trigger has nothing to show and is hidden on the peeking
// side (prompt actions, when present, would keep it visible — unit-tested).
await expect(col.locator('button.trigger-btn[title="Quick actions"]')).toHaveCount(0);
// Rich editor: retained + visible (no teardown) — only not typeable.
await expect(masterEditor).toBeVisible();
// ── RUNTIME MUTATION (Codex P2): the invisible freeze is not merely visual —
// a field edit typed on the FROZEN master must actually PATCH the CORRECT
// item. Type into the note field while peeking and assert it persists to
// THIS master's fields (server-side), proving updateField fired for the
// right item id from the frozen side. ──
await noteInput.fill('edited-while-peeking');
await expect
.poll(
async () => {
const resp = await request.get(
`/api/v1/workspaces/${fixture.workspaceSlug}/items/${master.slug}`,
{ headers: authHeaders(fixture) },
);
if (!resp.ok()) return null;
const it = (await resp.json()) as { fields?: string | Record<string, unknown> };
const fields = typeof it.fields === 'string' ? JSON.parse(it.fields) : (it.fields ?? {});
return (fields as Record<string, unknown>).note;
},
{ timeout: 6000 },
)
.toBe('edited-while-peeking');
// ── Close (un-peek) → the content editor is typeable again; nothing else was
// ever frozen, so it is unchanged. ──
await pane.locator('button[aria-label="Close pane"]').click();
await expect(pane).toBeHidden();
await expect(col.locator('button.title', { hasText: 'FP freeze master' })).toBeVisible();
await expect(noteInput).toBeVisible();
await expect(col.locator('.compose')).toBeVisible();
await expect(masterEditor).toHaveAttribute('contenteditable', 'true', { timeout: SYNC_TIMEOUT });
});
// ── 1b. Quick-actions EXCEPTION (Codex P1): the owner "New/Manage" controls
// WRITE the whole collection settings from a per-item snapshot (last-write-wins
// across two items in one collection), so they gate on `!peeking` — confined to
// the active side. The read-only prompt-copy actions stay visible on both sides.
// This mounts the REAL QuickActionsMenu (seeded with a prompt action) to prove
// the gate wiring, not just the empty-trigger-hides case.
test('quick-actions: read-only prompt actions stay visible on the peeking side; the collection-settings write controls (New/Manage) are confined to the active side (Codex P1)', async ({
page,
fixture,
request,
}) => {
test.setTimeout(60_000);
await page.setViewportSize(DESKTOP);
await browserLogin(page);
// Seed a collection WITH a read-only prompt action so the trigger stays
// visible on the peeking side even once the write controls are gated.
const coll = await seedNoteCollection(fixture, request, 'FP qa', 'FPQA', [
{ label: 'Summarize', prompt: 'Summarize this item' },
]);
const master = await seedNoteItem(fixture, request, coll.slug, `FP qa master ${Date.now()}`, 'm', '');
const target = await seedNoteItem(fixture, request, coll.slug, `FP qa target ${Date.now()}`, 't', '');
await seedRelatedLink(fixture, request, master.slug, target.id);
await page.goto(fullPageUrl(fixture, coll.slug, master.slug));
const col = masterCol(page);
const trigger = col.locator('button.trigger-btn[title="Quick actions"]');
// Pre-peek (master active, owner): open the menu → the prompt action AND the
// owner write controls are all present.
await expect(trigger).toBeVisible();
await trigger.click();
await expect(col.locator('.action-label', { hasText: 'Summarize' })).toBeVisible();
await expect(col.locator('.action-label', { hasText: 'New quick action' })).toBeVisible();
await expect(col.locator('.action-label', { hasText: 'Manage actions' })).toBeVisible();
await trigger.click(); // close
// Open the pane and activate it → the master becomes the peeking side.
await openPaneViaRelated(page, 'FP qa target');
const pane = page.locator('.item-pane');
await expect(pane).toBeVisible();
await pane.locator(EDITOR_SELECTOR).click();
await expect(col.locator(EDITOR_SELECTOR)).toHaveAttribute('contenteditable', 'false');
// Peeking side: the trigger stays VISIBLE — the read-only prompt affordance
// keeps it (contrast the freeze test, where an EMPTY collection's trigger
// HIDES on the peeking side once the owner write controls gate off). We do NOT
// open it here: clicking the trigger re-activates the master (click-to-
// activate is the focus-follows model), so the write controls are only ever
// reached from the ACTIVE side — which is exactly the safety property. The
// render gate that drops New/Manage while `peeking` is asserted at the unit
// level (masterFreeze) where the state can be held without a click.
await expect(trigger).toBeVisible();
});
// ── 2. Bounded TWO-WS cost while peeking (D2 cost model / the guard core) ─
// Opening the pane yields master-provider + pane-provider = 2 collab WS to
// DISTINCT rooms, and the master's OWN room is NEVER given a second provider.
+67 -63
View File
@@ -109,7 +109,7 @@ test.describe('full-page pane host (PLAN-2154 Phase 2 / TASK-2174)', () => {
);
});
test('focus follows editing: open keeps the master editable (pane = read-only preview); clicking a side activates it and freezes the other; drill/back keep the pane active (PLAN-2179 DR-2/DR-3 / TASK-2181)', async ({
test('focus follows editing is INVISIBLE (BUG-2263): open keeps the master editable (pane = preview); clicking a side activates it and freezes the other; the frozen side keeps its editable title — only the content editor stops being typeable (PLAN-2179 DR-2/DR-3 / TASK-2181)', async ({
page,
fixture,
request,
@@ -126,17 +126,18 @@ test.describe('full-page pane host (PLAN-2154 Phase 2 / TASK-2174)', () => {
const relatedRef = await itemRef(fixture, request, related.slug);
const grandchildRef = await itemRef(fixture, request, grandchild.slug);
// Editable/frozen probes: a `button.title` is the click-to-edit title
// (editable side); an `h1.title.title-readonly` is the frozen side.
const masterEditable = page.locator('button.title', { hasText: 'FP host master' });
const masterFrozen = page.locator('h1.title.title-readonly', { hasText: 'FP host master' });
// BUG-2263: the freeze is INVISIBLE — the title stays a click-to-edit
// `button.title` on BOTH sides, so it is NO LONGER a peeking probe. Which
// side is EDITABLE is signalled by its CONTENT editor's `contenteditable`.
const masterTitleBtn = page.locator('.item-page-host > .item-page button.title', { hasText: 'FP host master' });
const masterEditor = page.locator('.item-page-host > .item-page .editor-wrapper .ProseMirror');
// Land on the MASTER full page. No pane yet: the flex-row host is present,
// the master title is EDITABLE (a click-to-edit button — not peeking), and
// there's no `?item=`.
// the master title is an editable button, its editor is typeable, no `?item=`.
await page.goto(fullPageUrl(fixture, master.slug));
await expect(page.locator('.item-page-host')).toBeVisible();
await expect(masterEditable).toBeVisible();
await expect(masterTitleBtn).toBeVisible();
await expect(masterEditor).toHaveAttribute('contenteditable', 'true');
await expect(page.locator('.item-pane')).toHaveCount(0);
expect(openItemParam(page)).toBeNull();
@@ -149,33 +150,34 @@ test.describe('full-page pane host (PLAN-2154 Phase 2 / TASK-2174)', () => {
const pane = page.locator('.item-pane');
await expect(pane).toBeVisible();
await expect.poll(() => openItemParam(page)).toBe(relatedRef);
const paneEditor = pane.locator('.editor-wrapper .ProseMirror');
// DR-2: opening does NOT freeze the master. Focus-follows-editing means the
// MASTER stays the active/editable side (openItemPaneByRef never moves focus
// into the pane), and the pane opens as a READ-ONLY PREVIEW — pane title is a
// non-editable <h1>, no click-to-edit button.
await expect(masterEditable).toBeVisible();
await expect(masterFrozen).toHaveCount(0);
await expect(pane.locator('h1.title.title-readonly', { hasText: 'FP host related' })).toBeVisible();
await expect(pane.locator('button.title')).toHaveCount(0);
// DR-2: opening does NOT freeze the master — it stays the active/editable
// side. The pane opens as a PREVIEW, but INVISIBLY (BUG-2263): its title is
// STILL an editable button (not a degraded <h1>); only its content editor is
// not typeable (contenteditable=false).
await expect(masterEditor).toHaveAttribute('contenteditable', 'true');
await expect(pane.locator('button.title', { hasText: 'FP host related' })).toBeVisible();
await expect(pane.locator('h1.title.title-readonly')).toHaveCount(0);
await expect(paneEditor).toHaveAttribute('contenteditable', 'false');
// Depth 0: the pane's Back chevron is hidden.
await expect(pane.locator('button.pane-back-btn')).toHaveCount(0);
// Drill a CHILD row directly from the FROZEN preview — on the FIRST click.
// Content-link / child-row navigation stays live while the master is active
// (the mini-browser preview), so the pointerdown activator EXCLUDES navigable
// targets: the freeze-flip can't re-init ChildItems' dndzone and swallow the
// click. The drill is pane-internal, so it ALSO activates the pane — pane
// editable, master frozen (PLAN-2179 DR-2 / TASK-2181). No pre-activation click.
// Drill a CHILD row directly from the preview — on the FIRST click. The drill
// is pane-internal, so it ALSO activates the pane — pane editable, master
// frozen (PLAN-2179 DR-2 / TASK-2181). No pre-activation click.
const masterPathname = new URL(page.url()).pathname;
await pane.locator('.child-row', { hasText: 'FP host grandchild' }).click();
await expect.poll(() => openItemParam(page)).toBe(grandchildRef);
expect(new URL(page.url()).pathname).toBe(masterPathname);
await expect(pane.locator('button.pane-back-btn')).toBeVisible();
await expect(pane.locator('button.title', { hasText: 'FP host grandchild' })).toBeVisible();
await expect(masterFrozen).toBeVisible();
await expect(masterEditable).toHaveCount(0);
// Pane active, master frozen — but the master's freeze is INVISIBLE: its
// title is still an editable button; only its editor is not typeable.
await expect(paneEditor).toHaveAttribute('contenteditable', 'true');
await expect(masterEditor).toHaveAttribute('contenteditable', 'false');
await expect(masterTitleBtn).toBeVisible();
// Browser BACK → pops one drill level back to B in the pane. A drill-pop is
// still pane-internal, so `activePane` stays 'pane' — pane editable, master
@@ -184,33 +186,31 @@ test.describe('full-page pane host (PLAN-2154 Phase 2 / TASK-2174)', () => {
await expect.poll(() => openItemParam(page)).toBe(relatedRef);
await expect(pane.locator('button.title', { hasText: 'FP host related' })).toBeVisible();
await expect(pane.locator('button.pane-back-btn')).toHaveCount(0);
await expect(masterFrozen).toBeVisible();
await expect(masterEditable).toHaveCount(0);
await expect(paneEditor).toHaveAttribute('contenteditable', 'true');
await expect(masterEditor).toHaveAttribute('contenteditable', 'false');
// Click BACK into the MASTER — on its now-read-only <h1> title, a NON-focusable,
// NON-navigable element that drops focus to <body>. The pointerdown activator
// re-activates the master, and the desktop backstop must NOT yank focus back to
// the pane. Master editable again; pane freezes. Exactly one side editable.
await masterFrozen.click();
await expect(masterEditable).toBeVisible();
await expect(masterFrozen).toHaveCount(0);
await expect(pane.locator('h1.title.title-readonly', { hasText: 'FP host related' })).toBeVisible();
await expect(pane.locator('button.title')).toHaveCount(0);
// Click into the PANE on its read-only title (a NON-navigable target) → the
// pointerdown activator makes the pane the active side; the master freezes.
await pane.locator('.title', { hasText: 'FP host related' }).click();
// Click BACK into the MASTER content editor → the pointerdown activator
// re-activates the master (and the same click lands the caret in the now-
// editable view — one gesture), and the desktop backstop must NOT yank focus
// back to the pane. Master editable again; pane freezes. Exactly one side.
await masterEditor.click();
await expect(masterEditor).toHaveAttribute('contenteditable', 'true');
await expect(paneEditor).toHaveAttribute('contenteditable', 'false');
// The frozen pane's title stays an editable button (invisible freeze).
await expect(pane.locator('button.title', { hasText: 'FP host related' })).toBeVisible();
await expect(masterFrozen).toBeVisible();
await expect(masterEditable).toHaveCount(0);
// Close (✕) → the pane unmounts cleanly, `?item=` drops, and the master is
// EDITABLE again (no longer peeking → click-to-edit button returns).
// Click into the PANE content editor → the pointerdown activator makes the
// pane the active side; the master freezes.
await paneEditor.click();
await expect(paneEditor).toHaveAttribute('contenteditable', 'true');
await expect(masterEditor).toHaveAttribute('contenteditable', 'false');
// Close (✕) → the pane unmounts cleanly, `?item=` drops, and the master's
// editor is typeable again.
await pane.locator('button[title="Close pane"]').click();
await expect(page.locator('.item-pane')).toHaveCount(0);
await expect.poll(() => openItemParam(page)).toBeNull();
await expect(masterEditable).toBeVisible();
await expect(page.locator('h1.title.title-readonly')).toHaveCount(0);
await expect(masterEditor).toHaveAttribute('contenteditable', 'true');
});
test('opening AND closing the pane freezes/thaws the master WITHOUT remounting its editor (PLAN-2179 DR-1 / TASK-2180 — reactive freeze)', async ({
@@ -255,17 +255,18 @@ test.describe('full-page pane host (PLAN-2154 Phase 2 / TASK-2174)', () => {
expect(await editorNode!.evaluate((el) => el.isConnected)).toBe(true);
await expect(masterMainEditor).toHaveAttribute('contenteditable', 'true');
// Click INTO the pane → the master FREEZES. The freeze is REACTIVE: the SAME
// editor DOM node is still connected (a `{#key}`-driven remount — the OLD
// peeking-in-the-key behavior — would have detached this handle, isConnected →
// false), and it merely flipped contenteditable=false in place. This is the
// whole point of PLAN-2179 DR-1: freeze without destroying/recreating the editor.
await pane.locator('.title', { hasText: 'FP host reactive-freeze related' }).click();
await expect(
page.locator('h1.title.title-readonly', { hasText: 'FP host reactive-freeze master' }),
).toBeVisible();
// Click INTO the pane's content editor → the master FREEZES. The freeze is
// REACTIVE: the SAME editor DOM node is still connected (a `{#key}`-driven
// remount — the OLD peeking-in-the-key behavior — would have detached this
// handle, isConnected → false), and it merely flipped contenteditable=false in
// place. This is the whole point of PLAN-2179 DR-1: freeze without
// destroying/recreating the editor.
await pane.locator('.editor-wrapper .ProseMirror').click();
expect(await editorNode!.evaluate((el) => el.isConnected)).toBe(true);
await expect(masterMainEditor).toHaveAttribute('contenteditable', 'false');
// BUG-2263 invisibility: the frozen master's title is STILL an editable
// button — the freeze degrades nothing but the content editor's typeability.
await expect(page.locator('.item-page-host > .item-page button.title', { hasText: 'FP host reactive-freeze master' })).toBeVisible();
// Close the pane → the master thaws back to editable. The editor node
// survives the un-freeze too (no remount on either edge), flipping
@@ -322,7 +323,7 @@ test.describe('full-page pane host (PLAN-2154 Phase 2 / TASK-2174)', () => {
.click();
await expect(pane).toBeVisible();
await expect.poll(() => openItemParam(page)).toBe(relatedRef);
await pane.locator('.title', { hasText: 'FP host drag-handle related' }).click();
await pane.locator('.editor-wrapper .ProseMirror').click();
await expect(masterMain).toHaveAttribute('contenteditable', 'false');
await page.mouse.move(5, 5); // leave the editor first
await masterMain.locator('p').first().hover({ force: true });
@@ -359,7 +360,7 @@ test.describe('full-page pane host (PLAN-2154 Phase 2 / TASK-2174)', () => {
await expect(page.locator('.item-pane')).toHaveCount(0);
await expect.poll(() => openItemParam(page)).toBeNull();
// The master stays fully EDITABLE (never went peeking).
await expect(page.locator('h1.title.title-readonly')).toHaveCount(0);
await expect(page.locator('.item-page-host > .item-page .editor-wrapper .ProseMirror')).toHaveAttribute('contenteditable', 'true');
});
test('a cold-loaded shared `?item=<a different item>` still mounts the pane (the self-collision mount-gate does not suppress legitimate cross-item cold loads)', async ({
@@ -385,10 +386,12 @@ test.describe('full-page pane host (PLAN-2154 Phase 2 / TASK-2174)', () => {
await expect.poll(() => openItemParam(page)).toBe(otherRef);
// DR-2 cold-load initializer (desktop): no focusin fires on a `?item=` deep
// load, so `activePane` seeds to the MASTER — it's EDITABLE beside the pane,
// which opens as a read-only PREVIEW (frozen title).
await expect(page.locator('button.title', { hasText: 'FP host cold master' })).toBeVisible();
await expect(page.locator('h1.title.title-readonly', { hasText: 'FP host cold master' })).toHaveCount(0);
await expect(pane.locator('h1.title.title-readonly', { hasText: 'FP host cold other' })).toBeVisible();
// which opens as a PREVIEW. INVISIBLE freeze (BUG-2263): the pane's title is
// an editable button; only its content editor is not typeable.
await expect(page.locator('.item-page-host > .item-page button.title', { hasText: 'FP host cold master' })).toBeVisible();
await expect(page.locator('.item-page-host > .item-page .editor-wrapper .ProseMirror')).toHaveAttribute('contenteditable', 'true');
await expect(pane.locator('button.title', { hasText: 'FP host cold other' })).toBeVisible();
await expect(pane.locator('.editor-wrapper .ProseMirror')).toHaveAttribute('contenteditable', 'false');
});
test('a cold-loaded `?item=<the master by its ref-shaped slug>` is stripped (server slug-fallback self-collision)', async ({
@@ -416,7 +419,7 @@ test.describe('full-page pane host (PLAN-2154 Phase 2 / TASK-2174)', () => {
// Must be stripped — never mount a second provider on the master's own room.
await expect(page.locator('.item-pane')).toHaveCount(0);
await expect.poll(() => openItemParam(page)).toBeNull();
await expect(page.locator('h1.title.title-readonly')).toHaveCount(0);
await expect(page.locator('.item-page-host > .item-page .editor-wrapper .ProseMirror')).toHaveAttribute('contenteditable', 'true');
});
test('Expand a pane item to full page, then browser Back, restores the pane (no stale-master strip)', async ({
@@ -460,8 +463,9 @@ test.describe('full-page pane host (PLAN-2154 Phase 2 / TASK-2174)', () => {
// latched to 'pane' and PERSISTS across the expand + Back (this route
// component is REUSED, never remounted). The restored pane is therefore the
// ACTIVE/editable side and the master A is frozen — the pane "stays active"
// across browser Back (PLAN-2179 DR-2).
// across browser Back (PLAN-2179 DR-2). The freeze is INVISIBLE (BUG-2263):
// the master's frozen state shows only on its content editor, not its title.
await expect(pane.locator('button.title', { hasText: 'FP host expand related' })).toBeVisible();
await expect(page.locator('h1.title.title-readonly', { hasText: 'FP host expand master' })).toBeVisible();
await expect(page.locator('.item-page-host > .item-page .editor-wrapper .ProseMirror')).toHaveAttribute('contenteditable', 'false');
});
});
@@ -117,6 +117,12 @@
}
function handleManage() {
// Recheck `canEdit` at dispatch time (mirrors handleSaveNewAction): if it
// flips false while the menu is open — e.g. the master-freeze passing
// canEdit=false once this side becomes the peeking preview (BUG-2263) — refuse
// to open the collection-settings editor. The trigger itself unmounts on the
// same flip (`{#if canEdit}`); this guards the render→click race.
if (!canEdit) return;
open = false;
resetCreateForm();
onmanage?.();
+84 -70
View File
@@ -2051,7 +2051,7 @@
});
async function startEditTitle() {
if (!item || !mutationsEnabled) return;
if (!item || !canEdit) return;
titleDraft = item.title;
editingTitle = true;
// Wait for the DOM to render the textarea, then focus + select all
@@ -2098,9 +2098,10 @@
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;
// Title edits are a single-item REST PATCH — side-independent, server-gated,
// no shared-content collision — so they are NOT frozen while peeking (BUG-2263):
// the freeze is invisible and gates only the content editor. Permission alone.
if (!canEdit) 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
@@ -2134,13 +2135,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).
// NO freeze recheck (BUG-2263): field editing is invisible to the freeze —
// FieldEditor stays interactive on the peeking side (`readonly={!canEdit}`),
// and a field write is a single-item, server-gated REST PATCH with a
// field-level MERGE, so it is side-independent and cannot collide with the
// active editor. Permission is enforced at the UI (readonly) and server-side.
// (This also lets a pre-flip pending onchange from FieldEditor's 500ms
// debounce complete rather than being suppressed.)
if (!item) return;
const updated = { ...fields, [key]: value };
const payload = JSON.stringify(updated);
@@ -2249,7 +2250,7 @@
const tagSavers = new Map<string, TagSaver>();
function updateTags(newTags: string[]) {
if (!item || !mutationsEnabled) return;
if (!item || !canEdit) return;
const targetItem = item;
const targetWs = wsSlug;
// Optimistic so chips react instantly.
@@ -2281,10 +2282,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.
// NO freeze recheck in the drain (BUG-2263): tag editing is invisible
// to the freeze (TagInput stays interactive via `readonly={!canEdit}`)
// and a tag write is a side-independent single-item REST PATCH, so it
// runs from either side; `updateTags` gates on canEdit (permission).
const toSave = saver.pending;
saver.pending = null;
const fresh = await api.items.update(saver.ws, saver.itemId, {
@@ -2533,7 +2534,7 @@
}
async function updateAssignedUser(userId: string | null) {
if (!item || !mutationsEnabled) return;
if (!item || !canEdit) 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).
@@ -2559,7 +2560,7 @@
}
async function updateAgentRole(roleId: string | null) {
if (!item || !mutationsEnabled) return;
if (!item || !canEdit) 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).
@@ -3237,7 +3238,7 @@
}
async function handleDelete() {
if (!item || !mutationsEnabled) return;
if (!item || !canEdit) 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()
@@ -3268,9 +3269,9 @@
// archived — surface that message the same way other handlers do. TASK-1829.
async function handleRestore() {
// `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;
// `canRestore` (checked at the render site). Restore is a single-item REST
// mutation — side-independent — so it is NOT frozen while peeking (BUG-2263).
if (!item || restoring) 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").
@@ -3352,7 +3353,7 @@
}
async function handleDeleteLink(linkId?: string) {
if (!linkId || !item || !mutationsEnabled) return;
if (!linkId || !item || !canEdit) 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 —
@@ -3442,7 +3443,7 @@
}
async function handleCreateLink(target: Item) {
if (!item || !mutationsEnabled) return;
if (!item || !canEdit) 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
@@ -3477,7 +3478,7 @@
}
async function handleMove(targetSlug: string) {
if (!item || moving || !mutationsEnabled) return;
if (!item || moving || !canEdit) return;
moving = true;
showMoveMenu = false;
// Capture FULL route identity (workspace, username, source
@@ -3776,7 +3777,7 @@
{/if}
<span class="archived-hint">It's read-only until restored.</span>
</div>
{#if canRestore && !peeking}
{#if canRestore}
<button class="archived-restore-btn" onclick={handleRestore} disabled={restoring}>
{restoring ? 'Restoring…' : 'Restore'}
</button>
@@ -3799,13 +3800,16 @@
onkeydown={handleTitleKeydown}
oninput={(e) => autoResizeTitle(e.currentTarget)}
></textarea>
{:else if mutationsEnabled}
{:else if canEdit}
<!-- Editable on BOTH master and pane, even while peeking (BUG-2263):
the freeze is invisible. Clicking flips activePane (pointerdown)
and startEditTitle opens the editor; the actual PATCH is
permission-gated (canEdit), not freeze-gated. -->
<button class="title" onclick={startEditTitle}>
{item.title}
</button>
{:else}
<!-- Read-only title (PLAN-1100 / TASK-1105; frozen while peeking,
TASK-2172) — no click-to-edit. -->
<!-- Read-only title for true viewers (no canEdit; PLAN-1100 / TASK-1105). -->
<h1 class="title title-readonly">{item.title}</h1>
{/if}
{#if typeof fields.pad_source_url === 'string' && fields.pad_source_url}
@@ -3829,7 +3833,11 @@
Refresh button so the import history is still
discoverable. (Per Codex review round 1.)
-->
{#if mutationsEnabled && !rawMode}
{#if canEdit && !rawMode}
<!-- Present on both master and pane even while peeking (BUG-2263);
the chip's click flips activePane before refreshFromSource runs,
whose own `mutationsEnabled` + `isEditable` guards keep the
content REPLACE on the now-active editor. -->
<button
type="button"
class="source-chip"
@@ -3886,24 +3894,26 @@
<!-- 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. -->
<!-- Star is a per-user, itemId-keyed REST toggle available to viewers too,
and cannot collide across sides — so it is NOT frozen while peeking
(BUG-2263). No canEdit or peeking gate. -->
<button
class="action-btn star-btn"
class:starred={starredStore.isStarred(item.id)}
disabled={peeking}
onclick={() => { if (peeking || !item) return; starredStore.toggle(wsSlug, item.slug, item.id); }}
onclick={() => { if (!item) return; starredStore.toggle(wsSlug, item.slug, item.id); }}
title={starredStore.isStarred(item.id) ? 'Unstar' : 'Star'}
>
{starredStore.isStarred(item.id) ? '★' : '☆'}
</button>
<!-- 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}
<!-- Quick-actions menu: the prompt-copy actions are read-only and stay
visible on the peeking side (invisible freeze, BUG-2263). But the
owner "New / Manage actions" controls WRITE the whole collection
`settings` JSON from this instance's local snapshot with no merge —
and master + pane can be DIFFERENT items in the SAME collection, so
two concurrent saves are last-write-wins. So `canEdit` (which gates
ONLY those write controls) folds in `!peeking`: the collection-config
writes are confined to the active side, the prompts stay on both. -->
{#if collection && (quickActions.length > 0 || isOwner)}
<!-- {#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
@@ -3923,7 +3933,7 @@
{collection}
scope="item"
{wsSlug}
canEdit={isOwner}
canEdit={isOwner && !peeking}
onmanage={() => {
editCollectionSection = 'actions';
editCollectionOpen = true;
@@ -3989,7 +3999,7 @@
📎 {backlinksCount}
</button>
{/if}
{#if mutationsEnabled}
{#if canEdit}
<div class="move-wrapper">
<button class="action-btn" onclick={() => { showMoveMenu = !showMoveMenu; }} disabled={moving}>
{moving ? 'Moving...' : 'Move to...'}
@@ -4024,17 +4034,16 @@
{/if}
</div>
{/if}
<!-- 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}
<!-- Share opens a dialog that dispatches owner-only, item-scoped grant /
share-link REST mutations — side-independent, so the trigger stays
available on the peeking side too (BUG-2263). Any open dialog is still
dismissed by the peek-begin transition when this side goes passive. -->
{#if isOwner}
<button class="action-btn" onclick={() => { shareDialogOpen = true; }}>
Share
</button>
{/if}
{#if mutationsEnabled}
{#if canEdit}
{#if confirmDelete}
<span class="delete-confirm">
Delete this item?
@@ -4131,7 +4140,7 @@
{field}
value={rawFieldValue}
onchange={(v) => updateField(field.key, v)}
readonly={!mutationsEnabled}
readonly={!canEdit}
/>
</div>
</div>
@@ -4146,7 +4155,7 @@
{tags}
suggestions={tagSuggestions}
onchange={updateTags}
readonly={!mutationsEnabled}
readonly={!canEdit}
/>
</div>
</div>
@@ -4159,7 +4168,7 @@
<div class="field-row">
<span class="field-label">Assigned to</span>
<div class="field-value">
{#if mutationsEnabled}
{#if canEdit}
<select
class="assignment-select"
value={item.assigned_user_id ?? ''}
@@ -4192,7 +4201,7 @@
<div class="field-row">
<span class="field-label">Role</span>
<div class="field-value">
{#if mutationsEnabled}
{#if canEdit}
<select
class="assignment-select"
value={item.agent_role_id ?? ''}
@@ -4591,7 +4600,7 @@
{#if entry.status}
<span class="link-status">{formatFieldDisplay(entry.status)}</span>
{/if}
{#if entry.linkId && mutationsEnabled}
{#if entry.linkId && canEdit}
<button class="link-delete-btn" title="Remove relationship" onclick={() => handleDeleteLink(entry.linkId)}>×</button>
{/if}
</span>
@@ -4604,9 +4613,11 @@
</div>
{/if}
<!-- Add Relationship — gated on the master-freeze predicate (PLAN-1100 /
TASK-1105; frozen while peeking, TASK-2172). -->
{#if item && mutationsEnabled}
<!-- Add Relationship — a source-item-scoped REST link create, side-independent,
so it stays available on the peeking side too (BUG-2263). Permission-gated
on canEdit; the add-link box is dismissed by the peek-begin transition when
this side goes passive. -->
{#if item && canEdit}
<div class="add-relationship-section">
{#if !showAddLink}
<button class="add-relationship-btn" onclick={() => { showAddLink = true; }}>
@@ -4663,13 +4674,12 @@
always-mounted SSE guarantee below. -->
{#if item}
<div id="item-children" class="children-anchor">
<!-- 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} />
<!-- ChildItems takes the REAL `canEdit` and is NOT frozen while peeking
(BUG-2263): add-child (create/link) and reorder are side-independent
REST ops, so they stay live on the peeking side. `frozen={false}`
also stops the dndzone from re-initing on activePane flips — removing
a source of drill-click swallowing. -->
<ChildItems {wsSlug} {username} {itemSlug} itemId={item.id} parentFields={fields} terminalStatuses={childTerminalStatuses} onChildrenChange={(children) => { if (keyedSlug !== itemSlug) return; handleChildrenChange(children); }} {canEdit} frozen={false} selfDirty={localDirty} selfLastSaveTime={localLastSaveTime} onOpenTarget={paneOpenTarget} />
</div>
{/if}
@@ -4697,10 +4707,13 @@
<!-- 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. -->
<!-- Timeline COMMENTS/REACTIONS are per-item / per-user REST entities,
side-independent, so they are NOT frozen while peeking (BUG-2263) —
composer + controls stay live on the passive side. VERSION RESTORE is
different: it REST-writes this item's `items.content` directly (not via
the Y.Doc applier), so on a peeking side whose Y.Doc is retained-alive a
later collab flush could overwrite it — a same-item collision. So it
stays frozen: `restoreFrozen={peeking}` (Codex P1). -->
<ItemTimeline
{wsSlug}
{username}
@@ -4710,7 +4723,8 @@
onRestore={handleVersionRestore}
itemId={item.id}
collectionId={item.collection_id}
frozen={peeking}
frozen={false}
restoreFrozen={peeking}
/>
</div>
{/key}
@@ -1,22 +1,28 @@
<script lang="ts">
// PLAN-2154 Phase 2 / HT-2176 Option A (TASK-2172) — pre-pane field-save probe.
// BUG-2263 — INVISIBLE-freeze field 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.
// Mounts the REAL `FieldEditor` to prove that a field stays INTERACTIVE across
// a peeking flip: ItemDetail now drives `readonly={!canEdit}` (NOT
// `!mutationsEnabled`), so opening the pane does NOT flip the field read-only
// the input stays mounted and a typed value's 500ms debounce still fires
// onchange (→ updateField, which is side-independent). A `<button>` flips
// peeking so the test can simulate the pane opening mid-edit; canEdit is fixed
// true here, so `readonly` is constant false regardless of peeking.
import FieldEditor from '$lib/components/fields/FieldEditor.svelte';
import type { FieldDef } from '$lib/types';
let { onchange }: { onchange: (v: any) => void } = $props();
// `peeking` is flipped in-test to simulate the pane opening. It is surfaced
// (below) so the test can confirm the flip happened — but it is DELIBERATELY
// NOT wired into the field's `readonly`, which tracks `!canEdit` alone. That is
// the whole point: peeking must NOT change the field's interactivity.
let peeking = $state(false);
const canEdit = true;
const field: FieldDef = { key: 'component', label: 'Component', type: 'text' };
</script>
<button data-testid="begin-peek" onclick={() => (peeking = true)}>peek</button>
<span data-testid="probe-peeking">{peeking}</span>
<FieldEditor {field} value="" {onchange} readonly={peeking} />
<FieldEditor {field} value="" {onchange} readonly={!canEdit} />
@@ -1,20 +1,21 @@
<script lang="ts">
// PLAN-2154 Phase 2 / D2 / R12 (TASK-2172) — master-freeze wiring probe.
// BUG-2263 — the master/pane freeze is INVISIBLE to the user.
//
// 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 freeze exists ONLY to keep exactly one TYPEABLE collab content editor
// (so the three singleton UX scalars — editorStore.dirty/lastSaveTime,
// collectionStore.activeItem, tab title — stay single-owner). It is NOT a
// data-collision barrier: master and pane are always DIFFERENT items, whose
// collab state is fully itemID-keyed / instance-local. So only the CONTENT
// surfaces stay frozen while peeking; every REST surface (fields, buttons,
// title, children, timeline, star, share, …) stays interactive on both sides
// and is gated on `canEdit` (permission) alone. A click flips activePane first
// (host pointerdown-capture), so the interaction lands in one gesture.
//
// 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.
// This probe renders the CANONICAL gate EXPRESSIONS from ItemDetail.svelte so
// the two can't drift — same pattern as GuardProbe.svelte for the localDirty
// shadow. `mutationsEnabled` (= canEdit && !peeking) survives, but now gates
// ONLY the content-editor chrome (bubble/link popover); the running-app
// assertions live in the pane-full-page e2e specs.
import { computeMutationsEnabled } from '../mutationGate';
let {
@@ -31,71 +32,94 @@
quickActionsPresent?: boolean;
} = $props();
// The exact derived from ItemDetail.svelte.
// The exact derived from ItemDetail.svelte — now scopes to content chrome only.
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>
<!-- CONTENT bucket — stays frozen while peeking (the one collision surface).
The editor stays visually identical (same live view, editable flipped in
place, no remount); a click activates the side and lands the caret. -->
<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. -->
<!-- REST bucket — INVISIBLE freeze: interactive on both sides, gated on canEdit. -->
<div data-testid="field-readonly">{!canEdit}</div>
<div data-testid="child-canEdit">{canEdit}</div>
<div data-testid="child-frozen">{peeking}</div>
<div data-testid="timeline-frozen">{peeking}</div>
<div data-testid="child-frozen">{false}</div>
<div data-testid="timeline-frozen">{false}</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). -->
Markdown destroys the retained collab provider), so it stays hidden on the
passive preview (`!peeking`); it reappears the instant you click in (which
you must do to edit content anyway). The one accepted visible exception. -->
{#if !peeking}
<button data-testid="mode-toggle">Rich / Markdown</button>
{/if}
<!-- Mutation UI gated on `mutationsEnabled` — unmounted while peeking. -->
{#if mutationsEnabled}
<!-- REST mutation UI gated on `canEdit` alone, present on both sides. -->
{#if canEdit}
<button data-testid="delete-btn">Delete</button>
{/if}
{#if mutationsEnabled}
{#if canEdit}
<button data-testid="move-btn">Move to…</button>
{/if}
{#if mutationsEnabled}
{#if canEdit}
<button data-testid="add-relationship-btn">+ Add relationship</button>
{/if}
<!-- Editor bubble/link popover is CONTENT chrome — it only appears on an editor
interaction (selection), which requires the side to be active, so it stays
gated on `mutationsEnabled` (invisible: never shows on the passive side). -->
{#if mutationsEnabled}
<button data-testid="editor-mutation-ui">bubble/link popover</button>
{/if}
<!-- Title: editable click-to-edit vs read-only heading. -->
{#if mutationsEnabled}
<!-- Title: editable click-to-edit on both sides (canEdit); read-only heading is
for TRUE viewers (no canEdit) only. -->
{#if canEdit}
<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}
<!-- Archived restore rides `canRestore` (canEdit is forced false for archived
items); NOT frozen while peeking — restore is a side-independent REST op. -->
{#if canRestore}
<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>
<!-- Star: per-user, itemId-keyed REST toggle — no canEdit or peeking gate. -->
<button data-testid="star-btn">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}
<!-- Share: side-independent owner-only REST op — present on both sides. -->
{#if isOwner}
<button data-testid="share-btn">Share</button>
{/if}
{#if (quickActionsPresent || isOwner) && !peeking}
<button data-testid="quickactions-menu">Quick actions</button>
<!-- Quick-actions menu: the prompt-copy actions are read-only and present on both
sides; the owner "Manage/New" controls WRITE the whole collection settings
from a per-item snapshot (last-write-wins across two items in one collection),
so they gate on `isOwner && !peeking` — the write is confined to the active
side (BUG-2263 / Codex P1). ItemDetail forwards `canEdit={isOwner && !peeking}`. -->
{#if quickActionsPresent || (isOwner && !peeking)}
<div data-testid="quickactions-menu">
{#if quickActionsPresent}
<button data-testid="quickactions-prompt">Copy a prompt</button>
{/if}
{#if isOwner && !peeking}
<button data-testid="quickactions-manage">Manage actions</button>
{/if}
</div>
{/if}
<!-- Version restore REST-writes this item's `items.content` directly, colliding
with the retained Y.Doc on a peeking side — a SAME-ITEM collision. So it stays
FROZEN while peeking (`restoreFrozen={peeking}`), unlike comments/reactions
which are side-independent and stay live (BUG-2263 / Codex P1). -->
{#if !peeking}
<button data-testid="version-restore-btn">Restore this version</button>
{/if}
@@ -3,19 +3,22 @@ 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.
// BUG-2263 — the master/pane freeze is INVISIBLE to the user.
//
// 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.
// The freeze's only job is to keep exactly one TYPEABLE collab content editor
// (single-owner of the editorStore/activeItem/tab-title singletons). It is NOT a
// data-collision barrier — master and pane are always different items. So while
// peeking, ONLY the content surfaces stay frozen (the rich editor's editable bit,
// the raw editor, the editor bubble/link chrome, and the provider-lifecycle mode
// toggle); EVERY REST surface — fields, title, delete/move/add-relationship,
// children, timeline, star, share, quick-actions, archived restore — stays
// interactive on both sides, gated on `canEdit` (permission) alone. A click flips
// activePane first, so the interaction lands in one gesture.
//
// 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.
// FreezeProbe renders the CANONICAL gate expressions from ItemDetail, backed by
// the shared `computeMutationsEnabled` helper (which now scopes to content chrome
// only), so the gate predicates can't drift. The running-app assertions live in
// the pane-full-page e2e specs.
function target(): HTMLElement {
return document.body.appendChild(document.createElement('div'));
@@ -33,20 +36,25 @@ 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 = [
// REST surfaces that stay LIVE on the peeking side (invisible freeze).
const REST_LIVE_SURFACES = [
'delete-btn',
'move-btn',
'add-relationship-btn',
'editor-mutation-ui',
'title-editable',
];
describe('retain-alive master freeze wiring (TASK-2172)', () => {
describe('invisible master/pane freeze wiring (BUG-2263)', () => {
let root: HTMLElement | null = null;
let instance: ReturnType<typeof mount> | null = null;
function render(props: { canEdit?: boolean; peeking?: boolean; canRestore?: boolean }) {
function render(props: {
canEdit?: boolean;
peeking?: boolean;
canRestore?: boolean;
isOwner?: boolean;
quickActionsPresent?: boolean;
}) {
root = target();
instance = mount(FreezeProbe, { target: root, props });
flushSync();
@@ -60,117 +68,138 @@ describe('retain-alive master freeze wiring (TASK-2172)', () => {
root = null;
});
it('peeking=true freezes EVERY mutation surface even for an editor (canEdit=true)', () => {
it('peeking=true freezes ONLY the content surfaces; every REST surface stays live', () => {
const r = render({ canEdit: true, peeking: true });
// mutationsEnabled still collapses to false while peeking, but now gates
// only the content-editor chrome.
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).
// CONTENT bucket — frozen: the editor is not typeable, the raw editor is
// read-only, the bubble/link chrome + provider-lifecycle mode toggle hide.
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, 'editor-mutation-ui')).toBe(false);
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');
// REST bucket — INVISIBLE: fields interactive, children/timeline not frozen.
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) {
// Every REST mutation control stays mounted while peeking.
for (const surface of REST_LIVE_SURFACES) {
expect(present(r, surface), `${surface} must stay live while peeking`).toBe(true);
}
// Title shows its editable affordance (not the viewer heading).
expect(present(r, 'title-readonly')).toBe(false);
// Archived restore, star (enabled), Share stay live.
expect(present(r, 'archived-restore-btn')).toBe(true);
expect(disabled(r, 'star-btn')).toBe(false);
expect(present(r, 'share-btn')).toBe(true);
// TWO documented exceptions (Codex P1) — same-item / same-collection WRITES
// stay confined to the ACTIVE side. On the peeking side: version restore
// (writes items.content, collides with the retained Y.Doc) is hidden; and the
// owner collection-management menu is hidden here because no prompt actions
// are seeded — see the dedicated exceptions test for the prompts-still-visible
// case where only the Manage control is gated.
expect(present(r, 'version-restore-btn')).toBe(false);
expect(present(r, 'quickactions-menu')).toBe(false);
});
it('peeking=false, canEdit=true is identical to peeking=true for every REST surface (invisibility)', () => {
const r = render({ canEdit: true, peeking: false });
expect(text(r, 'mutationsEnabled')).toBe('true');
// Content surfaces are the ONLY difference from the peeking case.
expect(text(r, 'editor-editable')).toBe('true');
expect(text(r, 'raw-readonly')).toBe('false');
expect(present(r, 'editor-mutation-ui')).toBe(true);
expect(present(r, 'mode-toggle')).toBe(true);
// REST surfaces are byte-identical to the peeking case above.
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 REST_LIVE_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);
// (quick-actions + version restore — the two exceptions — are covered in
// their own test below.)
});
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);
it('the two same-item/same-collection WRITE surfaces are confined to the active side; prompt actions stay visible (Codex P1)', () => {
// Seed prompt actions so the quick-actions menu trigger has read-only content
// to keep it visible on both sides.
// Active side (peeking=false): prompts + Manage + version restore all present.
let r = render({ canEdit: true, peeking: false, isOwner: true, quickActionsPresent: true });
expect(present(r, 'quickactions-menu')).toBe(true);
// Peeking freezes both regardless.
expect(present(r, 'quickactions-prompt')).toBe(true);
expect(present(r, 'quickactions-manage')).toBe(true);
expect(present(r, 'version-restore-btn')).toBe(true);
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);
instance = null;
root = null;
// Peeking side: prompts STILL visible (invisible freeze for the read-only
// part), but the collection-management WRITE (Manage) is gone and version
// restore is frozen — both would collide (last-write-wins / Y.Doc overwrite).
r = render({ canEdit: true, peeking: true, isOwner: true, quickActionsPresent: true });
expect(present(r, 'quickactions-menu')).toBe(true);
expect(present(r, 'quickactions-prompt')).toBe(true);
expect(present(r, 'quickactions-manage')).toBe(false);
expect(present(r, 'version-restore-btn')).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('a true viewer (canEdit=false) still sees the read-only forms, regardless of peeking', () => {
// canEdit=false is a genuine read-only viewer — fields/title degrade to
// read-only, mutation controls hide. This is PERMISSION, not the freeze,
// and is identical whether peeking or not.
for (const peeking of [false, true]) {
const r = render({ canEdit: false, peeking, isOwner: false });
expect(text(r, 'field-readonly')).toBe('true');
expect(present(r, 'title-readonly')).toBe(true);
expect(present(r, 'title-editable')).toBe(false);
for (const surface of REST_LIVE_SURFACES) {
if (surface === 'title-editable') continue;
expect(present(r, surface), `${surface} hidden for a viewer`).toBe(false);
}
// Star stays available to viewers.
expect(disabled(r, 'star-btn')).toBe(false);
unmount(instance!);
root!.remove();
instance = null;
root = null;
}
});
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 });
it('archived restore rides `canRestore` alone (not peeking) — archived items force canEdit false', () => {
// canRestore true: restore shows whether peeking or not (invisible freeze).
for (const peeking of [false, true]) {
const r = render({ canEdit: false, peeking, canRestore: true });
expect(present(r, 'archived-restore-btn')).toBe(true);
unmount(instance!);
root!.remove();
instance = null;
root = null;
}
// canRestore false: hidden regardless.
const r = render({ canEdit: false, peeking: true, canRestore: false });
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)', () => {
// BUG-2263: a FIELD stays interactive across a peeking flip — the input does NOT
// unmount, and a typed value's debounce still fires onchange (→ updateField,
// which is side-independent). This mounts the REAL FieldEditor to lock it in.
describe('field stays interactive across a peeking flip (BUG-2263)', () => {
let root: HTMLElement | null = null;
let instance: ReturnType<typeof mount> | null = null;
@@ -189,26 +218,27 @@ describe('pre-pane debounced field save completes under Option A (TASK-2172)', (
return root;
}
it('a value typed before peeking still fires onchange after the field goes read-only', () => {
it('the input stays mounted after peeking begins, and the debounced value still saves', () => {
vi.useFakeTimers();
const onchange = vi.fn();
const r = mountProbe(onchange);
// Type a value (pre-pane) — arms the 500ms debounce, does NOT fire yet.
// Type a value — 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.
// Pane opens mid-edit → the field must STAY interactive (invisible freeze):
// peeking flips true, but the input is NOT unmounted (contrast the old
// degrade-to-readonly behavior).
r.querySelector<HTMLButtonElement>('[data-testid="begin-peek"]')!.click();
flushSync();
expect(r.querySelector('input.field-input')).toBeNull(); // NEW edit blocked
expect(text(r, 'probe-peeking')).toBe('true');
expect(r.querySelector('input.field-input')).not.toBeNull();
// The debounce fires → the pre-pane value reaches the parent (→ updateField,
// which no longer suppresses it). The freeze did NOT drop the typed value.
// The debounce fires → the value reaches the parent (→ updateField).
vi.advanceTimersByTime(500);
expect(onchange).toHaveBeenCalledTimes(1);
expect(onchange).toHaveBeenCalledWith('ui/editor');
@@ -37,18 +37,26 @@
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` freezes the COMMENT/REACTION surfaces (composer, reply, edit,
* delete, reaction). These are per-item / per-user REST entities, so under
* the invisible-freeze model (BUG-2263) the full-page host leaves this
* `false` even while peeking — comments stay live on the passive side.
* Defaults false → byte-identical for every existing caller.
*/
frozen?: boolean;
/**
* `restoreFrozen` freezes ONLY version restore. Unlike comments, a restore
* REST-writes this item's `items.content` directly (not via the Y.Doc
* applier), so on a peeking side whose Y.Doc is retained-alive it can be
* overwritten by a later collab flush — a SAME-ITEM collision the
* different-master/pane premise does NOT cover (BUG-2263 / Codex P1). The
* host passes `restoreFrozen={peeking}` so restore stays confined to the
* active editor. Defaults false → byte-identical for every existing caller.
*/
restoreFrozen?: boolean;
}
let { wsSlug, username = '', itemSlug, currentContent, items = [], onRestore, itemId, collectionId, frozen = false }: Props = $props();
let { wsSlug, username = '', itemSlug, currentContent, items = [], onRestore, itemId, collectionId, frozen = false, restoreFrozen = false }: Props = $props();
// Resolve canEditItem reactively; falls to false if itemId/collectionId
// aren't supplied (e.g. an older caller). Folds in the master-freeze gate
@@ -495,7 +503,7 @@
{itemSlug}
{currentContent}
{onRestore}
{frozen}
frozen={frozen || restoreFrozen}
/>
{/if}
</div>
@@ -0,0 +1,64 @@
import { describe, it, expect, afterEach } from 'vitest';
import { flushSync, mount, unmount } from 'svelte';
import TimelineVersionCard from './TimelineVersionCard.svelte';
import type { Version } from '$lib/types';
// BUG-2263 / Codex P1 + P2 — REAL-component coverage for the version-restore
// exception. Unlike the other REST surfaces, version restore REST-writes this
// item's `items.content` directly (colliding with the retained Y.Doc on a peeking
// side), so it stays FROZEN while peeking. ItemDetail wires that as
// `restoreFrozen={peeking}` → ItemTimeline passes `frozen={frozen || restoreFrozen}`
// → TimelineVersionCard's `frozen`. This mounts the ACTUAL card (not a probe) to
// lock the terminal gate: the restore control is present iff NOT frozen.
function target(): HTMLElement {
return document.body.appendChild(document.createElement('div'));
}
// Minimal non-diff version so no mount-time API fetch fires (is_diff=false → the
// content-resolve effect returns early) and the card renders its restore area.
const version = {
id: 'v1',
item_id: 'i1',
content: 'hello',
is_diff: false,
change_summary: 'edited',
created_by: 'user',
source: 'web',
created_at: '2026-07-20T00:00:00Z',
} as unknown as Version;
describe('TimelineVersionCard restore gate (BUG-2263)', () => {
let root: HTMLElement | null = null;
let instance: ReturnType<typeof mount> | null = null;
function render(frozen: boolean) {
root = target();
instance = mount(TimelineVersionCard, {
target: root,
props: { version, wsSlug: 'ws', itemSlug: 'ITEM-1', currentContent: 'now', onRestore: () => {}, frozen },
});
flushSync();
// The restore-area lives inside the expanded card body — expand it first.
(root.querySelector('.card-header') as HTMLButtonElement).click();
flushSync();
return root;
}
afterEach(() => {
if (instance) unmount(instance);
root?.remove();
instance = null;
root = null;
});
it('shows the restore control when NOT frozen (active side)', () => {
const r = render(false);
expect(r.querySelector('.restore-area')).not.toBeNull();
});
it('HIDES the restore control when frozen (peeking side — same-item content collision)', () => {
const r = render(true);
expect(r.querySelector('.restore-area')).toBeNull();
});
});