From 73d0e12fe51c111a46bd840848d785320daab8fe Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:25:39 -0400 Subject: [PATCH 01/13] fix shared document reliability --- .changeset/fuzzy-lemons-save.md | 5 + .../useCollabReconcile.concurrent.spec.ts | 32 ++ .../toolkit/src/editor/useCollabReconcile.ts | 9 +- .../actions/update-document.db.test.ts | 75 ++++ templates/content/actions/update-document.ts | 204 +++++---- .../editor/CommentsSidebar.layout.test.ts | 125 ++++-- .../app/components/editor/CommentsSidebar.tsx | 417 ++++++++++++------ .../editor/DocumentEditor.layout.test.ts | 47 +- .../app/components/editor/DocumentEditor.tsx | 57 ++- templates/content/app/i18n-data.ts | 1 + ...-durable-content-visible-and-comment-th.md | 6 + 11 files changed, 691 insertions(+), 287 deletions(-) create mode 100644 .changeset/fuzzy-lemons-save.md create mode 100644 templates/content/changelog/2026-07-23-shared-pages-now-keep-durable-content-visible-and-comment-th.md diff --git a/.changeset/fuzzy-lemons-save.md b/.changeset/fuzzy-lemons-save.md new file mode 100644 index 0000000000..79342d6b32 --- /dev/null +++ b/.changeset/fuzzy-lemons-save.md @@ -0,0 +1,5 @@ +--- +"@agent-native/toolkit": patch +--- + +Allow newly created empty collaborative editors to persist their first real user edit after the shared document finishes loading. diff --git a/packages/toolkit/src/editor/useCollabReconcile.concurrent.spec.ts b/packages/toolkit/src/editor/useCollabReconcile.concurrent.spec.ts index cdcef92dca..a2b7a2c1db 100644 --- a/packages/toolkit/src/editor/useCollabReconcile.concurrent.spec.ts +++ b/packages/toolkit/src/editor/useCollabReconcile.concurrent.spec.ts @@ -330,6 +330,38 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => { expect(results).toEqual([true]); }); + it("allows the first user edit after an authoritative empty doc finishes loading", async () => { + let shouldIgnoreUpdate: + | ((transaction: Editor["state"]["tr"]) => boolean) + | null = null; + let editor: Editor | null = null; + + function Probe() { + editor = useEditor({ + extensions: createRichMarkdownExtensions({ dialect: "gfm" }), + content: "", + }); + const fakeYdoc = { clientID: 1, getXmlFragment: () => ({ length: 0 }) }; + const guards = useCollabReconcile({ + editor, + ydoc: fakeYdoc as never, + collabSynced: true, + value: "", + contentUpdatedAt: "2024-01-01T00:00:01.000Z", + editable: true, + }); + shouldIgnoreUpdate = guards.shouldIgnoreUpdate; + return React.createElement("div", null); + } + + act(() => root.render(React.createElement(Probe))); + await flush(); + + expect(editor).not.toBeNull(); + expect(shouldIgnoreUpdate).not.toBeNull(); + expect(shouldIgnoreUpdate!(editor!.state.tr)).toBe(false); + }); + it("refuses to persist an empty doc in collab mode (registerEmitted guard)", async () => { // Directly exercise the guard contract: in collab mode an empty markdown // string must not be registered/persisted (would clobber stored content diff --git a/packages/toolkit/src/editor/useCollabReconcile.ts b/packages/toolkit/src/editor/useCollabReconcile.ts index 4d38864faa..459079284f 100644 --- a/packages/toolkit/src/editor/useCollabReconcile.ts +++ b/packages/toolkit/src/editor/useCollabReconcile.ts @@ -299,8 +299,15 @@ export function useCollabReconcile({ if (!collab || !editor || editor.isDestroyed || !ydoc) return; if (seededRef.current) return; if (!collabSynced) return; + // An authoritative empty value has nothing to seed. Once the provider has + // finished loading, the shared fragment is ready for the first real user + // edit; leaving `seededRef` false here would classify every keystroke as + // transient pre-seed normalization and suppress the app's durable save. + if (!value.trim()) { + seededRef.current = true; + return; + } if (!isLeadClient) return; - if (!value.trim()) return; const fragment = ydoc.getXmlFragment("default"); const currentMarkdown = getMarkdown(editor); // Seed only when the shared doc is genuinely empty — either the fragment has diff --git a/templates/content/actions/update-document.db.test.ts b/templates/content/actions/update-document.db.test.ts index 7a528bc866..915fe7b036 100644 --- a/templates/content/actions/update-document.db.test.ts +++ b/templates/content/actions/update-document.db.test.ts @@ -17,6 +17,7 @@ let schema: Schema; let updateDocumentAction: typeof import("./update-document.js").default; const OWNER = "owner@example.com"; +const EDITOR = "editor@example.com"; beforeAll(async () => { process.env.DATABASE_URL = `file:${TEST_DB_PATH}`; @@ -218,4 +219,78 @@ describe("update-document compare-and-swap", () => { expect(current.title).toBe("Renamed"); expect(current.content).toBe("pulled from notion"); }); + + it("always snapshots the last nonempty body before an intentional clear", async () => { + const documentId = await createDocument({ content: "original body" }); + + await runWithRequestContext({ userEmail: OWNER }, () => + updateDocumentAction.run({ + id: documentId, + content: "second body within the snapshot interval", + }), + ); + await runWithRequestContext({ userEmail: OWNER }, () => + updateDocumentAction.run({ + id: documentId, + content: "", + }), + ); + + const versions = await getDb() + .select() + .from(schema.documentVersions) + .where(eq(schema.documentVersions.documentId, documentId)); + expect(versions.map((version: any) => version.content)).toEqual( + expect.arrayContaining([ + "original body", + "second body within the snapshot interval", + ]), + ); + expect(versions).toHaveLength(2); + }); + + it("targets a shared editor's update audit event to the document owner", async () => { + const documentId = await createDocument({ content: "owner body" }); + await getDb() + .insert(schema.documentShares) + .values({ + id: nextId("share"), + resourceId: documentId, + principalType: "user", + principalId: EDITOR, + role: "editor", + createdBy: OWNER, + createdAt: new Date().toISOString(), + }); + + const result = await runWithRequestContext({ userEmail: EDITOR }, () => + updateDocumentAction.run( + { id: documentId, content: "edited by collaborator" }, + { + caller: "frontend", + actionName: "update-document", + userEmail: EDITOR, + }, + ), + ); + const { queryAuditEvents } = await import("@agent-native/core/audit"); + const events = await queryAuditEvents( + { userEmail: OWNER }, + { + action: "update-document", + targetType: "document", + targetId: documentId, + }, + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + actorEmail: EDITOR, + ownerEmail: OWNER, + targetType: "document", + targetId: documentId, + status: "success", + }); + expect(JSON.stringify(result)).not.toContain(OWNER); + }); }); diff --git a/templates/content/actions/update-document.ts b/templates/content/actions/update-document.ts index 63e222aafd..2576c32bb9 100644 --- a/templates/content/actions/update-document.ts +++ b/templates/content/actions/update-document.ts @@ -40,6 +40,17 @@ export interface DocumentUpdateConflictResponse { document: DocumentUpdateResponse; } +const documentAuditOwner = Symbol("documentAuditOwner"); + +type DocumentAuditScopedResult = { + [documentAuditOwner]?: string; +}; + +function scopeDocumentAudit(result: T, ownerEmail: string) { + Object.defineProperty(result, documentAuditOwner, { value: ownerEmail }); + return result; +} + function nanoid(size = 12): string { const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; @@ -301,6 +312,20 @@ export default defineAction({ .default([]) .describe("Exact item versions that influenced this agent update."), }), + audit: { + target: (args, result) => ({ + type: "document", + id: args.id, + ownerEmail: (result as DocumentAuditScopedResult | null)?.[ + documentAuditOwner + ], + visibility: "private", + }), + summary: (args, result) => + (result as DocumentUpdateConflictResponse | null)?.conflict + ? `Document update conflicted for ${args.id}` + : `Updated document ${args.id}`, + }, run: async ( args, ctx, @@ -411,6 +436,11 @@ export default defineAction({ args.title !== undefined && args.title !== existing.title; const contentChanged = content !== undefined && content !== existing.content; + const clearsNonEmptyContent = + contentChanged && + content !== undefined && + isEffectivelyEmptyDocumentContent(content) && + !isEffectivelyEmptyDocumentContent(existing.content); const iconChanged = args.icon !== undefined && args.icon !== existing.icon; const favoriteChanged = args.isFavorite !== undefined && args.isFavorite !== currentFavorite; @@ -426,40 +456,6 @@ export default defineAction({ favoriteChanged || descriptionChanged; - // Snapshot the current state before applying content/title changes. - // Versions are scoped to the document owner, not the caller — an editor - // share collaborator shouldn't create a phantom version row under their - // own email. - if (titleChanged || contentChanged) { - const [latestVersion] = await db - .select({ createdAt: schema.documentVersions.createdAt }) - .from(schema.documentVersions) - .where( - and( - eq(schema.documentVersions.documentId, id), - eq(schema.documentVersions.ownerEmail, ownerEmail), - ), - ) - .orderBy(desc(schema.documentVersions.createdAt)) - .limit(1); - - const shouldSnapshot = - !latestVersion || - Date.now() - new Date(latestVersion.createdAt).getTime() > - SNAPSHOT_INTERVAL_MS; - - if (shouldSnapshot) { - await db.insert(schema.documentVersions).values({ - id: nanoid(), - ownerEmail, - documentId: id, - title: existing.title, - content: existing.content, - createdAt: new Date().toISOString(), - }); - } - } - let softDeletedDatabaseIds: string[] = []; let creativeContext: | Awaited> @@ -507,6 +503,36 @@ export default defineAction({ .where(eq(schema.documents.id, id)); } + if (titleChanged || contentChanged) { + const [latestVersion] = await tx + .select({ createdAt: schema.documentVersions.createdAt }) + .from(schema.documentVersions) + .where( + and( + eq(schema.documentVersions.documentId, id), + eq(schema.documentVersions.ownerEmail, ownerEmail), + ), + ) + .orderBy(desc(schema.documentVersions.createdAt)) + .limit(1); + const shouldSnapshot = + clearsNonEmptyContent || + !latestVersion || + Date.now() - new Date(latestVersion.createdAt).getTime() > + SNAPSHOT_INTERVAL_MS; + + if (shouldSnapshot) { + await tx.insert(schema.documentVersions).values({ + id: nanoid(), + ownerEmail, + documentId: id, + title: existing.title, + content: existing.content, + createdAt: new Date().toISOString(), + }); + } + } + if (favoriteChanged) { await setFavoriteMembership({ db: tx, @@ -580,30 +606,35 @@ export default defineAction({ .select() .from(schema.documents) .where(eq(schema.documents.id, id)); - return { - conflict: true, - id, - document: { - id: current.id, - urlPath: `/page/${current.id}`, - parentId: current.parentId, - title: current.title, - content: current.content, - description: current.description, - icon: current.icon, - position: current.position, - isFavorite: currentFavorite, - hideFromSearch: parseDocumentHideFromSearch(current.hideFromSearch), - visibility: current.visibility, - accessRole: access.role, - canEdit: canEditRole(access.role), - canManage: canManageRole(access.role), - createdAt: current.createdAt, - updatedAt: current.updatedAt, - source: serializeDocumentSource(current), - softDeletedDatabaseIds: [], - }, - }; + return scopeDocumentAudit( + { + conflict: true, + id, + document: { + id: current.id, + urlPath: `/page/${current.id}`, + parentId: current.parentId, + title: current.title, + content: current.content, + description: current.description, + icon: current.icon, + position: current.position, + isFavorite: currentFavorite, + hideFromSearch: parseDocumentHideFromSearch( + current.hideFromSearch, + ), + visibility: current.visibility, + accessRole: access.role, + canEdit: canEditRole(access.role), + canManage: canManageRole(access.role), + createdAt: current.createdAt, + updatedAt: current.updatedAt, + source: serializeDocumentSource(current), + softDeletedDatabaseIds: [], + }, + } satisfies DocumentUpdateConflictResponse, + ownerEmail, + ); } if (contentChanged) { @@ -665,32 +696,35 @@ export default defineAction({ await writeAppState("refresh-signal", { ts: Date.now() }); - return { - id: doc.id, - urlPath: `/page/${doc.id}`, - parentId: doc.parentId, - title: doc.title, - content: doc.content, - description: doc.description, - icon: doc.icon, - position: doc.position, - isFavorite: finalFavorite, - hideFromSearch: parseDocumentHideFromSearch(doc.hideFromSearch), - visibility: doc.visibility, - accessRole: access.role, - canEdit: canEditRole(access.role), - canManage: canManageRole(access.role), - createdAt: doc.createdAt, - updatedAt: doc.updatedAt, - source: serializeDocumentSource(doc), - softDeletedDatabaseIds, - ...(creativeContext - ? { - contextMode: creativeContext.contextMode, - contextPackId: creativeContext.contextPackId, - reuseLabels: creativeContext.reuseLabels, - } - : {}), - }; + return scopeDocumentAudit( + { + id: doc.id, + urlPath: `/page/${doc.id}`, + parentId: doc.parentId, + title: doc.title, + content: doc.content, + description: doc.description, + icon: doc.icon, + position: doc.position, + isFavorite: finalFavorite, + hideFromSearch: parseDocumentHideFromSearch(doc.hideFromSearch), + visibility: doc.visibility, + accessRole: access.role, + canEdit: canEditRole(access.role), + canManage: canManageRole(access.role), + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + source: serializeDocumentSource(doc), + softDeletedDatabaseIds, + ...(creativeContext + ? { + contextMode: creativeContext.contextMode, + contextPackId: creativeContext.contextPackId, + reuseLabels: creativeContext.reuseLabels, + } + : {}), + } satisfies DocumentUpdateResponse, + ownerEmail, + ); }, }); diff --git a/templates/content/app/components/editor/CommentsSidebar.layout.test.ts b/templates/content/app/components/editor/CommentsSidebar.layout.test.ts index 5510234149..170b000ded 100644 --- a/templates/content/app/components/editor/CommentsSidebar.layout.test.ts +++ b/templates/content/app/components/editor/CommentsSidebar.layout.test.ts @@ -2,15 +2,16 @@ import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { CommentThread } from "@/hooks/use-comments"; import { estimateThreadCardHeight, findPendingCommentOffset, - findThreadOffset, - shouldClearSelectedThreadOnScroll, + findThreadPosition, + layoutCommentThreads, + scrollToCommentAnchor, } from "./CommentsSidebar"; function rect(top: number) { @@ -28,35 +29,26 @@ function rect(top: number) { } describe("comments sidebar layout", () => { - it("positions thread cards from the visible highlight rect, not scrollTop", () => { + it("tracks both document and desktop-rail positions for a highlight", () => { document.body.innerHTML = - '
'; + '
'; const scroll = document.getElementById("scroll") as HTMLElement; - const highlight = scroll.querySelector( - "[data-comment-thread]", + const content = scroll.querySelector( + "[data-document-scroll-content]", ) as HTMLElement; - - Object.defineProperty(scroll, "scrollTop", { value: 240 }); - scroll.getBoundingClientRect = () => rect(100) as DOMRect; - highlight.getBoundingClientRect = () => rect(156) as DOMRect; - - expect(findThreadOffset("thread-1", null, scroll)).toBe(56); - }); - - it("can position thread cards relative to the sidebar rail", () => { - document.body.innerHTML = - '
'; const rail = document.getElementById("rail") as HTMLElement; - const scroll = document.getElementById("scroll") as HTMLElement; const highlight = scroll.querySelector( "[data-comment-thread]", ) as HTMLElement; - rail.getBoundingClientRect = () => rect(48) as DOMRect; - scroll.getBoundingClientRect = () => rect(100) as DOMRect; + content.getBoundingClientRect = () => rect(40) as DOMRect; + rail.getBoundingClientRect = () => rect(80) as DOMRect; highlight.getBoundingClientRect = () => rect(156) as DOMRect; - expect(findThreadOffset("thread-1", null, scroll, rail)).toBe(108); + expect(findThreadPosition("thread-1", null, scroll, rail)).toEqual({ + documentTop: 116, + layoutTop: 76, + }); }); it("positions pending comments from the pending highlight rect", () => { @@ -74,22 +66,91 @@ describe("comments sidebar layout", () => { expect(findPendingCommentOffset(scroll)).toBe(45); }); - it("clears selected threads once their anchor scrolls out of view", () => { - expect(shouldClearSelectedThreadOnScroll(null, 500)).toBe(true); - expect(shouldClearSelectedThreadOnScroll(-41, 500)).toBe(true); - expect(shouldClearSelectedThreadOnScroll(541, 500)).toBe(true); - expect(shouldClearSelectedThreadOnScroll(120, 500)).toBe(false); + it("gives the selected thread first claim near its anchor without overlap", () => { + const first = { + threadId: "first", + comments: [{ id: "first-comment" }], + } as CommentThread; + const selected = { + threadId: "selected", + comments: [{ id: "selected-comment" }], + } as CommentThread; + const third = { + threadId: "third", + comments: [{ id: "third-comment" }], + } as CommentThread; + const positions = new Map([ + ["first", { documentTop: 100, layoutTop: 100 }], + ["selected", { documentTop: 120, layoutTop: 120 }], + ["third", { documentTop: 140, layoutTop: 140 }], + ]); + const heights = new Map([ + ["first", 80], + ["selected", 80], + ["third", 80], + ]); + + const items = layoutCommentThreads( + [first, selected, third], + positions, + heights, + "selected", + ); + + expect(items.map((item) => item.top)).toEqual([28, 120, 212]); + expect(items[0].top + 80).toBeLessThanOrEqual(items[1].top - 12); + expect(items[1].top + 80).toBeLessThanOrEqual(items[2].top - 12); + }); + + it("keeps narrow layouts sequential and puts missing anchors last", () => { + const anchored = { + threadId: "anchored", + comments: [{ id: "anchored-comment" }], + } as CommentThread; + const orphaned = { + threadId: "orphaned", + comments: [{ id: "orphaned-comment" }], + } as CommentThread; + const positions = new Map([ + ["anchored", { documentTop: 400, layoutTop: null }], + ]); + + const items = layoutCommentThreads( + [orphaned, anchored], + positions, + new Map(), + null, + ); + + expect(items.map((item) => item.thread.threadId)).toEqual([ + "anchored", + "orphaned", + ]); + expect(items[0].top).toBe(0); + expect(items[1].top).toBe(112); + expect(items[1].isOrphaned).toBe(true); + }); + + it("bounds explicit anchor navigation inside the document scroller", () => { + const scroll = document.createElement("div"); + Object.defineProperty(scroll, "scrollHeight", { value: 1000 }); + Object.defineProperty(scroll, "clientHeight", { value: 400 }); + const scrollTo = vi.fn(); + scroll.scrollTo = scrollTo; + + expect(scrollToCommentAnchor(scroll, 900)).toBe(true); + expect(scrollTo).toHaveBeenCalledWith({ top: 600, behavior: "smooth" }); }); - it("checks selected-thread visibility with viewport-relative offsets", () => { + it("does not couple selection or layout state to ordinary scrolling", () => { const source = readFileSync("app/components/editor/CommentsSidebar.tsx", { encoding: "utf8", }); - expect(source).toContain("offsets.get(selectedThreadId) ?? null"); - expect(source).not.toContain( - "offsets.get(selectedThreadId)! - container.scrollTop", - ); + expect(source).not.toContain('container.addEventListener("scroll"'); + expect(source).not.toContain("scrollIntoView"); + expect(source).toContain("data-comment-connector"); + expect(source).toContain("data-unanchored-comments"); }); it("keeps card height estimates based on the thread reply count", () => { diff --git a/templates/content/app/components/editor/CommentsSidebar.tsx b/templates/content/app/components/editor/CommentsSidebar.tsx index e63237c26a..1971bdeca1 100644 --- a/templates/content/app/components/editor/CommentsSidebar.tsx +++ b/templates/content/app/components/editor/CommentsSidebar.tsx @@ -9,6 +9,7 @@ import { IconChevronDown, } from "@tabler/icons-react"; import { + Fragment, useState, useRef, useEffect, @@ -95,29 +96,35 @@ function cssEscape(value: string) { : value.replace(/["\\]/g, "\\$&"); } -/** - * Y offset (relative to the scroll container's visible viewport) of a thread's inline - * highlight. Prefers the real decoration element rendered by the - * CommentHighlight plugin (`[data-comment-thread]`); falls back to a text search - * by quoted text for legacy/unresolved anchors so older comments still line up. - */ -export function findThreadOffset( +export type CommentThreadPosition = { + documentTop: number; + layoutTop: number | null; +}; + +export function findThreadPosition( threadId: string, quotedText: string | null, scrollContainer: HTMLElement | null, - positionContainer: HTMLElement | null = scrollContainer, -): number | null { + layoutContainer: HTMLElement | null, +): CommentThreadPosition | null { if (!scrollContainer) return null; - const containerRect = ( - positionContainer ?? scrollContainer - ).getBoundingClientRect(); + const documentContent = + (scrollContainer.querySelector( + "[data-document-scroll-content]", + ) as HTMLElement | null) ?? scrollContainer; + const documentRect = documentContent.getBoundingClientRect(); const marked = scrollContainer.querySelector( `[data-comment-thread="${cssEscape(threadId)}"]`, ) as HTMLElement | null; if (marked) { const rect = marked.getBoundingClientRect(); - return rect.top - containerRect.top; + return { + documentTop: rect.top - documentRect.top, + layoutTop: layoutContainer + ? rect.top - layoutContainer.getBoundingClientRect().top + : null, + }; } if (!quotedText) return null; @@ -135,7 +142,12 @@ export function findThreadOffset( const range = window.document.createRange(); range.selectNode(node); const rect = range.getBoundingClientRect(); - return rect.top - containerRect.top; + return { + documentTop: rect.top - documentRect.top, + layoutTop: layoutContainer + ? rect.top - layoutContainer.getBoundingClientRect().top + : null, + }; } } return null; @@ -157,18 +169,127 @@ export function findPendingCommentOffset( return rect.top - containerRect.top; } -export function shouldClearSelectedThreadOnScroll( - offset: number | null, - containerHeight: number, - margin = 40, -) { - return ( - offset == null || offset < -margin || offset > containerHeight + margin +export function estimateThreadCardHeight(thread: CommentThread) { + return 80 + Math.max(0, thread.comments.length - 1) * 44; +} + +type CommentLayoutItem = { + thread: CommentThread; + top: number; + marginTop: number; + anchorTop: number | null; + isOrphaned: boolean; +}; + +export function layoutCommentThreads( + threads: CommentThread[], + positions: Map, + heights: Map, + selectedThreadId: string | null | undefined, + gap = 12, +): CommentLayoutItem[] { + const ordered = [...threads].sort((left, right) => { + const leftTop = positions.get(left.threadId)?.documentTop ?? Infinity; + const rightTop = positions.get(right.threadId)?.documentTop ?? Infinity; + return leftTop - rightTop; + }); + const anchored = ordered.filter( + (thread) => positions.get(thread.threadId)?.layoutTop != null, + ); + const sequential = ordered.filter( + (thread) => positions.get(thread.threadId)?.layoutTop == null, + ); + const tops = new Map(); + const heightFor = (thread: CommentThread) => + heights.get(thread.threadId) ?? estimateThreadCardHeight(thread); + const selectedIndex = anchored.findIndex( + (thread) => thread.threadId === selectedThreadId, ); + + if (selectedIndex >= 0) { + const selected = anchored[selectedIndex]; + tops.set( + selected.threadId, + Math.max(0, positions.get(selected.threadId)?.layoutTop ?? 0), + ); + for (let index = selectedIndex - 1; index >= 0; index -= 1) { + const thread = anchored[index]; + const next = anchored[index + 1]; + const nextTop = tops.get(next.threadId) ?? 0; + const target = positions.get(thread.threadId)?.layoutTop ?? 0; + tops.set( + thread.threadId, + Math.min(target, nextTop - gap - heightFor(thread)), + ); + } + const firstTop = tops.get(anchored[0]?.threadId ?? "") ?? 0; + if (firstTop < 0) { + for (let index = 0; index <= selectedIndex; index += 1) { + const thread = anchored[index]; + tops.set(thread.threadId, (tops.get(thread.threadId) ?? 0) - firstTop); + } + } + for (let index = selectedIndex + 1; index < anchored.length; index += 1) { + const thread = anchored[index]; + const previous = anchored[index - 1]; + const previousBottom = + (tops.get(previous.threadId) ?? 0) + heightFor(previous); + const target = positions.get(thread.threadId)?.layoutTop ?? 0; + tops.set(thread.threadId, Math.max(target, previousBottom + gap)); + } + } else { + let cursor = 0; + for (const thread of anchored) { + const target = positions.get(thread.threadId)?.layoutTop ?? 0; + const top = Math.max(target, cursor === 0 ? 0 : cursor + gap); + tops.set(thread.threadId, top); + cursor = top + heightFor(thread); + } + } + + let cursor = anchored.reduce( + (bottom, thread) => + Math.max(bottom, (tops.get(thread.threadId) ?? 0) + heightFor(thread)), + 0, + ); + for (const thread of sequential) { + const sectionGap = positions.has(thread.threadId) ? gap : gap + 20; + const top = cursor === 0 ? 0 : cursor + sectionGap; + tops.set(thread.threadId, top); + cursor = top + heightFor(thread); + } + + let previousBottom = 0; + return ordered.map((thread) => { + const top = tops.get(thread.threadId) ?? previousBottom; + const position = positions.get(thread.threadId); + const item = { + thread, + top, + marginTop: Math.max(0, top - previousBottom), + anchorTop: position?.layoutTop ?? null, + isOrphaned: !position, + }; + previousBottom = top + heightFor(thread); + return item; + }); } -export function estimateThreadCardHeight(thread: CommentThread) { - return 80 + Math.max(0, thread.comments.length - 1) * 44; +export function scrollToCommentAnchor( + scrollContainer: HTMLElement | null, + documentTop: number | null | undefined, + topPadding = 72, +) { + if (!scrollContainer || documentTop == null) return false; + const maxScrollTop = Math.max( + 0, + scrollContainer.scrollHeight - scrollContainer.clientHeight, + ); + scrollContainer.scrollTo({ + top: Math.min(maxScrollTop, Math.max(0, documentTop - topPadding)), + behavior: "smooth", + }); + return true; } interface CommentsSidebarProps { @@ -185,9 +306,11 @@ interface CommentsSidebarProps { scrollContainerRef?: RefObject; activeThreadId?: string | null; selectedThreadId?: string | null; + onActivateThread?: (id: string) => void; onSelectedThreadChange?: (id: string | null) => void; onHoveredThreadChange?: (id: string | null) => void; currentUserEmail?: string; + alignToAnchors?: boolean; forceVisible?: boolean; } @@ -200,9 +323,11 @@ export function CommentsSidebar({ scrollContainerRef, activeThreadId, selectedThreadId, + onActivateThread, onSelectedThreadChange, onHoveredThreadChange, currentUserEmail, + alignToAnchors = true, forceVisible = false, }: CommentsSidebarProps) { const t = useT(); @@ -290,12 +415,9 @@ export function CommentsSidebar({ }); }; - // Y positions for each open thread, derived from the inline highlight rects. - // Recomputed when the thread set changes, when a highlight is (de)emphasized, - // and whenever the editor DOM mutates so cards track edits live. - const [threadOffsets, setThreadOffsets] = useState>( - new Map(), - ); + const [threadPositions, setThreadPositions] = useState< + Map + >(new Map()); const [threadCardHeights, setThreadCardHeights] = useState< Map >(new Map()); @@ -319,60 +441,51 @@ export function CommentsSidebar({ const recomputeOffsets = useCallback(() => { const container = scrollContainerRef?.current ?? null; if (!container || openThreads.length === 0) { - setThreadOffsets((prev) => (prev.size === 0 ? prev : new Map())); + setThreadPositions((prev) => (prev.size === 0 ? prev : new Map())); setPendingOffset((prev) => { const next = pendingComment - ? findPendingCommentOffset(container, sidebarRef.current ?? container) + ? findPendingCommentOffset( + container, + alignToAnchors ? sidebarRef.current : null, + ) : null; return prev === next ? prev : next; }); return; } - const positionContainer = sidebarRef.current ?? container; - const offsets = new Map(); + const layoutContainer = alignToAnchors ? sidebarRef.current : null; + const positions = new Map(); for (const thread of openThreads) { - const offset = findThreadOffset( + const position = findThreadPosition( thread.threadId, thread.quotedText, container, - positionContainer, + layoutContainer, ); - if (offset != null) offsets.set(thread.threadId, offset); + if (position) positions.set(thread.threadId, position); } const nextPendingOffset = pendingComment - ? findPendingCommentOffset(container, positionContainer) + ? findPendingCommentOffset(container, layoutContainer) : null; - setThreadOffsets((prev) => { + setThreadPositions((prev) => { if ( - prev.size === offsets.size && - [...offsets].every(([k, v]) => prev.get(k) === v) + prev.size === positions.size && + [...positions].every(([key, value]) => { + const prior = prev.get(key); + return ( + prior?.documentTop === value.documentTop && + prior?.layoutTop === value.layoutTop + ); + }) ) { return prev; } - return offsets; + return positions; }); setPendingOffset((prev) => prev === nextPendingOffset ? prev : nextPendingOffset, ); - if ( - selectedThreadId && - shouldClearSelectedThreadOnScroll( - offsets.get(selectedThreadId) ?? null, - container.clientHeight, - ) - ) { - onSelectedThreadChange?.(null); - setReplyingThreadId(null); - setReplyText(""); - setReplyMentions([]); - } - }, [ - openThreads, - onSelectedThreadChange, - pendingComment, - scrollContainerRef, - selectedThreadId, - ]); + }, [alignToAnchors, openThreads, pendingComment, scrollContainerRef]); useEffect(() => { const container = scrollContainerRef?.current ?? null; @@ -392,13 +505,17 @@ export function CommentsSidebar({ subtree: true, characterData: true, }); - container.addEventListener("scroll", schedule, { passive: true }); + const resizeObserver = + typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(schedule); + resizeObserver?.observe(container); window.addEventListener("resize", schedule); return () => { cancelAnimationFrame(raf); observer.disconnect(); - container.removeEventListener("scroll", schedule); + resizeObserver?.disconnect(); window.removeEventListener("resize", schedule); }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -419,48 +536,27 @@ export function CommentsSidebar({ }, [openThreads]); useEffect(() => { - if (!selectedThreadId) return; - if (!openThreads.some((t) => t.threadId === selectedThreadId)) { + if ( + selectedThreadId && + !openThreads.some((thread) => thread.threadId === selectedThreadId) + ) { onSelectedThreadChange?.(null); setReplyingThreadId(null); setReplyText(""); setReplyMentions([]); - return; } - const card = window.document.querySelector( - `[data-thread-card="${cssEscape(selectedThreadId)}"]`, - ); - card?.scrollIntoView({ block: "nearest", behavior: "smooth" }); }, [onSelectedThreadChange, selectedThreadId, openThreads]); const hasContent = openThreads.length > 0 || !!pendingComment || resolvedThreads.length > 0; if (!hasContent && !isLoading && !forceVisible) return null; - // Sort open threads by their position in the document. - const sortedThreads = [...openThreads].sort((a, b) => { - const aOff = threadOffsets.get(a.threadId) ?? Infinity; - const bOff = threadOffsets.get(b.threadId) ?? Infinity; - return aOff - bOff; - }); - - // Stack each card to align with its referenced text without overlapping. - const items: { thread: CommentThread; marginTop: number }[] = []; - let cursor = 0; - for (const thread of sortedThreads) { - const targetTop = threadOffsets.get(thread.threadId); - const marginTop = - targetTop != null - ? Math.max(0, targetTop - cursor) - : cursor === 0 - ? 0 - : 12; - items.push({ thread, marginTop }); - cursor += - marginTop + - (threadCardHeights.get(thread.threadId) ?? - estimateThreadCardHeight(thread)); - } + const items = layoutCommentThreads( + openThreads, + threadPositions, + threadCardHeights, + selectedThreadId, + ); const handleResolve = (thread: CommentThread) => { resolveComment.mutate({ @@ -533,41 +629,73 @@ export function CommentsSidebar({ )} {/* Open thread cards — positioned to align with their referenced text */} - {items.map(({ thread, marginTop }) => ( - - onHoveredThreadChange?.(hovered ? thread.threadId : null) - } - onExpand={() => { - onSelectedThreadChange?.(thread.threadId); - setReplyingThreadId((current) => - current === thread.threadId ? null : thread.threadId, - ); - setReplyText(""); - setReplyMentions([]); - }} - onCollapse={() => { - setReplyingThreadId(null); - onSelectedThreadChange?.(null); - setReplyText(""); - setReplyMentions([]); - }} - onReplyChange={setReplyText} - onReplyMentionAdd={(m) => setReplyMentions((prev) => [...prev, m])} - onHeightChange={handleThreadCardHeightChange} - members={members} - onSubmitReply={() => handleReply(thread.threadId)} - onResolve={() => handleResolve(thread)} - onSendToAI={() => handleSendToAI(thread)} - t={t} - /> - ))} + {items.map((item, index) => { + const { thread, marginTop, top, anchorTop, isOrphaned } = item; + const isActive = activeThreadId === thread.threadId; + const startsOrphanedSection = + isOrphaned && + !items.slice(0, index).some((prior) => prior.isOrphaned); + return ( + + {alignToAnchors && anchorTop != null ? ( + + ) : null} + {startsOrphanedSection ? ( +
+ + {t("comments.unanchored")} + +
+ ) : null} + + onHoveredThreadChange?.(hovered ? thread.threadId : null) + } + onExpand={() => { + onActivateThread?.(thread.threadId); + scrollToCommentAnchor( + scrollContainerRef?.current ?? null, + threadPositions.get(thread.threadId)?.documentTop, + ); + setReplyingThreadId((current) => + current === thread.threadId ? null : thread.threadId, + ); + setReplyText(""); + setReplyMentions([]); + }} + onCollapse={() => { + setReplyingThreadId(null); + onSelectedThreadChange?.(null); + setReplyText(""); + setReplyMentions([]); + }} + onReplyChange={setReplyText} + onReplyMentionAdd={(mention) => + setReplyMentions((prev) => [...prev, mention]) + } + onHeightChange={handleThreadCardHeightChange} + members={members} + onSubmitReply={() => handleReply(thread.threadId)} + onResolve={() => handleResolve(thread)} + onSendToAI={() => handleSendToAI(thread)} + t={t} + /> +
+ ); + })} {/* Resolved comments — collapsible, reopenable */} {resolvedThreads.length > 0 && ( @@ -600,6 +728,39 @@ export function CommentsSidebar({ ); } +function CommentConnector({ + anchorTop, + cardTop, + active, +}: { + anchorTop: number; + cardTop: number; + active: boolean; +}) { + const cardPoint = cardTop + 20; + if (Math.abs(anchorTop - cardPoint) < 6) return null; + const top = Math.min(anchorTop, cardPoint); + const height = Math.abs(anchorTop - cardPoint); + const colorClass = active ? "border-primary/60" : "border-border"; + + return ( +
+ + + +
+ ); +} + function ThreadView({ thread, marginTop, diff --git a/templates/content/app/components/editor/DocumentEditor.layout.test.ts b/templates/content/app/components/editor/DocumentEditor.layout.test.ts index 8209c012d8..d0f784b691 100644 --- a/templates/content/app/components/editor/DocumentEditor.layout.test.ts +++ b/templates/content/app/components/editor/DocumentEditor.layout.test.ts @@ -231,22 +231,16 @@ describe("document editor layout", () => { expect(source).toContain("if (!canEditRef.current) return"); }); - it("gives viewers live collab while keeping write-only surfaces editor-gated", () => { + it("renders viewers from SQL while retaining scoped presence", () => { const documentEditorSource = readFileSync( new URL("./DocumentEditor.tsx", import.meta.url), { encoding: "utf8", }, ); - const visualEditorSource = readFileSync( - new URL("./VisualEditor.tsx", import.meta.url), - { - encoding: "utf8", - }, - ); - // Viewers join the shared Y.Doc read-only: collab is enabled whenever the - // doc is not a local-file doc, regardless of `canEdit`. + // Every SQL-backed reader keeps the scoped collaboration subscription for + // presence, but only editors bind the rendered body to Yjs. expect(documentEditorSource).toContain( "const collabEnabled = !isLocalFileDocument;", ); @@ -254,11 +248,15 @@ describe("document editor layout", () => { 'docId: collabEnabled ? documentId : "",', ); expect(documentEditorSource).toContain( - "ydoc={collabEnabled ? ydoc : null}", + "const collabEditorEnabled = collabEnabled && editorCanEdit;", + ); + expect(documentEditorSource).toContain( + "ydoc={collabEditorEnabled ? ydoc : null}", ); expect(documentEditorSource).toContain( - "awareness={collabEnabled ? awareness : null}", + "awareness={collabEditorEnabled ? awareness : null}", ); + expect(documentEditorSource).toContain("snapshot:${document.updatedAt}"); expect(documentEditorSource).toContain( 'awareness.setLocalStateField("canFlushDocument", editorCanEdit)', ); @@ -271,15 +269,28 @@ describe("document editor layout", () => { "canEdit && !isLocalFileDocument ? documentId : null", ); - // A read-only client must never mutate the shared Y.Doc: VisualEditor - // neuters seed + reconcile-apply so no local `/update` POST can originate. - expect(visualEditorSource).toContain( - "setContent: (e, value, options) => {", + expect(documentEditorSource).toContain( + "canEdit &&\n collabLoading", ); - expect(visualEditorSource).toContain("if (!editable) return;"); - expect(visualEditorSource).toContain( - "shouldSeed: ({ value, currentMarkdown, fragmentLength }) =>\n editable &&", + }); + + it("opens comments and selects a highlighted thread atomically", () => { + const source = readFileSync( + new URL("./DocumentEditor.tsx", import.meta.url), + "utf8", + ); + const activationStart = source.indexOf("const activateCommentThread"); + const activationEnd = source.indexOf( + "const handleUtilityPanelChange", + activationStart, ); + const activation = source.slice(activationStart, activationEnd); + + expect(activationStart).toBeGreaterThan(-1); + expect(activation).toContain("setSelectedThreadId(threadId)"); + expect(activation).toContain('setUtilityPanel("comments")'); + expect(source).toContain("onActivateThread={activateCommentThread}"); + expect(source).not.toContain("? setSelectedThreadId\n"); }); it("keeps title and content save watermarks independent after partial saves", () => { diff --git a/templates/content/app/components/editor/DocumentEditor.tsx b/templates/content/app/components/editor/DocumentEditor.tsx index 55ee18867f..c13992cc49 100644 --- a/templates/content/app/components/editor/DocumentEditor.tsx +++ b/templates/content/app/components/editor/DocumentEditor.tsx @@ -643,16 +643,8 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { } : undefined; - // Live collaboration for everyone who can open the doc — editors and viewers - // alike. Viewers join the shared Y.Doc read-only: they see live keystrokes, - // cursors, and presence (Google-Docs style) instead of a lagging SQL snapshot. - // The server enforces the split — collab READ routes (state / awareness GET / - // users) require viewer access, WRITE routes (update) require editor — so a - // viewer's client can subscribe but never push. The editor stays non-editable - // for viewers (see `editable={canEdit}` below), and VisualEditor additionally - // neutralizes every local Y.Doc mutation for viewers (no seed, no reconcile - // apply) so a read-only client can never originate a rejected `/update` POST. - // Local-file documents are still excluded (they have no SQL-backed collab doc). + // All SQL-backed readers subscribe for presence. Only editors bind the body + // to Yjs; viewers render canonical SQL so missing collab state cannot hide it. const collabEnabled = !isLocalFileDocument; const { ydoc, @@ -670,6 +662,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { const bodyHydrationPending = documentBodyHydrationIsPending(document); const editorCanEdit = canEdit && !bodyHydrationPending && (isLocalFileDocument || !collabLoading); + const collabEditorEnabled = collabEnabled && editorCanEdit; canEditRef.current = editorCanEdit; // Viewers intentionally join awareness so they receive live cursors, but @@ -1371,6 +1364,24 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { setHoveredThreadId(null); }, []); + const activateCommentThread = useCallback((threadId: string) => { + setPendingComment(null); + setHoveredThreadId(null); + setSelectedThreadId(threadId); + setUtilityPanel("comments"); + }, []); + + const handleUtilityPanelChange = useCallback( + (nextPanel: DocumentUtilityPanel) => { + setUtilityPanel(nextPanel); + if (nextPanel !== "comments") { + setPendingComment(null); + clearCommentFocus(); + } + }, + [clearCommentFocus], + ); + useEffect(() => { setPendingComment(null); setUtilityPanel(null); @@ -1511,9 +1522,11 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { scrollContainerRef={scrollContainerRef} activeThreadId={activeThreadId} selectedThreadId={selectedThreadId} + onActivateThread={activateCommentThread} onSelectedThreadChange={setSelectedThreadId} onHoveredThreadChange={setHoveredThreadId} currentUserEmail={session?.email} + alignToAnchors={hasUtilityRailSpace} forceVisible /> ); @@ -1561,7 +1574,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { type="button" className="ms-auto flex size-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label={t("editor.toolbar.closeUtilityPanel")} - onClick={() => setUtilityPanel(null)} + onClick={() => handleUtilityPanelChange(null)} > @@ -1621,7 +1634,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { } onDelete={handleDeleteDocument} utilityPanel={utilityPanel} - onUtilityPanelChange={setUtilityPanel} + onUtilityPanelChange={handleUtilityPanelChange} showCommentsControl={editorCanEdit && !isLocalFileDocument} onOpenBreadcrumbItem={handleOpenToolbarBreadcrumb} /> @@ -1814,7 +1827,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { // fields. const primaryEditor = ( { if (!open) { - setUtilityPanel(null); - setPendingComment(null); + handleUtilityPanelChange(null); } }} > diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index 7f944a920a..9d80e8cc32 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -3085,6 +3085,7 @@ const enUS = { askAi: "Ask AI", resolve: "Resolve", resolved: "Resolved ({{count}})", + unanchored: "Highlight unavailable", reply: "Reply...", reopen: "Reopen", agentRegardingText: 'Regarding this text: "{{text}}"', diff --git a/templates/content/changelog/2026-07-23-shared-pages-now-keep-durable-content-visible-and-comment-th.md b/templates/content/changelog/2026-07-23-shared-pages-now-keep-durable-content-visible-and-comment-th.md new file mode 100644 index 0000000000..6378e859aa --- /dev/null +++ b/templates/content/changelog/2026-07-23-shared-pages-now-keep-durable-content-visible-and-comment-th.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-23 +--- + +Shared pages now keep durable content visible, and comment threads stay stable and connected while you navigate. From daa36b07d836428be4b1d714c684259cf08c6fb6 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:40:39 -0400 Subject: [PATCH 02/13] fix content landing blockers --- .../actions/update-document.db.test.ts | 54 +++++++++++++++++++ templates/content/actions/update-document.ts | 26 ++++++--- .../editor/CommentsSidebar.layout.test.ts | 1 + .../app/components/editor/CommentsSidebar.tsx | 2 +- .../editor/DocumentEditor.layout.test.ts | 30 +++++++++++ .../app/components/editor/DocumentEditor.tsx | 10 ++-- templates/content/app/i18n-data.ts | 14 +++++ templates/content/app/i18n/zh-TW.ts | 1 + 8 files changed, 125 insertions(+), 13 deletions(-) diff --git a/templates/content/actions/update-document.db.test.ts b/templates/content/actions/update-document.db.test.ts index 915fe7b036..bd2ac0f9b9 100644 --- a/templates/content/actions/update-document.db.test.ts +++ b/templates/content/actions/update-document.db.test.ts @@ -18,6 +18,7 @@ let updateDocumentAction: typeof import("./update-document.js").default; const OWNER = "owner@example.com"; const EDITOR = "editor@example.com"; +const VIEWER = "viewer@example.com"; beforeAll(async () => { process.env.DATABASE_URL = `file:${TEST_DB_PATH}`; @@ -293,4 +294,57 @@ describe("update-document compare-and-swap", () => { }); expect(JSON.stringify(result)).not.toContain(OWNER); }); + + it("keeps a viewer's favorite preference in the viewer's private audit trail", async () => { + const documentId = await createDocument({ content: "owner body" }); + await getDb() + .insert(schema.documentShares) + .values({ + id: nextId("share"), + resourceId: documentId, + principalType: "user", + principalId: VIEWER, + role: "viewer", + createdBy: OWNER, + createdAt: new Date().toISOString(), + }); + + await runWithRequestContext({ userEmail: VIEWER }, () => + updateDocumentAction.run( + { id: documentId, isFavorite: true }, + { + caller: "frontend", + actionName: "update-document", + userEmail: VIEWER, + }, + ), + ); + const { queryAuditEvents } = await import("@agent-native/core/audit"); + const viewerEvents = await queryAuditEvents( + { userEmail: VIEWER }, + { + action: "update-document", + targetType: "document", + targetId: documentId, + }, + ); + const ownerEvents = await queryAuditEvents( + { userEmail: OWNER }, + { + action: "update-document", + targetType: "document", + targetId: documentId, + }, + ); + + expect(viewerEvents).toHaveLength(1); + expect(viewerEvents[0]).toMatchObject({ + actorEmail: VIEWER, + ownerEmail: VIEWER, + targetType: "document", + targetId: documentId, + status: "success", + }); + expect(ownerEvents).toHaveLength(0); + }); }); diff --git a/templates/content/actions/update-document.ts b/templates/content/actions/update-document.ts index 2576c32bb9..5beb05e864 100644 --- a/templates/content/actions/update-document.ts +++ b/templates/content/actions/update-document.ts @@ -313,14 +313,24 @@ export default defineAction({ .describe("Exact item versions that influenced this agent update."), }), audit: { - target: (args, result) => ({ - type: "document", - id: args.id, - ownerEmail: (result as DocumentAuditScopedResult | null)?.[ - documentAuditOwner - ], - visibility: "private", - }), + target: (args, result) => { + const favoriteOnly = + args.isFavorite !== undefined && + args.title === undefined && + args.content === undefined && + args.description === undefined && + args.icon === undefined; + return { + type: "document", + id: args.id, + // Favorites are a private preference owned by the actor, even when + // the underlying document belongs to somebody else. + ownerEmail: favoriteOnly + ? undefined + : (result as DocumentAuditScopedResult | null)?.[documentAuditOwner], + visibility: "private", + }; + }, summary: (args, result) => (result as DocumentUpdateConflictResponse | null)?.conflict ? `Document update conflicted for ${args.id}` diff --git a/templates/content/app/components/editor/CommentsSidebar.layout.test.ts b/templates/content/app/components/editor/CommentsSidebar.layout.test.ts index 170b000ded..bc65307165 100644 --- a/templates/content/app/components/editor/CommentsSidebar.layout.test.ts +++ b/templates/content/app/components/editor/CommentsSidebar.layout.test.ts @@ -167,6 +167,7 @@ describe("comments sidebar layout", () => { }); expect(source).toContain("data-comments-sidebar"); + expect(source).toContain("relative w-full min-w-0 shrink-0 pb-16"); expect(source).not.toContain("w-80 shrink-0 overflow-auto"); expect(source).not.toContain("overflow-auto relative"); }); diff --git a/templates/content/app/components/editor/CommentsSidebar.tsx b/templates/content/app/components/editor/CommentsSidebar.tsx index 1971bdeca1..290c3f8b6c 100644 --- a/templates/content/app/components/editor/CommentsSidebar.tsx +++ b/templates/content/app/components/editor/CommentsSidebar.tsx @@ -583,7 +583,7 @@ export function CommentsSidebar({ return (
{!hasContent && !isLoading ? ( diff --git a/templates/content/app/components/editor/DocumentEditor.layout.test.ts b/templates/content/app/components/editor/DocumentEditor.layout.test.ts index d0f784b691..e8125b64bc 100644 --- a/templates/content/app/components/editor/DocumentEditor.layout.test.ts +++ b/templates/content/app/components/editor/DocumentEditor.layout.test.ts @@ -256,6 +256,12 @@ describe("document editor layout", () => { expect(documentEditorSource).toContain( "awareness={collabEditorEnabled ? awareness : null}", ); + expect(documentEditorSource).toContain( + 'collabEditorEnabled && ydoc ? "live-ready"', + ); + expect(documentEditorSource).toContain( + 'canEdit && !isLocalFileDocument ? "live-pending"', + ); expect(documentEditorSource).toContain("snapshot:${document.updatedAt}"); expect(documentEditorSource).toContain( 'awareness.setLocalStateField("canFlushDocument", editorCanEdit)', @@ -293,6 +299,30 @@ describe("document editor layout", () => { expect(source).not.toContain("? setSelectedThreadId\n"); }); + it("does not clear comment focus at the start of a touch or scroll gesture", () => { + const source = readFileSync( + new URL("./DocumentEditor.tsx", import.meta.url), + "utf8", + ); + + expect(source).not.toContain("onPointerDownCapture={(event) => {"); + expect(source).toContain("onClickCapture={(event) => {"); + }); + + it("keeps the narrow utility sheet width-safe and vertically reachable", () => { + const source = readFileSync( + new URL("./DocumentEditor.tsx", import.meta.url), + "utf8", + ); + + expect(source).toContain( + 'className="flex min-h-0 w-[85vw] max-w-sm flex-col overflow-hidden p-0"', + ); + expect(source).toContain( + 'className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto"', + ); + }); + it("keeps title and content save watermarks independent after partial saves", () => { const source = readFileSync( new URL("./DocumentEditor.tsx", import.meta.url), diff --git a/templates/content/app/components/editor/DocumentEditor.tsx b/templates/content/app/components/editor/DocumentEditor.tsx index c13992cc49..9e772a6003 100644 --- a/templates/content/app/components/editor/DocumentEditor.tsx +++ b/templates/content/app/components/editor/DocumentEditor.tsx @@ -1602,7 +1602,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
{ + onClickCapture={(event) => { const target = event.target as HTMLElement | null; if ( target?.closest("[data-comments-sidebar], [data-comment-thread]") @@ -1827,7 +1827,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { // fields. const primaryEditor = ( {utilityPanelTitle} - {utilityPanelContent} +
+ {utilityPanelContent} +
) : null} diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index 9d80e8cc32..835b5590c9 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -8762,6 +8762,19 @@ const contentReferenceMessagesByLocale = { }, } satisfies Partial>; +const commentMessagesByLocale = { + "zh-CN": { unanchored: "高亮不可用" }, + "zh-TW": { unanchored: "無法使用醒目提示" }, + "es-ES": { unanchored: "Resaltado no disponible" }, + "fr-FR": { unanchored: "Surlignage indisponible" }, + "de-DE": { unanchored: "Hervorhebung nicht verfügbar" }, + "ja-JP": { unanchored: "ハイライトを利用できません" }, + "ko-KR": { unanchored: "강조 표시를 사용할 수 없음" }, + "pt-BR": { unanchored: "Destaque indisponível" }, + "hi-IN": { unanchored: "हाइलाइट उपलब्ध नहीं है" }, + "ar-SA": { unanchored: "التمييز غير متاح" }, +} satisfies Partial>>; + function mergeMessages(overrides: PartialMessages): Messages { return { root: { ...enUS.root, ...overrides.root }, @@ -8863,6 +8876,7 @@ function mergeMessagesForLocale( }); return { ...base, + comments: { ...base.comments, ...commentMessagesByLocale[locale] }, root: { ...base.root, ...rawLiteralOverrides.root }, team: { ...base.team, ...rawLiteralOverrides.team }, settings: { ...base.settings, ...rawLiteralOverrides.settings }, diff --git a/templates/content/app/i18n/zh-TW.ts b/templates/content/app/i18n/zh-TW.ts index d49281b62f..f6be8b1ef6 100644 --- a/templates/content/app/i18n/zh-TW.ts +++ b/templates/content/app/i18n/zh-TW.ts @@ -567,6 +567,7 @@ const messages = { askAi: "詢問 AI", resolve: "解決", resolved: "已解決({{count}})", + unanchored: "無法使用醒目提示", reply: "回覆...", reopen: "重新開啟", agentRegardingText: "關於這段文字:“{{text}}”", From a4cc20eacb3bcdd3086be2625a039b5df01a7c63 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:43:52 -0400 Subject: [PATCH 03/13] separate document and favorite audits --- .../actions/update-document.db.test.ts | 28 +++++++++++++++ templates/content/actions/update-document.ts | 35 ++++++++++++------- 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/templates/content/actions/update-document.db.test.ts b/templates/content/actions/update-document.db.test.ts index bd2ac0f9b9..0820ed1448 100644 --- a/templates/content/actions/update-document.db.test.ts +++ b/templates/content/actions/update-document.db.test.ts @@ -347,4 +347,32 @@ describe("update-document compare-and-swap", () => { }); expect(ownerEvents).toHaveLength(0); }); + + it("rejects mixed document and personal favorite mutations", async () => { + const documentId = await createDocument({ content: "owner body" }); + await getDb() + .insert(schema.documentShares) + .values({ + id: nextId("share"), + resourceId: documentId, + principalType: "user", + principalId: EDITOR, + role: "editor", + createdBy: OWNER, + createdAt: new Date().toISOString(), + }); + + await expect( + runWithRequestContext({ userEmail: EDITOR }, () => + updateDocumentAction.run({ + id: documentId, + content: "collaborator body", + isFavorite: true, + }), + ), + ).rejects.toThrow( + "isFavorite must be updated separately from document fields", + ); + expect((await documentRow(documentId)).content).toBe("owner body"); + }); }); diff --git a/templates/content/actions/update-document.ts b/templates/content/actions/update-document.ts index 5beb05e864..8e148a1692 100644 --- a/templates/content/actions/update-document.ts +++ b/templates/content/actions/update-document.ts @@ -51,6 +51,22 @@ function scopeDocumentAudit(result: T, ownerEmail: string) { return result; } +function isFavoriteOnlyUpdate(args: { + isFavorite?: boolean; + title?: string; + content?: string; + description?: string; + icon?: string | null; +}) { + return ( + args.isFavorite !== undefined && + args.title === undefined && + args.content === undefined && + args.description === undefined && + args.icon === undefined + ); +} + function nanoid(size = 12): string { const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; @@ -314,12 +330,7 @@ export default defineAction({ }), audit: { target: (args, result) => { - const favoriteOnly = - args.isFavorite !== undefined && - args.title === undefined && - args.content === undefined && - args.description === undefined && - args.icon === undefined; + const favoriteOnly = isFavoriteOnlyUpdate(args); return { type: "document", id: args.id, @@ -342,6 +353,11 @@ export default defineAction({ ): Promise => { const id = args.id; if (!id) throw new Error("--id is required"); + if (args.isFavorite !== undefined && !isFavoriteOnlyUpdate(args)) { + throw new Error( + "isFavorite must be updated separately from document fields", + ); + } // Only surface AI presence for genuine agent invocations (in-app tool loop, // sub-agents/A2A → "tool"; external MCP agents → "mcp"). The browser editor @@ -350,12 +366,7 @@ export default defineAction({ const isAgentCaller = ctx?.caller === "tool" || ctx?.caller === "mcp" || ctx?.caller === "a2a"; - const favoriteOnly = - args.isFavorite !== undefined && - args.title === undefined && - args.content === undefined && - args.description === undefined && - args.icon === undefined; + const favoriteOnly = isFavoriteOnlyUpdate(args); const access = favoriteOnly ? await resolveContentDocumentAccess(id) : await assertAccess("document", id, "editor"); From 47dbf48c9d0069a13e8397be63ec9fc9b0a4073f Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:52:26 -0400 Subject: [PATCH 04/13] preserve mixed updates and mobile comments --- .../actions/update-document.db.test.ts | 36 ++++++++++++++----- templates/content/actions/update-document.ts | 9 +++-- .../editor/CommentsSidebar.layout.test.ts | 12 +++++++ .../app/components/editor/CommentsSidebar.tsx | 29 +++++++++------ 4 files changed, 62 insertions(+), 24 deletions(-) diff --git a/templates/content/actions/update-document.db.test.ts b/templates/content/actions/update-document.db.test.ts index 0820ed1448..9a10c08b40 100644 --- a/templates/content/actions/update-document.db.test.ts +++ b/templates/content/actions/update-document.db.test.ts @@ -348,7 +348,7 @@ describe("update-document compare-and-swap", () => { expect(ownerEvents).toHaveLength(0); }); - it("rejects mixed document and personal favorite mutations", async () => { + it("preserves mixed updates without exposing their inputs to the owner", async () => { const documentId = await createDocument({ content: "owner body" }); await getDb() .insert(schema.documentShares) @@ -362,17 +362,37 @@ describe("update-document compare-and-swap", () => { createdAt: new Date().toISOString(), }); - await expect( - runWithRequestContext({ userEmail: EDITOR }, () => - updateDocumentAction.run({ + await runWithRequestContext({ userEmail: EDITOR }, () => + updateDocumentAction.run( + { id: documentId, content: "collaborator body", isFavorite: true, - }), + }, + { + caller: "frontend", + actionName: "update-document", + userEmail: EDITOR, + }, ), - ).rejects.toThrow( - "isFavorite must be updated separately from document fields", ); - expect((await documentRow(documentId)).content).toBe("owner body"); + const { queryAuditEvents } = await import("@agent-native/core/audit"); + const ownerEvents = await queryAuditEvents( + { userEmail: OWNER }, + { + action: "update-document", + targetType: "document", + targetId: documentId, + }, + ); + + expect((await documentRow(documentId)).content).toBe("collaborator body"); + expect(ownerEvents).toHaveLength(1); + expect(ownerEvents[0]).toMatchObject({ + actorEmail: EDITOR, + ownerEmail: OWNER, + input: null, + status: "success", + }); }); }); diff --git a/templates/content/actions/update-document.ts b/templates/content/actions/update-document.ts index 8e148a1692..b45ca80614 100644 --- a/templates/content/actions/update-document.ts +++ b/templates/content/actions/update-document.ts @@ -329,6 +329,10 @@ export default defineAction({ .describe("Exact item versions that influenced this agent update."), }), audit: { + // Document bodies and personal favorite preferences are both sensitive. + // Keep actor/target/outcome attribution without copying mutation payloads + // into an owner-visible audit row. + recordInputs: false, target: (args, result) => { const favoriteOnly = isFavoriteOnlyUpdate(args); return { @@ -353,11 +357,6 @@ export default defineAction({ ): Promise => { const id = args.id; if (!id) throw new Error("--id is required"); - if (args.isFavorite !== undefined && !isFavoriteOnlyUpdate(args)) { - throw new Error( - "isFavorite must be updated separately from document fields", - ); - } // Only surface AI presence for genuine agent invocations (in-app tool loop, // sub-agents/A2A → "tool"; external MCP agents → "mcp"). The browser editor diff --git a/templates/content/app/components/editor/CommentsSidebar.layout.test.ts b/templates/content/app/components/editor/CommentsSidebar.layout.test.ts index bc65307165..e2e558caaf 100644 --- a/templates/content/app/components/editor/CommentsSidebar.layout.test.ts +++ b/templates/content/app/components/editor/CommentsSidebar.layout.test.ts @@ -153,6 +153,18 @@ describe("comments sidebar layout", () => { expect(source).toContain("data-unanchored-comments"); }); + it("keeps the pending composer in normal flow on narrow sheets", () => { + const source = readFileSync("app/components/editor/CommentsSidebar.tsx", { + encoding: "utf8", + }); + + expect(source).toContain("pendingComment && alignToAnchors"); + expect(source).toContain( + '"relative mx-2 mt-3 rounded-lg bg-popover p-3 shadow-md ring-1 ring-border/50"', + ); + expect(source).toContain(": undefined"); + }); + it("keeps card height estimates based on the thread reply count", () => { const thread = { comments: [{ id: "root" }, { id: "reply" }], diff --git a/templates/content/app/components/editor/CommentsSidebar.tsx b/templates/content/app/components/editor/CommentsSidebar.tsx index 290c3f8b6c..08cba5cbe9 100644 --- a/templates/content/app/components/editor/CommentsSidebar.tsx +++ b/templates/content/app/components/editor/CommentsSidebar.tsx @@ -443,12 +443,10 @@ export function CommentsSidebar({ if (!container || openThreads.length === 0) { setThreadPositions((prev) => (prev.size === 0 ? prev : new Map())); setPendingOffset((prev) => { - const next = pendingComment - ? findPendingCommentOffset( - container, - alignToAnchors ? sidebarRef.current : null, - ) - : null; + const next = + pendingComment && alignToAnchors + ? findPendingCommentOffset(container, sidebarRef.current) + : null; return prev === next ? prev : next; }); return; @@ -464,9 +462,10 @@ export function CommentsSidebar({ ); if (position) positions.set(thread.threadId, position); } - const nextPendingOffset = pendingComment - ? findPendingCommentOffset(container, layoutContainer) - : null; + const nextPendingOffset = + pendingComment && alignToAnchors + ? findPendingCommentOffset(container, layoutContainer) + : null; setThreadPositions((prev) => { if ( prev.size === positions.size && @@ -594,8 +593,16 @@ export function CommentsSidebar({ {/* Pending new comment — positioned at the selection Y offset */} {pendingComment && (
Date: Thu, 23 Jul 2026 14:12:17 -0400 Subject: [PATCH 05/13] guard incomplete content sidebar data --- .../editor/database/sidebar.test.tsx | 28 +++++++++++++++++++ .../components/editor/database/sidebar.tsx | 4 +-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/templates/content/app/components/editor/database/sidebar.test.tsx b/templates/content/app/components/editor/database/sidebar.test.tsx index 9b0d90baa1..9b1f5c3198 100644 --- a/templates/content/app/components/editor/database/sidebar.test.tsx +++ b/templates/content/app/components/editor/database/sidebar.test.tsx @@ -57,6 +57,34 @@ describe("DatabaseSidebarView", () => { ).toEqual(["item-first", "item-second"]); }); + it("renders an empty fallback while database data is incomplete", () => { + const markup = renderToStaticMarkup( + + + + + , + ); + + expect(markup).toContain('aria-label="Files"'); + }); + it("renders compact router links for an ungrouped saved view", () => { const markup = renderToStaticMarkup( diff --git a/templates/content/app/components/editor/database/sidebar.tsx b/templates/content/app/components/editor/database/sidebar.tsx index 196cea9b78..75b7327e6c 100644 --- a/templates/content/app/components/editor/database/sidebar.tsx +++ b/templates/content/app/components/editor/database/sidebar.tsx @@ -137,7 +137,7 @@ export function ContentFilesSidebarView({ >; }) { const viewConfig = applyPersonalSidebarViewOverrides( - data?.database.viewConfig ?? defaultDatabaseViewConfig(), + data?.database?.viewConfig ?? defaultDatabaseViewConfig(), overrides, ); const [selectedViewId, setSelectedViewId] = useState( @@ -154,7 +154,7 @@ export function ContentFilesSidebarView({ useEffect(() => { setConstraintsCleared(false); }, [activeFilterKey, activeView.id]); - const items = data + const items = data?.database ? applyDatabaseView( data.items, data.properties, From 2af5c56f03120780d01e0082865a9c78b9d6137a Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:30:40 -0400 Subject: [PATCH 06/13] harden incomplete content sidebar payloads --- .../editor/database/sidebar.test.tsx | 7 +---- .../components/editor/database/sidebar.tsx | 27 +++++++++++++------ 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/templates/content/app/components/editor/database/sidebar.test.tsx b/templates/content/app/components/editor/database/sidebar.test.tsx index 9b1f5c3198..48b5320cae 100644 --- a/templates/content/app/components/editor/database/sidebar.test.tsx +++ b/templates/content/app/components/editor/database/sidebar.test.tsx @@ -62,12 +62,7 @@ describe("DatabaseSidebarView", () => { ; }) { + const usableData = + data?.database && + Array.isArray(data.items) && + Array.isArray(data.properties) + ? data + : undefined; const viewConfig = applyPersonalSidebarViewOverrides( - data?.database?.viewConfig ?? defaultDatabaseViewConfig(), + usableData?.database.viewConfig ?? defaultDatabaseViewConfig(), overrides, ); const [selectedViewId, setSelectedViewId] = useState( @@ -154,10 +160,10 @@ export function ContentFilesSidebarView({ useEffect(() => { setConstraintsCleared(false); }, [activeFilterKey, activeView.id]); - const items = data?.database + const items = usableData ? applyDatabaseView( - data.items, - data.properties, + usableData.items, + usableData.properties, "", constraintsCleared ? [] : activeView.filters, activeView.sorts, @@ -167,16 +173,18 @@ export function ContentFilesSidebarView({ const groups = databaseVisibleGroups( databaseViewItemGroups( items, - data?.properties ?? [], + usableData?.properties ?? [], activeView.groupByPropertyId, ), activeView.hideEmptyGroups === true, ); - const hasFilesHierarchy = data?.properties.some( + const hasFilesHierarchy = usableData?.properties.some( (property) => property.definition.systemRole === "files_parent", ); const hierarchyItems = hasFilesHierarchy ? items : undefined; - const hierarchyUniverseItems = hasFilesHierarchy ? data?.items : undefined; + const hierarchyUniverseItems = hasFilesHierarchy + ? usableData?.items + : undefined; return (
{viewConfig.views.length > 1 && ( @@ -204,7 +212,10 @@ export function ContentFilesSidebarView({ {...labels} groups={groups} grouped={ - !!databaseViewGroupingProperty(activeView, data?.properties ?? []) + !!databaseViewGroupingProperty( + activeView, + usableData?.properties ?? [], + ) } isLoading={isLoading} hasActiveConstraints={ From 42a23940acceb0c2721d284c4e9f3c5ac972a314 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:55:14 -0400 Subject: [PATCH 07/13] fix collaborative document reload duplication --- .../useCollabReconcile.concurrent.spec.ts | 164 +++++++++++++++++- .../toolkit/src/editor/useCollabReconcile.ts | 81 +++++++-- .../editor/DocumentEditor.layout.test.ts | 2 +- .../app/components/editor/DocumentEditor.tsx | 7 +- 4 files changed, 234 insertions(+), 20 deletions(-) diff --git a/packages/toolkit/src/editor/useCollabReconcile.concurrent.spec.ts b/packages/toolkit/src/editor/useCollabReconcile.concurrent.spec.ts index a2b7a2c1db..0fb011d614 100644 --- a/packages/toolkit/src/editor/useCollabReconcile.concurrent.spec.ts +++ b/packages/toolkit/src/editor/useCollabReconcile.concurrent.spec.ts @@ -1,9 +1,12 @@ // @vitest-environment happy-dom +import { Editor as CoreEditor } from "@tiptap/core"; import { useEditor, type Editor } from "@tiptap/react"; import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Awareness } from "y-protocols/awareness"; +import * as Y from "yjs"; import { createRichMarkdownExtensions } from "./RichMarkdownEditor.js"; import { useCollabReconcile, getEditorMarkdown } from "./useCollabReconcile.js"; @@ -355,7 +358,6 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => { } act(() => root.render(React.createElement(Probe))); - await flush(); expect(editor).not.toBeNull(); expect(shouldIgnoreUpdate).not.toBeNull(); @@ -431,6 +433,166 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => { expect(setContentValues).toEqual(["second seed"]); }); + it("does not seed beside persisted Y.Doc content projected during initial sync", async () => { + const persistedYdoc = new Y.Doc(); + const persistedEditor = new CoreEditor({ + extensions: createRichMarkdownExtensions({ + dialect: "gfm", + ydoc: persistedYdoc, + }), + }); + persistedEditor.commands.setContent("persisted collab body"); + const persistedUpdate = Y.encodeStateAsUpdate(persistedYdoc); + persistedEditor.destroy(); + persistedYdoc.destroy(); + + const liveYdoc = new Y.Doc(); + const setContentValues: string[] = []; + let capturedEditor: Editor | null = null; + + function Probe({ collabSynced }: { collabSynced: boolean }) { + const editor = useEditor({ + extensions: createRichMarkdownExtensions({ + dialect: "gfm", + ydoc: liveYdoc, + }), + }); + capturedEditor = editor; + useCollabReconcile({ + editor, + ydoc: liveYdoc, + collabSynced, + value: "persisted collab body", + contentUpdatedAt: "2024-01-01T00:00:01.000Z", + editable: true, + setContent: (_editor, nextValue) => { + setContentValues.push(nextValue); + }, + }); + return React.createElement("div", null); + } + + act(() => root.render(React.createElement(Probe, { collabSynced: false }))); + act(() => Y.applyUpdate(liveYdoc, persistedUpdate, "remote")); + act(() => root.render(React.createElement(Probe, { collabSynced: true }))); + await flush(); + + expect(getEditorMarkdown(capturedEditor!)).toBe("persisted collab body"); + expect(setContentValues).toEqual([]); + expect( + getEditorMarkdown(capturedEditor!).match(/persisted collab body/g), + ).toHaveLength(1); + liveYdoc.destroy(); + }); + + it("adopts a nonempty synced Y.Doc instead of reconciling an empty SQL snapshot", async () => { + const persistedYdoc = new Y.Doc(); + const persistedEditor = new CoreEditor({ + extensions: createRichMarkdownExtensions({ + dialect: "gfm", + ydoc: persistedYdoc, + }), + }); + persistedEditor.commands.setContent("live collaborator body"); + const liveYdoc = new Y.Doc(); + Y.applyUpdate(liveYdoc, Y.encodeStateAsUpdate(persistedYdoc), "remote"); + persistedEditor.destroy(); + persistedYdoc.destroy(); + + const awareness = new Awareness(liveYdoc); + awareness.getStates().set(4_294_967_295, { + user: { name: "Active peer" }, + visible: true, + }); + const setContentValues: string[] = []; + let capturedEditor: Editor | null = null; + + function Probe() { + const editor = useEditor({ + extensions: createRichMarkdownExtensions({ + dialect: "gfm", + ydoc: liveYdoc, + }), + }); + capturedEditor = editor; + useCollabReconcile({ + editor, + ydoc: liveYdoc, + awareness, + collabSynced: true, + value: "", + contentUpdatedAt: "2024-01-01T00:00:01.000Z", + editable: true, + setContent: (_editor, nextValue) => { + setContentValues.push(nextValue); + }, + }); + return React.createElement("div", null); + } + + act(() => root.render(React.createElement(Probe))); + await flush(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 350)); + }); + + expect(getEditorMarkdown(capturedEditor!)).toBe("live collaborator body"); + expect(setContentValues).toEqual([]); + awareness.destroy(); + liveYdoc.destroy(); + }); + + it("lets canonical empty SQL clear stale persisted Y.Doc content with no active peer", async () => { + const persistedYdoc = new Y.Doc(); + const persistedEditor = new CoreEditor({ + extensions: createRichMarkdownExtensions({ + dialect: "gfm", + ydoc: persistedYdoc, + }), + }); + persistedEditor.commands.setContent("stale persisted body"); + const liveYdoc = new Y.Doc(); + Y.applyUpdate(liveYdoc, Y.encodeStateAsUpdate(persistedYdoc), "remote"); + persistedEditor.destroy(); + persistedYdoc.destroy(); + + const setContentValues: string[] = []; + let capturedEditor: Editor | null = null; + + function Probe() { + const editor = useEditor({ + extensions: createRichMarkdownExtensions({ + dialect: "gfm", + ydoc: liveYdoc, + }), + }); + capturedEditor = editor; + useCollabReconcile({ + editor, + ydoc: liveYdoc, + collabSynced: true, + value: "", + contentUpdatedAt: "2024-01-01T00:00:01.000Z", + editable: true, + setContent: (editorToClear, nextValue) => { + setContentValues.push(nextValue); + editorToClear.commands.setContent(nextValue); + }, + }); + return React.createElement("div", null); + } + + act(() => root.render(React.createElement(Probe))); + await flush(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 350)); + }); + + expect(setContentValues).toContain(""); + expect(getEditorMarkdown(capturedEditor!)).toBe(""); + liveYdoc.destroy(); + }); + it("applies a genuinely newer external value once the user is no longer focused", async () => { const { captured, Harness } = makeHarness(); diff --git a/packages/toolkit/src/editor/useCollabReconcile.ts b/packages/toolkit/src/editor/useCollabReconcile.ts index 459079284f..ec7d92dc4d 100644 --- a/packages/toolkit/src/editor/useCollabReconcile.ts +++ b/packages/toolkit/src/editor/useCollabReconcile.ts @@ -264,6 +264,11 @@ export function useCollabReconcile({ // arrives via Yjs, so external markdown reconcile must defer (avoid applying // the same change through both Yjs and setContent). const peerCountRef = useRef(0); + // Briefly gates the first empty-SQL reconcile while Collaboration projects + // a nonempty fragment. `seededRef` is still released immediately so a real + // first keystroke can persist; this ref only postpones the ambiguous clear + // decision until we can distinguish an active/local edit from stale CRDT. + const emptySnapshotDecisionPendingRef = useRef(false); useEffect(() => { if (!collab || !awareness || !ydoc) { setIsLeadClient(true); @@ -299,32 +304,70 @@ export function useCollabReconcile({ if (!collab || !editor || editor.isDestroyed || !ydoc) return; if (seededRef.current) return; if (!collabSynced) return; - // An authoritative empty value has nothing to seed. Once the provider has - // finished loading, the shared fragment is ready for the first real user - // edit; leaving `seededRef` false here would classify every keystroke as - // transient pre-seed normalization and suppress the app's durable save. + // An empty SQL value has nothing to seed. Release the first real keystroke + // immediately, but when a fragment already exists defer the ambiguous + // reconcile decision for one task: active-peer or just-emitted local content + // is live and must be adopted; stale persisted CRDT with no active writer is + // cleared by the canonical SQL snapshot. if (!value.trim()) { seededRef.current = true; - return; + const fragment = ydoc.getXmlFragment("default"); + const currentMarkdown = getMarkdown(editor); + if (fragment.length === 0 && !currentMarkdown.trim()) return; + + emptySnapshotDecisionPendingRef.current = true; + let cancelled = false; + const adoptTimer = setTimeout(() => { + if (cancelled || editor.isDestroyed) return; + const projectedMarkdown = getMarkdown(editor); + const isOwnFreshEdit = + projectedMarkdown.trim() && + (projectedMarkdown === lastEmittedRef.current || + recentEmittedRef.current.includes(projectedMarkdown)); + if ( + projectedMarkdown.trim() && + (peerCountRef.current > 0 || isOwnFreshEdit) + ) { + lastAppliedValueRef.current = value; + lastAppliedSerializedRef.current = projectedMarkdown; + if (contentUpdatedAt) { + lastAppliedUpdatedAtRef.current = contentUpdatedAt; + } + } + emptySnapshotDecisionPendingRef.current = false; + }, 0); + return () => { + cancelled = true; + clearTimeout(adoptTimer); + emptySnapshotDecisionPendingRef.current = false; + }; } if (!isLeadClient) return; - const fragment = ydoc.getXmlFragment("default"); - const currentMarkdown = getMarkdown(editor); - // Seed only when the shared doc is genuinely empty — either the fragment has - // no nodes yet, or it holds no semantic markdown (an empty paragraph, or an - // app's sentinel-empty filler via a custom `shouldSeed`). - if ( - !shouldSeed({ value, currentMarkdown, fragmentLength: fragment.length }) - ) { - seededRef.current = true; - return; - } - let cancelled = false; // Defer via a timer task (NOT a microtask — microtasks can still run // inside React's commit and trigger flushSync-from-lifecycle warnings). + // Read the editor and fragment INSIDE that task. The collaboration + // extension can finish projecting an already-populated Y.Doc into + // ProseMirror after this effect is scheduled. Capturing the pre-projection + // empty editor here would incorrectly seed the SQL snapshot alongside the + // existing CRDT content, duplicating the whole document after reload. const seedTimer = setTimeout(() => { if (cancelled || editor.isDestroyed) return; + const fragment = ydoc.getXmlFragment("default"); + const currentMarkdown = getMarkdown(editor); + // Seed only when the shared doc is genuinely empty — either the fragment + // has no nodes yet, or it holds no semantic markdown (an empty paragraph, + // or an app's sentinel-empty filler via a custom `shouldSeed`). + if ( + !shouldSeed({ + value, + currentMarkdown, + fragmentLength: fragment.length, + }) + ) { + seededRef.current = true; + return; + } isSettingContentRef.current = true; try { setContent(editor, value, {}); @@ -379,6 +422,10 @@ export function useCollabReconcile({ retry = setTimeout(() => apply(deferred), 300); return; } + if (collab && emptySnapshotDecisionPendingRef.current) { + retry = setTimeout(() => apply(deferred), 50); + return; + } const currentMarkdown = getMarkdown(editor); // Compare against the canonical form the editor would emit so a serializer // that re-normalizes (Content's NFM) still recognizes "already in sync". diff --git a/templates/content/app/components/editor/DocumentEditor.layout.test.ts b/templates/content/app/components/editor/DocumentEditor.layout.test.ts index e8125b64bc..69c1992baf 100644 --- a/templates/content/app/components/editor/DocumentEditor.layout.test.ts +++ b/templates/content/app/components/editor/DocumentEditor.layout.test.ts @@ -248,7 +248,7 @@ describe("document editor layout", () => { 'docId: collabEnabled ? documentId : "",', ); expect(documentEditorSource).toContain( - "const collabEditorEnabled = collabEnabled && editorCanEdit;", + "collabEnabled && canEdit && !bodyHydrationPending;", ); expect(documentEditorSource).toContain( "ydoc={collabEditorEnabled ? ydoc : null}", diff --git a/templates/content/app/components/editor/DocumentEditor.tsx b/templates/content/app/components/editor/DocumentEditor.tsx index 9e772a6003..2bdec751f3 100644 --- a/templates/content/app/components/editor/DocumentEditor.tsx +++ b/templates/content/app/components/editor/DocumentEditor.tsx @@ -662,7 +662,12 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { const bodyHydrationPending = documentBodyHydrationIsPending(document); const editorCanEdit = canEdit && !bodyHydrationPending && (isLocalFileDocument || !collabLoading); - const collabEditorEnabled = collabEnabled && editorCanEdit; + // Bind an editor's stable Y.Doc on its first mount, even while the initial + // state is loading. Editability remains gated by `editorCanEdit`, and the + // reconcile hook remains gated by `collabSynced`; keeping the Y.Doc binding + // stable avoids a snapshot -> collab remount that can seed the same SQL body + // beside freshly projected persisted CRDT content. + const collabEditorEnabled = collabEnabled && canEdit && !bodyHydrationPending; canEditRef.current = editorCanEdit; // Viewers intentionally join awareness so they receive live cursors, but From e6f4bc8f03f5c8803fe4ef7d42badf1b522ec127 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:27:07 -0400 Subject: [PATCH 08/13] fix collaborative reconcile handoff timing --- packages/toolkit/src/editor/useCollabReconcile.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/toolkit/src/editor/useCollabReconcile.ts b/packages/toolkit/src/editor/useCollabReconcile.ts index ec7d92dc4d..20772fa6b0 100644 --- a/packages/toolkit/src/editor/useCollabReconcile.ts +++ b/packages/toolkit/src/editor/useCollabReconcile.ts @@ -418,10 +418,18 @@ export function useCollabReconcile({ if (cancelled || editor.isDestroyed) return; // In collab mode, defer all reconcile until the shared doc is seeded so we // never setContent over an unseeded fragment. - if (collab && (!collabSynced || !seededRef.current)) { + if (collab && !collabSynced) { retry = setTimeout(() => apply(deferred), 300); return; } + // The seed decision itself is deferred one task so Collaboration can + // project persisted Y.Doc state before we inspect it. Poll that short + // handoff promptly: waiting the full provider cadence here makes a newer + // canonical SQL snapshot visibly stale after reload. + if (collab && !seededRef.current) { + retry = setTimeout(() => apply(deferred), 25); + return; + } if (collab && emptySnapshotDecisionPendingRef.current) { retry = setTimeout(() => apply(deferred), 50); return; From 1f16a3a736cc1dcdc501b8cdd96b3bd63956fd9b Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:55:59 -0400 Subject: [PATCH 09/13] fix collaboration awareness handoff --- .../useCollabReconcile.concurrent.spec.ts | 99 ++++++++++++++++--- .../toolkit/src/editor/useCollabReconcile.ts | 46 +++++---- .../editor/CommentsSidebar.layout.test.ts | 28 ++++++ .../app/components/editor/CommentsSidebar.tsx | 3 +- 4 files changed, 143 insertions(+), 33 deletions(-) diff --git a/packages/toolkit/src/editor/useCollabReconcile.concurrent.spec.ts b/packages/toolkit/src/editor/useCollabReconcile.concurrent.spec.ts index 0fb011d614..ad536e2166 100644 --- a/packages/toolkit/src/editor/useCollabReconcile.concurrent.spec.ts +++ b/packages/toolkit/src/editor/useCollabReconcile.concurrent.spec.ts @@ -4,7 +4,7 @@ import { Editor as CoreEditor } from "@tiptap/core"; import { useEditor, type Editor } from "@tiptap/react"; import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Awareness } from "y-protocols/awareness"; import * as Y from "yjs"; @@ -41,6 +41,7 @@ beforeEach(() => { afterEach(() => { act(() => root.unmount()); + vi.useRealTimers(); container.remove(); }); @@ -486,6 +487,7 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => { }); it("adopts a nonempty synced Y.Doc instead of reconciling an empty SQL snapshot", async () => { + vi.useFakeTimers(); const persistedYdoc = new Y.Doc(); const persistedEditor = new CoreEditor({ extensions: createRichMarkdownExtensions({ @@ -500,10 +502,6 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => { persistedYdoc.destroy(); const awareness = new Awareness(liveYdoc); - awareness.getStates().set(4_294_967_295, { - user: { name: "Active peer" }, - visible: true, - }); const setContentValues: string[] = []; let capturedEditor: Editor | null = null; @@ -531,13 +529,24 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => { } act(() => root.render(React.createElement(Probe))); - await flush(); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 350)); + await act(async () => vi.advanceTimersByTimeAsync(0)); + // The document state can finish syncing before the first awareness poll. + // Publish the active peer after mount to cover that real transport order. + act(() => { + awareness.getStates().set(4_294_967_295, { + user: { name: "Active peer" }, + visible: true, + }); + awareness.emit("change", [ + { added: [4_294_967_295], updated: [], removed: [] }, + "remote", + ]); }); + await act(async () => vi.advanceTimersByTimeAsync(2500)); expect(getEditorMarkdown(capturedEditor!)).toBe("live collaborator body"); expect(setContentValues).toEqual([]); + vi.useRealTimers(); awareness.destroy(); liveYdoc.destroy(); }); @@ -558,6 +567,7 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => { const setContentValues: string[] = []; let capturedEditor: Editor | null = null; + const awareness = new Awareness(liveYdoc); function Probe() { const editor = useEditor({ @@ -570,6 +580,7 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => { useCollabReconcile({ editor, ydoc: liveYdoc, + awareness, collabSynced: true, value: "", contentUpdatedAt: "2024-01-01T00:00:01.000Z", @@ -582,14 +593,78 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => { return React.createElement("div", null); } + vi.useFakeTimers(); act(() => root.render(React.createElement(Probe))); - await flush(); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 350)); - }); + await act(async () => vi.advanceTimersByTimeAsync(2499)); + expect(setContentValues).toEqual([]); + await act(async () => vi.advanceTimersByTimeAsync(51)); expect(setContentValues).toContain(""); expect(getEditorMarkdown(capturedEditor!)).toBe(""); + vi.useRealTimers(); + awareness.destroy(); + liveYdoc.destroy(); + }); + + it("preserves and permits a first local edit during the awareness settle window", async () => { + const persistedYdoc = new Y.Doc(); + const persistedEditor = new CoreEditor({ + extensions: createRichMarkdownExtensions({ + dialect: "gfm", + ydoc: persistedYdoc, + }), + }); + persistedEditor.commands.setContent("stale persisted body"); + const liveYdoc = new Y.Doc(); + Y.applyUpdate(liveYdoc, Y.encodeStateAsUpdate(persistedYdoc), "remote"); + persistedEditor.destroy(); + persistedYdoc.destroy(); + + const awareness = new Awareness(liveYdoc); + let capturedEditor: Editor | null = null; + let guards: ReturnType | null = null; + const setContentValues: string[] = []; + + function Probe() { + const editor = useEditor({ + extensions: createRichMarkdownExtensions({ + dialect: "gfm", + ydoc: liveYdoc, + }), + }); + capturedEditor = editor; + guards = useCollabReconcile({ + editor, + ydoc: liveYdoc, + awareness, + collabSynced: true, + value: "", + contentUpdatedAt: "2024-01-01T00:00:01.000Z", + editable: true, + setContent: (editorToSet, nextValue) => { + setContentValues.push(nextValue); + editorToSet.commands.setContent(nextValue); + }, + }); + return React.createElement("div", null); + } + + vi.useFakeTimers(); + act(() => root.render(React.createElement(Probe))); + await act(async () => vi.advanceTimersByTimeAsync(0)); + + expect(guards).not.toBeNull(); + expect(capturedEditor).not.toBeNull(); + expect(guards!.shouldIgnoreUpdate(capturedEditor!.state.tr)).toBe(false); + expect(guards!.registerEmitted("first local edit")).toBe(true); + act(() => capturedEditor!.commands.setContent("first local edit")); + + await act(async () => vi.advanceTimersByTimeAsync(2500)); + expect(getEditorMarkdown(capturedEditor!)).toBe("first local edit"); + expect(setContentValues).toEqual([]); + + vi.useRealTimers(); + awareness.destroy(); liveYdoc.destroy(); }); diff --git a/packages/toolkit/src/editor/useCollabReconcile.ts b/packages/toolkit/src/editor/useCollabReconcile.ts index 20772fa6b0..4bd1a71c4a 100644 --- a/packages/toolkit/src/editor/useCollabReconcile.ts +++ b/packages/toolkit/src/editor/useCollabReconcile.ts @@ -33,6 +33,11 @@ export function getEditorMarkdown(editor: Editor): string { * is identical, so skipping it is safe by construction. */ const EMITTED_RING_MAX = 16; +// The hosted awareness transport polls every 2s when its SSE gateway cannot +// forward presence. Give that first snapshot one poll plus margin before an +// empty SQL value is allowed to clear a nonempty Y.Doc. This is the same settle +// window used below when a known peer may deliver an edit through Yjs. +const PEER_SETTLE_MS = 2500; function pushEmittedRing(ring: string[], value: string): void { if (!value) return; if (ring[ring.length - 1] === value) return; @@ -317,25 +322,28 @@ export function useCollabReconcile({ emptySnapshotDecisionPendingRef.current = true; let cancelled = false; - const adoptTimer = setTimeout(() => { - if (cancelled || editor.isDestroyed) return; - const projectedMarkdown = getMarkdown(editor); - const isOwnFreshEdit = - projectedMarkdown.trim() && - (projectedMarkdown === lastEmittedRef.current || - recentEmittedRef.current.includes(projectedMarkdown)); - if ( - projectedMarkdown.trim() && - (peerCountRef.current > 0 || isOwnFreshEdit) - ) { - lastAppliedValueRef.current = value; - lastAppliedSerializedRef.current = projectedMarkdown; - if (contentUpdatedAt) { - lastAppliedUpdatedAtRef.current = contentUpdatedAt; + const adoptTimer = setTimeout( + () => { + if (cancelled || editor.isDestroyed) return; + const projectedMarkdown = getMarkdown(editor); + const isOwnFreshEdit = + projectedMarkdown.trim() && + (projectedMarkdown === lastEmittedRef.current || + recentEmittedRef.current.includes(projectedMarkdown)); + if ( + projectedMarkdown.trim() && + (peerCountRef.current > 0 || isOwnFreshEdit) + ) { + lastAppliedValueRef.current = value; + lastAppliedSerializedRef.current = projectedMarkdown; + if (contentUpdatedAt) { + lastAppliedUpdatedAtRef.current = contentUpdatedAt; + } } - } - emptySnapshotDecisionPendingRef.current = false; - }, 0); + emptySnapshotDecisionPendingRef.current = false; + }, + awareness ? PEER_SETTLE_MS : 0, + ); return () => { cancelled = true; clearTimeout(adoptTimer); @@ -412,8 +420,6 @@ export function useCollabReconcile({ // With peers present, a peer's edit also arrives via Yjs. Defer one poll // cycle (+margin) and re-check before applying via setContent so the same // change isn't inserted twice (Yjs + setContent → duplicated region). - const PEER_SETTLE_MS = 2500; - const apply = (deferred = false) => { if (cancelled || editor.isDestroyed) return; // In collab mode, defer all reconcile until the shared doc is seeded so we diff --git a/templates/content/app/components/editor/CommentsSidebar.layout.test.ts b/templates/content/app/components/editor/CommentsSidebar.layout.test.ts index e2e558caaf..b7cf18a474 100644 --- a/templates/content/app/components/editor/CommentsSidebar.layout.test.ts +++ b/templates/content/app/components/editor/CommentsSidebar.layout.test.ts @@ -131,6 +131,34 @@ describe("comments sidebar layout", () => { expect(items[1].isOrphaned).toBe(true); }); + it("separates layout-unanchored threads from the anchored rail section", () => { + const anchored = { + threadId: "anchored", + comments: [{ id: "anchored-comment" }], + } as CommentThread; + const unanchored = { + threadId: "unanchored", + comments: [{ id: "unanchored-comment" }], + } as CommentThread; + const positions = new Map([ + ["anchored", { documentTop: 100, layoutTop: 100 }], + ["unanchored", { documentTop: 200, layoutTop: null }], + ]); + + const items = layoutCommentThreads( + [anchored, unanchored], + positions, + new Map([ + ["anchored", 80], + ["unanchored", 80], + ]), + null, + ); + + expect(items.map((item) => item.top)).toEqual([100, 212]); + expect(items[1].marginTop).toBe(32); + }); + it("bounds explicit anchor navigation inside the document scroller", () => { const scroll = document.createElement("div"); Object.defineProperty(scroll, "scrollHeight", { value: 1000 }); diff --git a/templates/content/app/components/editor/CommentsSidebar.tsx b/templates/content/app/components/editor/CommentsSidebar.tsx index 08cba5cbe9..c2d5931d84 100644 --- a/templates/content/app/components/editor/CommentsSidebar.tsx +++ b/templates/content/app/components/editor/CommentsSidebar.tsx @@ -253,7 +253,8 @@ export function layoutCommentThreads( 0, ); for (const thread of sequential) { - const sectionGap = positions.has(thread.threadId) ? gap : gap + 20; + const sectionGap = + positions.get(thread.threadId)?.layoutTop != null ? gap : gap + 20; const top = cursor === 0 ? 0 : cursor + sectionGap; tops.set(thread.threadId, top); cursor = top + heightFor(thread); From 47b761ca6f348ecccbf07abd383ed7405a3e2070 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:44:28 -0400 Subject: [PATCH 10/13] Stabilize A2A poll deadline test --- packages/core/src/a2a/client.spec.ts | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/core/src/a2a/client.spec.ts b/packages/core/src/a2a/client.spec.ts index 2558d8f63b..06557b630d 100644 --- a/packages/core/src/a2a/client.spec.ts +++ b/packages/core/src/a2a/client.spec.ts @@ -173,12 +173,9 @@ describe("A2AClient", () => { { role: "user", parts: [{ type: "text", text: "hello" }] }, { timeoutMs: 5_000, pollIntervalMs: 1_000 }, ); - const assertion = expect(result).rejects.toMatchObject({ - name: "A2ATaskTimeoutError", - taskId: "task-hung-poll", - lastState: "working", - timeoutMs: 5_000, - }); + // Attach a handler before advancing timers so the intentional rejection is + // never reported as unhandled while the fake clock is moving. + void result.catch(() => undefined); const hasTaskRead = () => fetchMock.mock.calls.some( @@ -186,9 +183,20 @@ describe("A2AClient", () => { init?.method === "POST" && JSON.parse(String(init.body)).method === "tasks/get", ); - while (!hasTaskRead()) await vi.advanceTimersByTimeAsync(1); - await vi.advanceTimersByTimeAsync(5_000); - await assertion; + // waitFor advances fake time in coarse intervals. Stepping the clock 1ms at + // a time performs 1,000 async flushes and can exceed Vitest's real 5s test + // timeout when the full suite is under load. + await vi.waitFor(() => expect(hasTaskRead()).toBe(true), { + interval: 100, + timeout: 5_000, + }); + await vi.runAllTimersAsync(); + await expect(result).rejects.toMatchObject({ + name: "A2ATaskTimeoutError", + taskId: "task-hung-poll", + lastState: "working", + timeoutMs: 5_000, + }); expect(hasTaskRead()).toBe(true); expect( fetchMock.mock.calls.find( From a58bc089cb7af61b1a1302f12a6bcb135a12259a Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:45:34 -0400 Subject: [PATCH 11/13] Add Core changeset for A2A test stability --- .changeset/calm-clocks-wait.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/calm-clocks-wait.md diff --git a/.changeset/calm-clocks-wait.md b/.changeset/calm-clocks-wait.md new file mode 100644 index 0000000000..b688cc509f --- /dev/null +++ b/.changeset/calm-clocks-wait.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Stabilize A2A polling deadline coverage under full-suite load. From 74bbbc18369fc8aa958ac12364be35ea933c0dc5 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:16:10 -0400 Subject: [PATCH 12/13] Stop document flush request storms --- .../editor/CommentsSidebar.layout.test.ts | 14 ++++ .../app/components/editor/CommentsSidebar.tsx | 77 ++++++++++++------- .../editor/DocumentEditor.layout.test.ts | 7 ++ .../app/components/editor/DocumentEditor.tsx | 9 ++- ...ay-open-when-saving-fails-and-long-live.md | 6 ++ 5 files changed, 84 insertions(+), 29 deletions(-) create mode 100644 templates/content/changelog/2026-07-24-comment-drafts-now-stay-open-when-saving-fails-and-long-live.md diff --git a/templates/content/app/components/editor/CommentsSidebar.layout.test.ts b/templates/content/app/components/editor/CommentsSidebar.layout.test.ts index b7cf18a474..b60d178c2c 100644 --- a/templates/content/app/components/editor/CommentsSidebar.layout.test.ts +++ b/templates/content/app/components/editor/CommentsSidebar.layout.test.ts @@ -193,6 +193,20 @@ describe("comments sidebar layout", () => { expect(source).toContain(": undefined"); }); + it("keeps comment drafts open until their mutation succeeds", () => { + const source = readFileSync("app/components/editor/CommentsSidebar.tsx", { + encoding: "utf8", + }); + + expect(source).toContain("createComment.isPending"); + expect(source).toContain("onSuccess: () => {"); + expect(source).toContain("onError: (error) => {"); + expect(source).toContain('toast.error(t("empty.genericError")'); + expect(source).toMatch( + /createComment\.mutate\([\s\S]*?onSuccess: \(\) => \{[\s\S]*?setPendingText\(""\)/, + ); + }); + it("keeps card height estimates based on the thread reply count", () => { const thread = { comments: [{ id: "root" }, { id: "reply" }], diff --git a/templates/content/app/components/editor/CommentsSidebar.tsx b/templates/content/app/components/editor/CommentsSidebar.tsx index c2d5931d84..e6fa7bd5e5 100644 --- a/templates/content/app/components/editor/CommentsSidebar.tsx +++ b/templates/content/app/components/editor/CommentsSidebar.tsx @@ -17,6 +17,7 @@ import { useCallback, type RefObject, } from "react"; +import { toast } from "sonner"; import { Tooltip, @@ -366,20 +367,31 @@ export function CommentsSidebar({ }, [pendingComment]); const handlePendingSubmit = () => { - if (!pendingText.trim()) return; - createComment.mutate({ - documentId, - content: pendingText.trim(), - quotedText: pendingComment?.quotedText, - anchorPrefix: pendingComment?.anchor?.prefix, - anchorSuffix: pendingComment?.anchor?.suffix, - anchorStartOffset: pendingComment?.anchor?.startOffset, - authorName, - mentions: mentionsJsonFor(pendingText, pendingMentions), - }); - setPendingText(""); - setPendingMentions([]); - onPendingDone?.(); + if (!pendingText.trim() || createComment.isPending) return; + createComment.mutate( + { + documentId, + content: pendingText.trim(), + quotedText: pendingComment?.quotedText, + anchorPrefix: pendingComment?.anchor?.prefix, + anchorSuffix: pendingComment?.anchor?.suffix, + anchorStartOffset: pendingComment?.anchor?.startOffset, + authorName, + mentions: mentionsJsonFor(pendingText, pendingMentions), + }, + { + onSuccess: () => { + setPendingText(""); + setPendingMentions([]); + onPendingDone?.(); + }, + onError: (error) => { + toast.error(t("empty.genericError"), { + description: error.message, + }); + }, + }, + ); }; const handlePendingCancel = () => { @@ -389,19 +401,30 @@ export function CommentsSidebar({ }; const handleReply = (threadId: string) => { - if (!replyText.trim()) return; + if (!replyText.trim() || createComment.isPending) return; const thread = threads?.find((t) => t.threadId === threadId); - createComment.mutate({ - documentId, - content: replyText.trim(), - threadId, - parentId: thread?.comments[0]?.id, - authorName, - mentions: mentionsJsonFor(replyText, replyMentions), - }); - setReplyText(""); - setReplyMentions([]); - setReplyingThreadId(null); + createComment.mutate( + { + documentId, + content: replyText.trim(), + threadId, + parentId: thread?.comments[0]?.id, + authorName, + mentions: mentionsJsonFor(replyText, replyMentions), + }, + { + onSuccess: () => { + setReplyText(""); + setReplyMentions([]); + setReplyingThreadId(null); + }, + onError: (error) => { + toast.error(t("empty.genericError"), { + description: error.message, + }); + }, + }, + ); }; const handleSendToAI = (thread: CommentThread) => { @@ -627,7 +650,7 @@ export function CommentsSidebar({