From bda30156b33bf2f3bf270d5ae84b9169bfa77cc2 Mon Sep 17 00:00:00 2001 From: xarmian Date: Sat, 26 Sep 2026 13:37:34 -0400 Subject: [PATCH] =?UTF-8?q?fix(web):=20a=20body=20the=20server=20marks=20a?= =?UTF-8?q?s=20behind=20its=20live=20document=20says=20so=20wherever=20it?= =?UTF-8?q?=20is=20rendered=20=E2=80=94=20share=20pages,=20the=20read-only?= =?UTF-8?q?=20pane,=20the=20diff,=20and=20a=20dot=20on=20marked=20rows=20i?= =?UTF-8?q?n=20lists,=20backlinks=20and=20the=20palette=20(BUG-3050=20U3)?= =?UTF-8?q?=20(#1577)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server marks a body that is behind its live collaborative document with `content_state: applied_pending_flush` (BUG-3000). No browser render read it: share pages dropped it in field copies, and every other surface showed the stale body unmarked. - One helper (lib/items/staleBody.ts) and two components carry the ruled treatments: a one-line notice "This may not include the latest edits." above a body that is the view's subject (share page incl. the password-unlocked copy, the public collection's inline expansion, the item pane's read-only viewer, the timeline diff's current side), and a muted dot with that text as tooltip and label on MARKED rows only (conventions list, playbooks list, backlinks, command palette). Unmarked rows are unchanged. - A body kept locally over a fresh server row keeps its OWN marker (adoptServerItem and four save/refresh paths), and an Item built with a blank body from an index row drops the row's marker, matching the server's rule that the marker is emitted only where a real body is. - Guard tables re-read and re-stamped for the ItemDetail edits; a new guard pins the kept/blanked-body population. Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz --- .../server/content_state_population_test.go | 3 +- web/e2e/bug-3050-stale-body-renders.spec.ts | 232 ++++++++++++++++++ web/src/lib/components/BacklinksPanel.svelte | 4 +- .../lib/components/common/StaleBodyDot.svelte | 26 ++ .../components/common/StaleBodyNotice.svelte | 14 ++ .../lib/components/items/ItemDetail.svelte | 22 +- .../childIdentityFencePopulation.test.ts | 2 + .../items/itemDetailIdentityFenceAst.test.ts | 36 +-- .../components/search/CommandPalette.svelte | 9 +- .../share/PublicItemExpansion.svelte | 2 + web/src/lib/components/share/shareView.ts | 7 +- .../components/timeline/ItemTimeline.svelte | 6 +- .../timeline/TimelineEntryList.svelte | 5 + .../timeline/TimelineVersionCard.svelte | 7 +- web/src/lib/items/staleBody.ts | 23 ++ web/src/lib/items/staleBodyKeptBody.test.ts | 71 ++++++ .../lib/items/staleBodyRenders.svelte.test.ts | 143 +++++++++++ web/src/lib/stores/localIndex.svelte.ts | 2 +- web/src/lib/types/index.ts | 11 +- .../[workspace]/[collection]/+page.svelte | 2 +- .../[workspace]/conventions/+page.svelte | 3 + .../[workspace]/playbooks/+page.svelte | 3 + web/src/routes/s/[token]/+page.svelte | 7 + 23 files changed, 600 insertions(+), 40 deletions(-) create mode 100644 web/e2e/bug-3050-stale-body-renders.spec.ts create mode 100644 web/src/lib/components/common/StaleBodyDot.svelte create mode 100644 web/src/lib/components/common/StaleBodyNotice.svelte create mode 100644 web/src/lib/items/staleBody.ts create mode 100644 web/src/lib/items/staleBodyKeptBody.test.ts create mode 100644 web/src/lib/items/staleBodyRenders.svelte.test.ts diff --git a/internal/server/content_state_population_test.go b/internal/server/content_state_population_test.go index 6f6edf98f..5d67f25dd 100644 --- a/internal/server/content_state_population_test.go +++ b/internal/server/content_state_population_test.go @@ -80,7 +80,8 @@ import ( // quick-action prompts, the conventions and playbook editors, and // ItemDetail's editor-seed and timeline-diff strings. An earlier version of // this comment described that surface as a pure rendering choice, which was -// inaccurate. +// inaccurate. BUG-3050 closed it in three units: the writes (U1), the agent +// push (U2) and the renders (U3, `web/src/lib/items/staleBody.ts`). func TestBodyDerivedTextSitesAreAllRuledOn(t *testing.T) { // The helpers whose output is item-body text under another name. Adding one // here is how this guard learns about a new shape. diff --git a/web/e2e/bug-3050-stale-body-renders.spec.ts b/web/e2e/bug-3050-stale-body-renders.spec.ts new file mode 100644 index 000000000..cdfe05685 --- /dev/null +++ b/web/e2e/bug-3050-stale-body-renders.spec.ts @@ -0,0 +1,232 @@ +import { test, expect } from './fixtures'; +import { browserLogin } from './lib/collab-helpers'; +import { authJson } from './lib/attachment-viewer'; +import type { Page, APIRequestContext, Route } from '@playwright/test'; +import type { SuiteFixture } from './fixtures'; + +/** + * BUG-3050 U3: every page that renders a body the server marked as behind the + * live document (`content_state: applied_pending_flush`, BUG-3000) says so. + * The ruled treatments: a one-line notice above a body that is the view's + * subject (share pages, the read-only item pane, the diff's current side), and + * a muted dot on MARKED rows only where the body is a snippet among many + * (conventions, playbooks, the command palette; backlinks is a unit leg). + * + * These legs pin the BINDING from each page's API response to its render, so + * the marker is added to the real response by interception, on one row only: + * a real marker lasts only until a tab flushes, which no leg can schedule. The + * components themselves are pinned in src/lib/items/staleBodyRenders.svelte.test.ts. + */ + +const MARK = 'applied_pending_flush'; +const NOTICE = 'This may not include the latest edits.'; + +async function create(fixture: SuiteFixture, request: APIRequestContext, coll: string, title: string, body: string, fields: Record = {}) { + const resp = await request.post(`/api/v1/workspaces/${fixture.workspaceSlug}/collections/${coll}/items`, { + headers: authJson(fixture), + data: { title, content: body, fields: JSON.stringify(fields) }, + }); + if (!resp.ok()) throw new Error(`create failed (${resp.status()}): ${await resp.text()}`); + return (await resp.json()) as { slug: string; ref: string; id: string }; +} + +async function share(fixture: SuiteFixture, request: APIRequestContext, path: string, data: Record = {}) { + const resp = await request.post(`/api/v1/workspaces/${fixture.workspaceSlug}/${path}/share-links`, { + headers: authJson(fixture), + data, + }); + if (!resp.ok()) throw new Error(`share failed (${resp.status()}): ${await resp.text()}`); + return ((await resp.json()) as { token: string }).token; +} + +/** Fulfil `route` with the real response, rewritten by `edit`. */ +async function rewrite(route: Route, edit: (json: any) => void) { + const resp = await route.fetch(); + const json = await resp.json(); + edit(json); + await route.fulfill({ response: resp, json }); +} + +/** Mark the rows of a JSON array (or `{items}`/`{results}` wrapper) whose id is in `ids`. */ +function markRows(json: any, ids: string[]) { + const rows: any[] = Array.isArray(json) ? json : (json.items ?? json.results ?? []); + let marked = 0; + for (const r of rows) { + const row = r.item ?? r; + if (ids.includes(row.id)) { row.content_state = MARK; marked++; } + } + return marked; +} + +/** Mark the item GET for `id`, whatever URL the pane reads it by (ref, slug, query). */ +async function markItemReads(page: Page, fixture: SuiteFixture, id: string) { + await page.route(`**/api/v1/workspaces/${fixture.workspaceSlug}/items/**`, async (r) => { + if (r.request().method() !== 'GET') return r.continue(); + const resp = await r.fetch(); + const text = await resp.text(); + let json: any; + try { json = JSON.parse(text); } catch { return r.fulfill({ response: resp, body: text }); } + if (json && !Array.isArray(json) && json.id === id) json.content_state = MARK; + await r.fulfill({ response: resp, json }); + }); +} + +test.describe('stale body renders (BUG-3050 U3)', () => { + test.setTimeout(90_000); + test.beforeEach(({}, testInfo) => { + test.skip(testInfo.project.name !== 'desktop-chromium', 'one project is enough for a render binding'); + }); + + test('share page: a marked item shows the notice above its body, an unmarked one does not (C1)', async ({ browser, fixture, request }) => { + const stamp = Date.now(); + const item = await create(fixture, request, 'docs', `Shared ${stamp}`, 'Shared body text.'); + const token = await share(fixture, request, `items/${item.slug}`); + const anon = await browser.newContext(); + try { + const p = await anon.newPage(); + await p.goto(`/s/${token}`); + await expect(p.locator('.item-content')).toContainText('Shared body text.'); + await expect(p.getByText(NOTICE)).toHaveCount(0); + + await p.route(`**/api/v1/s/${token}`, (r) => rewrite(r, (j) => { j.item.content_state = MARK; })); + await p.reload(); + await expect(p.locator('.item-content')).toContainText('Shared body text.'); + await expect(p.getByText(NOTICE)).toBeVisible(); + } finally { + await anon.close(); + } + }); + + test('share page: the password-unlocked copy carries the marker too (C1)', async ({ browser, fixture, request }) => { + const stamp = Date.now(); + const item = await create(fixture, request, 'docs', `Locked ${stamp}`, 'Locked body text.'); + const token = await share(fixture, request, `items/${item.slug}`, { password: `pw-${stamp}` }); + const anon = await browser.newContext(); + try { + const p = await anon.newPage(); + await p.route(`**/api/v1/s/${token}`, (r) => rewrite(r, (j) => { if (j.item) j.item.content_state = MARK; })); + await p.goto(`/s/${token}`); + await p.getByPlaceholder(/password/i).fill(`pw-${stamp}`); + await p.getByRole('button', { name: /View content/ }).click(); + await expect(p.locator('.item-content')).toContainText('Locked body text.'); + await expect(p.getByText(NOTICE)).toBeVisible(); + } finally { + await anon.close(); + } + }); + + test('shared collection: the inline expansion of a marked item shows the notice, its neighbour\'s does not (C2/C3)', async ({ browser, fixture, request }) => { + const stamp = Date.now(); + const marked = await create(fixture, request, 'docs', `Coll marked ${stamp}`, `Marked expansion body ${stamp}.`); + const plain = await create(fixture, request, 'docs', `Coll plain ${stamp}`, `Plain expansion body ${stamp}.`); + const token = await share(fixture, request, 'collections/docs'); + const anon = await browser.newContext(); + try { + const p = await anon.newPage(); + // Public share items carry no id (an allow-list payload), so mark by title. + await p.route(`**/api/v1/s/${token}`, (r) => rewrite(r, (j) => { + for (const it of j.items ?? []) if (it.title === `Coll marked ${stamp}`) it.content_state = MARK; + })); + await p.goto(`/s/${token}`); + for (const [it, body, noticeCount] of [[plain, `Plain expansion body ${stamp}.`, 0], [marked, `Marked expansion body ${stamp}.`, 1]] as const) { + await p.getByText(`Coll ${it === marked ? 'marked' : 'plain'} ${stamp}`).first().click(); + const region = p.getByRole('region', { name: new RegExp(`Coll ${it === marked ? 'marked' : 'plain'} ${stamp} details`) }); + await expect(region).toContainText(body); + await expect(region.getByText(NOTICE)).toHaveCount(noticeCount); + } + } finally { + await anon.close(); + } + }); + + test('item pane: a read-only viewer sees the notice above a marked body (C4)', async ({ page, fixture, request }) => { + const stamp = Date.now(); + const item = await create(fixture, request, 'docs', `Viewer ${stamp}`, `Viewer body ${stamp}.`); + await browserLogin(page); + // A viewer's membership, on the one call canEdit derives from. + await page.route(`**/api/v1/workspaces/${fixture.workspaceSlug}/me`, (r) => rewrite(r, (j) => { j.role = 'viewer'; j.item_grants = []; j.collection_grants = []; })); + await markItemReads(page, fixture, item.id); + await page.goto(`/${fixture.adminUsername}/${fixture.workspaceSlug}/docs?item=${item.ref}`); + await expect(page.getByText(`Viewer body ${stamp}.`)).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText(NOTICE)).toBeVisible(); + }); + + test('item pane: a read-only viewer on an unmarked item sees no notice (C4 control)', async ({ page, fixture, request }) => { + const stamp = Date.now(); + const item = await create(fixture, request, 'docs', `Viewer ctl ${stamp}`, `Viewer ctl body ${stamp}.`); + await browserLogin(page); + await page.route(`**/api/v1/workspaces/${fixture.workspaceSlug}/me`, (r) => rewrite(r, (j) => { j.role = 'viewer'; j.item_grants = []; j.collection_grants = []; })); + await page.goto(`/${fixture.adminUsername}/${fixture.workspaceSlug}/docs?item=${item.ref}`); + await expect(page.getByText(`Viewer ctl body ${stamp}.`)).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText(NOTICE)).toHaveCount(0); + }); + + test('versions tab: the diff against a marked current body shows the notice (C5)', async ({ page, fixture, request }) => { + const stamp = Date.now(); + const item = await create(fixture, request, 'docs', `Diff ${stamp}`, `First body ${stamp}.`); + const upd = await request.patch(`/api/v1/workspaces/${fixture.workspaceSlug}/items/${item.slug}`, { + headers: authJson(fixture), + data: { content: `Second body ${stamp}.` }, + }); + expect(upd.ok(), await upd.text()).toBeTruthy(); + await browserLogin(page); + await markItemReads(page, fixture, item.id); + await page.goto(`/${fixture.adminUsername}/${fixture.workspaceSlug}/docs?item=${item.ref}`); + await page.getByRole('tab', { name: 'Versions' }).click(); + const card = page.locator('.card-header').first(); + await expect(card).toBeVisible({ timeout: 15_000 }); + await card.click(); + await expect(page.locator('.diff-container').first()).toBeVisible(); + await expect(page.locator('.diff-container').first().getByText(NOTICE)).toBeVisible(); + }); + + async function dotsIn(page: Page, rowSelector: string, title: string) { + return page.locator(rowSelector).filter({ hasText: title }).locator('[data-testid="stale-body-dot"]'); + } + + test('conventions list: a dot on the marked row only (C6)', async ({ page, fixture, request }) => { + const stamp = Date.now(); + const marked = await create(fixture, request, 'conventions', `Conv marked ${stamp}`, 'Rule body.', { status: 'active', trigger: 'always' }); + await create(fixture, request, 'conventions', `Conv plain ${stamp}`, 'Rule body.', { status: 'active', trigger: 'always' }); + await browserLogin(page); + await page.route(`**/api/v1/workspaces/${fixture.workspaceSlug}/collections/conventions/items*`, (r) => rewrite(r, (j) => markRows(j, [marked.id]))); + await page.goto(`/${fixture.adminUsername}/${fixture.workspaceSlug}/conventions`); + await expect(page.getByText(`Conv plain ${stamp}`)).toBeVisible({ timeout: 15_000 }); + await expect(await dotsIn(page, '.convention-row', `Conv marked ${stamp}`)).toHaveCount(1); + await expect(await dotsIn(page, '.convention-row', `Conv plain ${stamp}`)).toHaveCount(0); + await expect((await dotsIn(page, '.convention-row', `Conv marked ${stamp}`)).first()).toHaveAttribute('title', NOTICE); + }); + + test('playbooks list: a dot on the marked card only (C7)', async ({ page, fixture, request }) => { + const stamp = Date.now(); + const marked = await create(fixture, request, 'playbooks', `PB marked ${stamp}`, '1. Step', { status: 'draft' }); + await create(fixture, request, 'playbooks', `PB plain ${stamp}`, '1. Step', { status: 'draft' }); + await browserLogin(page); + await page.route(`**/api/v1/workspaces/${fixture.workspaceSlug}/collections/playbooks/items*`, (r) => rewrite(r, (j) => markRows(j, [marked.id]))); + await page.goto(`/${fixture.adminUsername}/${fixture.workspaceSlug}/playbooks`); + await expect(page.getByText(`PB plain ${stamp}`)).toBeVisible({ timeout: 15_000 }); + await expect(await dotsIn(page, '.card', `PB marked ${stamp}`)).toHaveCount(1); + await expect(await dotsIn(page, '.card', `PB plain ${stamp}`)).toHaveCount(0); + }); + + test('command palette: a content match from a marked item carries the dot, its neighbour does not (C9)', async ({ page, fixture, request }) => { + const stamp = Date.now(); + const word = `zqpal${stamp.toString(36)}`; + const marked = await create(fixture, request, 'docs', `Pal marked ${stamp}`, `Body mentions ${word} here.`); + await create(fixture, request, 'docs', `Pal plain ${stamp}`, `Body mentions ${word} too.`); + await browserLogin(page); + await page.route('**/api/v1/search?*', (r) => rewrite(r, (j) => markRows(j, [marked.id]))); + await page.goto(`/${fixture.adminUsername}/${fixture.workspaceSlug}/docs`); + const input = page.getByPlaceholder('Search items, collections, docs...'); + // The shortcut is bound once the app shell has loaded; press until the palette opens. + await expect(async () => { + if (!(await input.isVisible())) await page.keyboard.press('Control+k'); + await expect(input).toBeVisible({ timeout: 1_000 }); + }).toPass({ timeout: 20_000 }); + await input.fill(word); + const rows = page.locator('button.result'); + await expect(rows.filter({ hasText: `Pal plain ${stamp}` })).toBeVisible({ timeout: 15_000 }); + await expect(rows.filter({ hasText: `Pal marked ${stamp}` }).locator('[data-testid="stale-body-dot"]')).toHaveCount(1); + await expect(rows.filter({ hasText: `Pal plain ${stamp}` }).locator('[data-testid="stale-body-dot"]')).toHaveCount(0); + }); +}); diff --git a/web/src/lib/components/BacklinksPanel.svelte b/web/src/lib/components/BacklinksPanel.svelte index 738f3e97d..953c6e209 100644 --- a/web/src/lib/components/BacklinksPanel.svelte +++ b/web/src/lib/components/BacklinksPanel.svelte @@ -4,6 +4,8 @@ import { authStore } from '$lib/stores/auth.svelte'; import type { Backlink } from '$lib/types'; import { relativeTime } from '$lib/utils/markdown'; + import StaleBodyDot from '$lib/components/common/StaleBodyDot.svelte'; + import { isBodyStale } from '$lib/items/staleBody'; interface Props { wsSlug: string; @@ -220,7 +222,7 @@ {#if bl.snippet} -
{bl.snippet}
+
{#if isBodyStale(bl)}{/if}{bl.snippet}
{/if} {/each} diff --git a/web/src/lib/components/common/StaleBodyDot.svelte b/web/src/lib/components/common/StaleBodyDot.svelte new file mode 100644 index 000000000..0937d1199 --- /dev/null +++ b/web/src/lib/components/common/StaleBodyDot.svelte @@ -0,0 +1,26 @@ + + + + + diff --git a/web/src/lib/components/common/StaleBodyNotice.svelte b/web/src/lib/components/common/StaleBodyNotice.svelte new file mode 100644 index 000000000..f6079003c --- /dev/null +++ b/web/src/lib/components/common/StaleBodyNotice.svelte @@ -0,0 +1,14 @@ + + +

{STALE_BODY_NOTICE}

+ + diff --git a/web/src/lib/components/items/ItemDetail.svelte b/web/src/lib/components/items/ItemDetail.svelte index 3b2b90ead..fcd81c293 100644 --- a/web/src/lib/components/items/ItemDetail.svelte +++ b/web/src/lib/components/items/ItemDetail.svelte @@ -23,6 +23,8 @@ import { sseService } from '$lib/services/sse.svelte'; import { visibility } from '$lib/services/visibility.svelte'; import Editor from '$lib/components/editor/Editor.svelte'; + import StaleBodyNotice from '$lib/components/common/StaleBodyNotice.svelte'; + import { isBodyStale } from '$lib/items/staleBody'; import EditorBubbleMenu from '$lib/components/editor/EditorBubbleMenu.svelte'; import EditorLinkPopover from '$lib/components/editor/EditorLinkPopover.svelte'; import RawMarkdownEditor from '$lib/components/editor/RawMarkdownEditor.svelte'; @@ -3748,11 +3750,12 @@ // Realtime-refresh convenience: applies the content-adoption rule (under // collab the editor reads Y.Doc so adopt server content verbatim; otherwise - // preserve the live local content) and the tag overlay. + // preserve the live local content) and the tag overlay. The kept body keeps + // ITS `content_state` (BUG-3050 U3): the new row's marker describes the new + // row's body, and a render reading it would label a body it was not about. function adoptServerItem(updated: Item): Item { - return withInflightTags( - collabProvider ? updated : { ...updated, content: item?.content ?? updated.content } - ); + if (collabProvider || item?.content == null) return withInflightTags(updated); + return withInflightTags({ ...updated, content: item.content, content_state: item.content_state }); } /** @@ -4538,7 +4541,7 @@ // Newer pending edit; keep local content, adopt // server-side metadata only. The next debounce // cycle will land the queued edit. - item = withInflightTags({ ...updated, content: item.content }); + item = withInflightTags({ ...updated, content: item.content, content_state: item.content_state }); } }).catch((e) => { if (!item || item.id !== reqItemId || genAtSave !== loadGeneration) return; @@ -4660,7 +4663,7 @@ // Newer edit pending — keep our local // content but adopt server-side metadata // (timestamps, version, modified_by). - item = withInflightTags({ ...updated, content: item.content }); + item = withInflightTags({ ...updated, content: item.content, content_state: item.content_state }); } } catch (e) { // Don't surface A's save failure over B (a superseded @@ -5377,7 +5380,7 @@ // Refresh item to update parent info const refreshed = await api.items.get(targetWs, targetSlug); if (switchedAway(targetItem, gen) || !item) return; - item = withInflightTags({ ...refreshed, content: item.content }); + item = withInflightTags({ ...refreshed, content: item.content, content_state: item.content_state }); toastStore.show('Relationship removed', 'success'); } catch (e: any) { if (switchedAway(targetItem, gen)) return; @@ -5428,7 +5431,7 @@ // Refresh item to update parent info const refreshed = await api.items.get(targetWs, targetSlug); if (switchedAway(sourceItem, gen) || !item) return; - item = withInflightTags({ ...refreshed, content: item.content }); + item = withInflightTags({ ...refreshed, content: item.content, content_state: item.content_state }); toastStore.show('Relationship added', 'success'); } catch (e: any) { if (switchedAway(sourceItem, gen)) return; @@ -6763,6 +6766,7 @@ read-only y-binding is deferred to TASK-1266. --> {#if !canEdit} + {#if isBodyStale(item)}{/if} {#key `${item.id}:false:${identityKey}`} { if (handedDown !== identityKey) return; handleVersionRestore(updated); }} flushBeforeRestore={flushCollabBeforeRestore} @@ -7163,6 +7168,7 @@ {username} {itemSlug} currentContent={item.content ?? ''} + currentContentStale={isBodyStale(item)} items={localIndex.getAll(wsSlug)} changeContext={timelineChangeContext} hostToken={attachmentHostToken} diff --git a/web/src/lib/components/items/childIdentityFencePopulation.test.ts b/web/src/lib/components/items/childIdentityFencePopulation.test.ts index 601e151cd..f9a0a468d 100644 --- a/web/src/lib/components/items/childIdentityFencePopulation.test.ts +++ b/web/src/lib/components/items/childIdentityFencePopulation.test.ts @@ -180,6 +180,8 @@ const POPULATION: string[] = [ 'lib/components/common/MenuItem.svelte', 'lib/components/common/ContentSkeleton.svelte', 'lib/components/common/ContentError.svelte', + // BUG-3050 U3: static notice text, no script beyond an import; no await, no send. + 'lib/components/common/StaleBodyNotice.svelte', 'lib/components/collections/EditCollectionModal.svelte', 'lib/components/ShareDialog.svelte', 'lib/components/items/CopyItemDialog.svelte', diff --git a/web/src/lib/components/items/itemDetailIdentityFenceAst.test.ts b/web/src/lib/components/items/itemDetailIdentityFenceAst.test.ts index 8e6a0ff8b..3bb593063 100644 --- a/web/src/lib/components/items/itemDetailIdentityFenceAst.test.ts +++ b/web/src/lib/components/items/itemDetailIdentityFenceAst.test.ts @@ -112,7 +112,7 @@ const ASYNC_FUNCTIONS: Record = { jumpToSection: { reviewed: '17407aeb431b', why: 'switches this instance\'s tab and scrolls to an anchor', may: ['document.getElementById', 'document.getElementById(anchorId).scrollIntoView'] }, ensureGraphComp: { reviewed: 'c1565cfb8a18', why: 'lazy-loads a component module into this instance', may: ['ItemGraphComp', 'graphLoadError'] }, handleCopyRef: { reviewed: 'a5e0a0394fcc', why: 'switchedAway before the copied flag' }, - loadData: { reviewed: '8eb393047b3e', why: 'IS the load: myGen against loadGeneration after every await; the BUG-3198 re-read of a changed item runs after the install, keyed on the installed item id, and fences itself on itemGen' }, + loadData: { reviewed: '7450124bf4bf', why: 'IS the load: myGen against loadGeneration after every await; the BUG-3198 re-read of a changed item runs after the install, keyed on the installed item id, and fences itself on itemGen' }, startEditTitle: { reviewed: '17f04352d420', why: 'focuses and sizes the input it opened synchronously', may: ['el', 'titleInputEl.focus', 'titleInputEl.setSelectionRange'] }, // Re-reviewed for BUG-2836: saveTitle's own lines are unchanged (the diff // touches only a $derived beside titleDraft and the textarea's aria @@ -161,7 +161,7 @@ const ASYNC_FUNCTIONS: Record = { updateAssignedUser: { reviewed: '5e4128e45ae7', why: 'gen against loadGeneration on both arms' + SETTLE_NOTE, may: ['saves.settle'] }, updateAgentRole: { reviewed: '9b0a6af92cc2', why: 'gen against loadGeneration on both arms' + SETTLE_NOTE, may: ['saves.settle'] }, flushRawIfPending: { - reviewed: '425804645744', + reviewed: 'b25da2794031', why: 'genAtFlush against loadGeneration after each PATCH; the re-entrancy waiter returns state; the finally clears this drain\'s own in-flight flag' + SETTLE_NOTE, may: ['saves.settle', 'rawFlushInFlight'], bareAwaits: ['await new Promise((r) => setTimeout(r, 50));'], @@ -184,8 +184,8 @@ const ASYNC_FUNCTIONS: Record = { handleCopied: { reviewed: 'f35fb099838d', why: 'switchedAway before adopting the refreshed item' }, handleDelete: { reviewed: 'b66adcf84794', why: 'switchedAway on both arms' }, handleRestore: { reviewed: 'c3a1732554a4', why: 'switchedAway on every arm' }, - handleDeleteLink: { reviewed: 'e51f23ad3790', why: 'switchedAway after each await' }, - handleCreateLink: { reviewed: '473b62faa93a', why: 'switchedAway after each await' }, + handleDeleteLink: { reviewed: 'd147285bb264', why: 'switchedAway after each await' }, + handleCreateLink: { reviewed: 'ef19e8ce0bc8', why: 'switchedAway after each await' }, handleMove: { reviewed: 'da5513ba767f', why: 'stillOnSource() on every arm, including inside navIfStillCurrent' }, }; @@ -396,7 +396,7 @@ const CONTINUATIONS: SignedRow[] = [ may: ['renameOverride'], }, { call: /^setTimeout\($/, body: /copied = false/, why: 'copy-flag reset: switchedAway', reviewed: '8a8d4754017f' }, - { call: /api\.items\.get\(wsSlug, itemSlug\)\.catch\($/, body: /./, why: 'loadData item fetch: sets a flag local to that load and re-throws', reviewed: 'f2267a80cece' }, + { call: /api\.items\.get\(wsSlug, itemSlug\)\.catch\($/, body: /./, why: 'loadData item fetch: sets a flag local to that load and re-throws', reviewed: 'f46985737ace' }, { call: /^setTimeout\($/, body: /staleConnecting = true/, @@ -412,7 +412,7 @@ const CONTINUATIONS: SignedRow[] = [ body: /teardownFlushed/, in: 'onBeforeUnload', code: '() => { teardownFlushed = false; }', - why: 're-arms the BUG-3005 teardown latch, itself identity-checked', reviewed: 'e598de5a2771', + why: 're-arms the BUG-3005 teardown latch, itself identity-checked', reviewed: '05b21a3dd559', may: ['teardownFlushed'], }, { call: /^queueMicrotask\($/, body: /./, why: 'collab lazy seed: refuses a retired or re-identified context first', reviewed: '197a4a45d219' }, @@ -429,32 +429,32 @@ const CONTINUATIONS: SignedRow[] = [ call: /^setTimeout\($/, body: /content: toSave, client_write: nextClientWrite\(\) \}\)\.then/, in: 'handleContentUpdate', - why: 'content debounce: loadData clears this timer before its first await, so the callback never runs across a load', reviewed: 'd05baa756ebd', + why: 'content debounce: loadData clears this timer before its first await, so the callback never runs across a load', reviewed: '6858c043b334', startSafe: true, pin: (src, unit) => clearsBeforeFirstAwait(src, 'loadData', 'contentDebounceTimer') ?? assignedTo(src, unit, 'contentDebounceTimer'), }, - { call: /\{ content: toSave, client_write: nextClientWrite\(\) \}\)\.then\($/, body: /^\(\) =>/, why: 'content save: switchedAway', reviewed: '160532c3b115' }, - { call: /showSaved\(saveTok\); \}\)\.catch\($/, body: /./, why: 'content save failure: switchedAway', reviewed: '73e9f73724f5' }, + { call: /\{ content: toSave, client_write: nextClientWrite\(\) \}\)\.then\($/, body: /^\(\) =>/, why: 'content save: switchedAway', reviewed: '5afbf6c10e2d' }, + { call: /showSaved\(saveTok\); \}\)\.catch\($/, body: /./, why: 'content save failure: switchedAway', reviewed: '0cb1078817c7' }, { call: /\.finally\($/, body: /^\(\) => saves\.settle\(saveTok\)$/, in: 'setTimeout(…)', code: '() => saves.settle(saveTok)', why: SETTLE_WHY, - reviewed: '4fbcd658ebba', + reviewed: '08fccaaec1ae', may: ['saves.settle'], }, - { call: /\{ keepalive: true \}\) \.then\($/, body: /./, why: 'raw keepalive save: genAtSave against loadGeneration', reviewed: '758b235c3705' }, - { call: /localDirty = false; \} \}\) \.catch\($/, body: /^\(\) => \{\}$/, why: 'raw keepalive failure: empty', reviewed: '66b6e4855355' }, - { call: /reqItemId, \{ content: toSave, client_write: nextClientWrite\(\) \}\)\.then\($/, body: /./, why: 'raw foreground save: genAtSave against loadGeneration', reviewed: '109114fbb1b8' }, - { call: /content: item\.content \}\); \} \}\)\.catch\($/, body: /./, why: 'raw foreground failure: genAtSave against loadGeneration', reviewed: 'a47d5c0c6f20' }, + { call: /\{ keepalive: true \}\) \.then\($/, body: /./, why: 'raw keepalive save: genAtSave against loadGeneration', reviewed: 'b95a7c2a3e88' }, + { call: /localDirty = false; \} \}\) \.catch\($/, body: /^\(\) => \{\}$/, why: 'raw keepalive failure: empty', reviewed: '2ad797bd41d1' }, + { call: /reqItemId, \{ content: toSave, client_write: nextClientWrite\(\) \}\)\.then\($/, body: /./, why: 'raw foreground save: genAtSave against loadGeneration', reviewed: '05f885ec2567' }, + { call: /content: item\.content, content_state: item\.content_state \}\); \} \}\)\.catch\($/, body: /./, why: 'raw foreground failure: genAtSave against loadGeneration', reviewed: '67af6367e790' }, { call: /\.finally\($/, body: /^\(\) => saves\.settle\(saveTok\)$/, in: '{save}', code: '() => saves.settle(saveTok)', why: SETTLE_WHY, - reviewed: '0ef0106d13cc', + reviewed: 'd16dc96fd13d', may: ['saves.settle'], }, ]; @@ -468,7 +468,9 @@ const CONTINUATIONS: SignedRow[] = [ const HELPERS: Record = { primeCanonicalSeed: '9d90b6bf0245', adoptCollection: 'ab38368cd8fb', - adoptServerItem: '198d4de5b450', + // BUG-3050 U3: the kept local body keeps ITS content_state. Still synchronous, + // reads only collabProvider and item, and commits only through withInflightTags. + adoptServerItem: 'e11e0282e197', applyProgress: 'b28014625819', autoResizeTitle: '7b1e2fcd0526', captureIdentity: '01f30996ab05', @@ -477,7 +479,7 @@ const HELPERS: Record = { identityHeld: '1c5505d51f73', navigateToCollectionRoot: '64dce75693d3', refreshPrintMeta: '2312cc481ca5', - runTeardownFlush: '8251de42632d', + runTeardownFlush: '6c7e2a216f4f', // BUG-3124 unit B: the cursor-advance settle. Synchronous; arms the flusher's // single timer only for the current, identity-held context. showSaved: '456dd972dcf5', diff --git a/web/src/lib/components/search/CommandPalette.svelte b/web/src/lib/components/search/CommandPalette.svelte index fe1e16f67..2a4309646 100644 --- a/web/src/lib/components/search/CommandPalette.svelte +++ b/web/src/lib/components/search/CommandPalette.svelte @@ -20,6 +20,8 @@ import { getFieldValue, itemUrlId, formatItemRef } from '$lib/types'; import { relativeTime } from '$lib/utils/markdown'; import { fieldMatches } from '$lib/fields/fieldShape'; + import StaleBodyDot from '$lib/components/common/StaleBodyDot.svelte'; + import { isBodyStale } from '$lib/items/staleBody'; const RECENT_SEARCHES_KEY = 'pad-recent-searches'; const MAX_RECENT = 10; @@ -198,7 +200,7 @@ // The CommandPalette only reads title/fields/ref/etc., so an // empty content body is fine — and matches the same widening // pattern the collection page uses at TASK-1357. - const item: Item = { ...(row as ItemIndexRow), content: '' } as Item; + const item: Item = { ...(row as ItemIndexRow), content: '', content_state: undefined } as Item; out.push({ item, snippet: '', @@ -1042,6 +1044,7 @@ {#if r.snippet}
+ {#if isBodyStale(r.item)}{/if} {stripHtml(r.snippet)}
{/if} @@ -1089,7 +1092,7 @@ {/if} {#if r.snippet} -
{stripHtml(r.snippet)}
+
{#if isBodyStale(r.item)}{/if}{stripHtml(r.snippet)}
{/if} {/each} @@ -1166,7 +1169,7 @@ {/if} {#if r.snippet} -
{stripHtml(r.snippet)}
+
{#if isBodyStale(r.item)}{/if}{stripHtml(r.snippet)}
{/if} {/each} diff --git a/web/src/lib/components/share/PublicItemExpansion.svelte b/web/src/lib/components/share/PublicItemExpansion.svelte index 0f383c95e..c78e179ac 100644 --- a/web/src/lib/components/share/PublicItemExpansion.svelte +++ b/web/src/lib/components/share/PublicItemExpansion.svelte @@ -21,6 +21,7 @@ PUBLIC_RELATION_TITLE, } from './shareView'; import { isRelationType } from '$lib/items/relationFieldTypes'; + import StaleBodyNotice from '$lib/components/common/StaleBodyNotice.svelte'; interface Props { item: PublicItem; @@ -97,6 +98,7 @@ {/if} {#if html} + {#if item.contentStale}{/if}
diff --git a/web/src/lib/components/share/shareView.ts b/web/src/lib/components/share/shareView.ts index 6b1a3799c..fa85449af 100644 --- a/web/src/lib/components/share/shareView.ts +++ b/web/src/lib/components/share/shareView.ts @@ -18,6 +18,7 @@ import type { FieldDef } from '$lib/types'; import { isRelationType, relationValuesOf } from '$lib/items/relationFieldTypes'; import { UNPARENTED_FILTER_FIELD } from '$lib/collections/unparentedFilter'; import { fieldMatches, safeString, safeText } from '$lib/fields/fieldShape'; +import { isBodyStale } from '$lib/items/staleBody'; // Re-exported so existing/future imports of `UNPARENTED_FILTER_FIELD` from // this module keep working — the canonical definition lives in @@ -58,6 +59,9 @@ export interface PublicItem { ref: string; fields: Record; content: string; + /** True when the server marked `content` as behind the live document + * (`content_state`, BUG-3000); renderers say so (BUG-3050 U3). */ + contentStale: boolean; } // ── Defensive parsing ─────────────────────────────────────────────────────── @@ -160,7 +164,8 @@ export function parsePublicItem(raw: unknown, index = 0): PublicItem { title: typeof obj.title === 'string' && obj.title ? obj.title : 'Untitled', ref, fields: coerceObject(obj.fields), - content: typeof obj.content === 'string' ? obj.content : '' + content: typeof obj.content === 'string' ? obj.content : '', + contentStale: isBodyStale(obj as { content_state?: string }) }; } diff --git a/web/src/lib/components/timeline/ItemTimeline.svelte b/web/src/lib/components/timeline/ItemTimeline.svelte index 0ed8a4f39..64a58f069 100644 --- a/web/src/lib/components/timeline/ItemTimeline.svelte +++ b/web/src/lib/components/timeline/ItemTimeline.svelte @@ -37,6 +37,9 @@ username?: string; itemSlug: string; currentContent: string; + /** The server marked `currentContent` as behind the live document + * (BUG-3000 `content_state`); the diff says so (BUG-3050 U3). */ + currentContentStale?: boolean; items?: Item[]; onRestore?: (item: Item) => void; /** @@ -128,7 +131,7 @@ emptyLabel?: string; } - let { wsSlug, username = '', itemSlug, currentContent, items = [], onRestore, itemId, collectionId, frozen = false, restoreFrozen = false, flushBeforeRestore, visibleKinds, hostToken = '', parentArchived = false, feed = $bindable(), title = 'Timeline', emptyLabel = 'No timeline entries yet.' }: Props = $props(); + let { wsSlug, username = '', itemSlug, currentContent, currentContentStale = false, items = [], onRestore, itemId, collectionId, frozen = false, restoreFrozen = false, flushBeforeRestore, visibleKinds, hostToken = '', parentArchived = false, feed = $bindable(), title = 'Timeline', emptyLabel = 'No timeline entries yet.' }: 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 @@ -1430,6 +1433,7 @@ onRemoveReaction={handleRemoveReaction} {itemSlug} {currentContent} + {currentContentStale} {onRestore} {flushBeforeRestore} {restoreFrozen} diff --git a/web/src/lib/components/timeline/TimelineEntryList.svelte b/web/src/lib/components/timeline/TimelineEntryList.svelte index fbc0eb3de..879eef713 100644 --- a/web/src/lib/components/timeline/TimelineEntryList.svelte +++ b/web/src/lib/components/timeline/TimelineEntryList.svelte @@ -78,6 +78,9 @@ // Version cards. itemSlug?: string; currentContent?: string; + /** The server marked `currentContent` as behind the live document + * (BUG-3000 `content_state`); the diff says so (BUG-3050 U3). */ + currentContentStale?: boolean; onRestore?: (item: Item) => void; flushBeforeRestore?: () => Promise; restoreFrozen?: boolean; @@ -105,6 +108,7 @@ onRemoveReaction = () => {}, itemSlug = '', currentContent = '', + currentContentStale = false, onRestore, flushBeforeRestore, restoreFrozen = false @@ -153,6 +157,7 @@ {wsSlug} {itemSlug} {currentContent} + {currentContentStale} {onRestore} {flushBeforeRestore} frozen={frozen || restoreFrozen} diff --git a/web/src/lib/components/timeline/TimelineVersionCard.svelte b/web/src/lib/components/timeline/TimelineVersionCard.svelte index 41b6ee52b..c14aaf6cf 100644 --- a/web/src/lib/components/timeline/TimelineVersionCard.svelte +++ b/web/src/lib/components/timeline/TimelineVersionCard.svelte @@ -3,6 +3,7 @@ import { api, isContentPendingFlushError } from '$lib/api/client'; import { authStore } from '$lib/stores/auth.svelte'; import DiffView from '$lib/components/versions/DiffView.svelte'; + import StaleBodyNotice from '$lib/components/common/StaleBodyNotice.svelte'; import Chip from '$lib/components/common/Chip.svelte'; import { relativeTime } from '$lib/utils/markdown'; @@ -11,6 +12,9 @@ wsSlug: string; itemSlug: string; currentContent: string; + /** The server marked `currentContent` as behind the live document + * (BUG-3000 `content_state`); the diff says so (BUG-3050 U3). */ + currentContentStale?: boolean; onRestore?: (item: Item) => void; /** * PLAN-2154 Phase 2 / D2 / R12 (TASK-2172): master-freeze. The restore @@ -36,7 +40,7 @@ flushBeforeRestore?: () => Promise; } - let { version, wsSlug, itemSlug, currentContent, onRestore, frozen = false, flushBeforeRestore }: Props = $props(); + let { version, wsSlug, itemSlug, currentContent, currentContentStale = false, onRestore, frozen = false, flushBeforeRestore }: Props = $props(); let expanded = $state(false); let confirming = $state(false); @@ -226,6 +230,7 @@ {:else if resolveError}

Couldn't load this version's content.

{:else if displayContent !== null} + {#if currentContentStale}{/if} {/if}
diff --git a/web/src/lib/items/staleBody.ts b/web/src/lib/items/staleBody.ts new file mode 100644 index 000000000..ce9bea7a4 --- /dev/null +++ b/web/src/lib/items/staleBody.ts @@ -0,0 +1,23 @@ +// How a browser render says a body may be behind (BUG-3050 U3). +// +// A row whose `content_state` is `applied_pending_flush` serves a body the +// server knows is BEHIND the live collaborative document (BUG-3000): an open +// tab holds edits the row does not have yet. Every surface that renders such +// a body says so, in one of two treatments ruled on the trail: +// +// - a one-line notice above the body, where the body is the page's subject +// (share pages, the read-only item pane, the diff's current side); +// - a muted dot with a tooltip on a MARKED row only, where the body is one +// snippet among many (backlinks, the command palette, the conventions and +// playbooks lists). An unmarked row renders exactly as before. +// +// The marker is a signal to re-read later. Nothing here re-fetches or +// re-sends. + +/** The one wording every treatment uses. */ +export const STALE_BODY_NOTICE = 'This may not include the latest edits.'; + +/** True when the server marked this row's body as behind the live document. */ +export function isBodyStale(row: { content_state?: string | null } | null | undefined): boolean { + return row?.content_state === 'applied_pending_flush'; +} diff --git a/web/src/lib/items/staleBodyKeptBody.test.ts b/web/src/lib/items/staleBodyKeptBody.test.ts new file mode 100644 index 000000000..572119180 --- /dev/null +++ b/web/src/lib/items/staleBodyKeptBody.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +// BUG-3050 U3 (codex round 1): a site that builds an item from a server row +// but keeps a DIFFERENT body must keep that body's `content_state` too, or the +// renders that read the marker label a body it does not describe. Two shapes: +// +// - the local body kept over a fresh row: `{ ...row, content: item.content }` +// must carry `content_state: item.content_state`; +// - a blanked body built from an index row, which still carries the row's +// marker: `{ ...row, content: '' }` must clear it, since the server emits the +// marker only where a real body is. +// +// The scan finds every object literal that spreads something and sets +// `content`, in the files that hold the population (census on the BUG-3050 +// trail, round 1). It fails naming the literal. + +const SRC = resolve(__dirname, '..'); +const FILES = [ + 'components/items/ItemDetail.svelte', + 'components/search/CommandPalette.svelte', + 'stores/localIndex.svelte.ts', + '../routes/[username]/[workspace]/[collection]/+page.svelte', +]; + +function spreadsSettingContent(source: string): string[] { + const out: string[] = []; + const re = /\{\s*\.\.\.[^{}]*?\bcontent\s*:[^{}]*\}/gs; + for (const m of source.matchAll(re)) out.push(m[0].replace(/\s+/g, ' ')); + return out; +} + +function violation(literal: string): string | null { + const content = /\bcontent\s*:\s*([^,}]+)/.exec(literal)?.[1].trim(); + if (content === undefined) return null; + // The row's own body travels with its own marker: nothing to carry. + if (/^\w+\.content$/.test(content) && literal.includes(`...${content.split('.')[0]}`)) return null; + if (content === "''" || content === '""') { + return /content_state\s*:\s*undefined/.test(literal) ? null : 'blank body keeps the row\'s marker'; + } + const owner = /^(\w+)\??\.content$/.exec(content)?.[1]; + if (owner && new RegExp(`content_state\\s*:\\s*${owner}\\??\\.content_state`).test(literal)) return null; + return `kept body ${content} without its content_state`; +} + +describe('a kept or blanked body carries the marker that describes it (BUG-3050 U3)', () => { + it('the scan sees the population it was written for', () => { + const all = FILES.flatMap((f) => spreadsSettingContent(readFileSync(resolve(SRC, f), 'utf8'))); + // 5 kept-body sites in ItemDetail + 3 blanked index rows. + expect(all.length).toBe(8); + }); + + for (const f of FILES) { + it(`${f}: every such literal carries the right marker`, () => { + const bad = spreadsSettingContent(readFileSync(resolve(SRC, f), 'utf8')) + .map((l) => [l, violation(l)] as const) + .filter(([, v]) => v !== null) + .map(([l, v]) => `${v}: ${l}`); + expect(bad).toEqual([]); + }); + } + + it('the checker refuses each broken shape (negative controls)', () => { + expect(violation("{ ...updated, content: item.content }")).not.toBeNull(); + expect(violation("{ ...row, content: '' }")).not.toBeNull(); + expect(violation("{ ...updated, content: item.content, content_state: updated.content_state }")).not.toBeNull(); + expect(violation("{ ...updated, content: item.content, content_state: item.content_state }")).toBeNull(); + expect(violation("{ ...row, content: '', content_state: undefined }")).toBeNull(); + }); +}); diff --git a/web/src/lib/items/staleBodyRenders.svelte.test.ts b/web/src/lib/items/staleBodyRenders.svelte.test.ts new file mode 100644 index 000000000..2e92b431f --- /dev/null +++ b/web/src/lib/items/staleBodyRenders.svelte.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { flushSync, mount, unmount, tick } from 'svelte'; +import type { Backlink, Version } from '$lib/types'; + +// BUG-3050 U3: a body the server marked as behind the live document +// (`content_state: applied_pending_flush`, BUG-3000) says so wherever it is +// rendered. Two treatments, ruled on the trail: a one-line notice above a body +// that is the subject of the view, and a muted dot on a MARKED row where the +// body is one snippet among many. An unmarked body renders nothing new. +// +// Every leg mounts the real component with a marked AND an unmarked input, so +// a render that ignores the marker and a render that marks everything both +// fail. The page bindings (share pages, the item pane, the lists, the palette) +// are covered in e2e/bug-3050-stale-body-renders.spec.ts. + +const { backlinkRows } = vi.hoisted(() => ({ backlinkRows: { value: [] as unknown[] } })); + +vi.mock('$lib/api/client', () => ({ + api: { + items: { backlinks: vi.fn(async () => backlinkRows.value) }, + versions: { restore: vi.fn(), get: vi.fn(async () => ({ content: '' })) }, + }, +})); + +vi.mock('$lib/stores/auth.svelte', () => ({ + authStore: { identityFence: () => () => true, user: null }, +})); + +import { isBodyStale, STALE_BODY_NOTICE } from './staleBody'; +import { parsePublicItem } from '$lib/components/share/shareView'; +import PublicItemExpansion from '$lib/components/share/PublicItemExpansion.svelte'; +import TimelineVersionCard from '$lib/components/timeline/TimelineVersionCard.svelte'; +import BacklinksPanel from '$lib/components/BacklinksPanel.svelte'; + +const MARK = 'applied_pending_flush'; +const mounted: { root: HTMLElement; instance: ReturnType }[] = []; + +function render

>(component: unknown, props: P): HTMLElement { + const root = document.body.appendChild(document.createElement('div')); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const instance = mount(component as any, { target: root, props }); + flushSync(); + mounted.push({ root, instance }); + return root; +} + +afterEach(() => { + for (const m of mounted.splice(0)) { + unmount(m.instance); + m.root.remove(); + } +}); + +describe('isBodyStale', () => { + it('is true only for the server marker', () => { + expect(isBodyStale({ content_state: MARK })).toBe(true); + expect(isBodyStale({ content_state: '' })).toBe(false); + expect(isBodyStale({ content_state: 'something_else' })).toBe(false); + expect(isBodyStale({})).toBe(false); + expect(isBodyStale(null)).toBe(false); + expect(isBodyStale(undefined)).toBe(false); + }); +}); + +describe('share: parsePublicItem carries the marker (C2)', () => { + it('copies content_state into contentStale', () => { + expect(parsePublicItem({ title: 't', content: 'b', content_state: MARK }).contentStale).toBe(true); + expect(parsePublicItem({ title: 't', content: 'b' }).contentStale).toBe(false); + }); +}); + +describe('share: the inline expansion shows the notice on a marked body (C3)', () => { + function expansion(stale: boolean) { + const item = parsePublicItem({ title: 't', content: 'body', ...(stale ? { content_state: MARK } : {}) }); + return render(PublicItemExpansion, { item, fields: [], html: '

body

' }); + } + it('marked: the notice precedes the body', () => { + const root = expansion(true); + const notice = root.querySelector('[data-testid="stale-body-notice"]'); + expect(notice?.textContent).toBe(STALE_BODY_NOTICE); + const body = root.querySelector('.expansion-content')!; + expect(notice!.compareDocumentPosition(body) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + it('unmarked: no notice', () => { + expect(expansion(false).querySelector('[data-testid="stale-body-notice"]')).toBeNull(); + }); +}); + +describe('timeline: the diff\'s current side says it may be behind (C5)', () => { + const version = { + id: 'v1', + item_id: 'i1', + content: 'old', + is_diff: false, + change_summary: 'edited', + created_by: 'user', + source: 'web', + created_at: '2026-07-20T00:00:00Z', + } as unknown as Version; + function card(currentContentStale: boolean) { + const root = render(TimelineVersionCard, { version, wsSlug: 'ws', itemSlug: 'ITEM-1', currentContent: 'now', currentContentStale }); + (root.querySelector('.card-header') as HTMLButtonElement).click(); + flushSync(); + return root; + } + it('marked: the notice is shown with the diff', () => { + const root = card(true); + expect(root.querySelector('[data-testid="stale-body-notice"]')?.textContent).toBe(STALE_BODY_NOTICE); + }); + it('unmarked: no notice', () => { + const root = card(false); + expect(root.querySelector('.diff-container')).not.toBeNull(); + expect(root.querySelector('[data-testid="stale-body-notice"]')).toBeNull(); + }); +}); + +describe('backlinks: a dot on MARKED rows only (C8)', () => { + function row(id: string, title: string, stale: boolean): Backlink { + return { + source_item_id: id, + source_ref: `DOC-${id}`, + source_title: title, + source_slug: `s-${id}`, + source_collection_slug: 'docs', + source_collection_name: 'Docs', + source_collection_icon: '', + snippet: `...links to it from ${title}...`, + updated_at: '2026-09-26T00:00:00Z', + ...(stale ? { content_state: MARK } : {}), + } as unknown as Backlink; + } + it('marks the marked row and leaves its neighbour alone', async () => { + backlinkRows.value = [row('1', 'Marked', true), row('2', 'Plain', false)]; + const root = render(BacklinksPanel, { wsSlug: 'ws', username: 'u', itemSlug: 'target', itemId: 't1' }); + await vi.waitFor(() => expect(root.querySelectorAll('.snippet').length).toBe(2)); + await tick(); + const dots = root.querySelectorAll('[data-testid="stale-body-dot"]'); + expect(dots.length).toBe(1); + expect(dots[0].closest('li')?.textContent).toContain('Marked'); + expect(dots[0].getAttribute('title')).toBe(STALE_BODY_NOTICE); + expect(dots[0].getAttribute('aria-label')).toBe(STALE_BODY_NOTICE); + }); +}); diff --git a/web/src/lib/stores/localIndex.svelte.ts b/web/src/lib/stores/localIndex.svelte.ts index b33e65dfd..12e3f3270 100644 --- a/web/src/lib/stores/localIndex.svelte.ts +++ b/web/src/lib/stores/localIndex.svelte.ts @@ -1635,7 +1635,7 @@ export const localIndex = { // content:'' — link resolution/picker never read the body; // the skinny index rows don't carry it. Matches how the // collection page adapts getByCollection rows to Item. - out.push({ ...row, content: '' } as Item); + out.push({ ...row, content: '', content_state: undefined } as Item); } out.sort((a, b) => { if (a.updated_at !== b.updated_at) { diff --git a/web/src/lib/types/index.ts b/web/src/lib/types/index.ts index de0043e75..579f977af 100644 --- a/web/src/lib/types/index.ts +++ b/web/src/lib/types/index.ts @@ -114,6 +114,8 @@ export interface PublicShareItem { ref?: string; fields?: string; content?: string; + /** BUG-3000 marker: `content` is behind the live document (BUG-3050 U3). */ + content_state?: 'applied_pending_flush'; } /** The shape returned by GET /api/v1/s/{token}. Auth/password gates short-circuit @@ -133,6 +135,8 @@ export interface SharePayload { item_ref?: string; collection_name?: string; collection_icon?: string; + /** BUG-3000 marker: `content` is behind the live document (BUG-3050 U3). */ + content_state?: 'applied_pending_flush'; }; collection?: PublicShareCollection; items?: PublicShareItem[]; @@ -1528,11 +1532,8 @@ export interface Backlink { * It describes the SOURCE of each link, never the item the panel is about, * so a list can mix marked and unmarked rows. * - * NOTE: the API carries this; no browser surface RENDERS it yet. Where a - * stale marker belongs in a backlinks panel or a command-palette result is - * a UI decision rather than a field copy, tracked as BUG-3050. This - * declaration exists so a consumer can see the field rather than discover - * it in a network tab. + * BacklinksPanel renders it as a muted dot on the marked row only, beside + * the snippet (BUG-3050 U3, `lib/items/staleBody.ts`). */ content_state?: 'applied_pending_flush'; updated_at: string; diff --git a/web/src/routes/[username]/[workspace]/[collection]/+page.svelte b/web/src/routes/[username]/[workspace]/[collection]/+page.svelte index 3fb332f29..3a55ab513 100644 --- a/web/src/routes/[username]/[workspace]/[collection]/+page.svelte +++ b/web/src/routes/[username]/[workspace]/[collection]/+page.svelte @@ -312,7 +312,7 @@ wsSlug && collSlug ? localIndex .getByCollection(wsSlug, collSlug, { includeArchived: showArchived }) - .map((row) => ({ ...row, content: '' }) as Item) + .map((row) => ({ ...row, content: '', content_state: undefined }) as Item) : [], ); diff --git a/web/src/routes/[username]/[workspace]/conventions/+page.svelte b/web/src/routes/[username]/[workspace]/conventions/+page.svelte index 4b88b8e33..5b68896f9 100644 --- a/web/src/routes/[username]/[workspace]/conventions/+page.svelte +++ b/web/src/routes/[username]/[workspace]/conventions/+page.svelte @@ -14,6 +14,8 @@ import Button from '$lib/components/common/Button.svelte'; import PageHeader from '$lib/components/common/PageHeader.svelte'; import EmptyState from '$lib/components/common/EmptyState.svelte'; + import StaleBodyDot from '$lib/components/common/StaleBodyDot.svelte'; + import { isBodyStale } from '$lib/items/staleBody'; const TRIGGERS = ['always','on-task-start','on-task-complete','on-implement','on-commit','on-pr-create','on-plan-start','on-plan-complete','on-plan'] as const; type Trigger = typeof TRIGGERS[number]; @@ -665,6 +667,7 @@ {item.title} + {#if isBodyStale(item)}{/if} {#if convention.category} {convention.category} {/if} diff --git a/web/src/routes/[username]/[workspace]/playbooks/+page.svelte b/web/src/routes/[username]/[workspace]/playbooks/+page.svelte index ba1952d1c..0b31aca29 100644 --- a/web/src/routes/[username]/[workspace]/playbooks/+page.svelte +++ b/web/src/routes/[username]/[workspace]/playbooks/+page.svelte @@ -22,6 +22,8 @@ import PageHeader from '$lib/components/common/PageHeader.svelte'; import EmptyState from '$lib/components/common/EmptyState.svelte'; import PlaybookFormFields from '$lib/components/playbooks/PlaybookFormFields.svelte'; + import StaleBodyDot from '$lib/components/common/StaleBodyDot.svelte'; + import { isBodyStale } from '$lib/items/staleBody'; import { PLAYBOOK_SKELETON_BODY, argumentsToJSON, @@ -603,6 +605,7 @@