{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({