From 7f0ab56a0d6756fb011e0e9756a545f5fdb156ea Mon Sep 17 00:00:00 2001 From: Alice Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:04:42 +0000 Subject: [PATCH 01/15] feat: add reviewable suggested edits --- .changeset/calm-pages-suggest.md | 5 + packages/core/package.json | 7 + packages/core/src/client/review/index.ts | 6 + packages/core/src/client/review/use-review.ts | 57 +++ packages/core/src/collab/index.ts | 2 + packages/core/src/collab/storage.spec.ts | 45 +- packages/core/src/collab/storage.ts | 29 +- packages/core/src/collab/ydoc-manager.spec.ts | 58 ++- packages/core/src/collab/ydoc-manager.ts | 78 ++++ packages/core/src/framework-tools.ts | 7 + .../review/actions/react-to-review-comment.ts | 42 ++ .../review/actions/set-review-thread-muted.ts | 37 ++ .../actions/set-review-thread-unread.ts | 36 ++ packages/core/src/review/index.ts | 14 + packages/core/src/review/store.ts | 88 +++- .../core/src/review/suggestions/actions.ts | 379 +++++++++++++++++ .../actions/create-resource-suggestion.ts | 1 + .../actions/decide-resource-suggestion.ts | 1 + .../actions/get-resource-suggestion.ts | 1 + .../actions/list-resource-suggestions.ts | 1 + .../src/review/suggestions/registry.spec.ts | 31 ++ .../core/src/review/suggestions/registry.ts | 32 ++ .../core/src/review/suggestions/store.spec.ts | 129 ++++++ packages/core/src/review/suggestions/store.ts | 304 ++++++++++++++ packages/core/src/review/suggestions/types.ts | 83 ++++ packages/core/src/server/action-discovery.ts | 31 ++ .../core/src/server/app-sync-state.spec.ts | 30 ++ packages/core/src/server/index.ts | 2 + packages/core/src/server/poll.ts | 103 +++++ packages/core/src/server/release-schema.ts | 7 + packages/core/src/vite/action-types-plugin.ts | 32 ++ .../.agents/skills/document-editing/SKILL.md | 25 ++ templates/content/AGENTS.md | 3 +- templates/content/actions/_database-utils.ts | 1 + templates/content/actions/update-document.ts | 94 +++-- templates/content/actions/view-screen.ts | 4 + .../app/components/editor/CommentsSidebar.tsx | 98 ++++- .../editor/DocumentEditor.layout.test.ts | 4 +- .../app/components/editor/DocumentEditor.tsx | 190 ++++++++- .../app/components/editor/DocumentToolbar.tsx | 35 ++ .../editor/suggestions/controller.test.ts | 59 +++ .../editor/suggestions/controller.ts | 52 +++ .../suggestions/editor-isolation.test.ts | 31 ++ .../editor/suggestions/editor-isolation.ts | 18 + .../suggestions/markdown-operation.test.ts | 27 ++ .../editor/suggestions/markdown-operation.ts | 64 +++ .../editor/suggestions/model.test.ts | 103 +++++ .../components/editor/suggestions/model.ts | 263 ++++++++++++ templates/content/app/i18n-data.ts | 105 ++++- templates/content/app/i18n/zh-TW.ts | 7 + ...02-content-suggested-edits-parity-shape.md | 395 ++++++++++++++++++ .../suggested-edits-accept-reject.png | Bin 0 -> 63977 bytes .../lib/canonical-document-body-mutation.ts | 8 + .../server/lib/suggested-edits.spec.ts | 260 ++++++++++++ .../content/server/lib/suggested-edits.ts | 353 ++++++++++++++++ .../content/server/plugins/feature-flags.ts | 5 + .../content/server/plugins/suggested-edits.ts | 7 + templates/content/shared/api.ts | 1 + templates/content/shared/feature-flags.ts | 9 + 59 files changed, 3828 insertions(+), 71 deletions(-) create mode 100644 .changeset/calm-pages-suggest.md create mode 100644 packages/core/src/review/actions/react-to-review-comment.ts create mode 100644 packages/core/src/review/actions/set-review-thread-muted.ts create mode 100644 packages/core/src/review/actions/set-review-thread-unread.ts create mode 100644 packages/core/src/review/suggestions/actions.ts create mode 100644 packages/core/src/review/suggestions/actions/create-resource-suggestion.ts create mode 100644 packages/core/src/review/suggestions/actions/decide-resource-suggestion.ts create mode 100644 packages/core/src/review/suggestions/actions/get-resource-suggestion.ts create mode 100644 packages/core/src/review/suggestions/actions/list-resource-suggestions.ts create mode 100644 packages/core/src/review/suggestions/registry.spec.ts create mode 100644 packages/core/src/review/suggestions/registry.ts create mode 100644 packages/core/src/review/suggestions/store.spec.ts create mode 100644 packages/core/src/review/suggestions/store.ts create mode 100644 packages/core/src/review/suggestions/types.ts create mode 100644 templates/content/app/components/editor/suggestions/controller.test.ts create mode 100644 templates/content/app/components/editor/suggestions/controller.ts create mode 100644 templates/content/app/components/editor/suggestions/editor-isolation.test.ts create mode 100644 templates/content/app/components/editor/suggestions/editor-isolation.ts create mode 100644 templates/content/app/components/editor/suggestions/markdown-operation.test.ts create mode 100644 templates/content/app/components/editor/suggestions/markdown-operation.ts create mode 100644 templates/content/app/components/editor/suggestions/model.test.ts create mode 100644 templates/content/app/components/editor/suggestions/model.ts create mode 100644 templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md create mode 100644 templates/content/docs/solutions/evidence/suggested-edits-accept-reject.png create mode 100644 templates/content/server/lib/canonical-document-body-mutation.ts create mode 100644 templates/content/server/lib/suggested-edits.spec.ts create mode 100644 templates/content/server/lib/suggested-edits.ts create mode 100644 templates/content/server/plugins/feature-flags.ts create mode 100644 templates/content/server/plugins/suggested-edits.ts create mode 100644 templates/content/shared/feature-flags.ts diff --git a/.changeset/calm-pages-suggest.md b/.changeset/calm-pages-suggest.md new file mode 100644 index 00000000000..eb319af2229 --- /dev/null +++ b/.changeset/calm-pages-suggest.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Add a reusable, action-backed executable suggestion lifecycle for reviewable resources. diff --git a/packages/core/package.json b/packages/core/package.json index c6d5cf87dcc..638fc622b88 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -247,6 +247,13 @@ "./review/actions/get-review-feedback": "./dist/review/actions/get-review-feedback.js", "./review/actions/set-review-status": "./dist/review/actions/set-review-status.js", "./review/actions/send-review-thread-to-agent": "./dist/review/actions/send-review-thread-to-agent.js", + "./review/actions/react-to-review-comment": "./dist/review/actions/react-to-review-comment.js", + "./review/actions/set-review-thread-unread": "./dist/review/actions/set-review-thread-unread.js", + "./review/actions/set-review-thread-muted": "./dist/review/actions/set-review-thread-muted.js", + "./review/suggestions/actions/create-resource-suggestion": "./dist/review/suggestions/actions/create-resource-suggestion.js", + "./review/suggestions/actions/list-resource-suggestions": "./dist/review/suggestions/actions/list-resource-suggestions.js", + "./review/suggestions/actions/get-resource-suggestion": "./dist/review/suggestions/actions/get-resource-suggestion.js", + "./review/suggestions/actions/decide-resource-suggestion": "./dist/review/suggestions/actions/decide-resource-suggestion.js", "./comments-review/actions/list-review-comments": "./dist/review/actions/list-review-comments.js", "./comments-review/actions/create-review-comment": "./dist/review/actions/create-review-comment.js", "./comments-review/actions/reply-review-comment": "./dist/review/actions/reply-review-comment.js", diff --git a/packages/core/src/client/review/index.ts b/packages/core/src/client/review/index.ts index 152ef3b58f7..9f6143c5362 100644 --- a/packages/core/src/client/review/index.ts +++ b/packages/core/src/client/review/index.ts @@ -24,6 +24,9 @@ export { useReviewFeedback, useSetReviewStatus, useSendReviewThreadToAgent, + useResourceSuggestions, + useCreateResourceSuggestion, + useDecideResourceSuggestion, type ConsumeReviewFeedbackInput, type CreateReviewCommentInput, type DeleteReviewCommentInput, @@ -35,4 +38,7 @@ export { type ResolveReviewThreadInput, type SetReviewStatusInput, type SendReviewThreadToAgentInput, + type ListResourceSuggestionsParams, + type CreateResourceSuggestionInput, + type DecideResourceSuggestionInput, } from "./use-review.js"; diff --git a/packages/core/src/client/review/use-review.ts b/packages/core/src/client/review/use-review.ts index 50f534da5a4..0907df54793 100644 --- a/packages/core/src/client/review/use-review.ts +++ b/packages/core/src/client/review/use-review.ts @@ -1,3 +1,9 @@ +import type { + ResourceSuggestion, + SuggestionDecision, + SuggestionOperation, + SuggestionStatus, +} from "../../review/suggestions/types.js"; import type { ReviewComment, ReviewCommentKind, @@ -95,6 +101,30 @@ export interface SetReviewStatusInput { metadata?: Record; } +export interface ListResourceSuggestionsParams { + resourceType: string; + resourceId: string; + statuses?: SuggestionStatus[]; +} + +export interface CreateResourceSuggestionInput { + resourceType: string; + resourceId: string; + adapterKind: string; + baseRevision: string; + summary: string; + idempotencyKey: string; + operations: SuggestionOperation[]; + metadata?: Record; +} + +export interface DecideResourceSuggestionInput { + id: string; + decision: SuggestionDecision; + idempotencyKey: string; + observedBase: string; +} + export function useReviewComments( params: ListReviewCommentsParams, options?: { enabled?: boolean }, @@ -184,3 +214,30 @@ export function useSetReviewStatus() { "set-review-status", ); } + +export function useResourceSuggestions( + params: ListResourceSuggestionsParams, + options?: { enabled?: boolean }, +) { + return useActionQuery<{ suggestions: ResourceSuggestion[] }>( + "list-resource-suggestions", + params, + { + enabled: + options?.enabled ?? Boolean(params.resourceType && params.resourceId), + }, + ); +} + +export function useCreateResourceSuggestion() { + return useActionMutation( + "create-resource-suggestion", + ); +} + +export function useDecideResourceSuggestion() { + return useActionMutation< + { suggestion: ResourceSuggestion; decision: unknown }, + DecideResourceSuggestionInput + >("decide-resource-suggestion"); +} diff --git a/packages/core/src/collab/index.ts b/packages/core/src/collab/index.ts index 5b60874d3aa..be2e6e855f9 100644 --- a/packages/core/src/collab/index.ts +++ b/packages/core/src/collab/index.ts @@ -14,6 +14,7 @@ export { export { CollabBaseVersionConflictError, getDoc, + withPreparedYDocMutation, applyUpdate, applyText, getText, @@ -26,6 +27,7 @@ export { applyPatchOps, getJson, seedFromJson, + type PreparedYDocMutationLease, } from "./ydoc-manager.js"; // XmlFragment operations diff --git a/packages/core/src/collab/storage.spec.ts b/packages/core/src/collab/storage.spec.ts index 08a395e364b..1ab9efae0b6 100644 --- a/packages/core/src/collab/storage.spec.ts +++ b/packages/core/src/collab/storage.spec.ts @@ -1,10 +1,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { getDbExec } from "../db/client.js"; import { listCollabDocIds, loadYDocRecord, + loadYDocRecordWithClient, saveYDocState, trySaveYDocState, + trySaveYDocStateWithClient, } from "./storage.js"; const rows = vi.hoisted( @@ -20,7 +23,7 @@ function toBase64(arr: Uint8Array): string { } vi.mock("../db/client.js", () => ({ - getDbExec: () => ({ + getDbExec: vi.fn(() => ({ execute: async (query: string | { sql: string; args?: unknown[] }) => { const sql = typeof query === "string" ? query : query.sql; const args = typeof query === "string" ? [] : (query.args ?? []); @@ -70,7 +73,7 @@ vi.mock("../db/client.js", () => ({ throw new Error(`Unexpected SQL: ${sql}`); }, - }), + })), isPostgres: () => false, })); @@ -113,4 +116,42 @@ describe("collab storage optimistic saves", () => { expect(await listCollabDocIds()).toEqual(new Set(["doc-1", "doc-2"])); }); + + it("uses an injected client for reads and stale CAS writes", async () => { + vi.mocked(getDbExec).mockClear(); + const injectedClient = { + execute: vi.fn( + async (query: string | { sql: string; args?: unknown[] }) => { + const sql = typeof query === "string" ? query : query.sql; + const args = typeof query === "string" ? [] : (query.args ?? []); + if (/^\s*SELECT yjs_state, version FROM _collab_docs/i.test(sql)) { + return { + rows: [{ yjs_state: toBase64(new Uint8Array([1])), version: 2 }], + rowsAffected: 0, + }; + } + if (/^\s*UPDATE _collab_docs\b/i.test(sql)) { + return { rows: [], rowsAffected: 0 }; + } + throw new Error(`Unexpected SQL: ${sql}`); + }, + ), + }; + + expect(await loadYDocRecordWithClient(injectedClient, "doc-1")).toEqual({ + state: new Uint8Array([1]), + version: 2, + }); + expect( + await trySaveYDocStateWithClient( + injectedClient, + "doc-1", + new Uint8Array([2]), + "two", + 1, + ), + ).toBe(false); + expect(injectedClient.execute).toHaveBeenCalledTimes(2); + expect(getDbExec).not.toHaveBeenCalled(); + }); }); diff --git a/packages/core/src/collab/storage.ts b/packages/core/src/collab/storage.ts index 2469148741e..a19dbb1ff85 100644 --- a/packages/core/src/collab/storage.ts +++ b/packages/core/src/collab/storage.ts @@ -5,7 +5,7 @@ * encoding for binary Yjs state) that works across SQLite and Postgres. */ -import { getDbExec, isPostgres } from "../db/client.js"; +import { getDbExec, isPostgres, type DbExec } from "../db/client.js"; import { ensureTableExists, ensureColumnExists } from "../db/ddl-guard.js"; let _initPromise: Promise | undefined; @@ -102,7 +102,14 @@ export async function loadYDocRecord( docId: string, ): Promise { await ensureTable(); - const client = getDbExec(); + return loadYDocRecordWithClient(getDbExec(), docId); +} + +/** Load through a caller-owned transaction after schema readiness is ensured. */ +export async function loadYDocRecordWithClient( + client: DbExec, + docId: string, +): Promise { const { rows } = await client.execute({ sql: `SELECT yjs_state, version FROM _collab_docs WHERE doc_id = ?`, args: [docId], @@ -151,7 +158,23 @@ export async function trySaveYDocState( expectedVersion: number | null, ): Promise { await ensureTable(); - const client = getDbExec(); + return trySaveYDocStateWithClient( + getDbExec(), + docId, + state, + textSnapshot, + expectedVersion, + ); +} + +/** CAS-save through a caller-owned transaction after schema readiness. */ +export async function trySaveYDocStateWithClient( + client: DbExec, + docId: string, + state: Uint8Array, + textSnapshot: string, + expectedVersion: number | null, +): Promise { const b64 = uint8ArrayToBase64(state); const nowExpr = isPostgres() ? "NOW()::text" : "datetime('now')"; if (expectedVersion === null) { diff --git a/packages/core/src/collab/ydoc-manager.spec.ts b/packages/core/src/collab/ydoc-manager.spec.ts index b7531ad2893..8cbf72c82a2 100644 --- a/packages/core/src/collab/ydoc-manager.spec.ts +++ b/packages/core/src/collab/ydoc-manager.spec.ts @@ -6,8 +6,11 @@ const storageMocks = vi.hoisted(() => ({ loadYDocVersion: vi.fn(), saveYDocState: vi.fn(), trySaveYDocState: vi.fn(), + trySaveYDocStateWithClient: vi.fn(), })); +const emitterMocks = vi.hoisted(() => ({ emitCollabUpdate: vi.fn() })); + vi.mock("./storage.js", () => ({ ...storageMocks, uint8ArrayToBase64: (value: Uint8Array) => @@ -15,7 +18,7 @@ vi.mock("./storage.js", () => ({ })); vi.mock("./emitter.js", () => ({ - emitCollabUpdate: vi.fn(), + emitCollabUpdate: emitterMocks.emitCollabUpdate, })); describe("ydoc-manager", () => { @@ -24,8 +27,10 @@ describe("ydoc-manager", () => { storageMocks.loadYDocRecord.mockReset(); storageMocks.saveYDocState.mockReset(); storageMocks.trySaveYDocState.mockReset(); + storageMocks.trySaveYDocStateWithClient.mockReset(); storageMocks.loadYDocState.mockReset(); storageMocks.loadYDocVersion.mockReset(); + emitterMocks.emitCollabUpdate.mockReset(); }); it("coalesces concurrent cache-miss loads for the same document", async () => { @@ -47,4 +52,55 @@ describe("ydoc-manager", () => { expect(first).toBe(second); expect(storageMocks.loadYDocRecord).toHaveBeenCalledTimes(1); }); + + it("publishes a prepared clone only after transactional persistence", async () => { + storageMocks.loadYDocRecord.mockResolvedValue(null); + storageMocks.loadYDocVersion.mockResolvedValue(null); + storageMocks.trySaveYDocStateWithClient.mockResolvedValue(true); + const tx = { execute: vi.fn() }; + const { getDoc, withPreparedYDocMutation } = + await import("./ydoc-manager.js"); + + await withPreparedYDocMutation("prepared-doc", "agent", async (lease) => { + lease.doc.getText("content").insert(0, "accepted"); + expect(emitterMocks.emitCollabUpdate).not.toHaveBeenCalled(); + await lease.persist(tx, "accepted"); + expect(emitterMocks.emitCollabUpdate).not.toHaveBeenCalled(); + }); + + expect(storageMocks.trySaveYDocStateWithClient).toHaveBeenCalledWith( + tx, + "prepared-doc", + expect.any(Uint8Array), + "accepted", + null, + ); + expect(emitterMocks.emitCollabUpdate).toHaveBeenCalledOnce(); + expect((await getDoc("prepared-doc")).getText("content").toString()).toBe( + "accepted", + ); + }); + + it("discards a prepared clone when the caller transaction rolls back", async () => { + storageMocks.loadYDocRecord.mockResolvedValue(null); + storageMocks.loadYDocVersion.mockResolvedValue(null); + storageMocks.trySaveYDocStateWithClient.mockResolvedValue(true); + const { getDoc, withPreparedYDocMutation } = + await import("./ydoc-manager.js"); + const current = await getDoc("rollback-doc"); + current.getText("content").insert(0, "before"); + + await expect( + withPreparedYDocMutation("rollback-doc", "agent", async (lease) => { + lease.doc.getText("content").insert(6, "-candidate"); + await lease.persist({ execute: vi.fn() }, "before-candidate"); + throw new Error("rollback"); + }), + ).rejects.toThrow("rollback"); + + expect(emitterMocks.emitCollabUpdate).not.toHaveBeenCalled(); + expect((await getDoc("rollback-doc")).getText("content").toString()).toBe( + "before", + ); + }); }); diff --git a/packages/core/src/collab/ydoc-manager.ts b/packages/core/src/collab/ydoc-manager.ts index a88c0b5047a..bb1c8d4f02b 100644 --- a/packages/core/src/collab/ydoc-manager.ts +++ b/packages/core/src/collab/ydoc-manager.ts @@ -21,6 +21,7 @@ import * as Y from "yjs"; +import type { DbExec } from "../db/client.js"; import { emitCollabUpdate } from "./emitter.js"; import { applyJsonDiff, @@ -35,6 +36,7 @@ import { loadYDocVersion, saveYDocState, trySaveYDocState, + trySaveYDocStateWithClient, } from "./storage.js"; import { uint8ArrayToBase64 } from "./storage.js"; import { applyTextToYDoc, initYDocWithText } from "./text-to-yjs.js"; @@ -138,6 +140,13 @@ const _refreshLocks = new Map>(); // doc (a memory leak that grows with concurrent read traffic). const _loadLocks = new Map>(); +export interface PreparedYDocMutationLease { + /** Isolated clone. Mutations stay invisible until the outer transaction commits. */ + doc: Y.Doc; + baseVersion: number | null; + persist(transaction: DbExec, textSnapshot: string): Promise; +} + function evictIfNeeded(): void { if (_cache.size <= MAX_CACHE) return; // Evict least-recently-accessed entry @@ -179,6 +188,75 @@ async function withDocWriteLock( } } +/** + * Serialize a caller-owned SQL transaction with Yjs writes. The callback + * mutates an isolated clone and persists it through its transaction. Only a + * successfully resolved callback replaces the shared cache and broadcasts. + */ +export async function withPreparedYDocMutation( + docId: string, + requestSource: string | undefined, + run: (lease: PreparedYDocMutationLease) => Promise, +): Promise { + return withDocWriteLock(docId, async () => { + const current = await getDocForWrite(docId); + const latest = await loadYDocRecord(docId); + if (latest?.state.length) Y.applyUpdate(current, latest.state); + + const candidate = new Y.Doc(); + Y.applyUpdate(candidate, Y.encodeStateAsUpdate(current)); + const baseVector = Y.encodeStateVector(candidate); + const baseVersion = latest?.version ?? null; + let persisted = false; + const lease: PreparedYDocMutationLease = { + doc: candidate, + baseVersion, + persist: async (transaction, textSnapshot) => { + if (persisted) { + throw new Error("Prepared Yjs mutation was already persisted"); + } + const saved = await trySaveYDocStateWithClient( + transaction, + docId, + Y.encodeStateAsUpdate(candidate), + textSnapshot, + baseVersion, + ); + if (!saved) { + throw new CollabBaseVersionConflictError( + `Document ${docId} changed while the prepared mutation was committing.`, + ); + } + persisted = true; + }, + }; + + try { + const result = await run(lease); + if (!persisted) { + candidate.destroy(); + return result; + } + const update = Y.encodeStateAsUpdate(candidate, baseVector); + const previous = _cache.get(docId); + previous?.doc.destroy(); + _cache.set(docId, { + doc: candidate, + lastAccess: Date.now(), + syncedVersion: baseVersion === null ? 0 : baseVersion + 1, + }); + evictIfNeeded(); + if (update.length > 0) { + emitCollabUpdate(docId, uint8ArrayToBase64(update), requestSource); + } + return result; + } catch (error) { + candidate.destroy(); + throw error; + } + }); +} + /** * Build state to persist. If the stored blob is significantly larger than * the freshly encoded state, store the compact (GC'd) form instead to diff --git a/packages/core/src/framework-tools.ts b/packages/core/src/framework-tools.ts index 5ed9237298c..c18318676d7 100644 --- a/packages/core/src/framework-tools.ts +++ b/packages/core/src/framework-tools.ts @@ -326,6 +326,13 @@ export const CORE_ACTION_GROUPS: Record = { "get-review-feedback": "review", "set-review-status": "review", "send-review-thread-to-agent": "review", + "react-to-review-comment": "review", + "set-review-thread-unread": "review", + "set-review-thread-muted": "review", + "create-resource-suggestion": "review", + "list-resource-suggestions": "review", + "get-resource-suggestion": "review", + "decide-resource-suggestion": "review", }; /** Structural view of the one field these helpers read, so tagging utilities diff --git a/packages/core/src/review/actions/react-to-review-comment.ts b/packages/core/src/review/actions/react-to-review-comment.ts new file mode 100644 index 00000000000..a343336ae06 --- /dev/null +++ b/packages/core/src/review/actions/react-to-review-comment.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; + +import { defineAction } from "../../action.js"; +import { assertReviewableResourceAccess } from "../registry.js"; +import { getReviewCommentById, setReviewCommentReaction } from "../store.js"; + +export default defineAction({ + description: "Add or remove your reaction on a review comment.", + schema: z.object({ + resourceType: z.string().min(1), + resourceId: z.string().min(1), + commentId: z.string().min(1), + reaction: z.string().trim().min(1).max(32), + active: z.boolean(), + }), + run: async (args, ctx) => { + await assertReviewableResourceAccess( + args.resourceType, + args.resourceId, + ctx as any, + "commenter", + ); + const comment = await getReviewCommentById(args.commentId, { + userEmail: (ctx as any)?.userEmail, + orgId: (ctx as any)?.orgId, + }); + if ( + !comment || + comment.resourceType !== args.resourceType || + comment.resourceId !== args.resourceId + ) + throw new Error("Review comment not found"); + const actorEmail = (ctx as any)?.userEmail; + if (!actorEmail) throw new Error("A signed-in actor is required"); + return setReviewCommentReaction({ + commentId: comment.id, + actorEmail, + reaction: args.reaction, + active: args.active, + }); + }, +}); diff --git a/packages/core/src/review/actions/set-review-thread-muted.ts b/packages/core/src/review/actions/set-review-thread-muted.ts new file mode 100644 index 00000000000..2a0278586b9 --- /dev/null +++ b/packages/core/src/review/actions/set-review-thread-muted.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +import { defineAction } from "../../action.js"; +import { assertReviewableResourceAccess } from "../registry.js"; +import { getReviewThreadRoot, setReviewThreadPreference } from "../store.js"; + +export default defineAction({ + description: + "Mute or unmute replies on a review thread for the current user.", + schema: z.object({ + resourceType: z.string().min(1), + resourceId: z.string().min(1), + threadId: z.string().min(1), + muted: z.boolean(), + }), + run: async (args, ctx) => { + await assertReviewableResourceAccess( + args.resourceType, + args.resourceId, + ctx as any, + "viewer", + ); + const root = await getReviewThreadRoot( + args.threadId, + { resourceType: args.resourceType, resourceId: args.resourceId }, + { userEmail: (ctx as any)?.userEmail, orgId: (ctx as any)?.orgId }, + ); + if (!root) throw new Error("Review thread not found"); + const userEmail = (ctx as any)?.userEmail; + if (!userEmail) throw new Error("A signed-in user is required"); + return setReviewThreadPreference({ + threadId: args.threadId, + userEmail, + muted: args.muted, + }); + }, +}); diff --git a/packages/core/src/review/actions/set-review-thread-unread.ts b/packages/core/src/review/actions/set-review-thread-unread.ts new file mode 100644 index 00000000000..4ecfc7eb462 --- /dev/null +++ b/packages/core/src/review/actions/set-review-thread-unread.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; + +import { defineAction } from "../../action.js"; +import { assertReviewableResourceAccess } from "../registry.js"; +import { getReviewThreadRoot, setReviewThreadPreference } from "../store.js"; + +export default defineAction({ + description: "Mark a review thread unread or read for the current user.", + schema: z.object({ + resourceType: z.string().min(1), + resourceId: z.string().min(1), + threadId: z.string().min(1), + unread: z.boolean(), + }), + run: async (args, ctx) => { + await assertReviewableResourceAccess( + args.resourceType, + args.resourceId, + ctx as any, + "viewer", + ); + const root = await getReviewThreadRoot( + args.threadId, + { resourceType: args.resourceType, resourceId: args.resourceId }, + { userEmail: (ctx as any)?.userEmail, orgId: (ctx as any)?.orgId }, + ); + if (!root) throw new Error("Review thread not found"); + const userEmail = (ctx as any)?.userEmail; + if (!userEmail) throw new Error("A signed-in user is required"); + return setReviewThreadPreference({ + threadId: args.threadId, + userEmail, + unread: args.unread, + }); + }, +}); diff --git a/packages/core/src/review/index.ts b/packages/core/src/review/index.ts index cee728f49f3..7d213ca5cbc 100644 --- a/packages/core/src/review/index.ts +++ b/packages/core/src/review/index.ts @@ -44,6 +44,20 @@ export { routeReviewThread, sendReviewThreadToAgent, } from "./store.js"; +export * from "./suggestions/types.js"; +export { + registerSuggestionAdapter, + getSuggestionAdapter, + listSuggestionAdapters, + __resetSuggestionAdaptersForTests, +} from "./suggestions/registry.js"; +export { + ensureSuggestionTables, + getDecision, + getSuggestion, + listSuggestions, + __resetSuggestionTablesForTests, +} from "./suggestions/store.js"; export type { GetReviewThreadSummaryInput, ReviewThreadSummary, diff --git a/packages/core/src/review/store.ts b/packages/core/src/review/store.ts index 283487dc8f4..305682a11b7 100644 --- a/packages/core/src/review/store.ts +++ b/packages/core/src/review/store.ts @@ -118,6 +118,21 @@ export async function ensureReviewTables(): Promise { org_id TEXT, visibility TEXT NOT NULL DEFAULT 'private', metadata_json TEXT + )`; + const createReactionsSql = `CREATE TABLE IF NOT EXISTS agent_review_comment_reactions ( + comment_id TEXT NOT NULL, + actor_email TEXT NOT NULL, + reaction TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (comment_id, actor_email, reaction) + )`; + const createPreferencesSql = `CREATE TABLE IF NOT EXISTS agent_review_thread_preferences ( + thread_id TEXT NOT NULL, + user_email TEXT NOT NULL, + muted INTEGER NOT NULL DEFAULT 0, + unread INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + PRIMARY KEY (thread_id, user_email) )`; const indexes = [ `CREATE INDEX IF NOT EXISTS idx_agent_review_comments_resource @@ -144,6 +159,14 @@ export async function ensureReviewTables(): Promise { if (isPostgres()) { await ensureTableExists("agent_review_comments", createCommentsSql); await ensureTableExists("agent_review_statuses", createStatusesSql); + await ensureTableExists( + "agent_review_comment_reactions", + createReactionsSql, + ); + await ensureTableExists( + "agent_review_thread_preferences", + createPreferencesSql, + ); await ensureIndexExists( "idx_agent_review_comments_resource", indexes[0], @@ -159,6 +182,8 @@ export async function ensureReviewTables(): Promise { } else { await client.execute(createCommentsSql); await client.execute(createStatusesSql); + await client.execute(createReactionsSql); + await client.execute(createPreferencesSql); for (const indexSql of indexes) { await client.execute(indexSql); } @@ -169,6 +194,65 @@ export async function ensureReviewTables(): Promise { await reviewTablesInitPromise; } +export async function setReviewCommentReaction(input: { + commentId: string; + actorEmail: string; + reaction: string; + active: boolean; +}) { + await ensureReviewTables(); + const client = getDbExec(); + if (input.active) { + await client.execute({ + sql: "INSERT INTO agent_review_comment_reactions (comment_id,actor_email,reaction,created_at) VALUES (?,?,?,?) ON CONFLICT (comment_id,actor_email,reaction) DO NOTHING", + args: [ + input.commentId, + input.actorEmail, + input.reaction, + new Date().toISOString(), + ], + }); + } else { + await client.execute({ + sql: "DELETE FROM agent_review_comment_reactions WHERE comment_id = ? AND actor_email = ? AND reaction = ?", + args: [input.commentId, input.actorEmail, input.reaction], + }); + } + return { ...input }; +} + +export async function setReviewThreadPreference(input: { + threadId: string; + userEmail: string; + muted?: boolean; + unread?: boolean; +}) { + await ensureReviewTables(); + const client = getDbExec(); + const existing = ( + await client.execute({ + sql: "SELECT muted,unread FROM agent_review_thread_preferences WHERE thread_id = ? AND user_email = ?", + args: [input.threadId, input.userEmail], + }) + ).rows[0]; + const muted = input.muted ?? Boolean(existing?.muted); + const unread = input.unread ?? Boolean(existing?.unread); + await client.execute({ + sql: "INSERT INTO agent_review_thread_preferences (thread_id,user_email,muted,unread,updated_at) VALUES (?,?,?,?,?) ON CONFLICT (thread_id,user_email) DO UPDATE SET muted = ?, unread = ?, updated_at = ?", + args: [ + input.threadId, + input.userEmail, + muted ? 1 : 0, + unread ? 1 : 0, + new Date().toISOString(), + muted ? 1 : 0, + unread ? 1 : 0, + new Date().toISOString(), + ], + }); + return { threadId: input.threadId, muted, unread }; +} + export async function insertReviewComment( input: InsertReviewCommentInput, ): Promise { @@ -223,7 +307,7 @@ export async function insertReviewReply( } } -async function insertReviewCommentWithClient( +export async function insertReviewCommentWithClient( input: InsertReviewCommentInput, client: DbExec, ): Promise { @@ -523,7 +607,7 @@ export async function resolveReviewThread( return client.transaction ? client.transaction(resolve) : resolve(client); } -async function resolveReviewThreadWithClient( +export async function resolveReviewThreadWithClient( client: DbExec, threadId: string, resolvedBy?: string | null, diff --git a/packages/core/src/review/suggestions/actions.ts b/packages/core/src/review/suggestions/actions.ts new file mode 100644 index 00000000000..d58e8232623 --- /dev/null +++ b/packages/core/src/review/suggestions/actions.ts @@ -0,0 +1,379 @@ +import { z } from "zod"; + +import { defineAction } from "../../action.js"; +import { getDbExec } from "../../db/client.js"; +import { notifyReviewComment } from "../notifications.js"; +import { assertReviewableResourceAccess } from "../registry.js"; +import { + ensureReviewTables, + insertReviewCommentWithClient, + resolveReviewThreadWithClient, +} from "../store.js"; +import { getSuggestionAdapter } from "./registry.js"; +import { + getSuggestion, + ensureSuggestionTables, + insertSuggestion, + listSuggestions, + recordDecision, + getDecision, + getSuggestionByCreationKey, + recordSuggestionCreation, + replaceSuggestionStatus, + updateSuggestionStatus, +} from "./store.js"; +import type { ResourceSuggestion } from "./types.js"; + +const base = { resourceType: z.string().min(1), resourceId: z.string().min(1) }; +const operation = z.object({ + ordinal: z.number().int().nonnegative(), + kind: z.string().min(1), + targetId: z.string().nullable().optional(), + before: z.unknown().optional(), + after: z.unknown().optional(), + anchor: z.unknown().optional(), + dependencies: z.unknown().optional(), + schemaVersion: z.number().int().positive(), +}); + +export const createResourceSuggestion = defineAction({ + description: + "Create a typed pending suggestion without changing the canonical resource.", + schema: z.object({ + ...base, + adapterKind: z.string().min(1), + baseRevision: z.string().min(1), + summary: z.string().trim().min(1).max(500), + idempotencyKey: z.string().min(1).max(200), + operations: z.array(operation).min(1), + metadata: z.record(z.string(), z.unknown()).optional(), + }), + link: ({ args, result }) => { + const suggestion = result as ResourceSuggestion; + const url = getSuggestionAdapter(args.adapterKind)?.buildUrl?.( + args.resourceId, + suggestion.id, + ); + return url ? { url, label: "Open suggestion" } : null; + }, + run: async (args, ctx) => { + const access = await assertReviewableResourceAccess( + args.resourceType, + args.resourceId, + ctx as any, + "commenter", + ); + const adapter = getSuggestionAdapter(args.adapterKind); + if (!adapter) throw new Error("Suggestion adapter not registered"); + const adapterContext = { ...(ctx as any), suggestionAccess: access }; + const operations = + (await adapter.validateProposal({ ...args, ctx: adapterContext })) ?? + args.operations; + const actorKind = + (ctx as any)?.caller === "agent" || (ctx as any)?.caller === "tool" + ? "agent" + : (ctx as any)?.userEmail + ? "human" + : "system"; + const db = getDbExec(); + await ensureSuggestionTables(); + await ensureReviewTables(); + if (!db.transaction) + throw new Error( + "Suggestion creation requires an atomic database transaction", + ); + const result = await db.transaction(async (tx) => { + const prior = await getSuggestionByCreationKey(tx, args.idempotencyKey); + if (prior) { + if ( + prior.resourceType !== args.resourceType || + prior.resourceId !== args.resourceId || + prior.adapterKind !== args.adapterKind || + prior.baseRevision !== args.baseRevision + ) { + throw new Error( + "Idempotency key was already used for a different suggestion", + ); + } + return { suggestion: prior, threadComment: null }; + } + const created = await insertSuggestion( + { + resourceType: args.resourceType, + resourceId: args.resourceId, + adapterKind: adapter.kind, + adapterVersion: adapter.version, + threadId: `suggestion-thread-${globalThis.crypto.randomUUID()}`, + authorEmail: (ctx as any)?.userEmail ?? null, + actorKind, + baseRevision: args.baseRevision, + status: "pending", + summary: args.summary, + ownerEmail: access.ownerEmail ?? null, + orgId: access.orgId ?? null, + visibility: access.visibility ?? "private", + metadata: args.metadata ?? null, + operations, + }, + tx, + ); + await recordSuggestionCreation(tx, args.idempotencyKey, created.id); + const threadComment = await insertReviewCommentWithClient( + { + resourceType: created.resourceType, + resourceId: created.resourceId, + threadId: created.threadId, + targetId: created.id, + kind: "correction", + anchor: created.operations.map((item) => item.anchor ?? null), + body: created.summary, + authorEmail: created.authorEmail, + createdBy: actorKind, + resolutionTarget: "human", + ownerEmail: created.ownerEmail, + orgId: created.orgId, + visibility: created.visibility, + metadata: { suggestionId: created.id }, + }, + tx, + ); + return { suggestion: created, threadComment }; + }); + if (result.threadComment) await notifyReviewComment(result.threadComment); + return result.suggestion; + }, + audit: { + target: (args, result) => { + const suggestion = result as { + ownerEmail?: string | null; + orgId?: string | null; + visibility?: "private" | "org" | "public"; + }; + return { + type: args.resourceType, + id: args.resourceId, + ownerEmail: suggestion.ownerEmail, + orgId: suggestion.orgId, + visibility: suggestion.visibility, + }; + }, + }, +}); + +export const listResourceSuggestions = defineAction({ + description: "List typed suggestions for a resource.", + schema: z.object({ + ...base, + statuses: z + .array(z.enum(["pending", "accepted", "rejected", "stale", "superseded"])) + .optional(), + }), + http: { method: "GET" }, + readOnly: true, + parallelSafe: true, + run: async (args, ctx) => { + await assertReviewableResourceAccess( + args.resourceType, + args.resourceId, + ctx as any, + "viewer", + ); + return { + suggestions: await listSuggestions( + args.resourceType, + args.resourceId, + args.statuses, + ), + }; + }, +}); +export const getResourceSuggestion = defineAction({ + description: "Get one typed suggestion and its operations.", + schema: z.object({ id: z.string().min(1) }), + readOnly: true, + link: ({ result }) => { + const suggestion = result as ResourceSuggestion | null; + if (!suggestion) return null; + const url = getSuggestionAdapter(suggestion.adapterKind)?.buildUrl?.( + suggestion.resourceId, + suggestion.id, + ); + return url ? { url, label: "Open suggestion" } : null; + }, + run: async (args, ctx) => { + const suggestion = await getSuggestion(args.id); + if (!suggestion) throw new Error("Suggestion not found"); + await assertReviewableResourceAccess( + suggestion.resourceType, + suggestion.resourceId, + ctx as any, + "viewer", + ); + return suggestion; + }, +}); + +export const decideResourceSuggestion = defineAction({ + description: "Accept or reject a pending suggestion atomically.", + schema: z.object({ + id: z.string().min(1), + decision: z.enum(["accepted", "rejected"]), + idempotencyKey: z.string().min(1), + observedBase: z.string().min(1), + }), + run: async (args, ctx) => { + const suggestion = await getSuggestion(args.id); + if (!suggestion) throw new Error("Suggestion not found"); + const access = await assertReviewableResourceAccess( + suggestion.resourceType, + suggestion.resourceId, + ctx as any, + "editor", + ); + const db = getDbExec(); + if (!db.transaction) + throw new Error( + "Suggestion decisions require an atomic database transaction", + ); + const adapter = getSuggestionAdapter(suggestion.adapterKind); + if (!adapter || adapter.version !== suggestion.adapterVersion) + throw new Error("Suggestion adapter version is unavailable"); + const decide = (coordination?: unknown) => + db.transaction!(async (tx) => { + const current = await getSuggestion(args.id, tx); + if (!current) throw new Error("Suggestion not found"); + if (current.status !== "pending") { + const decision = await getDecision(tx, args.idempotencyKey); + if ( + !decision || + decision.suggestionId !== current.id || + decision.decision !== args.decision + ) { + throw new Error(`Suggestion is already ${current.status}`); + } + return { suggestion: current, decision }; + } + const currentAdapter = getSuggestionAdapter(current.adapterKind); + if ( + !currentAdapter || + currentAdapter.version !== current.adapterVersion + ) + throw new Error("Suggestion adapter version is unavailable"); + if (current.baseRevision !== args.observedBase) { + if (!(await updateSuggestionStatus(tx, current.id, "stale"))) + return { + suggestion: current, + decision: await getDecision(tx, args.idempotencyKey), + }; + const decision = await recordDecision(tx, { + suggestionId: current.id, + idempotencyKey: args.idempotencyKey, + reviewer: (ctx as any)?.userEmail ?? null, + decision: args.decision, + observedBase: args.observedBase, + outcome: "stale", + detail: "Base revision changed", + }); + return { + suggestion: await getSuggestion(current.id, tx), + decision: decision.record, + }; + } + const claimed = await updateSuggestionStatus( + tx, + current.id, + args.decision, + ); + if (!claimed) + throw new Error( + "Suggestion was decided concurrently; retry to inspect it", + ); + const prior = await recordDecision(tx, { + suggestionId: current.id, + idempotencyKey: args.idempotencyKey, + reviewer: (ctx as any)?.userEmail ?? null, + decision: args.decision, + observedBase: args.observedBase, + outcome: args.decision, + detail: null, + }); + if (!prior.duplicate && args.decision === "accepted") { + try { + await currentAdapter.apply({ + resourceType: current.resourceType, + resourceId: current.resourceId, + suggestion: current, + operations: current.operations, + access, + ctx: { ...(ctx as any), suggestionAccess: access }, + transaction: tx, + coordination, + }); + } catch (error) { + if ( + !(error instanceof Error) || + error.name !== "SuggestionStaleError" + ) { + throw error; + } + await replaceSuggestionStatus(tx, current.id, "accepted", "stale"); + await tx.execute({ + sql: "UPDATE agent_review_suggestion_decisions SET outcome = ?, detail = ? WHERE id = ?", + args: ["stale", error.message, prior.record.id], + }); + return { + suggestion: await getSuggestion(current.id, tx), + decision: { + ...prior.record, + outcome: "stale", + detail: error.message, + }, + }; + } + } + if (!prior.duplicate) { + await resolveReviewThreadWithClient( + tx, + current.threadId, + (ctx as any)?.userEmail ?? null, + { + resourceType: current.resourceType, + resourceId: current.resourceId, + }, + args.decision, + ); + } + return { + suggestion: await getSuggestion(current.id, tx), + decision: prior.record, + }; + }); + const decisionContext = { + resourceType: suggestion.resourceType, + resourceId: suggestion.resourceId, + suggestion, + operations: suggestion.operations, + decision: args.decision, + access, + ctx: { ...(ctx as any), suggestionAccess: access }, + }; + return adapter.coordinateDecision + ? adapter.coordinateDecision(decisionContext, decide) + : decide(); + }, + audit: { + target: (_args, result) => { + const suggestion = (result as { suggestion?: ResourceSuggestion }) + .suggestion; + return suggestion + ? { + type: suggestion.resourceType, + id: suggestion.resourceId, + ownerEmail: suggestion.ownerEmail, + orgId: suggestion.orgId, + visibility: suggestion.visibility, + } + : undefined; + }, + }, +}); diff --git a/packages/core/src/review/suggestions/actions/create-resource-suggestion.ts b/packages/core/src/review/suggestions/actions/create-resource-suggestion.ts new file mode 100644 index 00000000000..3409207d231 --- /dev/null +++ b/packages/core/src/review/suggestions/actions/create-resource-suggestion.ts @@ -0,0 +1 @@ +export { createResourceSuggestion as default } from "../actions.js"; diff --git a/packages/core/src/review/suggestions/actions/decide-resource-suggestion.ts b/packages/core/src/review/suggestions/actions/decide-resource-suggestion.ts new file mode 100644 index 00000000000..191ccd85325 --- /dev/null +++ b/packages/core/src/review/suggestions/actions/decide-resource-suggestion.ts @@ -0,0 +1 @@ +export { decideResourceSuggestion as default } from "../actions.js"; diff --git a/packages/core/src/review/suggestions/actions/get-resource-suggestion.ts b/packages/core/src/review/suggestions/actions/get-resource-suggestion.ts new file mode 100644 index 00000000000..17975653750 --- /dev/null +++ b/packages/core/src/review/suggestions/actions/get-resource-suggestion.ts @@ -0,0 +1 @@ +export { getResourceSuggestion as default } from "../actions.js"; diff --git a/packages/core/src/review/suggestions/actions/list-resource-suggestions.ts b/packages/core/src/review/suggestions/actions/list-resource-suggestions.ts new file mode 100644 index 00000000000..9b61cc96126 --- /dev/null +++ b/packages/core/src/review/suggestions/actions/list-resource-suggestions.ts @@ -0,0 +1 @@ +export { listResourceSuggestions as default } from "../actions.js"; diff --git a/packages/core/src/review/suggestions/registry.spec.ts b/packages/core/src/review/suggestions/registry.spec.ts new file mode 100644 index 00000000000..9f05611f3bd --- /dev/null +++ b/packages/core/src/review/suggestions/registry.spec.ts @@ -0,0 +1,31 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + __resetSuggestionAdaptersForTests, + getSuggestionAdapter, + registerSuggestionAdapter, +} from "./registry"; + +const adapter = (version: number) => ({ + kind: "document", + version, + validateProposal: () => undefined, + apply: () => undefined, +}); + +describe("suggestion adapter registry", () => { + beforeEach(__resetSuggestionAdaptersForTests); + + it("allows an identical-version startup registration", () => { + registerSuggestionAdapter(adapter(1)); + registerSuggestionAdapter(adapter(1)); + expect(getSuggestionAdapter("document")?.version).toBe(1); + }); + + it("rejects conflicting adapter versions", () => { + registerSuggestionAdapter(adapter(1)); + expect(() => registerSuggestionAdapter(adapter(2))).toThrow( + "conflicting versions", + ); + }); +}); diff --git a/packages/core/src/review/suggestions/registry.ts b/packages/core/src/review/suggestions/registry.ts new file mode 100644 index 00000000000..f4ee363bb4b --- /dev/null +++ b/packages/core/src/review/suggestions/registry.ts @@ -0,0 +1,32 @@ +import type { SuggestionAdapter } from "./types.js"; +const adapters = new Map(); +export function registerSuggestionAdapter(adapter: SuggestionAdapter): void { + if ( + !adapter.kind.trim() || + !Number.isInteger(adapter.version) || + adapter.version < 1 + ) + throw new Error( + "Suggestion adapter requires a non-empty kind and positive version", + ); + const existing = adapters.get(adapter.kind); + if (existing) { + if (existing.version === adapter.version) { + adapters.set(adapter.kind, adapter); + return; + } + throw new Error( + `Suggestion adapter ${adapter.kind} was registered at conflicting versions`, + ); + } + adapters.set(adapter.kind, adapter); +} +export function getSuggestionAdapter(kind: string) { + return adapters.get(kind); +} +export function listSuggestionAdapters() { + return [...adapters.values()]; +} +export function __resetSuggestionAdaptersForTests() { + adapters.clear(); +} diff --git a/packages/core/src/review/suggestions/store.spec.ts b/packages/core/src/review/suggestions/store.spec.ts new file mode 100644 index 00000000000..8936e66c7d1 --- /dev/null +++ b/packages/core/src/review/suggestions/store.spec.ts @@ -0,0 +1,129 @@ +import Database from "better-sqlite3"; +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; + +let sqlite: Database.Database; +const rawClient = { + execute: vi.fn(async (input: string | { sql: string; args?: unknown[] }) => { + if (typeof input === "string") { + sqlite.exec(input); + return { rows: [], rowsAffected: 0 }; + } + const stmt = sqlite.prepare(input.sql); + const args = (input.args ?? []) as unknown[]; + if (/^\s*select/i.test(input.sql)) + return { rows: stmt.all(...args), rowsAffected: 0 }; + const info = stmt.run(...args); + return { rows: [], rowsAffected: info.changes }; + }), + transaction: async (fn: (tx: typeof rawClient) => Promise) => { + sqlite.exec("BEGIN"); + try { + const result = await fn(rawClient); + sqlite.exec("COMMIT"); + return result; + } catch (error) { + sqlite.exec("ROLLBACK"); + throw error; + } + }, +}; +vi.mock("../../db/client.js", () => ({ + getDbExec: () => rawClient, + isPostgres: () => false, +})); +const { + ensureSuggestionTables, + insertSuggestion, + getSuggestion, + recordDecision, + __resetSuggestionTablesForTests, +} = await import("./store.js"); + +beforeEach(async () => { + sqlite = new Database(":memory:"); + __resetSuggestionTablesForTests(); + await ensureSuggestionTables(); +}); +afterEach(() => sqlite.close()); + +const input = { + resourceType: "document", + resourceId: "d1", + adapterKind: "document", + adapterVersion: 1, + threadId: "thread-1", + authorEmail: "alice@example.com", + actorKind: "human" as const, + baseRevision: "rev-1", + status: "pending" as const, + summary: "Replace text", + ownerEmail: "alice@example.com", + orgId: null, + visibility: "private" as const, + metadata: null, + operations: [ + { + ordinal: 0, + kind: "replace_text", + before: "old", + after: "new", + schemaVersion: 1, + }, + ], +}; + +describe("suggestion store", () => { + it("persists operations and rolls back an atomic failed insert", async () => { + const suggestion = await rawClient.transaction((tx) => + insertSuggestion(input, tx), + ); + expect((await getSuggestion(suggestion.id))?.operations[0].after).toBe( + "new", + ); + await expect( + rawClient.transaction(async (tx) => { + await insertSuggestion(input, tx); + throw new Error("rollback"); + }), + ).rejects.toThrow("rollback"); + expect((await getSuggestion(suggestion.id))?.operations).toHaveLength(1); + }); + + it("records idempotent decisions and rejects conflicting key reuse", async () => { + const suggestion = await insertSuggestion(input); + const first = await recordDecision(rawClient, { + suggestionId: suggestion.id, + idempotencyKey: "key-1", + reviewer: "editor@example.com", + decision: "accepted", + observedBase: "rev-1", + outcome: "accepted", + detail: null, + }); + expect(first.duplicate).toBe(false); + expect( + ( + await recordDecision(rawClient, { + suggestionId: suggestion.id, + idempotencyKey: "key-1", + reviewer: "editor@example.com", + decision: "accepted", + observedBase: "rev-1", + outcome: "accepted", + detail: null, + }) + ).duplicate, + ).toBe(true); + await expect( + recordDecision(rawClient, { + suggestionId: "other", + idempotencyKey: "key-1", + reviewer: null, + decision: "rejected", + observedBase: "rev-1", + outcome: "rejected", + detail: null, + }), + ).rejects.toThrow("different decision"); + }); +}); diff --git a/packages/core/src/review/suggestions/store.ts b/packages/core/src/review/suggestions/store.ts new file mode 100644 index 00000000000..0e771dc9d39 --- /dev/null +++ b/packages/core/src/review/suggestions/store.ts @@ -0,0 +1,304 @@ +import { getDbExec, isPostgres, type DbExec } from "../../db/client.js"; +import { ensureTableExists } from "../../db/ddl-guard.js"; +import type { Visibility } from "../../sharing/schema.js"; +import type { + ResourceSuggestion, + SuggestionDecision, + SuggestionStatus, +} from "./types.js"; + +let initialized: Promise | undefined; +export function __resetSuggestionTablesForTests(): void { + initialized = undefined; +} +const newId = () => globalThis.crypto.randomUUID(); +const encode = (value: unknown) => + value == null ? null : JSON.stringify(value); +const decode = (value: unknown) => + typeof value === "string" ? (JSON.parse(value) as T) : ((value as T) ?? null); + +export async function ensureSuggestionTables( + client = getDbExec(), +): Promise { + if (client !== getDbExec()) return; + if (!initialized) + initialized = (async () => { + const ddl = [ + `CREATE TABLE IF NOT EXISTS agent_review_suggestions (id TEXT PRIMARY KEY, resource_type TEXT NOT NULL, resource_id TEXT NOT NULL, adapter_kind TEXT NOT NULL, adapter_version INTEGER NOT NULL, thread_id TEXT NOT NULL, author_email TEXT, actor_kind TEXT NOT NULL, base_revision TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', summary TEXT NOT NULL, owner_email TEXT, org_id TEXT, visibility TEXT NOT NULL DEFAULT 'private', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, metadata_json TEXT)`, + `CREATE TABLE IF NOT EXISTS agent_review_suggestion_operations (id TEXT PRIMARY KEY, suggestion_id TEXT NOT NULL, ordinal INTEGER NOT NULL, operation_kind TEXT NOT NULL, target_id TEXT, before_json TEXT, after_json TEXT, anchor_json TEXT, dependencies_json TEXT, schema_version INTEGER NOT NULL)`, + `CREATE TABLE IF NOT EXISTS agent_review_suggestion_decisions (id TEXT PRIMARY KEY, suggestion_id TEXT NOT NULL, idempotency_key TEXT NOT NULL UNIQUE, reviewer TEXT, decision TEXT NOT NULL, observed_base TEXT, outcome TEXT NOT NULL, detail TEXT, created_at TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS agent_review_suggestion_creations (idempotency_key TEXT PRIMARY KEY, suggestion_id TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL)`, + ]; + for (const sql of ddl) { + const name = sql.match(/agent_review_[a-z_]+/)![0]; + if (isPostgres()) await ensureTableExists(name, sql); + else await client.execute(sql); + } + await client.execute( + "CREATE INDEX IF NOT EXISTS idx_review_suggestions_resource ON agent_review_suggestions (resource_type, resource_id, created_at)", + ); + await client.execute( + "CREATE INDEX IF NOT EXISTS idx_review_suggestion_operations ON agent_review_suggestion_operations (suggestion_id, ordinal)", + ); + })(); + await initialized; +} + +export async function getSuggestionByCreationKey( + client: DbExec, + idempotencyKey: string, +): Promise { + const row = ( + await client.execute({ + sql: "SELECT suggestion_id FROM agent_review_suggestion_creations WHERE idempotency_key = ?", + args: [idempotencyKey], + }) + ).rows[0]; + return row ? getSuggestion(String(row.suggestion_id), client) : null; +} + +export async function recordSuggestionCreation( + client: DbExec, + idempotencyKey: string, + suggestionId: string, +): Promise { + await client.execute({ + sql: "INSERT INTO agent_review_suggestion_creations (idempotency_key,suggestion_id,created_at) VALUES (?,?,?)", + args: [idempotencyKey, suggestionId, new Date().toISOString()], + }); +} + +export async function insertSuggestion( + input: Omit, + client = getDbExec(), +): Promise { + await ensureSuggestionTables(client); + const suggestionId = `suggestion-${newId()}`; + const now = new Date().toISOString(); + await client.execute({ + sql: "INSERT INTO agent_review_suggestions (id,resource_type,resource_id,adapter_kind,adapter_version,thread_id,author_email,actor_kind,base_revision,status,summary,owner_email,org_id,visibility,created_at,updated_at,metadata_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + args: [ + suggestionId, + input.resourceType, + input.resourceId, + input.adapterKind, + input.adapterVersion, + input.threadId, + input.authorEmail, + input.actorKind, + input.baseRevision, + input.status, + input.summary, + input.ownerEmail, + input.orgId, + input.visibility, + now, + now, + encode(input.metadata), + ], + }); + for (const operation of input.operations) + await client.execute({ + sql: "INSERT INTO agent_review_suggestion_operations (id,suggestion_id,ordinal,operation_kind,target_id,before_json,after_json,anchor_json,dependencies_json,schema_version) VALUES (?,?,?,?,?,?,?,?,?,?)", + args: [ + `${suggestionId}-${operation.ordinal}`, + suggestionId, + operation.ordinal, + operation.kind, + operation.targetId ?? null, + encode(operation.before), + encode(operation.after), + encode(operation.anchor), + encode(operation.dependencies), + operation.schemaVersion, + ], + }); + return { ...input, id: suggestionId, createdAt: now, updatedAt: now }; +} + +export async function getSuggestion( + suggestionId: string, + client = getDbExec(), +): Promise { + await ensureSuggestionTables(client); + const row = ( + await client.execute({ + sql: "SELECT * FROM agent_review_suggestions WHERE id = ?", + args: [suggestionId], + }) + ).rows[0]; + if (!row) return null; + const rows = ( + await client.execute({ + sql: "SELECT * FROM agent_review_suggestion_operations WHERE suggestion_id = ? ORDER BY ordinal", + args: [suggestionId], + }) + ).rows; + return { + id: String(row.id), + resourceType: String(row.resource_type), + resourceId: String(row.resource_id), + adapterKind: String(row.adapter_kind), + adapterVersion: Number(row.adapter_version), + threadId: String(row.thread_id), + authorEmail: row.author_email as string | null, + actorKind: row.actor_kind as ResourceSuggestion["actorKind"], + baseRevision: String(row.base_revision), + status: row.status as SuggestionStatus, + summary: String(row.summary), + ownerEmail: row.owner_email as string | null, + orgId: row.org_id as string | null, + visibility: row.visibility as Visibility, + createdAt: String(row.created_at), + updatedAt: String(row.updated_at), + metadata: decode>(row.metadata_json), + operations: rows.map((value) => ({ + id: String(value.id), + ordinal: Number(value.ordinal), + kind: String(value.operation_kind), + targetId: value.target_id as string | null, + before: decode(value.before_json), + after: decode(value.after_json), + anchor: decode(value.anchor_json), + dependencies: decode(value.dependencies_json), + schemaVersion: Number(value.schema_version), + })), + }; +} + +export async function listSuggestions( + resourceType: string, + resourceId: string, + statuses?: readonly SuggestionStatus[], + client = getDbExec(), +): Promise { + await ensureSuggestionTables(client); + const args: unknown[] = [resourceType, resourceId]; + const filter = statuses?.length + ? ` AND status IN (${statuses.map(() => "?").join(",")})` + : ""; + args.push(...(statuses ?? [])); + const rows = ( + await client.execute({ + sql: `SELECT id FROM agent_review_suggestions WHERE resource_type = ? AND resource_id = ?${filter} ORDER BY created_at`, + args, + }) + ).rows; + return ( + await Promise.all(rows.map((row) => getSuggestion(String(row.id), client))) + ).filter((value): value is ResourceSuggestion => Boolean(value)); +} + +export interface SuggestionDecisionRecord { + id: string; + suggestionId: string; + idempotencyKey: string; + reviewer: string | null; + decision: SuggestionDecision; + observedBase: string; + outcome: string; + detail: string | null; + createdAt: string; +} +export async function recordDecision( + client: DbExec, + input: Omit, +): Promise<{ record: SuggestionDecisionRecord; duplicate: boolean }> { + const row = ( + await client.execute({ + sql: "SELECT * FROM agent_review_suggestion_decisions WHERE idempotency_key = ?", + args: [input.idempotencyKey], + }) + ).rows[0]; + if (row) { + if ( + String(row.suggestion_id) !== input.suggestionId || + String(row.decision) !== input.decision + ) + throw new Error( + "Idempotency key was already used for a different decision", + ); + return { + duplicate: true, + record: { + id: String(row.id), + suggestionId: String(row.suggestion_id), + idempotencyKey: String(row.idempotency_key), + reviewer: row.reviewer as string | null, + decision: row.decision as SuggestionDecision, + observedBase: String(row.observed_base), + outcome: String(row.outcome), + detail: row.detail as string | null, + createdAt: String(row.created_at), + }, + }; + } + const record = { + ...input, + id: `decision-${newId()}`, + createdAt: new Date().toISOString(), + }; + await client.execute({ + sql: "INSERT INTO agent_review_suggestion_decisions (id,suggestion_id,idempotency_key,reviewer,decision,observed_base,outcome,detail,created_at) VALUES (?,?,?,?,?,?,?,?,?)", + args: [ + record.id, + record.suggestionId, + record.idempotencyKey, + record.reviewer, + record.decision, + record.observedBase, + record.outcome, + record.detail, + record.createdAt, + ], + }); + return { duplicate: false, record }; +} + +export async function getDecision( + client: DbExec, + idempotencyKey: string, +): Promise { + const row = ( + await client.execute({ + sql: "SELECT * FROM agent_review_suggestion_decisions WHERE idempotency_key = ?", + args: [idempotencyKey], + }) + ).rows[0]; + if (!row) return null; + return { + id: String(row.id), + suggestionId: String(row.suggestion_id), + idempotencyKey: String(row.idempotency_key), + reviewer: row.reviewer as string | null, + decision: row.decision as SuggestionDecision, + observedBase: String(row.observed_base), + outcome: String(row.outcome), + detail: row.detail as string | null, + createdAt: String(row.created_at), + }; +} +export async function updateSuggestionStatus( + client: DbExec, + suggestionId: string, + status: SuggestionStatus, +): Promise { + const result = await client.execute({ + sql: "UPDATE agent_review_suggestions SET status = ?, updated_at = ? WHERE id = ? AND status = 'pending'", + args: [status, new Date().toISOString(), suggestionId], + }); + return result.rowsAffected === 1; +} + +export async function replaceSuggestionStatus( + client: DbExec, + suggestionId: string, + from: SuggestionStatus, + to: SuggestionStatus, +): Promise { + const result = await client.execute({ + sql: "UPDATE agent_review_suggestions SET status = ?, updated_at = ? WHERE id = ? AND status = ?", + args: [to, new Date().toISOString(), suggestionId, from], + }); + return result.rowsAffected === 1; +} diff --git a/packages/core/src/review/suggestions/types.ts b/packages/core/src/review/suggestions/types.ts new file mode 100644 index 00000000000..414c4e4b61d --- /dev/null +++ b/packages/core/src/review/suggestions/types.ts @@ -0,0 +1,83 @@ +import type { Visibility } from "../../sharing/schema.js"; +export type SuggestionStatus = + | "pending" + | "accepted" + | "rejected" + | "stale" + | "superseded"; +export type SuggestionDecision = "accepted" | "rejected"; +export interface SuggestionOperation { + id?: string; + ordinal: number; + kind: string; + targetId?: string | null; + before?: unknown; + after?: unknown; + anchor?: unknown; + dependencies?: unknown; + schemaVersion: number; +} +export interface ResourceSuggestion { + id: string; + resourceType: string; + resourceId: string; + adapterKind: string; + adapterVersion: number; + threadId: string; + authorEmail: string | null; + actorKind: "human" | "agent" | "system"; + baseRevision: string; + status: SuggestionStatus; + summary: string; + ownerEmail: string | null; + orgId: string | null; + visibility: Visibility; + createdAt: string; + updatedAt: string; + metadata: Record | null; + operations: SuggestionOperation[]; +} +export interface SuggestionAccess { + role: "viewer" | "commenter" | "editor" | "admin" | "owner"; + ownerEmail?: string | null; + orgId?: string | null; + visibility?: Visibility | null; +} +export interface SuggestionContext { + resourceType: string; + resourceId: string; + suggestion: ResourceSuggestion; + operations: SuggestionOperation[]; + access: SuggestionAccess; + ctx?: Record; + transaction: unknown; + coordination?: unknown; +} +export interface SuggestionDecisionContext { + resourceType: string; + resourceId: string; + suggestion: ResourceSuggestion; + operations: SuggestionOperation[]; + decision: SuggestionDecision; + access: SuggestionAccess; + ctx?: Record; +} +export interface SuggestionAdapter { + kind: string; + version: number; + validateProposal(input: { + resourceType: string; + resourceId: string; + baseRevision: string; + operations: SuggestionOperation[]; + ctx?: Record; + }): Promise | void | SuggestionOperation[]; + preview?(context: SuggestionContext): Promise | unknown; + coordinateDecision?( + context: SuggestionDecisionContext, + run: (coordination?: unknown) => Promise, + ): Promise; + apply(context: SuggestionContext): Promise | unknown; + describeOperation?(operation: SuggestionOperation): string; + buildUrl?(resourceId: string, suggestionId: string): string; +} diff --git a/packages/core/src/server/action-discovery.ts b/packages/core/src/server/action-discovery.ts index dd6ec04fb95..c0d97b28e2a 100644 --- a/packages/core/src/server/action-discovery.ts +++ b/packages/core/src/server/action-discovery.ts @@ -879,6 +879,37 @@ export async function mergeCoreSharingActions( "send-review-thread-to-agent", () => import("../review/actions/send-review-thread-to-agent.js"), ], + [ + "react-to-review-comment", + () => import("../review/actions/react-to-review-comment.js"), + ], + [ + "set-review-thread-unread", + () => import("../review/actions/set-review-thread-unread.js"), + ], + [ + "set-review-thread-muted", + () => import("../review/actions/set-review-thread-muted.js"), + ], + [ + "create-resource-suggestion", + () => + import("../review/suggestions/actions/create-resource-suggestion.js"), + ], + [ + "list-resource-suggestions", + () => + import("../review/suggestions/actions/list-resource-suggestions.js"), + ], + [ + "get-resource-suggestion", + () => import("../review/suggestions/actions/get-resource-suggestion.js"), + ], + [ + "decide-resource-suggestion", + () => + import("../review/suggestions/actions/decide-resource-suggestion.js"), + ], // Org service tokens (CI credentials, e.g. PLAN_RECAP_TOKEN). Mint/revoke // are toolCallable:false — preserved via preserveActionFlags below. [ diff --git a/packages/core/src/server/app-sync-state.spec.ts b/packages/core/src/server/app-sync-state.spec.ts index 4aec816bdce..f780ff5632e 100644 --- a/packages/core/src/server/app-sync-state.spec.ts +++ b/packages/core/src/server/app-sync-state.spec.ts @@ -125,6 +125,36 @@ describe("AppSyncState multi-app isolation", () => { expect(ids[0]).not.toBe(ids[1]); }); + it("persists a transactional change before publishing it", async () => { + process.env.AGENT_NATIVE_SYNC_EVENTS_ENABLE_IN_TESTS = "1"; + const schemaDb = makeDb(); + const transaction = { + execute: vi.fn(async () => ({ rows: [], rowsAffected: 1 })), + }; + const state = new AppSyncState({ + getDb: () => schemaDb, + isPostgres: () => false, + }); + const change = await state.prepareTransactionalChange({ + source: "collab", + type: "change", + key: "doc-1", + resourceType: "document", + resourceId: "doc-1", + }); + + expect(state.getChangesSince(0).events).toEqual([]); + const persisted = await change.persist(transaction); + expect(state.getChangesSince(0).events).toEqual([]); + expect(transaction.execute).toHaveBeenCalledOnce(); + expect(persisted.resourceId).toBe("doc-1"); + + change.publish(); + expect(state.getChangesSince(0).events).toMatchObject([ + { source: "collab", resourceId: "doc-1" }, + ]); + }); + it("does not reuse an org-A access decision under an org-B session", async () => { const flush = async () => { for (let i = 0; i < 5; i++) await Promise.resolve(); diff --git a/packages/core/src/server/index.ts b/packages/core/src/server/index.ts index 3a0406cf9b6..c42aabf97f7 100644 --- a/packages/core/src/server/index.ts +++ b/packages/core/src/server/index.ts @@ -185,11 +185,13 @@ export { createDevScriptRegistry } from "../scripts/dev/index.js"; export { createPollHandler, recordChange, + prepareTransactionalChange, getVersion, getChangesSince, getPollEmitter, canSeeChangeForUser, POLL_CHANGE_EVENT, + type TransactionalChange, } from "./poll.js"; export { createPollEventsHandler } from "./poll-events.js"; export { createAuthPlugin, defaultAuthPlugin } from "./auth-plugin.js"; diff --git a/packages/core/src/server/poll.ts b/packages/core/src/server/poll.ts index 314de86b58f..c3ccad71c08 100644 --- a/packages/core/src/server/poll.ts +++ b/packages/core/src/server/poll.ts @@ -83,6 +83,11 @@ export interface ChangeEvent { [k: string]: unknown; } +export interface TransactionalChange { + persist(transaction: DbExec): Promise; + publish(): ChangeEvent; +} + // In-memory ring buffer of recent changes. Kept small since clients // poll frequently (every 2-3s) and only need events since their last poll. const MAX_BUFFER = 200; @@ -1215,6 +1220,95 @@ export class AppSyncState { ); } + /** + * Prepare a durable change whose row must commit with a caller-owned + * transaction. Persistence and in-process publication are deliberately + * separate: callers persist through the transaction, then publish only + * after that transaction resolves successfully. + */ + async prepareTransactionalChange(event: { + source: string; + type: string; + key?: string; + [k: string]: unknown; + }): Promise { + if (!(await this.ensureSyncEventsTable())) { + throw new Error( + "Transactional change delivery requires durable sync events", + ); + } + const id = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + let persisted: ChangeEvent | null = null; + return { + persist: async (transaction) => { + if (persisted) return persisted; + let version: number; + if (this.dbAssignedVersions && this.isPg()) { + const result = await transaction.execute({ + sql: ALLOCATING_INSERT_SQL, + args: [ + this.version + 1, + id, + JSON.stringify({ ...event, cursorId: id }) + .split("\\u0000") + .join(""), + event.source, + event.type, + event.key ?? null, + (event.owner as string | undefined) ?? null, + (event.orgId as string | undefined) ?? null, + (event.resourceType as string | undefined) ?? null, + (event.resourceId as string | undefined) ?? null, + Date.now(), + ], + }); + version = timestampValue(result.rows[0]?.version); + if (version <= 0) { + throw new Error("Durable sync version allocation failed"); + } + } else { + version = Math.max(this.version + 1, Date.now()); + const entry = { ...event, version, cursorId: id } as ChangeEvent; + const result = await transaction.execute({ + sql: this.isPg() + ? `INSERT INTO sync_events (id, version, event_json, source, type, event_key, owner, org_id, resource_type, resource_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO NOTHING` + : `INSERT OR IGNORE INTO sync_events (id, version, event_json, source, type, event_key, owner, org_id, resource_type, resource_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + id, + version, + JSON.stringify(entry), + entry.source, + entry.type, + entry.key ?? null, + entry.owner ?? null, + entry.orgId ?? null, + entry.resourceType ?? null, + entry.resourceId ?? null, + Date.now(), + ], + }); + if (result.rowsAffected !== 1) { + throw new Error("Durable sync event was not persisted"); + } + } + persisted = { ...event, version, cursorId: id } as ChangeEvent; + return persisted; + }, + publish: () => { + if (!persisted) { + throw new Error( + "Transactional change cannot publish before persistence commits", + ); + } + this.version = Math.max(this.version, persisted.version); + this.commitEntryForChain(persisted); + return persisted; + }, + }; + } + /** Buffer + emit. Shared by both version-allocation modes. */ private commitEntry(entry: ChangeEvent): void { this.buffer.push(entry); @@ -2184,6 +2278,15 @@ export function recordChange(event: { getDefaultAppSyncState().recordChange(event); } +export function prepareTransactionalChange(event: { + source: string; + type: string; + key?: string; + [k: string]: unknown; +}): Promise { + return getDefaultAppSyncState().prepareTransactionalChange(event); +} + setActionChangeFastPath((target) => { getDefaultAppSyncState().recordChange( { diff --git a/packages/core/src/server/release-schema.ts b/packages/core/src/server/release-schema.ts index afeee83d258..4c4e8209094 100644 --- a/packages/core/src/server/release-schema.ts +++ b/packages/core/src/server/release-schema.ts @@ -304,6 +304,13 @@ const FRAMEWORK_SCHEMA_ENSURES: readonly SchemaEnsure[] = [ "Review", () => import("../review/store.js").then((m) => m.ensureReviewTables()), ], + [ + "ReviewSuggestions", + () => + import("../review/suggestions/store.js").then((m) => + m.ensureSuggestionTables(), + ), + ], [ "SandboxExecutions", () => diff --git a/packages/core/src/vite/action-types-plugin.ts b/packages/core/src/vite/action-types-plugin.ts index a86186cdf8b..16c82aadafe 100644 --- a/packages/core/src/vite/action-types-plugin.ts +++ b/packages/core/src/vite/action-types-plugin.ts @@ -211,6 +211,38 @@ const CORE_SHARING_ACTIONS: Array<{ name: string; specifier: string }> = [ name: "send-review-thread-to-agent", specifier: "@agent-native/core/review/actions/send-review-thread-to-agent", }, + { + name: "react-to-review-comment", + specifier: "@agent-native/core/review/actions/react-to-review-comment", + }, + { + name: "set-review-thread-unread", + specifier: "@agent-native/core/review/actions/set-review-thread-unread", + }, + { + name: "set-review-thread-muted", + specifier: "@agent-native/core/review/actions/set-review-thread-muted", + }, + { + name: "create-resource-suggestion", + specifier: + "@agent-native/core/review/suggestions/actions/create-resource-suggestion", + }, + { + name: "list-resource-suggestions", + specifier: + "@agent-native/core/review/suggestions/actions/list-resource-suggestions", + }, + { + name: "get-resource-suggestion", + specifier: + "@agent-native/core/review/suggestions/actions/get-resource-suggestion", + }, + { + name: "decide-resource-suggestion", + specifier: + "@agent-native/core/review/suggestions/actions/decide-resource-suggestion", + }, ]; function isRuntimeSourceFile(filename: string): boolean { diff --git a/templates/content/.agents/skills/document-editing/SKILL.md b/templates/content/.agents/skills/document-editing/SKILL.md index 0e6d3d61b3b..044b2ae9a7b 100644 --- a/templates/content/.agents/skills/document-editing/SKILL.md +++ b/templates/content/.agents/skills/document-editing/SKILL.md @@ -85,6 +85,31 @@ pnpm action update-document --id abc123 --title "New Title" --content "New conte pnpm action update-document --id abc123 --description "Stable guidance for what belongs on this page" ``` +### Suggested edits + +When the user asks to **suggest**, **propose**, or **leave changes for review**, +do not call `edit-document` or `update-document`. Read the current Page and use +`create-resource-suggestion` with `resourceType: "document"`, adapter kind +`content.document-markdown`, the Page's exact `updatedAt` as `baseRevision`, a +fresh `idempotencyKey`, and one typed Page-body operation. Preserve both the +complete current and proposed Markdown in `before.markdown` and +`after.markdown`; include the narrow changed material and anchor when known. + +Use `list-resource-suggestions` to inspect pending and historical proposals. +Only accept or reject when the user has asked for that decision and the caller +has editor authority; call `decide-resource-suggestion` with a fresh +idempotency key and the suggestion's `baseRevision` as `observedBase`. A stale +result means canonical Content was not overwritten. Suggested edits are +unavailable for local-file, source-owned, externally linked, database-item, or +trashed Pages in this release. + +```bash +pnpm action create-resource-suggestion --resourceType document --resourceId abc123 \ + --adapterKind content.document-markdown --baseRevision '' \ + --idempotencyKey '' --summary 'Suggest edits' \ + --operations '[{"ordinal":0,"kind":"replace_text","targetId":"body","before":{"markdown":"Before","changedText":"Before"},"after":{"markdown":"After","changedText":"After"},"anchor":{"from":0,"to":6,"prefix":"","suffix":""},"schemaVersion":1}]' +``` + ### delete-document Move a document and all its children to Trash. IDs, bodies, hierarchy, and diff --git a/templates/content/AGENTS.md b/templates/content/AGENTS.md index 1be1060a83c..8f2dd75ac31 100644 --- a/templates/content/AGENTS.md +++ b/templates/content/AGENTS.md @@ -11,7 +11,7 @@ Read the relevant skill before deeper work: - `content` — Markdown/MDX authoring, local folder sources, databases, intake forms, and Slack/A2A artifact replies. - `document-editing` — document and comment actions, screen context and IDs, - common tasks, the data model, and the databases reference. + suggestions, common tasks, the data model, and the databases reference. - `notion-integration` — connected Notion workflows and the raw Notion provider API path. - `creative-context` — cross-app source reuse, pinned packs, provenance, and @@ -83,7 +83,6 @@ Read the relevant skill before deeper work: | `list-content-database-blocks` | List stable blocks and revisions in one exact database row/property | | `mutate-content-database-block` | Insert, update, upsert, delete, or reorder one supported stable block | | `migrate-content-database-rows` | Validate, atomically apply, verify, roll back, or finalize one bounded whole-database row migration | - Every action carries its own schema, and the rest of the app-specific surface (comments, sharing, databases, Notion, local file sources such as `remove-local-file-source`) is registered too — use `tool-search` instead of diff --git a/templates/content/actions/_database-utils.ts b/templates/content/actions/_database-utils.ts index 5572faac9c0..b76db454b89 100644 --- a/templates/content/actions/_database-utils.ts +++ b/templates/content/actions/_database-utils.ts @@ -304,6 +304,7 @@ export function serializeDatabaseMembership( databaseId: row.database.id, databaseDocumentId: row.database.documentId, databaseTitle: row.database.title || "Untitled database", + systemRole: row.database.systemRole, position: row.item.position, sourceId: row.sourceId ?? null, bodyHydration: serializeBodyHydration(row.item, { diff --git a/templates/content/actions/update-document.ts b/templates/content/actions/update-document.ts index 645733bc53a..057a9c34ca4 100644 --- a/templates/content/actions/update-document.ts +++ b/templates/content/actions/update-document.ts @@ -14,6 +14,7 @@ import { and, eq, desc, inArray } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; +import { commitCanonicalDocumentBodyMutation } from "../server/lib/canonical-document-body-mutation.js"; import { documentVersionChatContextFromAction, serializeDocumentVersionChatContext, @@ -535,43 +536,7 @@ export default defineAction({ id, ) : []; - if (useContentCas) { - const applied = await tx - .update(schema.documents) - .set(updates) - .where( - and( - eq(schema.documents.id, id), - eq(schema.documents.updatedAt, args.baseUpdatedAt as string), - ), - ) - .returning({ id: schema.documents.id }); - if (!applied || applied.length === 0) { - contentCasConflict = true; - return; - } - } else if (documentFieldsChanged) { - await tx - .update(schema.documents) - .set(updates) - .where(eq(schema.documents.id, id)); - } - - if (contentChanged && content !== undefined) { - for (const field of primaryBlocksFields) { - await persistBlocksFieldIdentity({ - db: tx as unknown as ReturnType, - ownerEmail: field.ownerEmail, - documentId: id, - propertyId: field.propertyId, - previousMarkdown: existing.content, - markdown: content, - now: updates.updatedAt as string, - }); - } - } - - if (titleChanged || contentChanged) { + const snapshotPreviousVersion = async () => { const [latestVersion] = await tx .select({ createdAt: schema.documentVersions.createdAt }) .from(schema.documentVersions) @@ -603,6 +568,61 @@ export default defineAction({ createdAt: new Date().toISOString(), }); } + }; + + if (contentChanged && content !== undefined) { + const applied = await commitCanonicalDocumentBodyMutation({ + write: async () => { + if (useContentCas) { + const rows = await tx + .update(schema.documents) + .set(updates) + .where( + and( + eq(schema.documents.id, id), + eq( + schema.documents.updatedAt, + args.baseUpdatedAt as string, + ), + ), + ) + .returning({ id: schema.documents.id }); + return rows.length > 0; + } + await tx + .update(schema.documents) + .set(updates) + .where(eq(schema.documents.id, id)); + return true; + }, + afterWrite: async () => { + for (const field of primaryBlocksFields) { + await persistBlocksFieldIdentity({ + db: tx as unknown as ReturnType, + ownerEmail: field.ownerEmail, + documentId: id, + propertyId: field.propertyId, + previousMarkdown: existing.content, + markdown: content, + now: updates.updatedAt as string, + }); + } + await snapshotPreviousVersion(); + }, + }); + if (!applied) { + contentCasConflict = true; + return; + } + } else if (documentFieldsChanged) { + await tx + .update(schema.documents) + .set(updates) + .where(eq(schema.documents.id, id)); + } + + if (titleChanged && !contentChanged) { + await snapshotPreviousVersion(); } if (favoriteChanged) { diff --git a/templates/content/actions/view-screen.ts b/templates/content/actions/view-screen.ts index 2fa68ef921a..2674c0f1464 100644 --- a/templates/content/actions/view-screen.ts +++ b/templates/content/actions/view-screen.ts @@ -683,11 +683,15 @@ export default defineAction({ http: false, run: async () => { const navigation = await readAppStateForCurrentTab("navigation"); + const suggestionMode = await readAppStateForCurrentTab( + "content-suggestion-mode", + ); const localFilesState = await readAppState("local-files"); const contentSpaceState = await readAppState("content-space"); const screen: Record = {}; if (navigation) screen.navigation = navigation; + if (suggestionMode) screen.suggestionMode = suggestionMode; if (contentSpaceState) screen.contentSpace = contentSpaceState; const nav = navigation as NavigationState | null; diff --git a/templates/content/app/components/editor/CommentsSidebar.tsx b/templates/content/app/components/editor/CommentsSidebar.tsx index db147036a96..8a93ad7f3dc 100644 --- a/templates/content/app/components/editor/CommentsSidebar.tsx +++ b/templates/content/app/components/editor/CommentsSidebar.tsx @@ -5,6 +5,11 @@ import { InlineMarkdown, type InlineMarkdownProtectedSpan, } from "@agent-native/core/client/markdown"; +import { ReviewThreadPanel } from "@agent-native/core/client/review"; +import type { + ResourceSuggestion, + SuggestionDecision, +} from "@agent-native/core/review"; import { IconCheck, IconMessageCircle, @@ -354,6 +359,13 @@ interface CommentsSidebarProps { canResolve?: boolean; alignToAnchors?: boolean; forceVisible?: boolean; + suggestions?: ResourceSuggestion[]; + canDecideSuggestions?: boolean; + decidingSuggestion?: boolean; + onDecideSuggestion?: ( + suggestion: ResourceSuggestion, + decision: SuggestionDecision, + ) => void; } export function CommentsSidebar({ @@ -373,6 +385,10 @@ export function CommentsSidebar({ canResolve = false, alignToAnchors = true, forceVisible = false, + suggestions = [], + canDecideSuggestions = false, + decidingSuggestion = false, + onDecideSuggestion, }: CommentsSidebarProps) { const t = useT(); const { data: members = [] } = useMentionMembers(); @@ -610,7 +626,10 @@ export function CommentsSidebar({ }, [onSelectedThreadChange, selectedThreadId, openThreads]); const hasContent = - openThreads.length > 0 || !!pendingComment || resolvedThreads.length > 0; + suggestions.length > 0 || + openThreads.length > 0 || + !!pendingComment || + resolvedThreads.length > 0; if (!hasContent && !isLoading && !forceVisible) return null; const items = layoutCommentThreads( @@ -655,6 +674,83 @@ export function CommentsSidebar({ {t("comments.empty")} ) : null} + {suggestions.length > 0 ? ( +
+ {suggestions.map((suggestion) => { + const operation = suggestion.operations[0]; + const before = operation?.before as + | { changedText?: string } + | undefined; + const after = operation?.after as + | { changedText?: string } + | undefined; + return ( +
+
+ + {operation?.kind.split("_").join(" ")} ·{" "} + {suggestion.authorEmail ?? suggestion.actorKind} + + {suggestion.status} +
+

+ {before?.changedText ? ( + + {before.changedText} + + ) : null} + {before?.changedText && after?.changedText ? " → " : null} + {after?.changedText ? {after.changedText} : null} +

+ {canDecideSuggestions && suggestion.status === "pending" ? ( +
+ + +
+ ) : null} + +
+ ); + })} +
+ ) : null} {/* Pending new comment — positioned at the selection Y offset */} {pendingComment && (
{ 'collabInitialization.status === "ready"', ); expect(documentEditorSource).toContain( - "ydoc={collabEditorEnabled ? ydoc : null}", + "suggestionEditorIsolation.bindCanonicalYDoc", ); expect(documentEditorSource).toContain( "awareness={collabEditorEnabled ? awareness : null}", @@ -620,7 +620,7 @@ describe("document editor layout", () => { expect(source).toContain("!collabInitializationFailed"); expect(source).toContain("data-collab-initialization-error"); expect(source).toContain("onRetry={() => globalThis.location.reload()}"); - expect(source).toContain("ydoc={collabEditorEnabled ? ydoc : null}"); + expect(source).toContain("suggestionEditorIsolation.bindCanonicalYDoc"); }); it("wakes live-editor flush reads from shared sync events instead of polling", () => { diff --git a/templates/content/app/components/editor/DocumentEditor.tsx b/templates/content/app/components/editor/DocumentEditor.tsx index 8379c1de7d2..0049f3f1e9a 100644 --- a/templates/content/app/components/editor/DocumentEditor.tsx +++ b/templates/content/app/components/editor/DocumentEditor.tsx @@ -8,6 +8,7 @@ import { emailToName, type CollabUser, } from "@agent-native/core/client/collab"; +import { useFeatureFlag } from "@agent-native/core/client/feature-flags"; import { setClientAppState, useAvatarUrl, @@ -15,6 +16,11 @@ import { useSession, } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; +import { + useCreateResourceSuggestion, + useDecideResourceSuggestion, + useResourceSuggestions, +} from "@agent-native/core/client/review"; import { normalizeDocumentTitle } from "@agent-native/core/shared"; import type { Document, DocumentSyncStatus } from "@shared/api"; import { @@ -34,7 +40,7 @@ import { useState, } from "react"; import type { ClipboardEvent, MutableRefObject } from "react"; -import { useNavigate } from "react-router"; +import { useLocation, useNavigate } from "react-router"; import { toast } from "sonner"; import { @@ -92,6 +98,7 @@ import { import { isDatabaseChoicePending } from "@/lib/optimistic-document"; import { cn } from "@/lib/utils"; +import { CONTENT_SUGGESTED_EDITS_FLAG } from "../../../shared/feature-flags"; import { documentBodyHydrationIsPending, isEffectivelyEmptyDocumentContent, @@ -115,6 +122,8 @@ import { type PendingLocalSourceWrite, } from "./local-source-write-state"; import { NotionConflictBanner } from "./NotionConflictBanner"; +import { suggestedEditorIsolation } from "./suggestions/editor-isolation"; +import { markdownSuggestionOperation } from "./suggestions/markdown-operation"; import { normalizeTitleText, stripMarkdownHeadingPrefixFromTitlePaste, @@ -624,6 +633,7 @@ function DocumentEditorBody({ [documentId, canEdit], ); const navigate = useNavigate(); + const location = useLocation(); const documentsQuery = useDocuments(); const documents: Document[] = documentsQuery.data ?? []; const contentSpacesQuery = useContentSpaces(); @@ -643,6 +653,28 @@ function DocumentEditorBody({ document.accessRole === "admin" || document.accessRole === "editor" || document.accessRole === "commenter")); + const suggestedEditsEnabled = useFeatureFlag( + CONTENT_SUGGESTED_EDITS_FLAG.key, + ); + const createSuggestion = useCreateResourceSuggestion(); + const decideSuggestion = useDecideResourceSuggestion(); + const suggestionsQuery = useResourceSuggestions( + { resourceType: "document", resourceId: documentId }, + { enabled: suggestedEditsEnabled && !isLocalFileDocument }, + ); + const [isSuggesting, setIsSuggesting] = useState(false); + const [suggestionDraft, setSuggestionDraft] = useState(document.content); + const [utilityPanel, setUtilityPanel] = useState(null); + const canSuggest = + suggestedEditsEnabled && + canComment && + !isLocalFileDocument && + !( + document.databaseMembership && + document.databaseMembership.systemRole == null + ) && + !document.database && + !document.source?.mode; const canDelete = !isLocalFileDocument && !document.database?.systemRole && @@ -750,6 +782,20 @@ function DocumentEditorBody({ navigate, t, ]); + + useEffect(() => { + const suggestionId = new URLSearchParams(location.search).get("suggestion"); + if (!suggestionId || !suggestionsQuery.data) return; + if (utilityPanel !== "comments") { + setUtilityPanel("comments"); + return; + } + const target = globalThis.document.querySelector( + `[data-suggestion-id="${CSS.escape(suggestionId)}"]`, + ); + target?.scrollIntoView({ block: "nearest" }); + target?.focus(); + }, [location.search, suggestionsQuery.data, utilityPanel]); const flushRequestKey = `flush-request-${documentId}`; const [flushRequestWake, setFlushRequestWake] = useState(0); const handleFlushRequestEvent = useCallback( @@ -958,6 +1004,12 @@ function DocumentEditorBody({ collabSynced && collabInitialization.status === "ready" && !collabInitializationFailed; + const suggestionEditorIsolation = suggestedEditorIsolation({ + suggesting: isSuggesting, + canSuggest, + canEdit: editorCanEdit, + collaborationReady: collabEditorEnabled, + }); canEditRef.current = editorCanEdit; // Viewers intentionally join awareness so they receive live cursors, but @@ -1853,6 +1905,86 @@ function DocumentEditorBody({ [debouncedSave, editorCanEdit], ); + const handleSuggestionModeChange = useCallback( + async (next: boolean) => { + if (next) { + if (!canSuggest) return; + setSuggestionDraft(document.content); + setIsSuggesting(true); + return; + } + if (!isSuggesting) return; + if (suggestionDraft === document.content) { + setIsSuggesting(false); + return; + } + try { + const operation = markdownSuggestionOperation( + document.content, + suggestionDraft, + ); + if (!operation) { + setIsSuggesting(false); + return; + } + await createSuggestion.mutateAsync({ + resourceType: "document", + resourceId: documentId, + adapterKind: "content.document-markdown", + baseRevision: document.updatedAt, + summary: t("editor.toolbar.suggestEdits"), + idempotencyKey: globalThis.crypto.randomUUID(), + operations: [operation], + }); + setIsSuggesting(false); + setUtilityPanel("comments"); + } catch (error) { + toast.error(t("editor.suggestionCreateFailed"), { + description: + error instanceof Error ? error.message : t("empty.genericError"), + }); + } + }, + [ + canSuggest, + createSuggestion, + document.content, + document.updatedAt, + documentId, + isSuggesting, + suggestionDraft, + t, + ], + ); + + useEffect(() => { + if (!canSuggest && isSuggesting) setIsSuggesting(false); + }, [canSuggest, isSuggesting]); + + useEffect(() => { + void setClientAppState( + "content-suggestion-mode", + { + documentId, + suggesting: isSuggesting, + draftChanged: isSuggesting && suggestionDraft !== document.content, + pendingCount: + suggestionsQuery.data?.suggestions.filter( + (suggestion) => suggestion.status === "pending", + ).length ?? 0, + }, + { requestSource: "content-editor" }, + ).catch(() => { + // Suggesting remains usable when best-effort agent context sync fails. + }); + }, [ + document.content, + documentId, + isSuggesting, + suggestionDraft, + suggestionsQuery.data?.suggestions, + ]); + const handleContentSaveNow = useCallback( async (newContent: string) => { if (!editorCanEdit) return false; @@ -1904,7 +2036,6 @@ function DocumentEditorBody({ } | null>(null); const [selectedThreadId, setSelectedThreadId] = useState(null); const [hoveredThreadId, setHoveredThreadId] = useState(null); - const [utilityPanel, setUtilityPanel] = useState(null); const activeThreadId = hoveredThreadId ?? selectedThreadId; const { data: threads, isLoading: commentsLoading } = useComments( !isLocalFileDocument ? documentId : null, @@ -2100,6 +2231,17 @@ function DocumentEditorBody({ canResolve={canEdit} alignToAnchors={hasUtilityRailSpace} forceVisible + suggestions={suggestionsQuery.data?.suggestions ?? []} + canDecideSuggestions={canEdit} + decidingSuggestion={decideSuggestion.isPending} + onDecideSuggestion={(suggestion, decision) => + decideSuggestion.mutate({ + id: suggestion.id, + decision, + idempotencyKey: globalThis.crypto.randomUUID(), + observedBase: suggestion.baseRevision, + }) + } /> ); const defaultIconKind = documentEditorDefaultIconKind(document); @@ -2229,6 +2371,11 @@ function DocumentEditorBody({ canRedo={editorHistoryState.canRedo} onUndo={() => editorHistoryControllerRef.current?.undo()} onRedo={() => editorHistoryControllerRef.current?.redo()} + canSuggest={canSuggest} + suggesting={isSuggesting} + onSuggestingChange={(next) => { + void handleSuggestionModeChange(next); + }} /> {!isLocalFileDocument ? ( @@ -2374,9 +2521,10 @@ function DocumentEditorBody({ rows={1} wrap="soft" value={localTitle} - onChange={(e) => - handleTitleChange(normalizeTitleText(e.target.value)) - } + onChange={(e) => { + if (!isSuggesting) + handleTitleChange(normalizeTitleText(e.target.value)); + }} onPaste={handleTitlePaste} onFocus={() => { titleFocusedRef.current = true; @@ -2385,7 +2533,7 @@ function DocumentEditorBody({ titleFocusedRef.current = false; }} onKeyDown={(e) => { - if (!editorCanEdit) return; + if (!editorCanEdit || isSuggesting) return; if (e.key === "Enter") { e.preventDefault(); const pm = window.document.querySelector( @@ -2396,7 +2544,7 @@ function DocumentEditorBody({ }} aria-label={t("editor.documentTitle")} placeholder={t("editor.title")} - readOnly={!editorCanEdit} + readOnly={!editorCanEdit || isSuggesting} style={{ fieldSizing: "content" } as any} className={cn( "block w-full resize-none overflow-hidden break-words border-none bg-transparent p-0 font-bold leading-tight text-foreground outline-none placeholder:text-muted-foreground/40", @@ -2511,7 +2659,7 @@ function DocumentEditorBody({
) : null} void; onRedo?: () => void; + canSuggest?: boolean; + suggesting?: boolean; + onSuggestingChange?: (suggesting: boolean) => void; } export function DocumentToolbar({ @@ -516,6 +520,9 @@ export function DocumentToolbar({ canRedo = false, onUndo, onRedo, + canSuggest = false, + suggesting = false, + onSuggestingChange, }: DocumentToolbarProps) { const t = useT(); const navigate = useNavigate(); @@ -987,6 +994,19 @@ export function DocumentToolbar({ )} + {suggesting ? ( + + ) : null} + @@ -1007,6 +1027,21 @@ export function DocumentToolbar({ + {canSuggest ? ( + <> + onSuggestingChange?.(!suggesting)} + > + + {t( + suggesting + ? "editor.toolbar.stopSuggesting" + : "editor.toolbar.suggestEdits", + )} + + + + ) : null} diff --git a/templates/content/app/components/editor/suggestions/controller.test.ts b/templates/content/app/components/editor/suggestions/controller.test.ts new file mode 100644 index 00000000000..860108034b4 --- /dev/null +++ b/templates/content/app/components/editor/suggestions/controller.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; + +import { + addPendingSuggestion, + createSuggestionDraft, + previewSuggestionDraft, + removePendingSuggestion, + suggestionDraftDecorations, +} from "./controller"; +import { digest, makeAnchor, textContent, type SuggestionNode } from "./model"; + +const makeDoc = (text: string): SuggestionNode => ({ + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text }] }], +}); + +describe("suggestion draft controller", () => { + it("keeps canonical content unchanged while deriving a preview", () => { + const canonical = makeDoc("Hello"); + const draft = createSuggestionDraft(canonical); + const next = addPendingSuggestion(draft, { + id: "add-1", + kind: "insert_text", + from: 5, + to: 5, + before: "", + after: " world", + baseDigest: digest(textContent(canonical)), + anchor: makeAnchor("Hello", 5, 5), + }); + + expect(textContent(previewSuggestionDraft(next))).toBe("Hello world"); + expect(textContent(canonical)).toBe("Hello"); + expect(next.pending).toHaveLength(1); + expect(suggestionDraftDecorations(next)[0]).toMatchObject({ + operationId: "add-1", + className: "suggestion-change", + }); + }); + + it("removes a pending operation without changing the baseline", () => { + const canonical = makeDoc("Hello"); + const draft = createSuggestionDraft(canonical); + const operation = { + id: "delete-1", + kind: "delete_text" as const, + from: 0, + to: 5, + before: "Hello", + after: "", + baseDigest: digest("Hello"), + anchor: makeAnchor("Hello", 0, 5), + }; + const pending = addPendingSuggestion(draft, operation); + const restored = removePendingSuggestion(pending, operation.id); + expect(textContent(previewSuggestionDraft(restored))).toBe("Hello"); + expect(restored.pending).toEqual([]); + }); +}); diff --git a/templates/content/app/components/editor/suggestions/controller.ts b/templates/content/app/components/editor/suggestions/controller.ts new file mode 100644 index 00000000000..f784aa288a3 --- /dev/null +++ b/templates/content/app/components/editor/suggestions/controller.ts @@ -0,0 +1,52 @@ +import { + applySuggestionOperations, + suggestionDecorations, + type SuggestionDecoration, + type SuggestionNode, + type SuggestionOperation, +} from "./model"; + +export type SuggestionDraft = { + canonical: SuggestionNode; + pending: readonly SuggestionOperation[]; +}; + +/** + * Isolated, persistence-free state for a suggesting editor. The canonical tree + * is retained as an immutable baseline; only the derived preview is projected + * from pending operations, so this controller cannot write SQL or Yjs state. + */ +export function createSuggestionDraft( + canonical: SuggestionNode, +): SuggestionDraft { + return { canonical, pending: [] }; +} + +export function addPendingSuggestion( + draft: SuggestionDraft, + operation: SuggestionOperation, +): SuggestionDraft { + // Validate against the current preview before retaining the operation. + applySuggestionOperations(draft.canonical, [...draft.pending, operation]); + return { ...draft, pending: [...draft.pending, operation] }; +} + +export function removePendingSuggestion( + draft: SuggestionDraft, + operationId: string, +): SuggestionDraft { + return { + ...draft, + pending: draft.pending.filter((operation) => operation.id !== operationId), + }; +} + +export function previewSuggestionDraft(draft: SuggestionDraft): SuggestionNode { + return applySuggestionOperations(draft.canonical, draft.pending); +} + +export function suggestionDraftDecorations( + draft: SuggestionDraft, +): SuggestionDecoration[] { + return suggestionDecorations(draft.canonical, draft.pending); +} diff --git a/templates/content/app/components/editor/suggestions/editor-isolation.test.ts b/templates/content/app/components/editor/suggestions/editor-isolation.test.ts new file mode 100644 index 00000000000..50f8ca6ac8b --- /dev/null +++ b/templates/content/app/components/editor/suggestions/editor-isolation.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { suggestedEditorIsolation } from "./editor-isolation"; + +describe("suggestedEditorIsolation", () => { + it("never binds or saves canonical state while suggesting", () => { + expect( + suggestedEditorIsolation({ + suggesting: true, + canSuggest: true, + canEdit: true, + collaborationReady: true, + }), + ).toEqual({ + editable: true, + bindCanonicalYDoc: false, + persistCanonical: false, + }); + }); + + it("lets a commenter use the isolated editor without direct edit rights", () => { + expect( + suggestedEditorIsolation({ + suggesting: true, + canSuggest: true, + canEdit: false, + collaborationReady: false, + }).editable, + ).toBe(true); + }); +}); diff --git a/templates/content/app/components/editor/suggestions/editor-isolation.ts b/templates/content/app/components/editor/suggestions/editor-isolation.ts new file mode 100644 index 00000000000..42a24548093 --- /dev/null +++ b/templates/content/app/components/editor/suggestions/editor-isolation.ts @@ -0,0 +1,18 @@ +export function suggestedEditorIsolation(args: { + suggesting: boolean; + canSuggest: boolean; + canEdit: boolean; + collaborationReady: boolean; +}) { + return args.suggesting + ? { + editable: args.canSuggest, + bindCanonicalYDoc: false, + persistCanonical: false, + } + : { + editable: args.canEdit, + bindCanonicalYDoc: args.collaborationReady, + persistCanonical: args.canEdit, + }; +} diff --git a/templates/content/app/components/editor/suggestions/markdown-operation.test.ts b/templates/content/app/components/editor/suggestions/markdown-operation.test.ts new file mode 100644 index 00000000000..83724d804c8 --- /dev/null +++ b/templates/content/app/components/editor/suggestions/markdown-operation.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { markdownSuggestionOperation } from "./markdown-operation"; + +describe("markdownSuggestionOperation", () => { + it.each([ + ["Hello", "Hello!", "insert_text"], + ["Hello!", "Hello", "delete_text"], + ["Hello", "Hi", "replace_text"], + ["Hello", "Hello\n\nNext", "add_text_block"], + ["Hello", "**Hello**", "set_inline_mark"], + ])("classifies %s -> %s as %s", (before, after, kind) => { + expect(markdownSuggestionOperation(before, after)?.kind).toBe(kind); + }); + + it("retains exact canonical snapshots and a narrow anchor", () => { + const operation = markdownSuggestionOperation( + "one two three", + "one 2 three", + ); + expect(operation).toMatchObject({ + before: { markdown: "one two three", changedText: "two" }, + after: { markdown: "one 2 three", changedText: "2" }, + anchor: { prefix: "one ", suffix: " three" }, + }); + }); +}); diff --git a/templates/content/app/components/editor/suggestions/markdown-operation.ts b/templates/content/app/components/editor/suggestions/markdown-operation.ts new file mode 100644 index 00000000000..530c5901a69 --- /dev/null +++ b/templates/content/app/components/editor/suggestions/markdown-operation.ts @@ -0,0 +1,64 @@ +export type MarkdownSuggestionOperation = { + ordinal: number; + kind: + | "insert_text" + | "delete_text" + | "replace_text" + | "add_text_block" + | "set_inline_mark"; + targetId: "body"; + before: { markdown: string; changedText: string }; + after: { markdown: string; changedText: string }; + anchor: { from: number; to: number; prefix: string; suffix: string }; + schemaVersion: 1; +}; + +const MARKDOWN_MARK = /(?:\*\*|__|~~|`|\[|\]\([^)]*\))/g; + +export function markdownSuggestionOperation( + before: string, + after: string, +): MarkdownSuggestionOperation | null { + if (before === after) return null; + let from = 0; + while (from < before.length && before[from] === after[from]) from += 1; + let suffixLength = 0; + while ( + suffixLength < before.length - from && + suffixLength < after.length - from && + before[before.length - suffixLength - 1] === + after[after.length - suffixLength - 1] + ) { + suffixLength += 1; + } + const beforeEnd = before.length - suffixLength; + const afterEnd = after.length - suffixLength; + const removed = before.slice(from, beforeEnd); + const inserted = after.slice(from, afterEnd); + const sameUnmarkedText = + removed.replace(MARKDOWN_MARK, "") === inserted.replace(MARKDOWN_MARK, ""); + const kind = + sameUnmarkedText && removed !== inserted + ? "set_inline_mark" + : !removed + ? inserted.includes("\n") + ? "add_text_block" + : "insert_text" + : !inserted + ? "delete_text" + : "replace_text"; + return { + ordinal: 0, + kind, + targetId: "body", + before: { markdown: before, changedText: removed }, + after: { markdown: after, changedText: inserted }, + anchor: { + from, + to: beforeEnd, + prefix: before.slice(Math.max(0, from - 32), from), + suffix: before.slice(beforeEnd, beforeEnd + 32), + }, + schemaVersion: 1, + }; +} diff --git a/templates/content/app/components/editor/suggestions/model.test.ts b/templates/content/app/components/editor/suggestions/model.test.ts new file mode 100644 index 00000000000..9f0d34199e3 --- /dev/null +++ b/templates/content/app/components/editor/suggestions/model.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; + +import { + applySuggestionOperations, + digest, + makeAnchor, + suggestionDecorations, + supportsSuggestionBlock, + supportsSuggestionMark, + textContent, + type SuggestionNode, + type SuggestionOperation, + type TextOperation, +} from "./model"; + +const doc = (text: string): SuggestionNode => ({ + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text }] }], +}); + +function textOp( + kind: "insert_text" | "delete_text" | "replace_text", + from: number, + to: number, + before: string, + after: string, + base: SuggestionNode, +): SuggestionOperation { + return { + id: kind, + kind, + from, + to, + before, + after, + baseDigest: digest(textContent(base)), + anchor: makeAnchor(textContent(base), from, to), + }; +} + +describe("suggestion model", () => { + it("applies exact ordered text operations without mutating canonical input", () => { + const canonical = doc("Hello world"); + const add = textOp("insert_text", 5, 5, "", ", brave", canonical); + const afterAdd = applySuggestionOperations(canonical, [add]); + const replace = textOp("replace_text", 13, 18, "world", "reader", afterAdd); + const result = applySuggestionOperations(canonical, [add, replace]); + + expect(textContent(result)).toBe("Hello, brave reader"); + expect(textContent(canonical)).toBe("Hello world"); + expect((add as TextOperation).before).toBe(""); + expect((replace as TextOperation).before).toBe("world"); + }); + + it("rejects stale or inaccurate before-state", () => { + const canonical = doc("Hello"); + expect(() => + applySuggestionOperations(canonical, [ + textOp("delete_text", 0, 5, "World", "", canonical), + ]), + ).toThrow("before-state"); + const stale = { + ...textOp("delete_text", 0, 5, "Hello", "", canonical), + baseDigest: "deadbeef", + }; + expect(() => applySuggestionOperations(canonical, [stale])).toThrow( + "stale base", + ); + }); + + it("supports new blocks and inline mark changes with gating", () => { + const canonical = doc("Hello"); + const block: SuggestionOperation = { + id: "block", + kind: "add_text_block", + index: 1, + block: { type: "paragraph", content: [{ type: "text", text: "Next" }] }, + baseDigest: digest(textContent(canonical)), + anchor: makeAnchor("Hello", 5, 5), + }; + const marked: SuggestionOperation = { + id: "mark", + kind: "set_inline_mark", + from: 0, + to: 5, + mark: { type: "bold" }, + enabled: true, + before: [[]], + after: [[{ type: "bold" }]], + baseDigest: digest(textContent(canonical)), + anchor: makeAnchor("Hello", 0, 5), + }; + const result = applySuggestionOperations(canonical, [block]); + expect(result.content).toHaveLength(2); + expect(suggestionDecorations(canonical, [marked])).toMatchObject([ + { kind: "set_inline_mark", from: 0, to: 5 }, + ]); + expect(supportsSuggestionBlock(block.block)).toBe(true); + expect(supportsSuggestionMark(marked.mark)).toBe(true); + expect(supportsSuggestionBlock({ type: "image" })).toBe(false); + expect(supportsSuggestionMark({ type: "highlight" } as any)).toBe(false); + }); +}); diff --git a/templates/content/app/components/editor/suggestions/model.ts b/templates/content/app/components/editor/suggestions/model.ts new file mode 100644 index 00000000000..bba51de5f82 --- /dev/null +++ b/templates/content/app/components/editor/suggestions/model.ts @@ -0,0 +1,263 @@ +export type SuggestionMark = + | "bold" + | "italic" + | "underline" + | "strike" + | "code" + | "link"; + +export type InlineMark = { + type: SuggestionMark; + attrs?: Record; +}; + +export type SuggestionNode = { + type: string; + attrs?: Record; + content?: SuggestionNode[]; + text?: string; + marks?: InlineMark[]; +}; + +export type TextOperation = { + id: string; + kind: "insert_text" | "delete_text" | "replace_text"; + from: number; + to: number; + before: string; + after: string; + baseDigest: string; + anchor: SuggestionAnchor; +}; + +export type AddBlockOperation = { + id: string; + kind: "add_text_block"; + index: number; + block: SuggestionNode; + baseDigest: string; + anchor: SuggestionAnchor; +}; + +export type MarkOperation = { + id: string; + kind: "set_inline_mark"; + from: number; + to: number; + mark: InlineMark; + enabled: boolean; + before: InlineMark[][]; + after: InlineMark[][]; + baseDigest: string; + anchor: SuggestionAnchor; +}; + +export type SuggestionOperation = + | TextOperation + | AddBlockOperation + | MarkOperation; + +export type SuggestionAnchor = { + prefix: string; + suffix: string; +}; + +export type SuggestionDecoration = { + operationId: string; + kind: SuggestionOperation["kind"]; + from: number; + to: number; + className: string; + attrs?: Record; +}; + +export const SUPPORTED_SUGGESTION_BLOCKS = new Set([ + "paragraph", + "heading", + "blockquote", + "bulletList", + "orderedList", + "listItem", + "taskList", + "taskItem", + "codeBlock", + "horizontalRule", +]); + +export const SUPPORTED_SUGGESTION_MARKS = new Set([ + "bold", + "italic", + "underline", + "strike", + "code", + "link", +]); + +function walkText( + node: SuggestionNode, + visit: (node: SuggestionNode, from: number) => void, + state = { offset: 0 }, +): void { + if (node.text !== undefined) { + visit(node, state.offset); + state.offset += node.text.length; + return; + } + node.content?.forEach((child) => walkText(child, visit, state)); +} + +export function textContent(doc: SuggestionNode): string { + let result = ""; + walkText(doc, (node) => { + result += node.text ?? ""; + }); + return result; +} + +/** Stable, dependency-free digest suitable for detecting a proposal base. */ +export function digest(value: string): string { + let hash = 2166136261; + for (let i = 0; i < value.length; i += 1) { + hash ^= value.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +export function makeAnchor( + text: string, + from: number, + to: number, +): SuggestionAnchor { + return { + prefix: text.slice(Math.max(0, from - 32), from), + suffix: text.slice(to, to + 32), + }; +} + +export function supportsSuggestionBlock(node: SuggestionNode): boolean { + return SUPPORTED_SUGGESTION_BLOCKS.has(node.type); +} + +export function supportsSuggestionMark(mark: InlineMark): boolean { + return SUPPORTED_SUGGESTION_MARKS.has(mark.type); +} + +function clone(value: T): T { + return structuredClone(value); +} + +function findTextRange(doc: SuggestionNode, from: number, to: number) { + let match: { node: SuggestionNode; start: number } | null = null; + walkText(doc, (node, start) => { + if (!match && from >= start && to <= start + (node.text?.length ?? 0)) { + match = { node, start }; + } + }); + return match as { node: SuggestionNode; start: number } | null; +} + +function replaceText( + doc: SuggestionNode, + from: number, + to: number, + value: string, +): SuggestionNode { + const result = clone(doc); + const found = findTextRange(result, from, to); + if (!found) + throw new Error("Suggestion range crosses unsupported text nodes"); + const localFrom = from - found.start; + const localTo = to - found.start; + found.node.text = `${found.node.text?.slice(0, localFrom) ?? ""}${value}${found.node.text?.slice(localTo) ?? ""}`; + return result; +} + +function marksEqual(a: InlineMark[], b: InlineMark[]): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +function applyMark( + doc: SuggestionNode, + operation: MarkOperation, +): SuggestionNode { + const result = clone(doc); + const found = findTextRange(result, operation.from, operation.to); + if (!found) throw new Error("Mark range crosses unsupported text nodes"); + const node = found.node; + const marks = node.marks ?? []; + const next = marks.filter( + (candidate) => candidate.type !== operation.mark.type, + ); + if (operation.enabled) next.push(operation.mark); + if (!marksEqual(marks, operation.before[0] ?? marks)) { + throw new Error("Suggestion mark before-state does not match document"); + } + node.marks = next; + return result; +} + +export function applySuggestionOperations( + canonical: SuggestionNode, + operations: readonly SuggestionOperation[], +): SuggestionNode { + let result = clone(canonical); + for (const operation of operations) { + if (digest(textContent(result)) !== operation.baseDigest) { + throw new Error(`Suggestion ${operation.id} has a stale base`); + } + if (operation.kind === "add_text_block") { + if (!supportsSuggestionBlock(operation.block)) + throw new Error("Unsupported suggestion block"); + const content = result.content ? [...result.content] : []; + content.splice(operation.index, 0, clone(operation.block)); + result = { ...result, content }; + } else if (operation.kind === "set_inline_mark") { + if (!supportsSuggestionMark(operation.mark)) + throw new Error("Unsupported suggestion mark"); + result = applyMark(result, operation); + } else { + const current = textContent(result).slice(operation.from, operation.to); + if (current !== operation.before) + throw new Error( + `Suggestion ${operation.id} before-state does not match`, + ); + result = replaceText( + result, + operation.from, + operation.to, + operation.after, + ); + } + } + return result; +} + +export function suggestionDecorations( + canonical: SuggestionNode, + operations: readonly SuggestionOperation[], +): SuggestionDecoration[] { + const decorations: SuggestionDecoration[] = []; + let delta = 0; + for (const operation of operations) { + if (operation.kind === "add_text_block") continue; + const from = operation.from + delta; + const oldLength = operation.to - operation.from; + const newLength = + operation.kind === "set_inline_mark" ? oldLength : operation.after.length; + decorations.push({ + operationId: operation.id, + kind: operation.kind, + from, + to: from + Math.max(oldLength, newLength), + className: + operation.kind === "delete_text" + ? "suggestion-delete" + : "suggestion-change", + attrs: { "data-suggestion-id": operation.id }, + }); + if (operation.kind !== "set_inline_mark") delta += newLength - oldLength; + } + void canonical; + return decorations; +} diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index 695ff903053..bb4a5628167 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -2867,6 +2867,9 @@ const editorToolbarMessages = { localAndNotionChanged: "Local and Notion changed since the last sync. Choose which version wins.", morePageActions: "More page actions", + suggestEdits: "Suggest edits", + suggesting: "Suggesting", + stopSuggesting: "Stop suggesting", noPagesFound: "No pages found", notifications: "Notifications", notionSync: "Notion sync", @@ -3197,6 +3200,10 @@ const enUS = { "Connecting live editor. Showing a read-only snapshot.", liveDocumentSaveBeforeSyncFailed: "The live document could not be saved before syncing.", + suggestionCreateFailed: "Could not create suggestion", + suggestionsCount: "{{count}} suggestions", + acceptSuggestion: "Accept", + rejectSuggestion: "Reject", documentTitle: "Document title", builderBodySyncing: "This page's content is still syncing from Builder", builderBodySyncingDescription: @@ -3535,6 +3542,10 @@ const rawLiteralLocaleMessages: Partial> = { goToDocuments: "前往文档", }, editor: { + suggestionCreateFailed: "无法创建建议", + suggestionsCount: "{{count}} 条建议", + acceptSuggestion: "接受", + rejectSuggestion: "拒绝", couldNotReadLocalSourceFile: "无法读取本地源文件", couldNotSaveLocalFile: "无法保存本地文件", localFileChangedWithUnsavedEdits: @@ -3546,6 +3557,9 @@ const rawLiteralLocaleMessages: Partial> = { localFileSavedHistoryNotUpdated: "本地文件已保存,但历史记录未更新", title: "标题", toolbar: { + suggestEdits: "建议修改", + suggesting: "建议中", + stopSuggesting: "停止建议", undo: "撤销", redo: "重做", conflict: "冲突", @@ -3770,6 +3784,10 @@ const rawLiteralLocaleMessages: Partial> = { goToDocuments: "Ir a documentos", }, editor: { + suggestionCreateFailed: "No se pudo crear la sugerencia", + suggestionsCount: "{{count}} sugerencias", + acceptSuggestion: "Aceptar", + rejectSuggestion: "Rechazar", couldNotReadLocalSourceFile: "No se pudo leer el archivo local de origen", couldNotSaveLocalFile: "No se pudo guardar el archivo local", localFileChangedWithUnsavedEdits: @@ -3843,6 +3861,9 @@ const rawLiteralLocaleMessages: Partial> = { "Transcribe este video y agrega la transcripción debajo.", }, toolbar: { + suggestEdits: "Sugerir cambios", + suggesting: "Sugiriendo", + stopSuggesting: "Dejar de sugerir", undo: "Deshacer", redo: "Rehacer", conflict: "Conflicto", @@ -3984,12 +4005,22 @@ const rawLiteralLocaleMessages: Partial> = { }, "fr-FR": { editor: { + suggestionCreateFailed: "Impossible de créer la suggestion", + suggestionsCount: "{{count}} suggestions", + acceptSuggestion: "Accepter", + rejectSuggestion: "Refuser", localFileChangedWithUnsavedEdits: "Le fichier sur le disque a changé alors que cette page contenait des modifications non enregistrées.", copyUnsavedText: "Copier mon texte non enregistré", unsavedTextCopied: "Texte non enregistré copié", useDiskVersion: "Utiliser la version du disque", - toolbar: { undo: "Annuler", redo: "Rétablir" }, + toolbar: { + undo: "Annuler", + redo: "Rétablir", + suggestEdits: "Suggérer des modifications", + suggesting: "Suggestion", + stopSuggesting: "Arrêter de suggérer", + }, }, sidebar: { addChild: "Ajouter un enfant", @@ -4028,12 +4059,22 @@ const rawLiteralLocaleMessages: Partial> = { }, "de-DE": { editor: { + suggestionCreateFailed: "Vorschlag konnte nicht erstellt werden", + suggestionsCount: "{{count}} Vorschläge", + acceptSuggestion: "Annehmen", + rejectSuggestion: "Ablehnen", localFileChangedWithUnsavedEdits: "Die Datei auf dem Datenträger wurde geändert, während diese Seite ungespeicherte Änderungen enthielt.", copyUnsavedText: "Meinen ungespeicherten Text kopieren", unsavedTextCopied: "Ungespeicherter Text kopiert", useDiskVersion: "Version vom Datenträger verwenden", - toolbar: { undo: "Rückgängig", redo: "Wiederholen" }, + toolbar: { + undo: "Rückgängig", + redo: "Wiederholen", + suggestEdits: "Änderungen vorschlagen", + suggesting: "Vorschlagen", + stopSuggesting: "Vorschlagen beenden", + }, }, sidebar: { addChild: "Unterelement hinzufügen", @@ -4071,12 +4112,22 @@ const rawLiteralLocaleMessages: Partial> = { }, "ja-JP": { editor: { + suggestionCreateFailed: "提案を作成できませんでした", + suggestionsCount: "{{count}} 件の提案", + acceptSuggestion: "承認", + rejectSuggestion: "却下", localFileChangedWithUnsavedEdits: "このページに未保存の編集がある間に、ディスク上のファイルが変更されました。", copyUnsavedText: "未保存のテキストをコピー", unsavedTextCopied: "未保存のテキストをコピーしました", useDiskVersion: "ディスク上の版を使用", - toolbar: { undo: "元に戻す", redo: "やり直す" }, + toolbar: { + undo: "元に戻す", + redo: "やり直す", + suggestEdits: "編集を提案", + suggesting: "提案中", + stopSuggesting: "提案を終了", + }, }, sidebar: { addChild: "子項目を追加", @@ -4112,12 +4163,22 @@ const rawLiteralLocaleMessages: Partial> = { }, "ko-KR": { editor: { + suggestionCreateFailed: "제안을 만들 수 없습니다", + suggestionsCount: "제안 {{count}}개", + acceptSuggestion: "수락", + rejectSuggestion: "거절", localFileChangedWithUnsavedEdits: "이 페이지에 저장하지 않은 편집 내용이 있는 동안 디스크의 파일이 변경되었습니다.", copyUnsavedText: "저장하지 않은 텍스트 복사", unsavedTextCopied: "저장하지 않은 텍스트를 복사했습니다", useDiskVersion: "디스크 버전 사용", - toolbar: { undo: "실행 취소", redo: "다시 실행" }, + toolbar: { + undo: "실행 취소", + redo: "다시 실행", + suggestEdits: "수정 제안", + suggesting: "제안 중", + stopSuggesting: "제안 중지", + }, }, sidebar: { addChild: "하위 항목 추가", @@ -4152,12 +4213,22 @@ const rawLiteralLocaleMessages: Partial> = { }, "pt-BR": { editor: { + suggestionCreateFailed: "Não foi possível criar a sugestão", + suggestionsCount: "{{count}} sugestões", + acceptSuggestion: "Aceitar", + rejectSuggestion: "Rejeitar", localFileChangedWithUnsavedEdits: "O arquivo no disco foi alterado enquanto esta página tinha edições não salvas.", copyUnsavedText: "Copiar meu texto não salvo", unsavedTextCopied: "Texto não salvo copiado", useDiskVersion: "Usar versão do disco", - toolbar: { undo: "Desfazer", redo: "Refazer" }, + toolbar: { + undo: "Desfazer", + redo: "Refazer", + suggestEdits: "Sugerir alterações", + suggesting: "Sugerindo", + stopSuggesting: "Parar de sugerir", + }, }, sidebar: { addChild: "Adicionar filho", @@ -4194,12 +4265,22 @@ const rawLiteralLocaleMessages: Partial> = { }, "hi-IN": { editor: { + suggestionCreateFailed: "सुझाव नहीं बनाया जा सका", + suggestionsCount: "{{count}} सुझाव", + acceptSuggestion: "स्वीकार करें", + rejectSuggestion: "अस्वीकार करें", localFileChangedWithUnsavedEdits: "इस पेज में सहेजे नहीं गए बदलाव होने के दौरान डिस्क की फ़ाइल बदल गई।", copyUnsavedText: "मेरा सहेजा नहीं गया टेक्स्ट कॉपी करें", unsavedTextCopied: "सहेजा नहीं गया टेक्स्ट कॉपी किया गया", useDiskVersion: "डिस्क वाला संस्करण उपयोग करें", - toolbar: { undo: "पूर्ववत करें", redo: "फिर से करें" }, + toolbar: { + undo: "पूर्ववत करें", + redo: "फिर से करें", + suggestEdits: "बदलाव सुझाएं", + suggesting: "सुझाव दे रहे हैं", + stopSuggesting: "सुझाव देना बंद करें", + }, }, sidebar: { addChild: "चाइल्ड जोड़ें", @@ -4234,12 +4315,22 @@ const rawLiteralLocaleMessages: Partial> = { }, "ar-SA": { editor: { + suggestionCreateFailed: "تعذر إنشاء الاقتراح", + suggestionsCount: "{{count}} اقتراحات", + acceptSuggestion: "قبول", + rejectSuggestion: "رفض", localFileChangedWithUnsavedEdits: "تغيّر الملف على القرص أثناء وجود تعديلات غير محفوظة في هذه الصفحة.", copyUnsavedText: "نسخ النص غير المحفوظ", unsavedTextCopied: "تم نسخ النص غير المحفوظ", useDiskVersion: "استخدام نسخة القرص", - toolbar: { undo: "تراجع", redo: "إعادة" }, + toolbar: { + undo: "تراجع", + redo: "إعادة", + suggestEdits: "اقتراح تعديلات", + suggesting: "جارٍ الاقتراح", + stopSuggesting: "إيقاف الاقتراح", + }, }, sidebar: { addChild: "إضافة عنصر فرعي", diff --git a/templates/content/app/i18n/zh-TW.ts b/templates/content/app/i18n/zh-TW.ts index 1e0e65fa7a5..e1237abc429 100644 --- a/templates/content/app/i18n/zh-TW.ts +++ b/templates/content/app/i18n/zh-TW.ts @@ -87,6 +87,10 @@ const messages = { genericError: "出了點問題", }, editor: { + suggestionCreateFailed: "無法建立建議", + suggestionsCount: "{{count}} 則建議", + acceptSuggestion: "接受", + rejectSuggestion: "拒絕", bold: "加粗", italic: "斜體", strikethrough: "刪除線", @@ -502,6 +506,9 @@ const messages = { videoDescription: "上傳或嵌入影片", }, toolbar: { + suggestEdits: "建議修改", + suggesting: "建議中", + stopSuggesting: "停止建議", undo: "復原", redo: "重做", info: "資訊", diff --git a/templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md b/templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md new file mode 100644 index 00000000000..64168a8cab4 --- /dev/null +++ b/templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md @@ -0,0 +1,395 @@ +--- +title: "Content Suggested Edits parity shape" +date: 2026-09-02 +status: shape-complete +authoritySchemaVersion: 3 +ledgerRevision: content-suggested-edits-shape-r2 +governingArtifactRevision: content-suggested-edits-shape-r2 +--- + +# Content Suggested Edits parity + +## Summary + +Add **Suggested Edits** to Agent-Native Content with behavioral parity to Notion's observed feature: a commenter, editor, admin, owner, or authorized agent can propose page-body text changes without changing the canonical page; reviewers inspect each proposal in place, discuss it, and accept or reject it durably. The first shipped slice deliberately matches Notion's narrow page-body boundary rather than prematurely implementing Content's broader typed-diff roadmap. + +The implementation should extend Core's existing review domain with executable suggestions and let Content register the document-specific operation and renderer adapter. Content owns page semantics, supported blocks, editor mode, and canonical mutation. Core owns proposal identity, thread/disposition lifecycle, permissions integration, notifications, audit/history seams, and shared actions. Suggestions are not comments with overloaded metadata, recovery versions, raw Yjs updates, or draft copies of pages. + +## Human problem and stakes + +Today, a Content collaborator can either comment without showing the exact desired edit or directly edit the canonical document. Agents face the same binary choice. Reviewers must translate prose feedback into changes or trust a direct rewrite after the fact. + +After this feature, a collaborator can make the intended revision directly in the familiar editor while the canonical document stays unchanged. The owner can understand the proposed result in context, discuss it, and make an attributable accept/reject decision. This matters most for agent-assisted editing and comment-level collaborators: both can contribute exact changes without receiving direct-edit authority. + +## Exact parity boundary + +“Parity” means parity with the firsthand Notion behavior observed on 2026-09-01, not parity with every future capability in Content's `Review changes in place` roadmap. + +| Behavior | Content contract | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Enter mode | `•••` page menu exposes **Suggest edits**; active mode is labeled **Suggesting** in the page header. | +| Exit mode | Header control or page menu stops suggesting; pending proposals remain untouched. | +| Permissions | Owner/admin/editor can edit and suggest. Commenter can suggest and comment but cannot directly edit. Viewer remains read-only. Server actions enforce the same matrix as the UI and agent. | +| Add | Inserted text is provisional and stored as an Add operation. | +| Delete | Deleted text remains represented until acceptance and is stored as a Delete operation. | +| Replace | Replacing a selected range is one atomic Replace operation with exact before/after text. | +| Formatting | Supported inline mark changes are typed operations, initially Bold, Italic, Underline, Strikethrough, Code, and Link add/remove/change where the renderer can preserve exact material. | +| New text block | A new supported block is one proposal, even if its internal operation contains a boundary plus content insertion. | +| Review | Each suggestion has author, timestamp, typed summary, Accept, Reject, reactions, replies, and a more menu. Decisions apply immediately and idempotently. | +| History | Pending, accepted, and rejected suggestions remain visible in **All discussions**. Resolution removes decision controls but not the thread. | +| Thread tools | Mark unread, copy deep link, and mute replies are available for suggestion threads. | +| Notifications | Page owner/relevant participants receive the existing durable notification flow for new suggestions, replies, mentions, reactions where policy calls for it, and dispositions. | +| Agent parity | Agents create the same suggestion objects through shared actions; they do not directly mutate canonical content while operating in suggestion mode. | +| Locking | A locked/read-only page disables suggestion creation even if the role would otherwise permit it. Existing proposals remain readable according to access. | +| Scope exclusions | No title, icon, cover, database property, inline database, media/embed, local-file, source-owned, or peek/preview suggestion editing in the first release. Unsupported content remains read-only in suggesting mode. | +| Bulk decisions | No Accept all / Reject all in parity v1. Content's future filtered-review capability remains separate. | + +One intentional correction to Notion's observed UI: a block badge reports suggestions and replies separately instead of inflating “suggestions” when someone replies. + +## Architecture grounding and fit + +### Demonstrated caller + +The demonstrated caller is an authenticated Content user or accountable agent opening a canonical Content Page and requesting “Suggest edits,” then proposing a supported body change for later review. + +### Existing primitives and seams + +- `templates/content/app/components/editor/VisualEditor.tsx` is the TipTap/ProseMirror editing surface and already participates in Yjs collaboration. +- `templates/content/server/plugins/collab.ts` and Core's collaboration substrate already synchronize human and agent changes, cursor state, and editor reconciliation. +- Content already exposes Page access as `canComment` and `canEdit`; Core sharing already has viewer/commenter/editor/admin/owner role ordering. +- Content's comments already provide text anchors, replies, mentions, resolution, and a comments rail. +- `packages/core/src/review` already owns reusable access-scoped review comments, threads, mentions, notifications, and review status actions. +- `packages/core/src/history` and automatic action audit provide reusable version/history and actor attribution donors. +- The approved Content records `content.diff.in-place`, `content.diff.filtered-review`, `content.diff.ai-assist`, `content.version.field-history`, and feature 7 already define the broader destination. + +### Ownership boundaries + +- **Core review domain:** stable suggestion/change/thread/decision types, stores, access-scoped list/create/reply/react/mute/unread/decide actions, idempotency, durable attribution, and notification hooks. +- **Content domain:** supported page-body operation grammar, document access/lock/source checks, base revision calculation, canonical apply transaction, comment-rail integration, and editor rendering. +- **TipTap editor:** ephemeral composition and provisional visual presentation. It is not the durable source of suggestion truth. +- **Yjs collaboration:** transports live canonical editor state and presence. Pending suggestions must not be written into the canonical Y.Doc as if accepted. +- **SQL:** owns durable suggestions, operations, thread state, dispositions, and canonical Content. Large editor snapshots or Yjs blobs are not copied into suggestion rows. +- **Audit/history:** records actual proposal and decision actions. A pending suggestion is not a committed Content revision; acceptance creates the canonical mutation and its normal history/audit record. + +### Return-to-shape decision: canonical mutation coordination + +The first implementation pass exposed one material architecture gap: suggestion acceptance can update canonical SQL and version history inside the suggestion transaction, but Core's current Yjs writer independently owns its document lock, persistence CAS, cache mutation, and broadcast. Calling that writer before the suggestion transaction commits can leak a rolled-back edit; calling it afterward leaves a committed SQL change vulnerable to an already-open client flushing stale cached Yjs state. Direct SQL from the adapter also bypasses Content's normal mutation side effects. + +The compatible boundary is a **prepare / commit / publish canonical mutation coordinator**, not a suggestion-specific second write path: + +1. Core collaboration provides a document-scoped mutation lease. It serializes the operation with in-process Yjs writers, loads and merges the latest durable Y.Doc, and prepares the mutation on an isolated clone. Preparation produces the next canonical text, encoded Yjs state/update, and the `_collab_docs.version` fence; it does not mutate the shared cache or emit. +2. Content provides one canonical document-body mutation helper used by both ordinary full-body saves and accepted suggestions. Given an existing database transaction and the prepared collaboration mutation, it re-resolves resource eligibility, verifies the immutable proposal base plus exact before/context against the current body, performs the document CAS, persists Blocks-field identity and the prepared `_collab_docs` state with its version fence, creates the ordinary document version/history effects, and records a durable resource-scoped sync event. +3. Core suggestion decision remains the transaction owner for acceptance. The adapter's decision coordinator acquires the document lease outside that transaction, then the existing decision transaction invokes the Content helper so suggestion disposition, decision row, canonical body, version/history, durable Yjs state, and sync event commit or roll back together. +4. After a successful commit, the lease publishes the prepared Y.Doc into the process cache and emits the minimal collaboration update. After rollback it discards the clone. If the process dies after commit but before broadcast, the durable Yjs row and sync event cause connected clients or polling/reconnect to converge; network delivery itself is not represented as transactional. +5. Cross-process safety is enforced by both the Content document CAS and `_collab_docs.version` fence. Either conflict becomes an explicit stale/retry outcome; no layer may coerce it into accepted success. A connected client based on an older Yjs version must merge/reload the committed state before its next durable write. + +This coordinator is a Core collaboration primitive because locking, cached Y.Doc lifecycle, version fencing, and broadcast are framework concerns. The canonical document mutation helper is Content-owned because Markdown/Blocks reconciliation, source/database exclusions, version policy, access, and mutation history are Content semantics. The generic suggestion registry gains only the lifecycle hook needed to wrap its existing transaction in an adapter-provided decision coordinator; it does not learn Content or Yjs details. + +The proposal revision token is immutable and continues to identify the exact `documents.updatedAt` observed at creation. Acceptance does not require the Page to remain globally unchanged: the Content adapter may safely rebase only when the exact before material plus bounded contextual anchor resolves uniquely against the current canonical body. The current body revision and `_collab_docs.version` are separate execution-time fences. Ambiguous, missing, multiply matched, or structurally incompatible material produces `stale`; it never silently broadens to whole-string replacement. + +### Smallest compatible delta + +Extend the existing Core review seam with a generic executable-suggestion lifecycle and registered resource adapter, plus the narrow decision-coordination hook required to join an adapter-owned mutation lease to Core's decision transaction. Add the Core prepare/commit/publish Yjs lease and a Content canonical body-mutation helper shared by ordinary saves and suggestion acceptance. Implement only Content document-body text and inline-mark operations first. Reuse the existing Content comments rail presentation through one review controller rather than creating a second discussion system. + +Do not begin with the generic cross-object typed graph promised by feature 7. The first slice establishes the stable lifecycle and Content adapter that the broader graph can later extend without changing user-visible semantics. + +### Legacy contracts that remain unchanged + +- Direct editor changes by editor/admin/owner continue to update the canonical document normally. +- Ordinary comments retain their current identities, anchors, reply/resolve behavior, and Notion comment synchronization. +- Existing whole-document versions remain recovery snapshots; they are not reclassified as suggestions. +- SQL remains canonical for Content body; Yjs remains the live collaboration transport and reconciliation layer. +- Local-file and externally source-owned pages retain their current authority and synchronization rules. +- Existing sharing role names stay fixed; only commenter capability copy changes to truthfully include suggesting for Content resources. + +### Evidence classification + +Direct evidence comes from the verified Notion interaction memo and screenshots, Content schema/actions/editor code, Core review/history/sharing code, and approved Content product records. The proposed Core adapter shape is an architectural inference from those seams. No unresolved domain-owner question changes the public contract; storage details and ProseMirror decoration technique remain implementation choices. + +## Durable model + +Add a Core-owned suggestion aggregate with append-only decisions: + +- `review_suggestions`: id, resource type/id, adapter kind/version, thread id, author/actor/run context, base revision token, status (`pending`, `accepted`, `rejected`, `stale`, `superseded`), summary, timestamps, access scope, and metadata. +- `review_suggestion_operations`: stable operation id, suggestion id, ordinal, operation kind, field/target identity, before/after payload, anchor/context, dependencies, and payload schema version. +- `review_suggestion_decisions`: stable idempotency key, suggestion id, reviewer, decision, observed base, outcome, failure/conflict detail, and timestamp. +- Reuse Core review comments/threads for replies, mentions, reactions, resolution, mute, unread, and deep links; link the thread to the suggestion ID explicitly. + +For Content v1, the operation grammar is `insert_text`, `delete_text`, `replace_text`, `add_text_block`, and `set_inline_mark`. Each operation records the affected Blocks field, ProseMirror-compatible range/shape, exact before/after material, surrounding anchor context, and a base digest. The durable payload is typed JSON, not serialized ProseMirror transactions or Yjs client updates. + +Suggestion creation validates that all operations are supported, belong to one Page body, and match the observed base. It does not mutate `documents.content` or create a document version. + +Acceptance runs under one document mutation lease and one Core-owned decision transaction: re-resolve access, feature flag, source link, database membership, and lock state; load the latest durable Y.Doc and current body; verify or uniquely rebase the exact operation; prepare the isolated Yjs mutation; apply it through Content's canonical body-mutation helper; CAS `documents.content` and `updatedAt`; persist the fenced Yjs state, Blocks-field identity, ordinary Content version/history effects, accepted decision, and durable sync event; then publish the cache/update only after commit. If any transactional step fails, neither canonical content, durable Yjs state, history, sync event, nor disposition commits. Reject appends only the decision/disposition and leaves canonical content unchanged. + +## Interaction design + +The mode should feel like Content, not like a separate diff application: + +1. The ordinary page editor stays in place. Unsupported page controls and blocks become non-editable while suggesting. +2. Provisional additions and formatting render inline; deletions remain visible with subdued strike treatment. Semantic tokens distinguish proposed material without relying on color alone. +3. A compact numbered marker aligns to each affected block. Selecting it opens the existing right utility rail on the exact suggestion thread. +4. The rail shows a typed operation summary first, then Accept/Reject for authorized reviewers, replies/reactions, and thread controls. It distinguishes counts for edits and replies. +5. **All discussions** filters ordinary comments and suggestion threads by Pending, Accepted, Rejected, and Resolved without hiding historical decisions. +6. Deep links reopen the Page, scroll to the current or historical anchor, and focus the thread. If the anchor is stale or deleted, the rail shows the retained before/after material and honest stale state. +7. Keyboard users can enter/exit suggesting, traverse markers, inspect before/after text, decide, reply, and return focus to the editor. Screen readers receive operation kind, before/after material, author, status, and affected block context. + +No explanatory banner, duplicate page heading, or permanent review dashboard is added. Suggesting state lives in the header and contextual rail. + +## Action surface + +Core/shared actions: + +- `create-resource-suggestion` +- `list-resource-suggestions` +- `get-resource-suggestion` +- `decide-resource-suggestion` +- `reply-review-comment` / existing thread actions +- `react-to-review-comment`, `set-review-thread-unread`, and `set-review-thread-muted` where missing + +Content registers a `document` suggestion adapter that implements `validateProposal`, `preview`, `apply`, `resolveAnchor`, and `describeOperation`. UI calls the same actions through `useActionQuery`/`useActionMutation`; agent tools expose the identical schemas. Direct agent edits remain available when authorized, but an explicit “suggest” request must use suggestion actions and return suggestion IDs/deep links rather than claim the Page changed. + +## Delivery plan + +### Slice 1 — lifecycle and permissions + +Add the Core suggestion types/store/registry/actions and decision-coordination hook; the Core prepare/commit/publish Yjs mutation lease; the shared Content canonical body-mutation helper; Content adapter registration; default-off `content-suggested-edits` flag; transaction-time role/lock/source/database enforcement; idempotent accept/reject; normal Content history/audit/sync effects; and focused database/collaboration tests. This slice may use fixture operations before editor composition exists. + +### Slice 2 — editor composition and in-place rendering + +Add Suggesting mode to `VisualEditor`, operation capture for supported text/mark changes, provisional decorations, block markers, header/menu entry/exit, pending persistence, reload restoration, and narrow/keyboard/accessibility states. Prevent pending material from entering canonical autosave/Yjs reconciliation. + +### Slice 3 — discussion and history parity + +Unify suggestion threads with the Content comments rail; add replies, mentions, reactions, unread, mute, deep links, pending/accepted/rejected history filters, notifications, and honest orphan/stale presentation. + +### Slice 4 — agent parity and rollout proof + +Teach Content's agent instructions/actions to propose rather than directly edit when asked, expose inspection links, group a run's related suggestions without bulk-deciding them, verify live cross-client updates, and dogfood behind the flag before wider rollout. + +### Deferred beyond parity v1 + +- Titles, Properties, database cells/schema, media, embeds, and arbitrary registry blocks. +- Accept/reject all, filtered bulk review, dependency-safe sets, and agent-generated review summaries. +- Named Versions, cross-Version merge, general typed change graphs, code review, and external-provider suggestion synchronization. +- Local-file suggestions and offline portable suggestion representation. + +These are plausible extensions of the stable lifecycle, not requirements for Notion parity. + +## Risks and controls + +- **Canonical leakage:** pending edits accidentally autosave or enter Yjs. Control with a separate suggestion editor transaction filter/state and tests that canonical Markdown/Y.Doc/drafts remain byte-identical until acceptance. +- **Split-brain acceptance:** SQL commits while a cached Y.Doc still contains the old body, allowing a connected client to overwrite the accepted edit. Control with the document mutation lease, isolated preparation, transactionally persisted fenced Yjs state, post-commit cache publication, and a two-client stale-flush test. +- **Commit/broadcast crash window:** the process dies after the database commit but before emitting to connected clients. Control by storing a resource-scoped sync event in the same transaction; reconnect/poll reloads durable canonical and Yjs state. Broadcast is an optimization, not the durable proof of mutation. +- **Stale acceptance:** current material changes after proposal. Control with base token plus exact before material and contextual anchor; fail as stale unless the adapter proves a unique safe rebase. +- **Commenter escalation:** proposal or acceptance bypasses role limits. Control at action/adaptor boundaries; commenter may create but only editor/admin/owner may accept/reject unless future policy explicitly changes. +- **Dual discussion models:** Content comments and Core review threads drift. Control by adopting the Core controller/store for suggestion threads and incrementally adapting ordinary Content comments rather than creating a third store. +- **History ambiguity:** proposal creation looks like committed content or acceptance bypasses normal document history. Control with distinct proposal/decision events and one shared Content canonical mutation helper; only acceptance emits the same body-version, audit attribution, Blocks-field reconciliation, and sync effects as an ordinary accepted body mutation. +- **External-source corruption:** suggestions apply to source-owned or syncing bodies. Control by excluding local-file, Notion-linked/source-owned conflict states, and other non-local authority in v1. +- **Payload drift:** editor schema evolves. Control with versioned operation payloads and adapter-owned migration/degraded rendering. + +## Acceptance story + +The acceptance interface is the real Content Page editor in a deployed beta surface with two authenticated test users (commenter and editor) plus an accountable agent run. Independence is preferred and custody may remain in the same context because the interaction is consequential but reversible, while technical review and automated invariants cover the persistence and authorization risk. + +Required assertions: + +1. A commenter enters Suggesting, creates Add/Delete/Replace/new-text-block/format suggestions, exits and reloads, and the canonical document and another viewer's canonical rendering remain unchanged. +2. An editor sees inline markers, exact before/after material, author/time, replies/reactions, and independently accepts one suggestion and rejects another; only the accepted operation changes canonical Content. +3. Accepted and rejected threads remain discoverable in All discussions with durable actor, time, decision, replies, reactions, and working deep links. +4. Viewer creation and commenter acceptance are denied identically through UI and actions; lock/source/unsupported-block constraints fail closed without partial state. +5. An agent asked to suggest creates inspectable pending suggestions through the same actions and does not directly modify canonical content. +6. Concurrent canonical editing produces either a uniquely proven contextual rebase or an explicit stale/conflict state; the immutable proposal base, current document CAS, and current Yjs version fence are independently enforced, and no no-op is reported as accepted. +7. Retry of create/accept/reject is idempotent; failures injected before SQL commit, during Yjs-state persistence, and after commit but before live broadcast leave canonical content, durable Yjs state, ordinary Content history, sync event, and suggestion disposition mutually consistent or durably convergent as specified. +8. With two open clients, acceptance updates the accepting client and the peer without a reload; a peer holding the pre-accept Yjs state cannot flush it over the accepted body. Proposal, reply, reaction, and disposition updates use shared sync without extra EventSource connections or editor jitter. +9. Keyboard and screen-reader workflows can enter/exit mode, identify operation types and before/after material, traverse suggestions, decide, reply, and restore focus. +10. With the feature flag Off, current editing, comments, sharing, history, agent direct-edit behavior, local files, and source sync remain unchanged. + +Automated coverage is required for model/store/action/permission/idempotency/stale/persistence/canonical-isolation behavior, including transaction rollback, cross-process CAS, post-commit broadcast failure, and stale connected-client flush. Real-interface evidence is required for the complete editor workflow at desktop and narrow widths with two simultaneous clients. A proportional independent technical review is required for the Core review boundary, transaction ownership, permission checks, and Yjs/canonical isolation. + +## Architecture fingerprint + +```yaml +stage: shape +authority-source: "User invoked $shape and requested exact Suggested Edits feature parity for Agent-Native Content." +authorized-scope: + repositories: + - /home/teenylilmonkey/Developer/agent-native + product-surfaces: + - Agent-Native Content page editor + - Agent-Native Core review substrate + outcome: Notion-parity Suggested Edits for supported Content page-body text and inline formatting +allowed-mutations: + - artifact-write +write-targets: + artifacts: + - templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md +governing-artifact: + path: templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md + revision: content-suggested-edits-shape-r2 +architecture-fingerprint: + outcome: Commenters, editors, and agents can propose supported page-body edits without changing canonical Content until an authorized reviewer accepts them. + shipping-surfaces: + - id: agent-native-content-template + repository: agent-native + product-surface: templates/content deployed application + constituency: authenticated Content page collaborators and accountable agents + durable-destination: agent-native repository main plus deployed Content beta/production surfaces + integration-action: merge + - id: agent-native-core-packages + repository: agent-native + product-surface: Core review/action/client packages consumed by templates + constituency: Agent-Native app developers and review-capable applications + durable-destination: agent-native repository main and publishable Core package release + integration-action: merge + governing-architecture: Core owns the reusable executable-suggestion lifecycle plus a prepare/commit/publish Yjs mutation lease; Content owns one canonical document-body mutation helper shared by ordinary saves and suggestion acceptance; the suggestion decision transaction atomically commits disposition, canonical SQL, history, fenced durable Yjs state, and a sync event before cache publication or broadcast. + acceptance-story: + id: content-suggested-edits-parity-v1 + summary: A commenter or agent proposes supported edits in the ordinary Content editor; an authorized editor discusses and decides each proposal; only accepted material reaches canonical Content and every state remains attributable and recoverable. + required-assertions: + - proposal creation preserves canonical content + - selective accept/reject applies exactly the decided operation + - discussion and resolved history persist with deep links + - UI, agent, and action permission parity fails closed + - stale/concurrent edits never overwrite newer content + - retries, persistence failures, and commit-to-broadcast crashes remain atomic, idempotent, or durably convergent according to the transaction boundary + - a connected client holding pre-accept Yjs state cannot overwrite accepted content + - cross-client sync and accessibility work through the real editor + - flag-off legacy behavior remains unchanged + acceptance-policy: + modality: real-interface + independence: preferred + custody: same-context-allowed + interface: deployed Content beta Page editor with commenter/editor users and an accountable agent run + rationale: The user-visible workflow requires real editor proof; reversible review actions permit same-context custody while authorization and canonical-isolation seams receive independent technical review. + risk-strategy: + kind: feature-flagged + production-validation-after-merge: true +architecture-grounding: + applicability: required + reason: The feature crosses shared review, permissions, history, action, collaboration, and Content domain boundaries. + status: grounded + demonstrated-callers: + - Content Page collaborator or accountable agent requesting Suggest edits + existing-primitives: + - Core review comments/threads/notifications/status + - Core sharing roles and action audit + - Core history/version donor + - Content TipTap/Yjs editor and comments rail + - Content document access/actions/version snapshots + ownership-boundaries: + - Core owns reusable suggestion lifecycle and shared actions + - Content owns document operation semantics and canonical application + - SQL owns durable truth; Yjs owns live collaborative transport + legacy-contracts: + - direct editing, comments, versions, sharing, local files, source sync, and agent direct edits remain unchanged when suggestion mode is not requested or flag is off + shared-vocabulary: + - Suggested Edit + - Suggesting + - suggestion operation + - suggestion thread + - pending + - accepted + - rejected + - stale + smallest-compatible-delta: Core executable-suggestion lifecycle and narrow decision coordinator, a Core prepared Yjs mutation lease, one shared Content canonical body-mutation helper, and the Content page-body text/mark adapter plus ordinary-editor presentation. + deferred-capabilities: + - generic typed Property/Database/media proposals + - filtered bulk review + - named Versions and selective cross-Version merge + - local-file and provider suggestion synchronization + reversibility: A default-off app-owned feature flag keeps dormant code inactive; suggestion tables are additive; pending proposals never alter canonical content. + direct-evidence: + - verified Notion Suggested Edits interaction memo dated 2026-09-01 + - packages/core/src/review and packages/core/src/history + - templates/content editor, actions, schema, comments, collaboration plugin + - Content feature 7 and diff/history/access capability records + inferences: + - extending Core review with an adapter registry is the smallest reusable implementation boundary + - typed JSON operations are more durable than persisted ProseMirror transactions or Yjs updates + - database commit is the atomic authority while cache publication and network broadcast are post-commit delivery backed by durable sync replay + - acquiring the document lease outside the decision transaction is necessary to prevent a live Yjs writer from straddling acceptance + unresolved-owner-questions: [] +delegation-ceiling: [] +acceptance-state: + status: in-progress + summary: Core and Content implementation is staged; local real-interface proof covers proposal isolation plus accept and reject decisions in the contextual rail. + evidence: + - templates/content/docs/solutions/evidence/suggested-edits-accept-reject.png + - focused Core and Content automated suites + remaining: + - commenter/editor role-separated acceptance + - accountable agent proposal acceptance + - two-client sync and narrow-width accessibility acceptance + - independent technical review closure + blockers: [] + last-land-packet: null +change-record: + from-revision: content-suggested-edits-shape-r1 + to-revision: content-suggested-edits-shape-r2 + trigger: Independent review proved that direct SQL acceptance could diverge from the cached Y.Doc and bypass normal Content mutation effects. + preserved: + - outcome and exact Notion-parity product boundary + - shipping surfaces and ownership split between Core and Content + - default-off feature-flag risk strategy + changed: + - canonical mutation coordination is now an explicit prepare/commit/publish architecture + - ordinary Content saves and accepted suggestions must share one Content body-mutation helper + - durable Yjs state and sync event join the suggestion decision transaction + - acceptance now proves stale-client overwrite prevention and crash-window convergence +ledger-revision: content-suggested-edits-shape-r2 +status: shape-complete +``` + +## Active Work envelope + +```yaml +stage: work +authority-source: "User invoked $work on 2026-09-03 against content-suggested-edits-shape-r2." +ledger-revision: content-suggested-edits-work-r2-2 +governing-artifact: + path: templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md + revision: content-suggested-edits-shape-r2 +allowed-mutations: + - artifact-write + - ephemeral-test-resource + - branch + - commit +write-targets: + repositories: + - /home/teenylilmonkey/.codex/worktrees/content-suggested-edits/agent-native +test-resources: + - id: content-suggested-edits-r2-page + kind: record + surface: local Content SQLite document id codex-suggested-edits-r2-20260903 + ownership-marker: title "[task-test] Suggested Edits r2 2026-09-03" + baseline: exact document id returned zero rows immediately before creation + allowed-actions: [create, update, exercise, delete] + cleanup-trigger: before Work completion + cleanup-method: delete suggestion/review/version/collab/membership rows for the exact document id, then delete that document row in one local SQLite transaction + cleanup-proof: read every named table for the exact document id and observe zero rows + shared-impact: none + isolation: local-runtime + ownership: task-created + production-data: false + customer-data: false + cost: none + boundary-evidence: + - fixed id is absent from the local Content database before creation + - pending replacement created through the real editor and accepted through the review card + - a second live client converged from Alpha old Omega to Alpha new Omega + - rejected replacement resolved without changing the accepted document body + - viewport screenshots retained under C:/Users/emdis/.codex/visualizations/2026/09/01/01a05dfc-8e21-7780-a44a-4937701320e4 + max-lifetime-minutes: 240 + declared-at: 2026-09-03T16:48:52Z + expires-at: 2026-09-03T20:48:52Z + status: cleaned + cleanup-result: documents=0, suggestions=0, comments=0, versions=0, collab_docs=0 after one local SQLite write batch + phase: work +acceptance-reconciliation: consistent +task-attention: autonomous +status: verification +``` + +## Next step + +Invoke `/work templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md` to revise the staged implementation against r2. Work resumes at Slice 1 by replacing the direct-SQL acceptance path with the frozen mutation coordinator and shared Content helper before proceeding to remaining interface acceptance. Failure to make the document lease span the decision transaction, persist the Yjs fence in that transaction, or prevent stale-client overwrite is a return-to-shape condition rather than permission to degrade silently. diff --git a/templates/content/docs/solutions/evidence/suggested-edits-accept-reject.png b/templates/content/docs/solutions/evidence/suggested-edits-accept-reject.png new file mode 100644 index 0000000000000000000000000000000000000000..48dec9a36be26f6aba8814fce77dbabd90f0f045 GIT binary patch literal 63977 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
o-6ef0lHA_$Sy2W}eTW^Hyopb8yu(A}bxF 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 Promise; + afterWrite: () => Promise; +}): Promise { + if (!(await input.write())) return false; + await input.afterWrite(); + return true; +} diff --git a/templates/content/server/lib/suggested-edits.spec.ts b/templates/content/server/lib/suggested-edits.spec.ts new file mode 100644 index 00000000000..9e7bb36837a --- /dev/null +++ b/templates/content/server/lib/suggested-edits.spec.ts @@ -0,0 +1,260 @@ +import { yDocToProsemirrorJSON } from "@tiptap/y-tiptap"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as Y from "yjs"; + +const exclusions = vi.fn(async () => ({ rows: [] })); +vi.mock("@agent-native/core/feature-flags", () => ({ + isFeatureFlagEnabled: vi.fn(async () => true), +})); +vi.mock("@agent-native/core/db", () => ({ + getDbExec: () => ({ execute: exclusions }), +})); + +const { applyMarkdownSuggestionOperation, contentDocumentSuggestionAdapter } = + await import("./suggested-edits"); + +const operation = { + ordinal: 0, + kind: "replace_text", + targetId: "body", + before: { markdown: "Before" }, + after: { markdown: "After" }, + anchor: { prefix: "", suffix: "" }, + schemaVersion: 1, +}; + +const access = { + role: "commenter" as const, + resource: { + id: "doc-1", + content: "Before", + updatedAt: "rev-1", + trashedAt: null, + sourceMode: null, + sourceKind: null, + sourcePath: null, + }, +}; + +function decisionCoordination() { + return { + ydoc: { + doc: new Y.Doc(), + baseVersion: null, + persist: vi.fn(async () => {}), + }, + sync: { + persist: vi.fn(async () => ({ version: 1 })), + publish: vi.fn(), + }, + }; +} + +describe("Content document suggestion adapter", () => { + beforeEach(() => exclusions.mockClear()); + + it("validates a proposal without mutating canonical content", async () => { + await expect( + contentDocumentSuggestionAdapter.validateProposal({ + resourceType: "document", + resourceId: "doc-1", + baseRevision: "rev-1", + operations: [operation], + ctx: { suggestionAccess: access }, + }), + ).resolves.toEqual([operation]); + expect(exclusions).toHaveBeenCalledOnce(); + }); + + it("rejects inline-database pages before creating a pending suggestion", async () => { + const markdown = 'Before\n\n'; + await expect( + contentDocumentSuggestionAdapter.validateProposal({ + resourceType: "document", + resourceId: "doc-1", + baseRevision: "rev-1", + operations: [ + { + ...operation, + before: { markdown }, + after: { markdown: markdown.replace("Before", "After") }, + }, + ], + ctx: { + suggestionAccess: { + ...access, + resource: { ...access.resource, content: markdown }, + }, + }, + }), + ).rejects.toThrow("inline databases cannot receive suggestions yet"); + }); + + it("snapshots and applies with an exact compare-and-swap", async () => { + const writes: string[] = []; + const tx = { + execute: vi.fn(async (query: string | { sql: string }) => { + const sql = typeof query === "string" ? query : query.sql; + writes.push(sql); + if (sql.startsWith("SELECT id,title")) { + return { + rows: [ + { + id: "doc-1", + title: "Page", + content: "Before", + owner_email: "owner@example.com", + updated_at: "rev-1", + source_mode: null, + source_kind: null, + source_path: null, + trashed_at: null, + }, + ], + }; + } + if (sql.startsWith("SELECT id FROM content_database_items")) { + return { rows: [] }; + } + return { rows: [], rowsAffected: 1 }; + }), + }; + const coordination = decisionCoordination(); + await contentDocumentSuggestionAdapter.apply({ + resourceType: "document", + resourceId: "doc-1", + suggestion: { + id: "suggestion-1", + resourceType: "document", + resourceId: "doc-1", + adapterKind: contentDocumentSuggestionAdapter.kind, + adapterVersion: 1, + threadId: "thread-1", + authorEmail: "commenter@example.com", + actorKind: "human", + baseRevision: "rev-1", + status: "pending", + summary: "Suggest edits", + ownerEmail: "owner@example.com", + orgId: null, + visibility: "private", + createdAt: "now", + updatedAt: "now", + metadata: null, + operations: [operation], + }, + operations: [operation], + access, + ctx: {}, + transaction: tx, + coordination, + }); + expect( + writes.some((sql) => sql.startsWith("INSERT INTO document_versions")), + ).toBe(true); + expect( + writes.some((sql) => sql.startsWith("UPDATE documents SET content")), + ).toBe(true); + expect(coordination.ydoc.persist).toHaveBeenCalledWith(tx, "After"); + expect(coordination.sync.persist).toHaveBeenCalledWith(tx); + expect( + yDocToProsemirrorJSON(coordination.ydoc.doc, "default"), + ).toMatchObject({ + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "After" }], + }, + ], + }); + }); + + it("reports an honest stale outcome before writing a version", async () => { + const tx = { + execute: vi.fn(async (query: string | { sql: string }) => { + const sql = typeof query === "string" ? query : query.sql; + if (sql.startsWith("SELECT id,title")) { + return { + rows: [ + { + id: "doc-1", + title: "Page", + content: "Newer", + owner_email: "owner@example.com", + updated_at: "rev-2", + }, + ], + }; + } + return { rows: [], rowsAffected: 0 }; + }), + }; + const coordination = decisionCoordination(); + await expect( + contentDocumentSuggestionAdapter.apply({ + resourceType: "document", + resourceId: "doc-1", + suggestion: { + id: "suggestion-1", + resourceType: "document", + resourceId: "doc-1", + adapterKind: contentDocumentSuggestionAdapter.kind, + adapterVersion: 1, + threadId: "thread-1", + authorEmail: null, + actorKind: "agent", + baseRevision: "rev-1", + status: "pending", + summary: "Suggest edits", + ownerEmail: null, + orgId: null, + visibility: "private", + createdAt: "now", + updatedAt: "now", + metadata: null, + operations: [operation], + }, + operations: [operation], + access, + ctx: {}, + transaction: tx, + coordination, + }), + ).rejects.toMatchObject({ name: "SuggestionStaleError" }); + expect(tx.execute).toHaveBeenCalledTimes(2); + }); +}); + +describe("applyMarkdownSuggestionOperation", () => { + const contextualOperation = { + ...operation, + before: { markdown: "Alpha old Omega", changedText: "old" }, + after: { markdown: "Alpha new Omega", changedText: "new" }, + anchor: { from: 6, to: 9, prefix: "Alpha ", suffix: " Omega" }, + }; + + it("applies an independent edit after unrelated canonical changes", () => { + expect( + applyMarkdownSuggestionOperation( + "Intro\nAlpha old Omega\nOutro", + contextualOperation, + ), + ).toBe("Intro\nAlpha new Omega\nOutro"); + }); + + it("refuses an ambiguous or changed anchor", () => { + expect( + applyMarkdownSuggestionOperation( + "Alpha old Omega and Alpha old Omega", + contextualOperation, + ), + ).toBeNull(); + expect( + applyMarkdownSuggestionOperation( + "Alpha changed Omega", + contextualOperation, + ), + ).toBeNull(); + }); +}); diff --git a/templates/content/server/lib/suggested-edits.ts b/templates/content/server/lib/suggested-edits.ts new file mode 100644 index 00000000000..5c52ef94042 --- /dev/null +++ b/templates/content/server/lib/suggested-edits.ts @@ -0,0 +1,353 @@ +import { + withPreparedYDocMutation, + type PreparedYDocMutationLease, +} from "@agent-native/core/collab"; +import { getDbExec, type DbExec } from "@agent-native/core/db"; +import { isFeatureFlagEnabled } from "@agent-native/core/feature-flags"; +import type { + SuggestionAdapter, + SuggestionOperation, +} from "@agent-native/core/review"; +import { + prepareTransactionalChange, + type TransactionalChange, +} from "@agent-native/core/server"; +import { getSchema } from "@tiptap/core"; +import { prosemirrorJSONToYXmlFragment } from "@tiptap/y-tiptap"; + +import { createVisualEditorExtensions } from "../../app/components/editor/VisualEditor.js"; +import { CONTENT_SUGGESTED_EDITS_FLAG } from "../../shared/feature-flags.js"; +import { nfmToDoc } from "../../shared/nfm.js"; +import { commitCanonicalDocumentBodyMutation } from "./canonical-document-body-mutation.js"; + +export const CONTENT_DOCUMENT_SUGGESTION_ADAPTER = "content.document-markdown"; + +type MarkdownOperationPayload = { + markdown: string; + changedText?: string; +}; + +type MarkdownOperationAnchor = { + from: number; + to: number; + prefix: string; + suffix: string; +}; + +function markdownPayload( + value: unknown, + label: "before" | "after", +): MarkdownOperationPayload { + if ( + !value || + typeof value !== "object" || + typeof (value as { markdown?: unknown }).markdown !== "string" + ) { + throw new Error(`Content suggestions require ${label}.markdown`); + } + const payload = value as MarkdownOperationPayload; + if (payload.markdown.length > 1_000_000) { + throw new Error("Content suggestion payload is too large"); + } + return payload; +} + +function validateOperations( + operations: SuggestionOperation[], +): SuggestionOperation[] { + if (operations.length !== 1) { + throw new Error( + "Content v1 accepts one independently reviewable edit per suggestion", + ); + } + const operation = operations[0]!; + if ( + ![ + "insert_text", + "delete_text", + "replace_text", + "add_text_block", + "set_inline_mark", + ].includes(operation.kind) + ) { + throw new Error( + `Unsupported Content suggestion operation: ${operation.kind}`, + ); + } + if (operation.schemaVersion !== 1 || operation.targetId !== "body") { + throw new Error( + "Content suggestions must target the version 1 Page body grammar", + ); + } + const before = markdownPayload(operation.before, "before"); + const after = markdownPayload(operation.after, "after"); + if (before.markdown === after.markdown) { + throw new Error("A suggestion must change the Page body"); + } + return operations; +} + +function operationAnchor(operation: SuggestionOperation) { + const anchor = operation.anchor as Partial | null; + if ( + !anchor || + !Number.isInteger(anchor.from) || + !Number.isInteger(anchor.to) || + typeof anchor.prefix !== "string" || + typeof anchor.suffix !== "string" + ) { + throw new Error("Content suggestions require a contextual body anchor"); + } + return anchor as MarkdownOperationAnchor; +} + +export function applyMarkdownSuggestionOperation( + currentMarkdown: string, + operation: SuggestionOperation, +) { + const before = markdownPayload(operation.before, "before"); + const after = markdownPayload(operation.after, "after"); + if (currentMarkdown === before.markdown) return after.markdown; + if ( + typeof before.changedText !== "string" || + typeof after.changedText !== "string" + ) { + return null; + } + const anchor = operationAnchor(operation); + const needle = `${anchor.prefix}${before.changedText}${anchor.suffix}`; + const replacement = `${anchor.prefix}${after.changedText}${anchor.suffix}`; + const index = currentMarkdown.indexOf(needle); + if (index < 0 || currentMarkdown.indexOf(needle, index + 1) >= 0) return null; + return `${currentMarkdown.slice(0, index)}${replacement}${currentMarkdown.slice(index + needle.length)}`; +} + +function documentFromContext(ctx: Record | undefined) { + const access = ctx?.suggestionAccess as + | { resource?: Record } + | undefined; + return access?.resource; +} + +function decisionChatContext(ctx: Record | undefined) { + const context = Object.fromEntries( + (["threadId", "runId", "turnId"] as const).flatMap((key) => + typeof ctx?.[key] === "string" && ctx[key].trim() + ? [[key, ctx[key]]] + : [], + ), + ); + return Object.keys(context).length ? JSON.stringify(context) : null; +} + +type ContentDecisionCoordination = { + ydoc: PreparedYDocMutationLease; + sync: TransactionalChange; +}; + +let contentEditorSchema: ReturnType | undefined; + +function replacePreparedCollabContent( + lease: PreparedYDocMutationLease, + markdown: string, +): void { + contentEditorSchema ??= getSchema(createVisualEditorExtensions()); + const proseMirrorDoc = contentEditorSchema.nodeFromJSON(nfmToDoc(markdown)); + prosemirrorJSONToYXmlFragment( + contentEditorSchema, + proseMirrorDoc.toJSON(), + lease.doc.getXmlFragment("default"), + ); +} + +export const contentDocumentSuggestionAdapter: SuggestionAdapter = { + kind: CONTENT_DOCUMENT_SUGGESTION_ADAPTER, + version: 1, + async validateProposal(input) { + if (input.resourceType !== "document") { + throw new Error("Content suggestions are only available for Pages"); + } + if ( + !(await isFeatureFlagEnabled(CONTENT_SUGGESTED_EDITS_FLAG, input.ctx)) + ) { + throw new Error("Content suggested edits are not enabled"); + } + const document = documentFromContext(input.ctx); + if (!document) throw new Error("Document access context is unavailable"); + if (document.trashedAt) + throw new Error("Trashed Pages cannot receive suggestions"); + if (document.sourceMode || document.sourceKind || document.sourcePath) { + throw new Error("Source-owned Pages cannot receive suggestions"); + } + if (document.updatedAt !== input.baseRevision) { + throw new Error("The Page changed before the suggestion was created"); + } + const exclusions = await getDbExec().execute({ + sql: `SELECT 'database' AS kind + FROM content_database_items i + INNER JOIN content_databases d ON d.id = i.database_id + WHERE i.document_id = ? AND d.system_role IS NULL + UNION ALL + SELECT 'external' AS kind FROM document_sync_links WHERE document_id = ? AND state != 'unlinked' + LIMIT 1`, + args: [input.resourceId, input.resourceId], + }); + if (exclusions.rows.length) { + throw new Error( + "Database item and externally linked Pages cannot receive suggestions yet", + ); + } + const operations = validateOperations(input.operations); + const before = markdownPayload(operations[0]!.before, "before"); + if (document.content !== before.markdown) { + throw new Error("The suggestion before-state does not match the Page"); + } + if (before.markdown.includes(" run({ ydoc, sync } satisfies ContentDecisionCoordination), + ); + if ( + (result as { decision?: { outcome?: string } }).decision?.outcome === + "accepted" + ) { + sync.publish(); + } + return result; + }, + async apply(context) { + if ( + !(await isFeatureFlagEnabled(CONTENT_SUGGESTED_EDITS_FLAG, context.ctx)) + ) { + throw new Error("Content suggested edits are not enabled"); + } + const operations = validateOperations(context.operations); + const operation = operations[0]!; + const tx = context.transaction as DbExec; + const coordination = context.coordination as + | ContentDecisionCoordination + | undefined; + if (!coordination) { + throw new Error("Content suggestion decision coordination is missing"); + } + const current = ( + await tx.execute({ + sql: "SELECT id,title,content,owner_email,updated_at,source_mode,source_kind,source_path,trashed_at FROM documents WHERE id = ?", + args: [context.resourceId], + }) + ).rows[0]; + if (!current) throw new Error("Document not found"); + if ( + current.source_mode || + current.source_kind || + current.source_path || + current.trashed_at + ) { + throw new Error("This Page can no longer accept suggestions"); + } + const sourceLink = await tx.execute({ + sql: "SELECT state FROM document_sync_links WHERE document_id = ? AND state != 'unlinked' LIMIT 1", + args: [context.resourceId], + }); + if (sourceLink.rows.length) { + throw new Error("Externally linked Pages cannot accept suggestions yet"); + } + const currentContent = String(current.content); + const nextContent = applyMarkdownSuggestionOperation( + currentContent, + operation, + ); + if (nextContent === null) { + const error = new Error( + "The Page changed after this suggestion was created", + ); + error.name = "SuggestionStaleError"; + throw error; + } + const membership = await tx.execute({ + sql: `SELECT i.id + FROM content_database_items i + INNER JOIN content_databases d ON d.id = i.database_id + WHERE i.document_id = ? AND d.system_role IS NULL + LIMIT 1`, + args: [context.resourceId], + }); + if (membership.rows.length) { + throw new Error( + "Database item Pages cannot receive body suggestions yet", + ); + } + if (currentContent.includes(" { + const updated = await tx.execute({ + sql: "UPDATE documents SET content = ?, updated_at = ? WHERE id = ? AND updated_at = ? AND content = ?", + args: [ + nextContent, + now, + context.resourceId, + current.updated_at, + currentContent, + ], + }); + return updated.rowsAffected === 1; + }, + afterWrite: async () => { + await tx.execute({ + sql: "INSERT INTO document_versions (id,owner_email,document_id,title,content,chat_context,created_at) VALUES (?,?,?,?,?,?,?)", + args: [ + globalThis.crypto.randomUUID(), + current.owner_email, + context.resourceId, + current.title, + current.content, + decisionChatContext(context.ctx), + now, + ], + }); + await coordination.ydoc.persist(tx, nextContent); + await coordination.sync.persist(tx); + }, + }); + if (!applied) { + const error = new Error( + "The Page changed while accepting this suggestion", + ); + error.name = "SuggestionStaleError"; + throw error; + } + return { resourceId: context.resourceId, updatedAt: now }; + }, + describeOperation(operation) { + return operation.kind.split("_").join(" "); + }, + buildUrl(resourceId, suggestionId) { + return `/page/${encodeURIComponent(resourceId)}?suggestion=${encodeURIComponent(suggestionId)}`; + }, +}; diff --git a/templates/content/server/plugins/feature-flags.ts b/templates/content/server/plugins/feature-flags.ts new file mode 100644 index 00000000000..6c833cf3fa9 --- /dev/null +++ b/templates/content/server/plugins/feature-flags.ts @@ -0,0 +1,5 @@ +import { createFeatureFlagsPlugin } from "@agent-native/core/server"; + +import { CONTENT_FEATURE_FLAGS } from "../../shared/feature-flags.js"; + +export default createFeatureFlagsPlugin({ flags: CONTENT_FEATURE_FLAGS }); diff --git a/templates/content/server/plugins/suggested-edits.ts b/templates/content/server/plugins/suggested-edits.ts new file mode 100644 index 00000000000..e326413843f --- /dev/null +++ b/templates/content/server/plugins/suggested-edits.ts @@ -0,0 +1,7 @@ +import { registerSuggestionAdapter } from "@agent-native/core/review"; + +import { contentDocumentSuggestionAdapter } from "../lib/suggested-edits.js"; + +export default function registerContentSuggestionAdapter() { + registerSuggestionAdapter(contentDocumentSuggestionAdapter); +} diff --git a/templates/content/shared/api.ts b/templates/content/shared/api.ts index 83fc4e301b8..d37711e4063 100644 --- a/templates/content/shared/api.ts +++ b/templates/content/shared/api.ts @@ -428,6 +428,7 @@ export interface ContentDatabaseMembership { databaseId: string | null; databaseDocumentId: string | null; databaseTitle: string | null; + systemRole?: string | null; position: number | null; sourceId?: string | null; bodyHydration?: ContentDatabaseBodyHydration; diff --git a/templates/content/shared/feature-flags.ts b/templates/content/shared/feature-flags.ts new file mode 100644 index 00000000000..71eb711f6a9 --- /dev/null +++ b/templates/content/shared/feature-flags.ts @@ -0,0 +1,9 @@ +import { defineFeatureFlag } from "@agent-native/core/feature-flags/registry"; + +export const CONTENT_SUGGESTED_EDITS_FLAG = defineFeatureFlag({ + key: "content.suggested-edits", + displayName: "Content suggested edits", + description: "Allow authorized collaborators and agents to propose page-body edits for review.", +}); + +export const CONTENT_FEATURE_FLAGS = [CONTENT_SUGGESTED_EDITS_FLAG] as const; From b8f9b33a3656d8df71c0c429479a5f1d5209e527 Mon Sep 17 00:00:00 2001 From: Alice Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:15:24 +0000 Subject: [PATCH 02/15] fix: harden suggestion decision races --- .../review/suggestions/actions.access.spec.ts | 95 +++++++++++++++++++ .../core/src/review/suggestions/actions.ts | 14 ++- .../core/src/server/app-sync-state.spec.ts | 2 + packages/core/src/server/poll.ts | 2 + .../editor/DocumentEditor.layout.test.ts | 14 +++ .../app/components/editor/DocumentEditor.tsx | 20 +++- .../server/lib/suggested-edits.spec.ts | 23 ++++- .../content/server/lib/suggested-edits.ts | 20 ++-- 8 files changed, 175 insertions(+), 15 deletions(-) create mode 100644 packages/core/src/review/suggestions/actions.access.spec.ts diff --git a/packages/core/src/review/suggestions/actions.access.spec.ts b/packages/core/src/review/suggestions/actions.access.spec.ts new file mode 100644 index 00000000000..eed4e0dc517 --- /dev/null +++ b/packages/core/src/review/suggestions/actions.access.spec.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const transaction = { execute: vi.fn() }; +const updateSuggestionStatus = vi.fn(); +const suggestion = { + id: "suggestion-1", + resourceType: "doc", + resourceId: "doc-1", + adapterKind: "test.adapter", + adapterVersion: 1, + threadId: "thread-1", + authorEmail: "commenter@example.com", + actorKind: "human" as const, + baseRevision: "revision-1", + status: "pending" as const, + summary: "Replace text", + ownerEmail: "owner@example.com", + orgId: null, + visibility: "private" as const, + createdAt: "now", + updatedAt: "now", + metadata: null, + operations: [], +}; + +vi.mock("../../db/client.js", () => ({ + getDialect: () => "sqlite", + getDbExec: () => ({ + transaction: async (run: (tx: typeof transaction) => Promise) => + run(transaction), + }), + intType: () => "INTEGER", + isPostgres: () => false, +})); +vi.mock("../notifications.js", () => ({ notifyReviewComment: vi.fn() })); +vi.mock("../store.js", () => ({ + ensureReviewTables: vi.fn(), + insertReviewCommentWithClient: vi.fn(), + resolveReviewThreadWithClient: vi.fn(), +})); +vi.mock("./store.js", () => ({ + ensureSuggestionTables: vi.fn(), + getSuggestion: vi.fn(async () => suggestion), + getSuggestionByCreationKey: vi.fn(), + insertSuggestion: vi.fn(), + listSuggestions: vi.fn(), + recordDecision: vi.fn(), + getDecision: vi.fn(), + recordSuggestionCreation: vi.fn(), + replaceSuggestionStatus: vi.fn(), + updateSuggestionStatus, +})); + +const { decideResourceSuggestion } = await import("./actions.js"); +const { __resetReviewableResourcesForTests, registerReviewableResource } = + await import("../registry.js"); +const { __resetSuggestionAdaptersForTests, registerSuggestionAdapter } = + await import("./registry.js"); + +describe("suggestion decision access", () => { + beforeEach(() => { + vi.clearAllMocks(); + __resetReviewableResourcesForTests(); + __resetSuggestionAdaptersForTests(); + registerReviewableResource({ + type: "doc", + resolveAccess: (_resourceId, ctx) => ({ + role: ctx?.transaction ? "viewer" : "editor", + ownerEmail: "owner@example.com", + visibility: "private", + }), + }); + registerSuggestionAdapter({ + kind: "test.adapter", + version: 1, + validateProposal: () => {}, + apply: vi.fn(), + }); + }); + + it("rechecks editor access inside the decision transaction", async () => { + await expect( + decideResourceSuggestion.run( + { + id: suggestion.id, + decision: "accepted", + idempotencyKey: "decision-1", + observedBase: suggestion.baseRevision, + }, + { userEmail: "editor@example.com" }, + ), + ).rejects.toThrow("Not allowed to access doc:doc-1"); + expect(updateSuggestionStatus).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/review/suggestions/actions.ts b/packages/core/src/review/suggestions/actions.ts index d58e8232623..330d15f6f0a 100644 --- a/packages/core/src/review/suggestions/actions.ts +++ b/packages/core/src/review/suggestions/actions.ts @@ -242,6 +242,12 @@ export const decideResourceSuggestion = defineAction({ db.transaction!(async (tx) => { const current = await getSuggestion(args.id, tx); if (!current) throw new Error("Suggestion not found"); + const decisionAccess = await assertReviewableResourceAccess( + current.resourceType, + current.resourceId, + { ...(ctx as any), transaction: tx }, + "editor", + ); if (current.status !== "pending") { const decision = await getDecision(tx, args.idempotencyKey); if ( @@ -304,8 +310,12 @@ export const decideResourceSuggestion = defineAction({ resourceId: current.resourceId, suggestion: current, operations: current.operations, - access, - ctx: { ...(ctx as any), suggestionAccess: access }, + access: decisionAccess, + ctx: { + ...(ctx as any), + suggestionAccess: decisionAccess, + transaction: tx, + }, transaction: tx, coordination, }); diff --git a/packages/core/src/server/app-sync-state.spec.ts b/packages/core/src/server/app-sync-state.spec.ts index f780ff5632e..f8494ad3e7d 100644 --- a/packages/core/src/server/app-sync-state.spec.ts +++ b/packages/core/src/server/app-sync-state.spec.ts @@ -142,9 +142,11 @@ describe("AppSyncState multi-app isolation", () => { resourceType: "document", resourceId: "doc-1", }); + expect(change.isPersisted()).toBe(false); expect(state.getChangesSince(0).events).toEqual([]); const persisted = await change.persist(transaction); + expect(change.isPersisted()).toBe(true); expect(state.getChangesSince(0).events).toEqual([]); expect(transaction.execute).toHaveBeenCalledOnce(); expect(persisted.resourceId).toBe("doc-1"); diff --git a/packages/core/src/server/poll.ts b/packages/core/src/server/poll.ts index c3ccad71c08..88af25d1ad7 100644 --- a/packages/core/src/server/poll.ts +++ b/packages/core/src/server/poll.ts @@ -85,6 +85,7 @@ export interface ChangeEvent { export interface TransactionalChange { persist(transaction: DbExec): Promise; + isPersisted(): boolean; publish(): ChangeEvent; } @@ -1296,6 +1297,7 @@ export class AppSyncState { persisted = { ...event, version, cursorId: id } as ChangeEvent; return persisted; }, + isPersisted: () => persisted !== null, publish: () => { if (!persisted) { throw new Error( diff --git a/templates/content/app/components/editor/DocumentEditor.layout.test.ts b/templates/content/app/components/editor/DocumentEditor.layout.test.ts index 6ee52978f64..735fddd8547 100644 --- a/templates/content/app/components/editor/DocumentEditor.layout.test.ts +++ b/templates/content/app/components/editor/DocumentEditor.layout.test.ts @@ -623,6 +623,20 @@ describe("document editor layout", () => { expect(source).toContain("suggestionEditorIsolation.bindCanonicalYDoc"); }); + it("binds suggestion operations to the body and revision captured at mode entry", () => { + const source = readFileSync( + new URL("./DocumentEditor.tsx", import.meta.url), + "utf8", + ); + + expect(source).toContain("suggestionBaseRef.current = {"); + expect(source).toContain( + "markdownSuggestionOperation(\n base.content", + ); + expect(source).toContain("baseRevision: base.updatedAt"); + expect(source).not.toContain("baseRevision: document.updatedAt"); + }); + it("wakes live-editor flush reads from shared sync events instead of polling", () => { 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 0049f3f1e9a..ee6784ff59f 100644 --- a/templates/content/app/components/editor/DocumentEditor.tsx +++ b/templates/content/app/components/editor/DocumentEditor.tsx @@ -664,6 +664,10 @@ function DocumentEditorBody({ ); const [isSuggesting, setIsSuggesting] = useState(false); const [suggestionDraft, setSuggestionDraft] = useState(document.content); + const suggestionBaseRef = useRef<{ + content: string; + updatedAt: string; + } | null>(null); const [utilityPanel, setUtilityPanel] = useState(null); const canSuggest = suggestedEditsEnabled && @@ -1909,34 +1913,43 @@ function DocumentEditorBody({ async (next: boolean) => { if (next) { if (!canSuggest) return; + suggestionBaseRef.current = { + content: document.content, + updatedAt: document.updatedAt, + }; setSuggestionDraft(document.content); setIsSuggesting(true); return; } if (!isSuggesting) return; - if (suggestionDraft === document.content) { + const base = suggestionBaseRef.current; + if (!base) return; + if (suggestionDraft === base.content) { setIsSuggesting(false); + suggestionBaseRef.current = null; return; } try { const operation = markdownSuggestionOperation( - document.content, + base.content, suggestionDraft, ); if (!operation) { setIsSuggesting(false); + suggestionBaseRef.current = null; return; } await createSuggestion.mutateAsync({ resourceType: "document", resourceId: documentId, adapterKind: "content.document-markdown", - baseRevision: document.updatedAt, + baseRevision: base.updatedAt, summary: t("editor.toolbar.suggestEdits"), idempotencyKey: globalThis.crypto.randomUUID(), operations: [operation], }); setIsSuggesting(false); + suggestionBaseRef.current = null; setUtilityPanel("comments"); } catch (error) { toast.error(t("editor.suggestionCreateFailed"), { @@ -1949,7 +1962,6 @@ function DocumentEditorBody({ canSuggest, createSuggestion, document.content, - document.updatedAt, documentId, isSuggesting, suggestionDraft, diff --git a/templates/content/server/lib/suggested-edits.spec.ts b/templates/content/server/lib/suggested-edits.spec.ts index 9e7bb36837a..26047f2c72a 100644 --- a/templates/content/server/lib/suggested-edits.spec.ts +++ b/templates/content/server/lib/suggested-edits.spec.ts @@ -10,8 +10,11 @@ vi.mock("@agent-native/core/db", () => ({ getDbExec: () => ({ execute: exclusions }), })); -const { applyMarkdownSuggestionOperation, contentDocumentSuggestionAdapter } = - await import("./suggested-edits"); +const { + applyMarkdownSuggestionOperation, + contentDocumentSuggestionAdapter, + publishPersistedAcceptedSuggestion, +} = await import("./suggested-edits"); const operation = { ordinal: 0, @@ -37,6 +40,11 @@ const access = { }; function decisionCoordination() { + const persistSync = vi.fn(async () => ({ + source: "action", + type: "change", + version: 1, + })); return { ydoc: { doc: new Y.Doc(), @@ -44,7 +52,8 @@ function decisionCoordination() { persist: vi.fn(async () => {}), }, sync: { - persist: vi.fn(async () => ({ version: 1 })), + persist: persistSync, + isPersisted: () => persistSync.mock.calls.length > 0, publish: vi.fn(), }, }; @@ -90,6 +99,14 @@ describe("Content document suggestion adapter", () => { ).rejects.toThrow("inline databases cannot receive suggestions yet"); }); + it("does not publish a duplicate accepted retry without a persisted event", () => { + const sync = decisionCoordination().sync; + publishPersistedAcceptedSuggestion(sync, { + decision: { outcome: "accepted" }, + }); + expect(sync.publish).not.toHaveBeenCalled(); + }); + it("snapshots and applies with an exact compare-and-swap", async () => { const writes: string[] = []; const tx = { diff --git a/templates/content/server/lib/suggested-edits.ts b/templates/content/server/lib/suggested-edits.ts index 5c52ef94042..3cd39b58378 100644 --- a/templates/content/server/lib/suggested-edits.ts +++ b/templates/content/server/lib/suggested-edits.ts @@ -145,6 +145,19 @@ type ContentDecisionCoordination = { sync: TransactionalChange; }; +export function publishPersistedAcceptedSuggestion( + sync: TransactionalChange, + result: unknown, +): void { + if ( + sync.isPersisted() && + (result as { decision?: { outcome?: string } }).decision?.outcome === + "accepted" + ) { + sync.publish(); + } +} + let contentEditorSchema: ReturnType | undefined; function replacePreparedCollabContent( @@ -227,12 +240,7 @@ export const contentDocumentSuggestionAdapter: SuggestionAdapter = { : undefined, (ydoc) => run({ ydoc, sync } satisfies ContentDecisionCoordination), ); - if ( - (result as { decision?: { outcome?: string } }).decision?.outcome === - "accepted" - ) { - sync.publish(); - } + publishPersistedAcceptedSuggestion(sync, result); return result; }, async apply(context) { From c820218ee5e811c0328d68e16085a0b1bcce2fc1 Mon Sep 17 00:00:00 2001 From: Alice Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:13:45 +0000 Subject: [PATCH 03/15] fix: complete suggested edits review navigation --- .../editor/CommentsSidebar.layout.test.ts | 4 ++ .../app/components/editor/CommentsSidebar.tsx | 63 ++++++++++++++++--- .../editor/DocumentEditor.layout.test.ts | 13 ++++ .../app/components/editor/DocumentEditor.tsx | 11 +++- templates/content/app/i18n-data.ts | 33 ++++++++++ templates/content/app/i18n/zh-TW.ts | 3 + 6 files changed, 116 insertions(+), 11 deletions(-) diff --git a/templates/content/app/components/editor/CommentsSidebar.layout.test.ts b/templates/content/app/components/editor/CommentsSidebar.layout.test.ts index 940400496ba..1070d2e195d 100644 --- a/templates/content/app/components/editor/CommentsSidebar.layout.test.ts +++ b/templates/content/app/components/editor/CommentsSidebar.layout.test.ts @@ -258,6 +258,10 @@ describe("comments sidebar layout", () => { expect(source).toContain('t("comments.statusFilter")'); expect(source).toContain('t("comments.authorFilter")'); expect(source).toContain("event.preventDefault()"); + expect(source).toContain( + '"pending",\n "accepted",\n "rejected"', + ); + expect(source).toContain("renderSuggestionCards(historySuggestions)"); expect(source).toContain( 'className="w-full min-w-0 overflow-hidden rounded-lg bg-popover', ); diff --git a/templates/content/app/components/editor/CommentsSidebar.tsx b/templates/content/app/components/editor/CommentsSidebar.tsx index 8029697db56..32050f33245 100644 --- a/templates/content/app/components/editor/CommentsSidebar.tsx +++ b/templates/content/app/components/editor/CommentsSidebar.tsx @@ -414,7 +414,7 @@ export function CommentsSidebar({ const [pendingText, setPendingText] = useState(""); const [pendingMentions, setPendingMentions] = useState([]); const [historyStatus, setHistoryStatus] = useState< - "all" | "open" | "resolved" + "all" | "open" | "resolved" | "pending" | "accepted" | "rejected" >("all"); const [historyAuthor, setHistoryAuthor] = useState(null); const sidebarRef = useRef(null); @@ -441,6 +441,14 @@ export function CommentsSidebar({ }, [canComment, presentation, selectedThreadId, selectedThreadIsOpen]); const historyAuthors = useMemo(() => { const authors = new Map(); + for (const suggestion of suggestions) { + if (suggestion.authorEmail) { + authors.set( + suggestion.authorEmail, + suggestion.authorEmail.split("@")[0], + ); + } + } for (const thread of threads) { for (const comment of thread.comments) { authors.set( @@ -452,9 +460,29 @@ export function CommentsSidebar({ return [...authors.entries()].sort((left, right) => left[1].localeCompare(right[1]), ); - }, [threads]); + }, [suggestions, threads]); + const historySuggestions = useMemo(() => { + return suggestions.filter((suggestion) => { + if (historyStatus === "open" && suggestion.status !== "pending") { + return false; + } + if (historyStatus === "resolved" && suggestion.status === "pending") { + return false; + } + if ( + ["pending", "accepted", "rejected"].includes(historyStatus) && + suggestion.status !== historyStatus + ) { + return false; + } + return !historyAuthor || suggestion.authorEmail === historyAuthor; + }); + }, [historyAuthor, historyStatus, suggestions]); const historyThreads = useMemo(() => { return threads.filter((thread) => { + if (["pending", "accepted", "rejected"].includes(historyStatus)) { + return false; + } if (historyStatus === "open" && thread.resolved) return false; if (historyStatus === "resolved" && !thread.resolved) return false; if ( @@ -719,10 +747,12 @@ export function CommentsSidebar({ }); }; - const renderSuggestionCards = () => - suggestions.length > 0 ? ( + const renderSuggestionCards = ( + displayedSuggestions: ResourceSuggestion[] = suggestions, + ) => + displayedSuggestions.length > 0 ? (
- {suggestions.map((suggestion) => { + {displayedSuggestions.map((suggestion) => { const operation = suggestion.operations[0]; const before = operation?.before as | { changedText?: string } @@ -813,7 +843,16 @@ export function CommentsSidebar({ {t("comments.statusFilter")} - {(["all", "open", "resolved"] as const).map((status) => ( + {( + [ + "all", + "open", + "resolved", + "pending", + "accepted", + "rejected", + ] as const + ).map((status) => ( ))} @@ -860,7 +905,7 @@ export function CommentsSidebar({
- {renderSuggestionCards()} + {renderSuggestionCards(historySuggestions)}
{isLoading ? ( [0, 1, 2].map((item) => ( @@ -870,7 +915,7 @@ export function CommentsSidebar({ aria-hidden="true" /> )) - ) : historyThreads.length === 0 ? ( + ) : historyThreads.length === 0 && historySuggestions.length === 0 ? (
{t("comments.noFilteredComments")}
diff --git a/templates/content/app/components/editor/DocumentEditor.layout.test.ts b/templates/content/app/components/editor/DocumentEditor.layout.test.ts index 3c3ebd2b380..c00e89f16e1 100644 --- a/templates/content/app/components/editor/DocumentEditor.layout.test.ts +++ b/templates/content/app/components/editor/DocumentEditor.layout.test.ts @@ -740,6 +740,19 @@ describe("document editor layout", () => { expect(source).not.toContain("baseRevision: document.updatedAt"); }); + it("opens the comments surface for suggestion deep links and new proposals", () => { + const source = readFileSync( + new URL("./DocumentEditor.tsx", import.meta.url), + "utf8", + ); + + expect( + source.match( + /setUtilityPanel\("comments"\);\s+setCommentsBrowseOpen\(true\)/g, + ), + ).toHaveLength(2); + }); + it("wakes live-editor flush reads from shared sync events instead of polling", () => { 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 97ff6667207..1d9915d3b33 100644 --- a/templates/content/app/components/editor/DocumentEditor.tsx +++ b/templates/content/app/components/editor/DocumentEditor.tsx @@ -851,8 +851,9 @@ function DocumentEditorBody({ useEffect(() => { const suggestionId = new URLSearchParams(location.search).get("suggestion"); if (!suggestionId || !suggestionsQuery.data) return; - if (utilityPanel !== "comments") { + if (utilityPanel !== "comments" || !commentsBrowseOpen) { setUtilityPanel("comments"); + setCommentsBrowseOpen(true); return; } const target = globalThis.document.querySelector( @@ -860,7 +861,12 @@ function DocumentEditorBody({ ); target?.scrollIntoView({ block: "nearest" }); target?.focus(); - }, [location.search, suggestionsQuery.data, utilityPanel]); + }, [ + commentsBrowseOpen, + location.search, + suggestionsQuery.data, + utilityPanel, + ]); const flushRequestKey = `flush-request-${documentId}`; const [flushRequestWake, setFlushRequestWake] = useState(0); const handleFlushRequestEvent = useCallback( @@ -2029,6 +2035,7 @@ function DocumentEditorBody({ setIsSuggesting(false); suggestionBaseRef.current = null; setUtilityPanel("comments"); + setCommentsBrowseOpen(true); } catch (error) { toast.error(t("editor.suggestionCreateFailed"), { description: diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index 8d94a2de379..b864f0ad424 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -3320,6 +3320,9 @@ const enUS = { allAuthors: "Everyone", open: "Open", resolvedStatus: "Resolved", + pending: "Pending", + accepted: "Accepted", + rejected: "Rejected", noFilteredComments: "No matching comments.", hideIndicators: "Hide comments and highlights", showIndicators: "Show comments and highlights", @@ -9423,6 +9426,9 @@ const commentMessagesByLocale = { allAuthors: "所有人", open: "未解决", resolvedStatus: "已解决", + pending: "待处理", + accepted: "已接受", + rejected: "已拒绝", noFilteredComments: "没有匹配的评论。", }, "zh-TW": { @@ -9438,6 +9444,9 @@ const commentMessagesByLocale = { allAuthors: "所有人", open: "未解決", resolvedStatus: "已解決", + pending: "待處理", + accepted: "已接受", + rejected: "已拒絕", noFilteredComments: "沒有相符的留言。", }, "es-ES": { @@ -9453,6 +9462,9 @@ const commentMessagesByLocale = { allAuthors: "Todos", open: "Abiertos", resolvedStatus: "Resueltos", + pending: "Pendientes", + accepted: "Aceptados", + rejected: "Rechazados", noFilteredComments: "No hay comentarios coincidentes.", }, "fr-FR": { @@ -9468,6 +9480,9 @@ const commentMessagesByLocale = { allAuthors: "Tout le monde", open: "Ouverts", resolvedStatus: "Résolus", + pending: "En attente", + accepted: "Acceptés", + rejected: "Refusés", noFilteredComments: "Aucun commentaire correspondant.", }, "de-DE": { @@ -9483,6 +9498,9 @@ const commentMessagesByLocale = { allAuthors: "Alle Personen", open: "Offen", resolvedStatus: "Erledigt", + pending: "Ausstehend", + accepted: "Angenommen", + rejected: "Abgelehnt", noFilteredComments: "Keine passenden Kommentare.", }, "ja-JP": { @@ -9498,6 +9516,9 @@ const commentMessagesByLocale = { allAuthors: "全員", open: "未解決", resolvedStatus: "解決済み", + pending: "保留中", + accepted: "承認済み", + rejected: "却下済み", noFilteredComments: "一致するコメントはありません。", }, "ko-KR": { @@ -9513,6 +9534,9 @@ const commentMessagesByLocale = { allAuthors: "모든 사용자", open: "열림", resolvedStatus: "해결됨", + pending: "대기 중", + accepted: "수락됨", + rejected: "거부됨", noFilteredComments: "일치하는 댓글이 없습니다.", }, "pt-BR": { @@ -9528,6 +9552,9 @@ const commentMessagesByLocale = { allAuthors: "Todos", open: "Abertos", resolvedStatus: "Resolvidos", + pending: "Pendentes", + accepted: "Aceitos", + rejected: "Rejeitados", noFilteredComments: "Nenhum comentário correspondente.", }, "hi-IN": { @@ -9543,6 +9570,9 @@ const commentMessagesByLocale = { allAuthors: "सभी लोग", open: "खुले", resolvedStatus: "सुलझाए गए", + pending: "लंबित", + accepted: "स्वीकृत", + rejected: "अस्वीकृत", noFilteredComments: "कोई मेल खाती टिप्पणी नहीं।", }, "ar-SA": { @@ -9558,6 +9588,9 @@ const commentMessagesByLocale = { allAuthors: "الجميع", open: "مفتوحة", resolvedStatus: "تم حلها", + pending: "قيد الانتظار", + accepted: "مقبولة", + rejected: "مرفوضة", noFilteredComments: "لا توجد تعليقات مطابقة.", }, } satisfies Partial>>; diff --git a/templates/content/app/i18n/zh-TW.ts b/templates/content/app/i18n/zh-TW.ts index 5a8ff7df01b..b0661af7f68 100644 --- a/templates/content/app/i18n/zh-TW.ts +++ b/templates/content/app/i18n/zh-TW.ts @@ -639,6 +639,9 @@ const messages = { allAuthors: "所有人", open: "未解決", resolvedStatus: "已解決", + pending: "待處理", + accepted: "已接受", + rejected: "已拒絕", noFilteredComments: "沒有相符的留言。", hideIndicators: "隱藏留言和醒目提示", showIndicators: "顯示留言和醒目提示", From d33a66dd21cf9acdb11eb08c581b248824677ee4 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:45:43 -0400 Subject: [PATCH 04/15] Fix suggested edit visibility and review --- .../editor/CommentsSidebar.layout.test.ts | 2 + .../app/components/editor/CommentsSidebar.tsx | 73 ++++-- .../editor/DocumentEditor.layout.test.ts | 14 +- .../app/components/editor/DocumentEditor.tsx | 153 +++++++++++- .../app/components/editor/VisualEditor.tsx | 172 ++++++++++++- .../extensions/SuggestionHighlight.spec.ts | 187 ++++++++++++++ .../editor/extensions/SuggestionHighlight.ts | 226 +++++++++++++++++ .../suggestions/markdown-operation.test.ts | 84 ++++++- .../editor/suggestions/markdown-operation.ts | 231 +++++++++++++++--- templates/content/app/global.css | 42 ++++ ...02-content-suggested-edits-parity-shape.md | 126 ++++++---- 11 files changed, 1209 insertions(+), 101 deletions(-) create mode 100644 templates/content/app/components/editor/extensions/SuggestionHighlight.spec.ts create mode 100644 templates/content/app/components/editor/extensions/SuggestionHighlight.ts diff --git a/templates/content/app/components/editor/CommentsSidebar.layout.test.ts b/templates/content/app/components/editor/CommentsSidebar.layout.test.ts index 1070d2e195d..c9385ac6aea 100644 --- a/templates/content/app/components/editor/CommentsSidebar.layout.test.ts +++ b/templates/content/app/components/editor/CommentsSidebar.layout.test.ts @@ -262,6 +262,8 @@ describe("comments sidebar layout", () => { '"pending",\n "accepted",\n "rejected"', ); expect(source).toContain("renderSuggestionCards(historySuggestions)"); + expect(source).toContain("renderSuggestionText(before.changedText)"); + expect(source).toContain(""); expect(source).toContain( 'className="w-full min-w-0 overflow-hidden rounded-lg bg-popover', ); diff --git a/templates/content/app/components/editor/CommentsSidebar.tsx b/templates/content/app/components/editor/CommentsSidebar.tsx index 32050f33245..a173775f009 100644 --- a/templates/content/app/components/editor/CommentsSidebar.tsx +++ b/templates/content/app/components/editor/CommentsSidebar.tsx @@ -58,6 +58,7 @@ import { useMentionMembers, type MentionMember, } from "@/hooks/use-mention-members"; +import { cn } from "@/lib/utils"; import type { CommentTextAnchor } from "./comment-anchors"; import { CommentComposer, type MentionEntry } from "./CommentComposer"; @@ -79,6 +80,10 @@ function commentMentionSpans( })); } +function renderSuggestionText(content: string) { + return ; +} + function renderCommentBody(content: string, mentions: CommentMention[]) { return ( void; + activeSuggestionId?: string | null; + onActivateSuggestion?: (id: string) => void; onSelectedThreadChange?: (id: string | null) => void; onHoveredThreadChange?: (id: string | null) => void; currentUserEmail?: string; @@ -390,6 +397,8 @@ export function CommentsSidebar({ activeThreadId, selectedThreadId, onActivateThread, + activeSuggestionId, + onActivateSuggestion, onSelectedThreadChange, onHoveredThreadChange, currentUserEmail, @@ -763,9 +772,19 @@ export function CommentsSidebar({ return (
onActivateSuggestion?.(suggestion.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onActivateSuggestion?.(suggestion.id); + } + }} >
@@ -777,11 +796,13 @@ export function CommentsSidebar({

{before?.changedText ? ( - {before.changedText} + {renderSuggestionText(before.changedText)} ) : null} {before?.changedText && after?.changedText ? " → " : null} - {after?.changedText ? {after.changedText} : null} + {after?.changedText ? ( + {renderSuggestionText(after.changedText)} + ) : null}

{canDecideSuggestions && suggestion.status === "pending" ? (
@@ -789,7 +810,10 @@ export function CommentsSidebar({ type="button" className="rounded-md px-2.5 py-1 text-xs text-muted-foreground hover:bg-accent" disabled={decidingSuggestion} - onClick={() => onDecideSuggestion?.(suggestion, "rejected")} + onClick={(event) => { + event.stopPropagation(); + onDecideSuggestion?.(suggestion, "rejected"); + }} > {t("editor.rejectSuggestion")} @@ -797,7 +821,10 @@ export function CommentsSidebar({ type="button" className="rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground disabled:opacity-40" disabled={decidingSuggestion} - onClick={() => onDecideSuggestion?.(suggestion, "accepted")} + onClick={(event) => { + event.stopPropagation(); + onDecideSuggestion?.(suggestion, "accepted"); + }} > {t("editor.acceptSuggestion")} @@ -977,9 +1004,19 @@ export function CommentsSidebar({ return (
onActivateSuggestion?.(suggestion.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onActivateSuggestion?.(suggestion.id); + } + }} >
@@ -991,11 +1028,13 @@ export function CommentsSidebar({

{before?.changedText ? ( - {before.changedText} + {renderSuggestionText(before.changedText)} ) : null} {before?.changedText && after?.changedText ? " → " : null} - {after?.changedText ? {after.changedText} : null} + {after?.changedText ? ( + {renderSuggestionText(after.changedText)} + ) : null}

{canDecideSuggestions && suggestion.status === "pending" ? (
@@ -1003,9 +1042,10 @@ export function CommentsSidebar({ type="button" className="rounded-md px-2.5 py-1 text-xs text-muted-foreground hover:bg-accent" disabled={decidingSuggestion} - onClick={() => - onDecideSuggestion?.(suggestion, "rejected") - } + onClick={(event) => { + event.stopPropagation(); + onDecideSuggestion?.(suggestion, "rejected"); + }} > {t("editor.rejectSuggestion")} @@ -1013,9 +1053,10 @@ export function CommentsSidebar({ type="button" className="rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground disabled:opacity-40" disabled={decidingSuggestion} - onClick={() => - onDecideSuggestion?.(suggestion, "accepted") - } + onClick={(event) => { + event.stopPropagation(); + onDecideSuggestion?.(suggestion, "accepted"); + }} > {t("editor.acceptSuggestion")} diff --git a/templates/content/app/components/editor/DocumentEditor.layout.test.ts b/templates/content/app/components/editor/DocumentEditor.layout.test.ts index c00e89f16e1..5ee0766bb43 100644 --- a/templates/content/app/components/editor/DocumentEditor.layout.test.ts +++ b/templates/content/app/components/editor/DocumentEditor.layout.test.ts @@ -734,7 +734,19 @@ describe("document editor layout", () => { expect(source).toContain("suggestionBaseRef.current = {"); expect(source).toContain( - "markdownSuggestionOperation(\n base.content", + "markdownSuggestionOperations(\n base.content", + ); + expect(source).toContain("for (const operation of operations)"); + expect(source).toContain("operations: [operation]"); + expect(source).toContain("createdSuggestionOperationsRef.current.get"); + expect(source).toContain("createdSuggestionOperationsRef.current.set"); + expect( + source.indexOf("createdSuggestionOperationsRef.current.set"), + ).toBeLessThan( + source.indexOf("const created = await createSuggestion.mutateAsync"), + ); + expect(source).toContain( + "idempotencyKey,\n operations: [operation]", ); expect(source).toContain("baseRevision: base.updatedAt"); expect(source).not.toContain("baseRevision: document.updatedAt"); diff --git a/templates/content/app/components/editor/DocumentEditor.tsx b/templates/content/app/components/editor/DocumentEditor.tsx index 1d9915d3b33..713cd1bfaba 100644 --- a/templates/content/app/components/editor/DocumentEditor.tsx +++ b/templates/content/app/components/editor/DocumentEditor.tsx @@ -21,6 +21,7 @@ import { useDecideResourceSuggestion, useResourceSuggestions, } from "@agent-native/core/client/review"; +import type { ResourceSuggestion } from "@agent-native/core/review"; import { normalizeDocumentTitle } from "@agent-native/core/shared"; import type { Document, DocumentSyncStatus } from "@shared/api"; import { @@ -128,7 +129,7 @@ import { } from "./local-source-write-state"; import { NotionConflictBanner } from "./NotionConflictBanner"; import { suggestedEditorIsolation } from "./suggestions/editor-isolation"; -import { markdownSuggestionOperation } from "./suggestions/markdown-operation"; +import { markdownSuggestionOperations } from "./suggestions/markdown-operation"; import { normalizeTitleText, stripMarkdownHeadingPrefixFromTitlePaste, @@ -136,6 +137,7 @@ import { import { VisualEditor } from "./VisualEditor"; import type { NotionPageLink, + VisualEditorSuggestion, VisualEditorHistoryController, VisualEditorHistoryState, } from "./VisualEditor"; @@ -152,6 +154,52 @@ type FieldSaveWatermark = { title: string; updatedAt: string | null }; type ContentSaveWatermark = { content: string; updatedAt: string | null }; type DocumentUtilityPanel = "info" | "comments" | null; +function suggestionPresentation( + suggestion: ResourceSuggestion, +): VisualEditorSuggestion | null { + if (suggestion.status !== "pending") return null; + const operation = suggestion.operations[0]; + if (!operation) return null; + const before = operation.before as { changedText?: unknown } | null; + const after = operation.after as { changedText?: unknown } | null; + const anchor = operation.anchor as { + from?: unknown; + prefix?: unknown; + suffix?: unknown; + } | null; + const supportedKinds = [ + "insert_text", + "delete_text", + "replace_text", + "add_text_block", + "set_inline_mark", + ] as const; + if ( + !supportedKinds.includes( + operation.kind as (typeof supportedKinds)[number], + ) || + typeof before?.changedText !== "string" || + typeof after?.changedText !== "string" || + typeof anchor?.from !== "number" || + typeof anchor.prefix !== "string" || + typeof anchor.suffix !== "string" + ) { + return null; + } + return { + id: suggestion.id, + kind: operation.kind as VisualEditorSuggestion["kind"], + beforeText: before.changedText, + afterText: after.changedText, + anchor: { + from: anchor.from, + prefix: anchor.prefix, + suffix: anchor.suffix, + }, + presentation: "canonical", + }; +} + export function metadataUpdatesWithPendingTitle< T extends { title?: string; @@ -719,10 +767,16 @@ function DocumentEditorBody({ ); const [isSuggesting, setIsSuggesting] = useState(false); const [suggestionDraft, setSuggestionDraft] = useState(document.content); + const [selectedSuggestionId, setSelectedSuggestionId] = useState< + string | null + >(null); const suggestionBaseRef = useRef<{ content: string; updatedAt: string; } | null>(null); + const createdSuggestionOperationsRef = useRef( + new Map(), + ); const [utilityPanel, setUtilityPanel] = useState(null); const [lastUtilityPanel, setLastUtilityPanel] = useState>("comments"); @@ -1997,6 +2051,7 @@ function DocumentEditorBody({ async (next: boolean) => { if (next) { if (!canSuggest) return; + createdSuggestionOperationsRef.current.clear(); suggestionBaseRef.current = { content: document.content, updatedAt: document.updatedAt, @@ -2011,29 +2066,52 @@ function DocumentEditorBody({ if (suggestionDraft === base.content) { setIsSuggesting(false); suggestionBaseRef.current = null; + createdSuggestionOperationsRef.current.clear(); return; } try { - const operation = markdownSuggestionOperation( + const operations = markdownSuggestionOperations( base.content, suggestionDraft, ); - if (!operation) { + if (operations.length === 0) { setIsSuggesting(false); suggestionBaseRef.current = null; return; } - await createSuggestion.mutateAsync({ - resourceType: "document", - resourceId: documentId, - adapterKind: "content.document-markdown", - baseRevision: base.updatedAt, - summary: t("editor.toolbar.suggestEdits"), - idempotencyKey: globalThis.crypto.randomUUID(), - operations: [operation], - }); + const createdIds: string[] = []; + for (const operation of operations) { + const operationKey = JSON.stringify(operation); + const existing = + createdSuggestionOperationsRef.current.get(operationKey); + if (existing?.suggestionId) { + createdIds.push(existing.suggestionId); + continue; + } + const idempotencyKey = + existing?.idempotencyKey ?? globalThis.crypto.randomUUID(); + createdSuggestionOperationsRef.current.set(operationKey, { + idempotencyKey, + }); + const created = await createSuggestion.mutateAsync({ + resourceType: "document", + resourceId: documentId, + adapterKind: "content.document-markdown", + baseRevision: base.updatedAt, + summary: t("editor.toolbar.suggestEdits"), + idempotencyKey, + operations: [operation], + }); + createdSuggestionOperationsRef.current.set(operationKey, { + idempotencyKey, + suggestionId: created.id, + }); + createdIds.push(created.id); + } setIsSuggesting(false); suggestionBaseRef.current = null; + createdSuggestionOperationsRef.current.clear(); + setSelectedSuggestionId(createdIds[0] ?? null); setUtilityPanel("comments"); setCommentsBrowseOpen(true); } catch (error) { @@ -2058,6 +2136,28 @@ function DocumentEditorBody({ if (!canSuggest && isSuggesting) setIsSuggesting(false); }, [canSuggest, isSuggesting]); + const visualSuggestions = useMemo(() => { + const base = suggestionBaseRef.current; + if (isSuggesting && base) { + return markdownSuggestionOperations(base.content, suggestionDraft).map( + (operation) => ({ + id: `draft-${operation.ordinal}`, + kind: operation.kind, + beforeText: operation.before.changedText, + afterText: operation.after.changedText, + anchor: operation.anchor, + presentation: "draft", + }), + ); + } + return (suggestionsQuery.data?.suggestions ?? []) + .map(suggestionPresentation) + .filter( + (suggestion): suggestion is VisualEditorSuggestion => + suggestion !== null, + ); + }, [isSuggesting, suggestionDraft, suggestionsQuery.data?.suggestions]); + useEffect(() => { void setClientAppState( "content-suggestion-mode", @@ -2234,6 +2334,7 @@ function DocumentEditorBody({ }, [clearCommentFocus, hasUtilityRailSpace]); const activateCommentThread = useCallback((threadId: string) => { + setSelectedSuggestionId(null); setPendingComment(null); setHoveredThreadId(null); setSelectedThreadId(threadId); @@ -2241,6 +2342,28 @@ function DocumentEditorBody({ setUtilityPanel("comments"); }, []); + const activateSuggestion = useCallback((suggestionId: string) => { + setPendingComment(null); + setHoveredThreadId(null); + setSelectedThreadId(null); + setSelectedSuggestionId(suggestionId); + setCommentsBrowseOpen(false); + setUtilityPanel("comments"); + requestAnimationFrame(() => { + const escaped = globalThis.CSS?.escape + ? globalThis.CSS.escape(suggestionId) + : suggestionId.replace(/["\\]/g, "\\$&"); + const targets = globalThis.document.querySelectorAll( + `[data-suggestion-id="${escaped}"]`, + ); + const pageTarget = Array.from(targets).find((target) => + target.closest(".notion-editor"), + ); + pageTarget?.scrollIntoView({ behavior: "smooth", block: "center" }); + pageTarget?.focus({ preventScroll: true }); + }); + }, []); + const handleUtilityPanelChange = useCallback( (nextPanel: DocumentUtilityPanel) => { setUtilityPanel(nextPanel); @@ -2262,6 +2385,7 @@ function DocumentEditorBody({ setCommentsBrowseOpen(false); setUtilityPanel(null); clearCommentFocus(); + setSelectedSuggestionId(null); }, [clearCommentFocus, documentId]); useEffect(() => { @@ -2481,6 +2605,8 @@ function DocumentEditorBody({ activeThreadId={activeThreadId} selectedThreadId={selectedThreadId} onActivateThread={activateCommentThread} + activeSuggestionId={selectedSuggestionId} + onActivateSuggestion={activateSuggestion} onSelectedThreadChange={setSelectedThreadId} onHoveredThreadChange={setHoveredThreadId} currentUserEmail={session?.email} @@ -3052,6 +3178,9 @@ function DocumentEditorBody({ ? activateCommentThread : undefined } + suggestions={visualSuggestions} + activeSuggestionId={selectedSuggestionId} + onActivateSuggestion={activateSuggestion} showCommentIndicators={showCommentIndicators} onJoinTitle={joinFirstBodyBlockToTitle} notionPageLinks={notionPageLinks} diff --git a/templates/content/app/components/editor/VisualEditor.tsx b/templates/content/app/components/editor/VisualEditor.tsx index 79b3b267865..d450c345779 100644 --- a/templates/content/app/components/editor/VisualEditor.tsx +++ b/templates/content/app/components/editor/VisualEditor.tsx @@ -60,7 +60,11 @@ import { contentBlockRegistry } from "@/blocks/contentBlockRegistry"; import type { CommentThread } from "@/hooks/use-comments"; import { BubbleToolbar } from "./BubbleToolbar"; -import { resolveAnchor, type CommentTextAnchor } from "./comment-anchors"; +import { + buildDocText, + resolveAnchor, + type CommentTextAnchor, +} from "./comment-anchors"; import { AudioNode } from "./extensions/AudioNode"; import { CodeBlock } from "./extensions/CodeBlockNode"; import { @@ -87,6 +91,11 @@ import { LockedSourceComponentBlocks, RegistryBlockNode, } from "./extensions/registryBlocks"; +import { + SuggestionHighlight, + setSuggestionHighlights, + type SuggestionHighlightSpec, +} from "./extensions/SuggestionHighlight"; import { VideoNode } from "./extensions/VideoNode"; import { getImageFiles, @@ -722,6 +731,102 @@ const NormalizeTableHeaders = Extension.create({ }, }); +export interface VisualEditorSuggestion { + id: string; + kind: + | "insert_text" + | "delete_text" + | "replace_text" + | "add_text_block" + | "set_inline_mark"; + beforeText: string; + afterText: string; + anchor: { from: number; prefix: string; suffix: string }; + /** Draft documents already contain the proposed result; canonical ones do not. */ + presentation: "draft" | "canonical"; +} + +function suggestionAnchorRange( + doc: ProseMirrorNode, + suggestion: VisualEditorSuggestion, +): { from: number; to: number } | null { + const quote = + suggestion.presentation === "draft" + ? suggestion.afterText + : suggestion.beforeText; + if (quote) { + return resolveAnchor(doc, { + quotedText: quote, + prefix: suggestion.anchor.prefix, + suffix: suggestion.anchor.suffix, + startOffset: suggestion.anchor.from, + }); + } + + const docText = buildDocText(doc); + const prefix = suggestion.anchor.prefix; + const suffix = suggestion.anchor.suffix; + const prefixIndex = prefix ? docText.text.lastIndexOf(prefix) : -1; + const suffixIndex = suffix ? docText.text.indexOf(suffix) : -1; + const offset = + prefixIndex >= 0 + ? prefixIndex + prefix.length + : suffixIndex >= 0 + ? suffixIndex + : 0; + const marker = + offset < docText.text.length + ? docText.text.slice(offset, offset + 1) + : docText.text.slice(-1); + const resolved = marker + ? resolveAnchor(doc, { quotedText: marker, startOffset: offset }) + : null; + if (!resolved) return { from: 1, to: 1 }; + const position = offset < docText.text.length ? resolved.from : resolved.to; + return { from: position, to: position }; +} + +function suggestionHighlightSpec( + doc: ProseMirrorNode, + suggestion: VisualEditorSuggestion, +): SuggestionHighlightSpec | null { + const range = suggestionAnchorRange(doc, suggestion); + if (!range) return null; + if (suggestion.presentation === "draft") { + if (!suggestion.afterText) { + return { + suggestionId: suggestion.id, + kind: "delete", + from: range.from, + to: range.to, + deletedText: suggestion.beforeText, + }; + } + return { + suggestionId: suggestion.id, + kind: "mark", + from: range.from, + to: range.to, + }; + } + return { + suggestionId: suggestion.id, + kind: + suggestion.kind === "delete_text" + ? "delete" + : suggestion.kind === "replace_text" + ? "replace" + : suggestion.kind === "set_inline_mark" + ? "mark" + : suggestion.kind === "add_text_block" + ? "add_block" + : "insert", + from: range.from, + to: range.to, + insertedText: suggestion.afterText, + }; +} + interface VisualEditorProps { documentId?: string; content: string; @@ -763,6 +868,9 @@ interface VisualEditorProps { pendingHighlight?: { from: number; to: number } | null; /** Called when the user clicks an inline highlight in the document. */ onActivateThread?: (threadId: string) => void; + suggestions?: VisualEditorSuggestion[]; + activeSuggestionId?: string | null; + onActivateSuggestion?: (suggestionId: string) => void; showCommentIndicators?: boolean; onJoinTitle?: (text: string) => void; notionPageLinks?: NotionPageLink[]; @@ -1717,6 +1825,7 @@ export function createVisualEditorExtensions({ }), LocalMdxComponentNode, CommentHighlight, + SuggestionHighlight, DragHandle, TypographyReplacements, NotionMarkdownShortcuts, @@ -2090,6 +2199,9 @@ export function VisualEditor({ activeThreadId, pendingHighlight, onActivateThread, + suggestions = [], + activeSuggestionId, + onActivateSuggestion, showCommentIndicators = true, onJoinTitle, notionPageLinks = [], @@ -2107,6 +2219,10 @@ export function VisualEditor({ onChangeRef.current = onChange; const onSaveContentRef = useRef(onSaveContent); onSaveContentRef.current = onSaveContent; + const onActivateThreadRef = useRef(onActivateThread); + onActivateThreadRef.current = onActivateThread; + const onActivateSuggestionRef = useRef(onActivateSuggestion); + onActivateSuggestionRef.current = onActivateSuggestion; const onHistoryStateChangeRef = useRef(onHistoryStateChange); onHistoryStateChangeRef.current = onHistoryStateChange; const historyStateNotificationRef = useRef( @@ -2898,6 +3014,60 @@ export function VisualEditor({ scheduleApply(true); }, [editor, scheduleApply, content, contentUpdatedAt]); + const suggestionsSignature = useMemo( + () => + suggestions + .map( + (suggestion) => + `${suggestion.id}:${suggestion.kind}:${suggestion.beforeText}:${suggestion.afterText}:${suggestion.presentation}`, + ) + .join("|"), + [suggestions], + ); + + useEffect(() => { + if (!editor || editor.isDestroyed) return; + const specs = suggestions + .map((suggestion) => + suggestionHighlightSpec(editor.state.doc, suggestion), + ) + .filter((spec): spec is SuggestionHighlightSpec => spec !== null); + setSuggestionHighlights(editor.view, { + specs, + activeId: activeSuggestionId ?? null, + }); + }, [activeSuggestionId, editor, suggestions, suggestionsSignature]); + + useEffect(() => { + if (!editor || editor.isDestroyed) return; + const handleClick = (event: MouseEvent) => { + const target = event.target instanceof Element ? event.target : null; + const suggestion = target?.closest("[data-suggestion-id]"); + if (suggestion?.dataset.suggestionId) { + onActivateSuggestionRef.current?.(suggestion.dataset.suggestionId); + return; + } + const comment = target?.closest("[data-comment-thread]"); + if (comment?.dataset.commentThread) { + onActivateThreadRef.current?.(comment.dataset.commentThread); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + const target = event.target instanceof Element ? event.target : null; + const suggestion = target?.closest("[data-suggestion-id]"); + if (!suggestion?.dataset.suggestionId) return; + event.preventDefault(); + onActivateSuggestionRef.current?.(suggestion.dataset.suggestionId); + }; + editor.view.dom.addEventListener("click", handleClick); + editor.view.dom.addEventListener("keydown", handleKeyDown); + return () => { + editor.view.dom.removeEventListener("click", handleClick); + editor.view.dom.removeEventListener("keydown", handleKeyDown); + }; + }, [editor]); + if (!editor) { return (
diff --git a/templates/content/app/components/editor/extensions/SuggestionHighlight.spec.ts b/templates/content/app/components/editor/extensions/SuggestionHighlight.spec.ts new file mode 100644 index 00000000000..8d9482ff8ad --- /dev/null +++ b/templates/content/app/components/editor/extensions/SuggestionHighlight.spec.ts @@ -0,0 +1,187 @@ +// @vitest-environment jsdom + +import { Schema, type Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { EditorState } from "@tiptap/pm/state"; +import { describe, expect, it } from "vitest"; + +import { + createSuggestionHighlightPlugin, + suggestionHighlightKey, + type SuggestionHighlightSpec, +} from "./SuggestionHighlight"; + +const schema = new Schema({ + nodes: { + doc: { content: "block+" }, + paragraph: { group: "block", content: "text*" }, + text: {}, + }, + marks: {}, +}); + +function doc(text: string): ProseMirrorNode { + return schema.node("doc", null, [ + schema.node("paragraph", null, text ? schema.text(text) : undefined), + ]); +} + +function state(text = "Before after"): EditorState { + return EditorState.create({ + doc: doc(text), + plugins: [createSuggestionHighlightPlugin()], + }); +} + +function setSpecs( + editorState: EditorState, + specs: SuggestionHighlightSpec[], + activeId: string | null = null, +): EditorState { + return editorState.apply( + editorState.tr.setMeta(suggestionHighlightKey, { specs, activeId }), + ); +} + +describe("SuggestionHighlight", () => { + it("renders deletion, replacement, insertion, block, and mark decorations without document marks", () => { + const editorState = setSpecs( + state(), + [ + { suggestionId: "delete", kind: "delete", from: 1, to: 7 }, + { + suggestionId: "replace", + kind: "replace", + from: 8, + to: 13, + insertedText: "new", + }, + { + suggestionId: "insert", + kind: "insert", + from: 1, + to: 1, + insertedText: "added", + }, + { + suggestionId: "block", + kind: "add_block", + from: 13, + to: 13, + insertedText: "New block", + }, + { suggestionId: "mark", kind: "mark", from: 1, to: 7 }, + ], + "replace", + ); + const decorations = suggestionHighlightKey + .getState(editorState)! + .decorations.find(); + + expect(editorState.doc.textContent).toBe("Before after"); + expect(decorations).toHaveLength(6); + expect( + decorations.find((deco) => deco.spec.key === "replace:inserted")?.from, + ).toBe(13); + expect( + decorations.find((deco) => deco.spec.key === "insert:inserted")?.from, + ).toBe(1); + const deleted = decorations.find( + (deco) => (deco as any).type.attrs?.class === "suggestion-delete", + ) as any; + const changed = decorations.find( + (deco) => (deco as any).type.attrs?.class === "suggestion-change", + ) as any; + expect(deleted.type.attrs).toMatchObject({ + class: "suggestion-delete", + "data-suggestion-id": "delete", + role: "button", + tabindex: "0", + }); + expect(changed.type.attrs).toMatchObject({ + class: "suggestion-change", + "data-suggestion-id": "mark", + }); + const replacement = decorations.find( + (deco) => deco.spec.key === "replace:inserted", + ) as any; + expect(replacement.type.toDOM().className).toContain( + "suggestion-highlight--active", + ); + }); + + it("maps persisted ranges across document transactions and preserves stable ids", () => { + let editorState = setSpecs(state("Hello world"), [ + { suggestionId: "stable", kind: "delete", from: 7, to: 12 }, + { + suggestionId: "insert", + kind: "insert", + from: 6, + to: 6, + insertedText: "brave ", + }, + ]); + editorState = editorState.apply(editorState.tr.insertText("big ", 1)); + const highlight = suggestionHighlightKey.getState(editorState)!; + + expect(highlight.specs).toEqual([ + { suggestionId: "stable", kind: "delete", from: 11, to: 16 }, + { + suggestionId: "insert", + kind: "insert", + from: 10, + to: 10, + insertedText: "brave ", + }, + ]); + }); + + it("clamps invalid ranges and keeps widget proposal text DOM-safe", () => { + const editorState = setSpecs(state("Safe"), [ + { suggestionId: "bad", kind: "delete", from: -10, to: 99 }, + { + suggestionId: "safe", + kind: "insert", + from: 999, + to: 999, + insertedText: "", + }, + ]); + const decorations = suggestionHighlightKey + .getState(editorState)! + .decorations.find(); + const widget = decorations.find( + (deco) => deco.spec.key === "safe:inserted", + )!; + const dom = (widget as any).type.toDOM(); + + expect( + decorations.find( + (deco) => (deco as any).type.attrs?.["data-suggestion-id"] === "bad", + )?.from, + ).toBe(0); + expect(widget.from).toBe(editorState.doc.content.size); + expect(dom.textContent).toBe(""); + expect(dom.querySelector("img")).toBeNull(); + expect(dom.getAttribute("data-suggestion-id")).toBe("safe"); + }); + + it("renders draft deletions as keyboard-focusable deletion widgets", () => { + const editorState = setSpecs(state("Keep this"), [ + { + suggestionId: "draft-delete", + kind: "delete", + from: 5, + to: 5, + deletedText: "removed", + }, + ]); + const decoration = suggestionHighlightKey + .getState(editorState)! + .decorations.find()[0] as any; + const dom = decoration.type.toDOM(); + + expect(dom.className).toContain("suggestion-delete-widget"); + expect(dom.textContent).toBe("removed"); + expect(dom.getAttribute("data-suggestion-id")).toBe("draft-delete"); + }); +}); diff --git a/templates/content/app/components/editor/extensions/SuggestionHighlight.ts b/templates/content/app/components/editor/extensions/SuggestionHighlight.ts new file mode 100644 index 00000000000..172760c6556 --- /dev/null +++ b/templates/content/app/components/editor/extensions/SuggestionHighlight.ts @@ -0,0 +1,226 @@ +import { Extension } from "@tiptap/core"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"; + +/** + * A pure, in-place presentation of a persisted suggestion. This never creates + * marks or changes document content: the canonical document stays canonical + * until the suggestion lifecycle accepts it. + */ +export type SuggestionHighlightKind = + | "delete" + | "replace" + | "insert" + | "add_block" + | "mark"; + +export interface SuggestionHighlightSpec { + suggestionId: string; + kind: SuggestionHighlightKind; + from: number; + to: number; + insertedText?: string; + deletedText?: string; +} + +export interface SuggestionHighlightState { + specs: SuggestionHighlightSpec[]; + activeId: string | null; + decorations: DecorationSet; +} + +export interface SuggestionHighlightMeta { + specs?: SuggestionHighlightSpec[]; + activeId?: string | null; +} + +export const suggestionHighlightKey = new PluginKey( + "suggestionHighlight", +); + +interface Range { + from: number; + to: number; +} + +function clampRange(from: number, to: number, size: number): Range | null { + const start = Math.max(0, Math.min(from, size)); + const end = Math.max(0, Math.min(to, size)); + return end > start ? { from: start, to: end } : null; +} + +function clampPosition(position: number, size: number): number { + return Math.max(0, Math.min(position, size)); +} + +function classes(base: string, active: boolean): string { + return active ? `${base} suggestion-highlight--active` : base; +} + +function insertionWidget(spec: SuggestionHighlightSpec, active: boolean) { + return () => { + const widget = document.createElement("button"); + widget.type = "button"; + widget.className = classes( + spec.kind === "add_block" ? "suggestion-add-block" : "suggestion-insert", + active, + ); + widget.setAttribute("data-suggestion-id", spec.suggestionId); + widget.setAttribute("data-suggestion-widget", "true"); + widget.setAttribute("aria-label", "Inspect suggested insertion"); + // textContent deliberately keeps persisted proposal text out of HTML. + widget.textContent = spec.insertedText ?? ""; + return widget; + }; +} + +function deletionWidget(spec: SuggestionHighlightSpec, active: boolean) { + return () => { + const widget = document.createElement("button"); + widget.type = "button"; + widget.className = classes("suggestion-delete-widget", active); + widget.setAttribute("data-suggestion-id", spec.suggestionId); + widget.setAttribute("data-suggestion-widget", "true"); + widget.setAttribute("aria-label", "Inspect suggested deletion"); + widget.textContent = spec.deletedText ?? ""; + return widget; + }; +} + +function buildDecorations( + doc: ProseMirrorNode, + specs: SuggestionHighlightSpec[], + activeId: string | null, +): DecorationSet { + const decorations: Decoration[] = []; + const size = doc.content.size; + + for (const spec of specs) { + const active = activeId === spec.suggestionId; + const range = clampRange(spec.from, spec.to, size); + const attrs = { + "data-suggestion-id": spec.suggestionId, + role: "button", + tabindex: "0", + "aria-label": "Inspect suggested change", + }; + + if (spec.kind === "delete" || spec.kind === "replace") { + if (range) { + decorations.push( + Decoration.inline(range.from, range.to, { + ...attrs, + class: classes("suggestion-delete", active), + }), + ); + } + } else if (spec.kind === "mark" && range) { + decorations.push( + Decoration.inline(range.from, range.to, { + ...attrs, + class: classes("suggestion-change", active), + }), + ); + } + + if ( + spec.kind === "replace" || + spec.kind === "insert" || + spec.kind === "add_block" + ) { + const anchor = clampPosition( + spec.kind === "replace" && range ? range.to : spec.from, + size, + ); + decorations.push( + Decoration.widget(anchor, insertionWidget(spec, active), { + key: `${spec.suggestionId}:inserted`, + side: 1, + ...attrs, + }), + ); + } + if (spec.kind === "delete" && !range && spec.deletedText) { + decorations.push( + Decoration.widget( + clampPosition(spec.from, size), + deletionWidget(spec, active), + { + key: `${spec.suggestionId}:deleted`, + side: 1, + ...attrs, + }, + ), + ); + } + } + + return DecorationSet.create(doc, decorations); +} + +export function createSuggestionHighlightPlugin(): Plugin { + return new Plugin({ + key: suggestionHighlightKey, + state: { + init: () => ({ + specs: [], + activeId: null, + decorations: DecorationSet.empty, + }), + apply(tr, value, _oldState, newState) { + const meta = tr.getMeta(suggestionHighlightKey) as + | SuggestionHighlightMeta + | undefined; + let specs = value.specs; + let activeId = value.activeId; + + if (meta) { + if (meta.specs !== undefined) specs = meta.specs; + if (meta.activeId !== undefined) activeId = meta.activeId; + } else if (tr.docChanged) { + specs = specs + .map((spec) => ({ + ...spec, + from: tr.mapping.map(spec.from, 1), + to: tr.mapping.map(spec.to, -1), + })) + .filter((spec) => + spec.kind === "insert" || spec.kind === "add_block" + ? true + : spec.to > spec.from, + ); + } else { + return value; + } + + return { + specs, + activeId, + decorations: buildDecorations(newState.doc, specs, activeId), + }; + }, + }, + props: { + decorations(state) { + return suggestionHighlightKey.getState(state)?.decorations ?? null; + }, + }, + }); +} + +export const SuggestionHighlight = Extension.create({ + name: "suggestionHighlight", + + addProseMirrorPlugins() { + return [createSuggestionHighlightPlugin()]; + }, +}); + +/** Push persisted suggestion specs and the centrally-managed active id. */ +export function setSuggestionHighlights( + view: EditorView, + meta: SuggestionHighlightMeta, +): void { + view.dispatch(view.state.tr.setMeta(suggestionHighlightKey, meta)); +} diff --git a/templates/content/app/components/editor/suggestions/markdown-operation.test.ts b/templates/content/app/components/editor/suggestions/markdown-operation.test.ts index 83724d804c8..482a00b8094 100644 --- a/templates/content/app/components/editor/suggestions/markdown-operation.test.ts +++ b/templates/content/app/components/editor/suggestions/markdown-operation.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { markdownSuggestionOperation } from "./markdown-operation"; +import { + markdownSuggestionOperation, + markdownSuggestionOperations, +} from "./markdown-operation"; describe("markdownSuggestionOperation", () => { it.each([ @@ -13,6 +16,85 @@ describe("markdownSuggestionOperation", () => { expect(markdownSuggestionOperation(before, after)?.kind).toBe(kind); }); + it("splits disjoint edits into independently applicable snapshots", () => { + const before = "Alpha old. Beta old. Gamma old."; + const after = "Alpha new. Beta old. Gamma fresh."; + const operations = markdownSuggestionOperations(before, after); + + expect(operations).toHaveLength(2); + expect(operations).toMatchObject([ + { + ordinal: 0, + kind: "replace_text", + targetId: "body", + before: { markdown: before, changedText: "old" }, + after: { + markdown: "Alpha new. Beta old. Gamma old.", + changedText: "new", + }, + schemaVersion: 1, + }, + { + ordinal: 1, + kind: "replace_text", + targetId: "body", + before: { markdown: before, changedText: "old" }, + after: { + markdown: "Alpha old. Beta old. Gamma fresh.", + changedText: "fresh", + }, + schemaVersion: 1, + }, + ]); + }); + + it.each([ + ["Hello", "Hello!", "insert_text"], + ["Hello!", "Hello", "delete_text"], + ["Hello", "Hi", "replace_text"], + ["Hello", "Hello\n\nNext", "add_text_block"], + ["Hello", "**Hello**", "set_inline_mark"], + ])("classifies split %s -> %s as %s", (before, after, kind) => { + expect(markdownSuggestionOperations(before, after)).toMatchObject([ + { ordinal: 0, kind, targetId: "body", schemaVersion: 1 }, + ]); + }); + + it("returns no operations when markdown is identical", () => { + expect(markdownSuggestionOperations("unchanged", "unchanged")).toEqual([]); + }); + + it("keeps anchors tied to the correct occurrence of repeated text", () => { + const before = "repeat same; repeat same; repeat same"; + const [operation] = markdownSuggestionOperations( + before, + "repeat same; repeat zxy; repeat same", + ); + + expect(operation).toMatchObject({ + before: { markdown: before, changedText: "same" }, + after: { + markdown: "repeat same; repeat zxy; repeat same", + changedText: "zxy", + }, + anchor: { + from: 20, + to: 24, + prefix: "repeat same; repeat ", + suffix: "; repeat same", + }, + }); + }); + + it("honestly falls back to one whole-document replacement beyond its size guard", () => { + const before = `${"a".repeat(64_000)}x`; + const after = `${"a".repeat(64_000)}y`; + + expect(markdownSuggestionOperations(before, after)).toEqual([ + markdownSuggestionOperation(before, after), + ]); + }); + it("retains exact canonical snapshots and a narrow anchor", () => { const operation = markdownSuggestionOperation( "one two three", diff --git a/templates/content/app/components/editor/suggestions/markdown-operation.ts b/templates/content/app/components/editor/suggestions/markdown-operation.ts index 530c5901a69..b468a36b2b3 100644 --- a/templates/content/app/components/editor/suggestions/markdown-operation.ts +++ b/templates/content/app/components/editor/suggestions/markdown-operation.ts @@ -14,6 +14,202 @@ export type MarkdownSuggestionOperation = { }; const MARKDOWN_MARK = /(?:\*\*|__|~~|`|\[|\]\([^)]*\))/g; +const MAX_DOCUMENT_LENGTH = 64_000; +const MAX_EDIT_DISTANCE = 1_024; + +type DiffPart = { type: "equal" | "insert" | "delete"; text: string }; + +function kindForChange(removed: string, inserted: string) { + const sameUnmarkedText = + removed.replace(MARKDOWN_MARK, "") === inserted.replace(MARKDOWN_MARK, ""); + return sameUnmarkedText && removed !== inserted + ? "set_inline_mark" + : !removed + ? inserted.includes("\n") + ? "add_text_block" + : "insert_text" + : !inserted + ? "delete_text" + : "replace_text"; +} + +function operationForChange( + before: string, + from: number, + to: number, + inserted: string, + ordinal: number, +): MarkdownSuggestionOperation { + const removed = before.slice(from, to); + return { + ordinal, + kind: kindForChange(removed, inserted), + targetId: "body", + before: { markdown: before, changedText: removed }, + after: { + markdown: `${before.slice(0, from)}${inserted}${before.slice(to)}`, + changedText: inserted, + }, + anchor: { + from, + to, + prefix: before.slice(Math.max(0, from - 32), from), + suffix: before.slice(to, to + 32), + }, + schemaVersion: 1, + }; +} + +function coalesce(parts: DiffPart[]): DiffPart[] { + const result: DiffPart[] = []; + for (const part of parts) { + if (!part.text) continue; + const previous = result[result.length - 1]; + if (previous?.type === part.type) previous.text += part.text; + else result.push({ ...part }); + } + return result; +} + +/** + * Returns a bounded Myers diff. The edit-distance cap keeps pathological + * documents from consuming unbounded memory; callers retain one whole-document + * replacement when the granular representation cannot be produced safely. + */ +function diffParts(before: string, after: string): DiffPart[] | null { + if (before.length + after.length > MAX_DOCUMENT_LENGTH) return null; + + const maxDistance = Math.min(before.length + after.length, MAX_EDIT_DISTANCE); + const offset = maxDistance + 1; + let frontier = new Int32Array(maxDistance * 2 + 3); + frontier.fill(-1); + frontier[offset + 1] = 0; + const trace: Int32Array[] = []; + + for (let distance = 0; distance <= maxDistance; distance += 1) { + trace.push(frontier.slice()); + for (let diagonal = -distance; diagonal <= distance; diagonal += 2) { + const stepDown = + diagonal === -distance || + (diagonal !== distance && + frontier[offset + diagonal - 1] < frontier[offset + diagonal + 1]); + let x = stepDown + ? frontier[offset + diagonal + 1] + : frontier[offset + diagonal - 1] + 1; + let y = x - diagonal; + while (x < before.length && y < after.length && before[x] === after[y]) { + x += 1; + y += 1; + } + frontier[offset + diagonal] = x; + if (x >= before.length && y >= after.length) { + return backtrack(trace, before, after, distance, offset); + } + } + } + return null; +} + +function backtrack( + trace: Int32Array[], + before: string, + after: string, + distance: number, + offset: number, +): DiffPart[] { + const reverseParts: DiffPart[] = []; + let x = before.length; + let y = after.length; + + for ( + let currentDistance = distance; + currentDistance > 0; + currentDistance -= 1 + ) { + const frontier = trace[currentDistance]!; + const diagonal = x - y; + const stepDown = + diagonal === -currentDistance || + (diagonal !== currentDistance && + frontier[offset + diagonal - 1] < frontier[offset + diagonal + 1]); + const previousDiagonal = stepDown ? diagonal + 1 : diagonal - 1; + const previousX = frontier[offset + previousDiagonal]!; + const previousY = previousX - previousDiagonal; + + while (x > previousX && y > previousY) { + reverseParts.push({ type: "equal", text: before[x - 1]! }); + x -= 1; + y -= 1; + } + if (stepDown) { + reverseParts.push({ type: "insert", text: after[previousY]! }); + y = previousY; + } else { + reverseParts.push({ type: "delete", text: before[previousX]! }); + x = previousX; + } + } + + while (x > 0 && y > 0) { + reverseParts.push({ type: "equal", text: before[x - 1]! }); + x -= 1; + y -= 1; + } + while (x > 0) { + reverseParts.push({ type: "delete", text: before[x - 1]! }); + x -= 1; + } + while (y > 0) { + reverseParts.push({ type: "insert", text: after[y - 1]! }); + y -= 1; + } + + return coalesce(reverseParts.reverse()); +} + +/** + * Produces independent, contextual-rebase-compatible operations for each + * disjoint markdown hunk. Every operation starts from the same canonical + * snapshot, so accepting or rejecting one does not require another first. + */ +export function markdownSuggestionOperations( + before: string, + after: string, +): MarkdownSuggestionOperation[] { + if (before === after) return []; + if (before.replace(MARKDOWN_MARK, "") === after.replace(MARKDOWN_MARK, "")) { + return [operationForChange(before, 0, before.length, after, 0)]; + } + const parts = diffParts(before, after); + if (!parts) return [markdownSuggestionOperation(before, after)!]; + + const operations: MarkdownSuggestionOperation[] = []; + let beforeOffset = 0; + for (let index = 0; index < parts.length; ) { + const part = parts[index]!; + if (part.type === "equal") { + beforeOffset += part.text.length; + index += 1; + continue; + } + + const from = beforeOffset; + let removed = ""; + let inserted = ""; + while (index < parts.length && parts[index]!.type !== "equal") { + const changed = parts[index]!; + if (changed.type === "delete") removed += changed.text; + else inserted += changed.text; + index += 1; + } + const to = from + removed.length; + operations.push( + operationForChange(before, from, to, inserted, operations.length), + ); + beforeOffset = to; + } + return operations; +} export function markdownSuggestionOperation( before: string, @@ -33,32 +229,11 @@ export function markdownSuggestionOperation( } const beforeEnd = before.length - suffixLength; const afterEnd = after.length - suffixLength; - const removed = before.slice(from, beforeEnd); - const inserted = after.slice(from, afterEnd); - const sameUnmarkedText = - removed.replace(MARKDOWN_MARK, "") === inserted.replace(MARKDOWN_MARK, ""); - const kind = - sameUnmarkedText && removed !== inserted - ? "set_inline_mark" - : !removed - ? inserted.includes("\n") - ? "add_text_block" - : "insert_text" - : !inserted - ? "delete_text" - : "replace_text"; - return { - ordinal: 0, - kind, - targetId: "body", - before: { markdown: before, changedText: removed }, - after: { markdown: after, changedText: inserted }, - anchor: { - from, - to: beforeEnd, - prefix: before.slice(Math.max(0, from - 32), from), - suffix: before.slice(beforeEnd, beforeEnd + 32), - }, - schemaVersion: 1, - }; + return operationForChange( + before, + from, + beforeEnd, + after.slice(from, afterEnd), + 0, + ); } diff --git a/templates/content/app/global.css b/templates/content/app/global.css index 6ef6e4b9583..3e2a7c8e319 100644 --- a/templates/content/app/global.css +++ b/templates/content/app/global.css @@ -2416,6 +2416,48 @@ audio.media-block__content { cursor: default; } +/* Pending suggested edits are presentation-only decorations over canonical text. */ +.notion-editor .suggestion-delete { + background: hsl(var(--destructive) / 0.12); + text-decoration: line-through; + text-decoration-color: hsl(var(--destructive)); +} + +.notion-editor .suggestion-delete-widget { + display: inline; + border: 0; + border-radius: 0.2em; + background: hsl(var(--destructive) / 0.12); + color: inherit; + font: inherit; + padding: 0 0.1em; + text-decoration: line-through; + text-decoration-color: hsl(var(--destructive)); +} + +.notion-editor .suggestion-change { + background: hsl(var(--primary) / 0.12); + box-shadow: inset 0 -2px hsl(var(--primary) / 0.55); +} + +.notion-editor .suggestion-insert, +.notion-editor .suggestion-add-block { + display: inline; + border: 0; + border-radius: 0.2em; + background: hsl(var(--primary) / 0.12); + box-shadow: inset 0 -2px hsl(var(--primary) / 0.55); + color: inherit; + font: inherit; + padding: 0 0.1em; + white-space: pre-wrap; +} + +.notion-editor .suggestion-highlight--active { + outline: 2px solid hsl(var(--primary)); + outline-offset: 2px; +} + /* @mention tokens inside comment bodies */ .comment-mention { border-radius: 3px; diff --git a/templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md b/templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md index 64168a8cab4..67cc948ca26 100644 --- a/templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md +++ b/templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md @@ -3,8 +3,8 @@ title: "Content Suggested Edits parity shape" date: 2026-09-02 status: shape-complete authoritySchemaVersion: 3 -ledgerRevision: content-suggested-edits-shape-r2 -governingArtifactRevision: content-suggested-edits-shape-r2 +ledgerRevision: content-suggested-edits-shape-r3 +governingArtifactRevision: content-suggested-edits-shape-r3 --- # Content Suggested Edits parity @@ -15,6 +15,39 @@ Add **Suggested Edits** to Agent-Native Content with behavioral parity to Notion The implementation should extend Core's existing review domain with executable suggestions and let Content register the document-specific operation and renderer adapter. Content owns page semantics, supported blocks, editor mode, and canonical mutation. Core owns proposal identity, thread/disposition lifecycle, permissions integration, notifications, audit/history seams, and shared actions. Suggestions are not comments with overloaded metadata, recovery versions, raw Yjs updates, or draft copies of pages. +## 2026-09-03 return-to-shape: in-place review repair + +Human QA of PR #4274 at `c820218ee5e811c0328d68e16085a0b1bcce2fc1` proved that the staged interface does not satisfy this artifact's already-frozen parity contract. While Suggesting is active, edits render as ordinary canonical-looking content and the Comments rail reports no proposal. Stopping Suggesting replaces the draft with canonical content, then creates one detached whole-session markdown diff. Pending changes have no marker in the Page after submission or reload, raw markdown leaks into the review card, and the tablet/mobile review modal obscures the Page. Accept, reject, and persistence work, but only after the reviewer discovers and reconstructs the detached proposal. + +The firsthand Notion reference keeps every pending change visibly anchored in the ordinary document. Insertions, replacements, formatting, and new blocks have inline treatment and per-location counts; activating an anchor opens the exact suggestion thread with author, time, discussion, and Accept/Reject beside the affected material. The repair therefore restores the existing contract rather than adding optional polish. + +### Bound repair + +1. **Capture semantic operations, not one markdown session diff.** The suggesting editor must translate supported ProseMirror transactions into typed Add/Delete/Replace/Add block/Set mark operations with stable operation and thread identity. Keystrokes that form one continuous edit may coalesce, but distinct ranges and operation kinds remain independently reviewable. `markdownSuggestionOperation(base, finalDraft)` may remain a compatibility/test helper; it cannot be the UI's primary capture boundary. +2. **Render one pending overlay in the ordinary editor.** Canonical Markdown/Yjs remains unchanged, while the editor projects canonical content plus local and durable pending operations. Insertions and marks receive visible non-color-only treatment; deletions remain readable with deletion treatment. Block markers/counts and keyboard-focusable anchors open the existing review rail at the matching thread. +3. **Persist without a disappearing-submit transition.** A completed semantic operation becomes a pending suggestion at a clear edit boundary and remains inline through mode exit, reload, and another authorized viewer. Exiting Suggesting changes authoring mode only; it does not submit an invisible batch or make proposed material vanish. Pending-save and failed-save states are explicit, and a failed proposal write cannot masquerade as a durable suggestion. +4. **Review the affected material, not serialized markdown.** The rail renders typed before/after content through the Content renderer, preserves formatting, identifies operation kind/author/time/status, and supports independent Accept/Reject where operations are compatible. Selecting a rail item focuses its Page anchor; selecting an anchor opens its rail item. Historical or stale operations retain an honest contextual fallback. +5. **Preserve context at narrow widths.** The narrow review surface may use a sheet, but it must provide the affected before/after material and a reliable return-to-anchor path. On widths that can support split review, the Page and decision surface remain simultaneously readable. + +### Smallest implementation sequence + +- Wire the existing typed suggestion model/controller into `VisualEditor` through a ProseMirror plugin that captures supported transactions and supplies decorations/markers. Delete the parallel plain-markdown draft as the source of suggestion semantics. +- Extend the existing Core suggestion aggregate only as needed for multiple stable operations and per-operation thread/decision identity; do not create a second Content-only persistence system. +- Hydrate durable pending operations through `useResourceSuggestions`, resolve their anchors against the canonical document, and render the same overlay used for local operations. Local operations transition to their returned durable IDs without visual replacement or focus loss. +- Replace the Comments rail's `operations[0]`/plain-text rendering with typed operation rows connected bidirectionally to editor anchors. Reuse existing review replies, reactions, unread, mute, deep links, and dispositions. +- Keep the already-shaped prepare/commit/publish acceptance path for canonical application. This repair changes proposal composition and presentation, not transaction ownership, access enforcement, or the SQL/Yjs authority boundary. + +### Explicit exclusions for this repair + +- No Property, database cell/schema, title, icon, cover, media, embed, local-file, or source-owned suggestions. +- No Accept all/Reject all, filtered-set review, AI summaries, or general change-graph work. +- No separate diff page, permanent review dashboard, duplicate discussion store, or canonical Yjs mutation while a suggestion is pending. +- No claim of complete Feature 7 availability; this remains the page-body parity slice of `content.revision.suggestions` and `content.diff.in-place`. + +### QA evidence + +The current exact-head report and screenshots are retained outside the product repository at `/Users/alicemoore/.codex/visualizations/2026/09/03/01a06892-a5f4-77d0-a834-27af7c39c530/suggested-edits-qa/HUMAN-QA.md`. They are task evidence, not intended shipping documentation. + ## Human problem and stakes Today, a Content collaborator can either comment without showing the exact desired edit or directly edit the canonical document. Agents face the same binary choice. Reviewers must translate prose feedback into changes or trust a direct rewrite after the fact. @@ -210,10 +243,10 @@ Automated coverage is required for model/store/action/permission/idempotency/sta ```yaml stage: shape -authority-source: "User invoked $shape and requested exact Suggested Edits feature parity for Agent-Native Content." +authority-source: "User invoked $shape on 2026-09-03 to shape the fixes after exact-head human QA of PR #4274." authorized-scope: repositories: - - /home/teenylilmonkey/Developer/agent-native + - /Users/alicemoore/.codex/worktrees/5438/agent-native product-surfaces: - Agent-Native Content page editor - Agent-Native Core review substrate @@ -225,7 +258,7 @@ write-targets: - templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md governing-artifact: path: templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md - revision: content-suggested-edits-shape-r2 + revision: content-suggested-edits-shape-r3 architecture-fingerprint: outcome: Commenters, editors, and agents can propose supported page-body edits without changing canonical Content until an authorized reviewer accepts them. shipping-surfaces: @@ -247,6 +280,9 @@ architecture-fingerprint: summary: A commenter or agent proposes supported edits in the ordinary Content editor; an authorized editor discusses and decides each proposal; only accepted material reaches canonical Content and every state remains attributable and recoverable. required-assertions: - proposal creation preserves canonical content + - each supported semantic edit is visibly distinguished and independently anchored while authoring and after persistence or reload + - exiting Suggesting changes authoring mode without removing pending material from the Page + - typed review renders exact before and after material without exposing serialized markdown syntax - selective accept/reject applies exactly the decided operation - discussion and resolved history persist with deep links - UI, agent, and action permission parity fails closed @@ -264,6 +300,7 @@ architecture-fingerprint: risk-strategy: kind: feature-flagged production-validation-after-merge: true + rationale: The repair crosses shared review persistence and collaborative SQL/Yjs mutation seams, so the existing default-off flag remains necessary for beta dogfooding and post-merge cross-client validation before ordinary-user enablement. architecture-grounding: applicability: required reason: The feature crosses shared review, permissions, history, action, collaboration, and Content domain boundaries. @@ -291,7 +328,7 @@ architecture-grounding: - accepted - rejected - stale - smallest-compatible-delta: Core executable-suggestion lifecycle and narrow decision coordinator, a Core prepared Yjs mutation lease, one shared Content canonical body-mutation helper, and the Content page-body text/mark adapter plus ordinary-editor presentation. + smallest-compatible-delta: Preserve the existing Core executable-suggestion lifecycle and canonical mutation coordinator; replace the UI's whole-session markdown diff with typed semantic operation capture, durable pending-operation hydration, in-place editor overlays and anchors, and typed rail rendering for the Content page-body slice. deferred-capabilities: - generic typed Property/Database/media proposals - filtered bulk review @@ -311,32 +348,38 @@ architecture-grounding: unresolved-owner-questions: [] delegation-ceiling: [] acceptance-state: - status: in-progress - summary: Core and Content implementation is staged; local real-interface proof covers proposal isolation plus accept and reject decisions in the contextual rail. + status: blocked + summary: PR #4274 preserves canonical isolation and can accept, reject, and reload proposals, but exact-head human QA fails the frozen in-place authoring and review story because pending changes are detached from the Page and collapsed into one markdown diff. evidence: - templates/content/docs/solutions/evidence/suggested-edits-accept-reject.png - focused Core and Content automated suites remaining: + - typed semantic operation capture in the real editor + - immediate and durable in-place decorations, markers, and bidirectional thread anchors + - typed before/after rail rendering without raw markdown leakage - commenter/editor role-separated acceptance - accountable agent proposal acceptance - two-client sync and narrow-width accessibility acceptance - independent technical review closure - blockers: [] + blockers: + - exact-head real-interface H1-H10 failed the in-place comparison and discoverability assertions last-land-packet: null change-record: - from-revision: content-suggested-edits-shape-r1 - to-revision: content-suggested-edits-shape-r2 - trigger: Independent review proved that direct SQL acceptance could diverge from the cached Y.Doc and bypass normal Content mutation effects. + from-revision: content-suggested-edits-shape-r2 + to-revision: content-suggested-edits-shape-r3 + trigger: Exact-head human QA proved that the staged plain-markdown draft and detached review card violate the already-frozen in-place parity contract. preserved: - outcome and exact Notion-parity product boundary - shipping surfaces and ownership split between Core and Content - default-off feature-flag risk strategy + - prepare/commit/publish canonical mutation coordination changed: - - canonical mutation coordination is now an explicit prepare/commit/publish architecture - - ordinary Content saves and accepted suggestions must share one Content body-mutation helper - - durable Yjs state and sync event join the suggestion decision transaction - - acceptance now proves stale-client overwrite prevention and crash-window convergence -ledger-revision: content-suggested-edits-shape-r2 + - whole-session markdown diff is explicitly rejected as the editor capture boundary + - typed semantic operations must remain independently anchored and reviewable + - pending suggestions must render in place while authoring and after persistence or reload + - the rail must render typed material and maintain bidirectional focus with Page anchors + - narrow-width review must preserve affected-content context and return focus +ledger-revision: content-suggested-edits-shape-r3 status: shape-complete ``` @@ -344,29 +387,32 @@ status: shape-complete ```yaml stage: work -authority-source: "User invoked $work on 2026-09-03 against content-suggested-edits-shape-r2." -ledger-revision: content-suggested-edits-work-r2-2 +authority-source: "User invoked $work on 2026-09-03 against content-suggested-edits-shape-r3." +ledger-revision: content-suggested-edits-work-r3-1 governing-artifact: path: templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md - revision: content-suggested-edits-shape-r2 + revision: content-suggested-edits-shape-r3 allowed-mutations: - artifact-write - ephemeral-test-resource - - branch - commit + - push + - pull-request write-targets: repositories: - - /home/teenylilmonkey/.codex/worktrees/content-suggested-edits/agent-native + - /Users/alicemoore/.codex/worktrees/5438/agent-native + artifacts: + - templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md test-resources: - - id: content-suggested-edits-r2-page + - id: content-suggested-edits-r3-page kind: record - surface: local Content SQLite document id codex-suggested-edits-r2-20260903 - ownership-marker: title "[task-test] Suggested Edits r2 2026-09-03" - baseline: exact document id returned zero rows immediately before creation - allowed-actions: [create, update, exercise, delete] + surface: local Content SQLite page BI4G0ihYdTeK at http://127.0.0.1:8080 + ownership-marker: title "QA Suggested Edits 2026-09-03 A" + baseline: task-created disposable page with Alpha, Beta, and Gamma baseline sentences before its first suggestion + allowed-actions: [update, exercise, delete] cleanup-trigger: before Work completion - cleanup-method: delete suggestion/review/version/collab/membership rows for the exact document id, then delete that document row in one local SQLite transaction - cleanup-proof: read every named table for the exact document id and observe zero rows + cleanup-method: delete the exact page and its suggestion/review/version/collaboration rows through the local Content action or exact database transaction + cleanup-proof: independently query every affected table for exact page id BI4G0ihYdTeK and observe zero rows shared-impact: none isolation: local-runtime ownership: task-created @@ -374,22 +420,18 @@ test-resources: customer-data: false cost: none boundary-evidence: - - fixed id is absent from the local Content database before creation - - pending replacement created through the real editor and accepted through the review card - - a second live client converged from Alpha old Omega to Alpha new Omega - - rejected replacement resolved without changing the accepted document body - - viewport screenshots retained under C:/Users/emdis/.codex/visualizations/2026/09/01/01a05dfc-8e21-7780-a44a-4937701320e4 - max-lifetime-minutes: 240 - declared-at: 2026-09-03T16:48:52Z - expires-at: 2026-09-03T20:48:52Z - status: cleaned - cleanup-result: documents=0, suggestions=0, comments=0, versions=0, collab_docs=0 after one local SQLite write batch + - unique task marker and exact returned page id + - local demo-only runtime at 127.0.0.1:8080 + max-lifetime-minutes: 720 + declared-at: 2026-09-03T15:30:00-04:00 + expires-at: 2026-09-04T03:30:00-04:00 + status: active phase: work -acceptance-reconciliation: consistent +acceptance-reconciliation: consistent — schema-v3 real-interface, preferred independence, same-context-allowed custody task-attention: autonomous -status: verification +status: implementation ``` ## Next step -Invoke `/work templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md` to revise the staged implementation against r2. Work resumes at Slice 1 by replacing the direct-SQL acceptance path with the frozen mutation coordinator and shared Content helper before proceeding to remaining interface acceptance. Failure to make the document lease span the decision transaction, persist the Yjs fence in that transaction, or prevent stale-client overwrite is a return-to-shape condition rather than permission to degrade silently. +Invoke `/work templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md` to revise PR #4274 against r3. Work begins with the editor capture/rendering boundary: preserve the accepted Core lifecycle and canonical mutation coordinator, replace the whole-session markdown diff with typed semantic operations, and prove that proposed material stays visibly anchored before and after persistence. The PR remains acceptance-blocked until the complete H1-H10 story passes on the exact head; accept/reject persistence alone is not sufficient. From 2e7c9c50a2c32c395272c28f569d3b2345abf22e Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:13:17 -0400 Subject: [PATCH 05/15] fix: make suggested edits reviewable in place --- .../editor/CommentsSidebar.layout.test.ts | 19 +- .../app/components/editor/CommentsSidebar.tsx | 351 +++++++++--------- .../editor/DocumentEditor.layout.test.ts | 32 +- .../app/components/editor/DocumentEditor.tsx | 55 ++- .../app/components/editor/DocumentToolbar.tsx | 41 +- .../app/components/editor/VisualEditor.tsx | 49 ++- .../components/editor/comment-anchors.spec.ts | 29 +- .../app/components/editor/comment-anchors.ts | 41 ++ templates/content/app/global.css | 39 +- ...02-content-suggested-edits-parity-shape.md | 228 ++++++++++-- 10 files changed, 605 insertions(+), 279 deletions(-) diff --git a/templates/content/app/components/editor/CommentsSidebar.layout.test.ts b/templates/content/app/components/editor/CommentsSidebar.layout.test.ts index c9385ac6aea..f90487b4ad7 100644 --- a/templates/content/app/components/editor/CommentsSidebar.layout.test.ts +++ b/templates/content/app/components/editor/CommentsSidebar.layout.test.ts @@ -246,15 +246,17 @@ describe("comments sidebar layout", () => { ); }); - it("combines supported comment history filters into one persistent checkbox menu", () => { + it("combines content controls with status and author filters", () => { const source = readFileSync("app/components/editor/CommentsSidebar.tsx", { encoding: "utf8", }); expect(source.match(//g)).toHaveLength(1); expect(source).toContain("DropdownMenuCheckboxItem"); - expect(source).not.toContain('t("comments.typeFilter")'); - expect(source).not.toContain('historyType === "suggestions"'); + expect(source).toContain('"all" | "comments" | "suggestions"'); + expect(source).toContain('historyKind === "comments"'); + expect(source).toContain('historyKind === "suggestions"'); + expect(source).toContain("aria-pressed={historyKind === kind}"); expect(source).toContain('t("comments.statusFilter")'); expect(source).toContain('t("comments.authorFilter")'); expect(source).toContain("event.preventDefault()"); @@ -262,6 +264,7 @@ describe("comments sidebar layout", () => { '"pending",\n "accepted",\n "rejected"', ); expect(source).toContain("renderSuggestionCards(historySuggestions)"); + expect(source).toContain("renderSuggestionCards()"); expect(source).toContain("renderSuggestionText(before.changedText)"); expect(source).toContain(""); expect(source).toContain( @@ -269,6 +272,16 @@ describe("comments sidebar layout", () => { ); }); + it("labels pending suggestions whose page anchor cannot be resolved", () => { + const source = readFileSync("app/components/editor/CommentsSidebar.tsx", { + encoding: "utf8", + }); + + expect(source).toContain('suggestion.status === "pending"'); + expect(source).toContain("!anchoredSuggestionIds?.includes(suggestion.id)"); + expect(source).toContain('t("comments.unanchored")'); + }); + it("captures inline comment activation at the document state boundary", () => { const source = readFileSync("app/components/editor/DocumentEditor.tsx", { encoding: "utf8", diff --git a/templates/content/app/components/editor/CommentsSidebar.tsx b/templates/content/app/components/editor/CommentsSidebar.tsx index a173775f009..b355fcad331 100644 --- a/templates/content/app/components/editor/CommentsSidebar.tsx +++ b/templates/content/app/components/editor/CommentsSidebar.tsx @@ -368,6 +368,7 @@ interface CommentsSidebarProps { selectedThreadId?: string | null; onActivateThread?: (id: string) => void; activeSuggestionId?: string | null; + anchoredSuggestionIds?: string[] | null; onActivateSuggestion?: (id: string) => void; onSelectedThreadChange?: (id: string | null) => void; onHoveredThreadChange?: (id: string | null) => void; @@ -398,6 +399,7 @@ export function CommentsSidebar({ selectedThreadId, onActivateThread, activeSuggestionId, + anchoredSuggestionIds, onActivateSuggestion, onSelectedThreadChange, onHoveredThreadChange, @@ -425,6 +427,11 @@ export function CommentsSidebar({ const [historyStatus, setHistoryStatus] = useState< "all" | "open" | "resolved" | "pending" | "accepted" | "rejected" >("all"); + const [historyKind, setHistoryKind] = useState< + "all" | "comments" | "suggestions" + >("all"); + const [historyPortalContainer, setHistoryPortalContainer] = + useState(null); const [historyAuthor, setHistoryAuthor] = useState(null); const sidebarRef = useRef(null); const pendingInputRef = useRef(null); @@ -471,6 +478,7 @@ export function CommentsSidebar({ ); }, [suggestions, threads]); const historySuggestions = useMemo(() => { + if (historyKind === "comments") return []; return suggestions.filter((suggestion) => { if (historyStatus === "open" && suggestion.status !== "pending") { return false; @@ -486,8 +494,9 @@ export function CommentsSidebar({ } return !historyAuthor || suggestion.authorEmail === historyAuthor; }); - }, [historyAuthor, historyStatus, suggestions]); + }, [historyAuthor, historyKind, historyStatus, suggestions]); const historyThreads = useMemo(() => { + if (historyKind === "suggestions") return []; return threads.filter((thread) => { if (["pending", "accepted", "rejected"].includes(historyStatus)) { return false; @@ -504,7 +513,7 @@ export function CommentsSidebar({ } return true; }); - }, [historyAuthor, historyStatus, threads]); + }, [historyAuthor, historyKind, historyStatus, threads]); useEffect(() => { if (pendingComment) { @@ -758,103 +767,152 @@ export function CommentsSidebar({ const renderSuggestionCards = ( displayedSuggestions: ResourceSuggestion[] = suggestions, - ) => - displayedSuggestions.length > 0 ? ( + ) => { + const visibleSuggestions = + presentation === "inline" && activeSuggestionId + ? displayedSuggestions.filter( + (suggestion) => suggestion.id === activeSuggestionId, + ) + : displayedSuggestions; + return visibleSuggestions.length > 0 ? (
- {displayedSuggestions.map((suggestion) => { - const operation = suggestion.operations[0]; - const before = operation?.before as - | { changedText?: string } - | undefined; - const after = operation?.after as - | { changedText?: string } - | undefined; - return ( -
onActivateSuggestion?.(suggestion.id)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onActivateSuggestion?.(suggestion.id); - } - }} - > -
- - {operation?.kind.split("_").join(" ")} ·{" "} - {suggestion.authorEmail ?? suggestion.actorKind} - - {suggestion.status} -
-

- {before?.changedText ? ( - - {renderSuggestionText(before.changedText)} - + {[...visibleSuggestions] + .sort((left, right) => + left.id === activeSuggestionId + ? -1 + : right.id === activeSuggestionId + ? 1 + : 0, + ) + .map((suggestion) => { + const anchorUnavailable = + suggestion.status === "pending" && + anchoredSuggestionIds !== null && + !anchoredSuggestionIds?.includes(suggestion.id); + const operation = suggestion.operations[0]; + const before = operation?.before as + | { changedText?: string } + | undefined; + const after = operation?.after as + | { changedText?: string } + | undefined; + return ( +

onActivateSuggestion?.(suggestion.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onActivateSuggestion?.(suggestion.id); + } + }} + > +
+ + {operation?.kind.split("_").join(" ")} ·{" "} + {suggestion.authorEmail ?? suggestion.actorKind} + + {suggestion.status} +
+ {anchorUnavailable ? ( +

+ {t("comments.unanchored")} +

) : null} - {before?.changedText && after?.changedText ? " → " : null} - {after?.changedText ? ( - {renderSuggestionText(after.changedText)} +

+ {before?.changedText ? ( + + {renderSuggestionText(before.changedText)} + + ) : null} + {before?.changedText && after?.changedText ? " → " : null} + {after?.changedText ? ( + {renderSuggestionText(after.changedText)} + ) : null} +

+ {canDecideSuggestions && suggestion.status === "pending" ? ( +
+ + +
) : null} -

- {canDecideSuggestions && suggestion.status === "pending" ? ( -
- - -
- ) : null} - -
- ); - })} + +
+ ); + })}
) : null; + }; if (presentation === "history") { return ( -
+
+
+ {(["all", "comments", "suggestions"] as const).map((kind) => ( + + ))} +
) : null} - {suggestions.length > 0 ? ( -
- {suggestions.map((suggestion) => { - const operation = suggestion.operations[0]; - const before = operation?.before as - | { changedText?: string } - | undefined; - const after = operation?.after as - | { changedText?: string } - | undefined; - return ( -
onActivateSuggestion?.(suggestion.id)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onActivateSuggestion?.(suggestion.id); - } - }} - > -
- - {operation?.kind.split("_").join(" ")} ·{" "} - {suggestion.authorEmail ?? suggestion.actorKind} - - {suggestion.status} -
-

- {before?.changedText ? ( - - {renderSuggestionText(before.changedText)} - - ) : null} - {before?.changedText && after?.changedText ? " → " : null} - {after?.changedText ? ( - {renderSuggestionText(after.changedText)} - ) : null} -

- {canDecideSuggestions && suggestion.status === "pending" ? ( -
- - -
- ) : null} - -
- ); - })} -
- ) : null} + {renderSuggestionCards()} {/* Pending new comment — positioned at the selection Y offset */} {pendingComment && (
{ expect(source).not.toContain("baseRevision: document.updatedAt"); }); - it("opens the comments surface for suggestion deep links and new proposals", () => { + it("freezes the suggestion editor while mode exit persists proposals", () => { + const source = readFileSync( + new URL("./DocumentEditor.tsx", import.meta.url), + "utf8", + ); + + expect(source).toContain("if (isSubmittingSuggestions) return"); + expect(source).toContain("setIsSubmittingSuggestions(true)"); + expect(source).toContain( + "suggestionEditorIsolation.editable &&\n !isSubmittingSuggestions", + ); + expect(source).toContain("setIsSubmittingSuggestions(false)"); + }); + + it("does not classify canonical suggestion anchors from a draft-only editor", () => { + const source = readFileSync( + new URL("./DocumentEditor.tsx", import.meta.url), + "utf8", + ); + + expect(source).toContain( + "anchoredSuggestionIds={isSuggesting ? null : anchoredSuggestionIds}", + ); + }); + + it("opens comments for deep links without coupling mode exit to navigation", () => { const source = readFileSync( new URL("./DocumentEditor.tsx", import.meta.url), "utf8", @@ -792,7 +817,10 @@ describe("document editor layout", () => { source.match( /setUtilityPanel\("comments"\);\s+setCommentsBrowseOpen\(true\)/g, ), - ).toHaveLength(2); + ).toHaveLength(1); + expect(source).not.toMatch( + /setIsSuggesting\(false\);[\s\S]{0,240}setUtilityPanel\("comments"\)/, + ); }); it("wakes live-editor flush reads from shared sync events instead of polling", () => { diff --git a/templates/content/app/components/editor/DocumentEditor.tsx b/templates/content/app/components/editor/DocumentEditor.tsx index 1b45a64e86d..83dcfa0b5b2 100644 --- a/templates/content/app/components/editor/DocumentEditor.tsx +++ b/templates/content/app/components/editor/DocumentEditor.tsx @@ -767,7 +767,11 @@ function DocumentEditorBody({ { enabled: suggestedEditsEnabled && !isLocalFileDocument }, ); const [isSuggesting, setIsSuggesting] = useState(false); + const [isSubmittingSuggestions, setIsSubmittingSuggestions] = useState(false); const [suggestionDraft, setSuggestionDraft] = useState(document.content); + const [anchoredSuggestionIds, setAnchoredSuggestionIds] = useState< + string[] | null + >(null); const [selectedSuggestionId, setSelectedSuggestionId] = useState< string | null >(null); @@ -2075,6 +2079,7 @@ function DocumentEditorBody({ return; } if (!isSuggesting) return; + if (isSubmittingSuggestions) return; const base = suggestionBaseRef.current; if (!base) return; if (suggestionDraft === base.content) { @@ -2083,6 +2088,7 @@ function DocumentEditorBody({ createdSuggestionOperationsRef.current.clear(); return; } + setIsSubmittingSuggestions(true); try { const operations = markdownSuggestionOperations( base.content, @@ -2093,13 +2099,11 @@ function DocumentEditorBody({ suggestionBaseRef.current = null; return; } - const createdIds: string[] = []; for (const operation of operations) { const operationKey = JSON.stringify(operation); const existing = createdSuggestionOperationsRef.current.get(operationKey); if (existing?.suggestionId) { - createdIds.push(existing.suggestionId); continue; } const idempotencyKey = @@ -2120,19 +2124,17 @@ function DocumentEditorBody({ idempotencyKey, suggestionId: created.id, }); - createdIds.push(created.id); } setIsSuggesting(false); suggestionBaseRef.current = null; createdSuggestionOperationsRef.current.clear(); - setSelectedSuggestionId(createdIds[0] ?? null); - setUtilityPanel("comments"); - setCommentsBrowseOpen(true); } catch (error) { toast.error(t("editor.suggestionCreateFailed"), { description: error instanceof Error ? error.message : t("empty.genericError"), }); + } finally { + setIsSubmittingSuggestions(false); } }, [ @@ -2141,6 +2143,7 @@ function DocumentEditorBody({ document.content, documentId, isSuggesting, + isSubmittingSuggestions, suggestionDraft, t, ], @@ -2288,18 +2291,22 @@ function DocumentEditorBody({ showCommentsHistoryDrawer && hasUtilityRailSpace; const hasOpenCommentThreads = threads?.some((thread) => !thread.resolved) ?? false; + const hasOpenSuggestions = + suggestionsQuery.data?.suggestions.some( + (suggestion) => suggestion.status === "pending", + ) ?? false; const showInlineComments = showCommentIndicators && hasUtilityRailSpace && !showCommentsHistoryDrawer && utilityPanel !== "info" && - (hasOpenCommentThreads || !!pendingComment); + (hasOpenCommentThreads || hasOpenSuggestions || !!pendingComment); const showDesktopInfoPanel = utilityPanel === "info" && hasUtilityRailSpace; const showDesktopRightRail = showInlineComments || showDesktopInfoPanel; const showAnchoredCommentPopover = utilityPanel === "comments" && !hasUtilityRailSpace && - (!!pendingComment || !!selectedThreadId); + (!!pendingComment || !!selectedThreadId || !!selectedSuggestionId); const showUtilityPanelSheet = (showCommentsHistoryDrawer && !showDesktopCommentsHistory) || (utilityPanel === "info" && !showDesktopInfoPanel); @@ -2357,6 +2364,7 @@ function DocumentEditorBody({ const clearCommentFocus = useCallback(() => { setSelectedThreadId(null); setHoveredThreadId(null); + setSelectedSuggestionId(null); }, []); const dismissCommentFocus = useCallback(() => { @@ -2449,10 +2457,17 @@ function DocumentEditorBody({ ? globalThis.CSS.escape(selectedThreadId) : selectedThreadId.replace(/["\\]/g, "\\$&") : null; + const escapedSuggestionId = selectedSuggestionId + ? globalThis.CSS?.escape + ? globalThis.CSS.escape(selectedSuggestionId) + : selectedSuggestionId.replace(/["\\]/g, "\\$&") + : null; const marked = scrollContainer.querySelector( escapedThreadId ? `[data-comment-thread="${escapedThreadId}"]` - : ".comment-highlight--pending", + : escapedSuggestionId + ? `[data-suggestion-id="${escapedSuggestionId}"]` + : ".comment-highlight--pending", ) as HTMLElement | null; if (!marked) { setAnchoredCommentPosition(null); @@ -2484,7 +2499,7 @@ function DocumentEditorBody({ childList: true, subtree: true, attributes: true, - attributeFilter: ["data-comment-thread", "class"], + attributeFilter: ["data-comment-thread", "data-suggestion-id", "class"], }); const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update); @@ -2499,7 +2514,12 @@ function DocumentEditorBody({ window.removeEventListener("resize", update); scrollContainer.removeEventListener("scroll", update); }; - }, [selectedThreadId, showAnchoredCommentPopover, scrollContainerRef]); + }, [ + selectedSuggestionId, + selectedThreadId, + showAnchoredCommentPopover, + scrollContainerRef, + ]); const focusTitleEnd = useCallback(() => { const textarea = titleInputRef.current; @@ -2640,6 +2660,7 @@ function DocumentEditorBody({ selectedThreadId={selectedThreadId} onActivateThread={activateCommentThread} activeSuggestionId={selectedSuggestionId} + anchoredSuggestionIds={isSuggesting ? null : anchoredSuggestionIds} onActivateSuggestion={activateSuggestion} onSelectedThreadChange={setSelectedThreadId} onHoveredThreadChange={setHoveredThreadId} @@ -2794,7 +2815,11 @@ function DocumentEditorBody({ activateCommentThread(threadId); return; } - if (target?.closest("[data-comments-sidebar]")) { + if ( + target?.closest( + "[data-comments-sidebar], [data-comments-history], [data-document-utility-panel]", + ) + ) { return; } dismissCommentFocus(); @@ -3247,7 +3272,10 @@ function DocumentEditorBody({ } awareness={collabEditorEnabled ? awareness : null} user={currentUser} - editable={suggestionEditorIsolation.editable} + editable={ + suggestionEditorIsolation.editable && + !isSubmittingSuggestions + } localFileMode={isLocalFileDocument} localFilePath={ isLocalFileDocument ? document.source?.path : null @@ -3264,6 +3292,7 @@ function DocumentEditorBody({ suggestions={visualSuggestions} activeSuggestionId={selectedSuggestionId} onActivateSuggestion={activateSuggestion} + onSuggestionAnchorsChange={setAnchoredSuggestionIds} showCommentIndicators={showCommentIndicators} onJoinTitle={joinFirstBodyBlockToTitle} notionPageLinks={notionPageLinks} diff --git a/templates/content/app/components/editor/DocumentToolbar.tsx b/templates/content/app/components/editor/DocumentToolbar.tsx index e1071a327b1..c7fc0044235 100644 --- a/templates/content/app/components/editor/DocumentToolbar.tsx +++ b/templates/content/app/components/editor/DocumentToolbar.tsx @@ -38,6 +38,7 @@ import { IconPin, IconPencil, IconTrash, + IconX, } from "@tabler/icons-react"; import { useQueryClient } from "@tanstack/react-query"; import { @@ -997,16 +998,25 @@ export function DocumentToolbar({ )} {suggesting ? ( - +
+
) : null} {showCommentsControl ? ( @@ -1056,7 +1066,16 @@ export function DocumentToolbar({ {canSuggest ? ( <> onSuggestingChange?.(!suggesting)} + onSelect={() => { + onSuggestingChange?.(!suggesting); + window.setTimeout(() => { + document + .querySelector( + ".notion-editor[contenteditable='true'], .notion-editor [contenteditable='true']", + ) + ?.focus({ preventScroll: true }); + }, 50); + }} > {t( diff --git a/templates/content/app/components/editor/VisualEditor.tsx b/templates/content/app/components/editor/VisualEditor.tsx index 651fa19d339..b6eb55b7445 100644 --- a/templates/content/app/components/editor/VisualEditor.tsx +++ b/templates/content/app/components/editor/VisualEditor.tsx @@ -61,8 +61,8 @@ import type { CommentThread } from "@/hooks/use-comments"; import { BubbleToolbar } from "./BubbleToolbar"; import { - buildDocText, resolveAnchor, + resolveAnchorPoint, type CommentTextAnchor, } from "./comment-anchors"; import { AudioNode } from "./extensions/AudioNode"; @@ -763,26 +763,12 @@ function suggestionAnchorRange( }); } - const docText = buildDocText(doc); - const prefix = suggestion.anchor.prefix; - const suffix = suggestion.anchor.suffix; - const prefixIndex = prefix ? docText.text.lastIndexOf(prefix) : -1; - const suffixIndex = suffix ? docText.text.indexOf(suffix) : -1; - const offset = - prefixIndex >= 0 - ? prefixIndex + prefix.length - : suffixIndex >= 0 - ? suffixIndex - : 0; - const marker = - offset < docText.text.length - ? docText.text.slice(offset, offset + 1) - : docText.text.slice(-1); - const resolved = marker - ? resolveAnchor(doc, { quotedText: marker, startOffset: offset }) - : null; - if (!resolved) return { from: 1, to: 1 }; - const position = offset < docText.text.length ? resolved.from : resolved.to; + const position = resolveAnchorPoint(doc, { + prefix: suggestion.anchor.prefix, + suffix: suggestion.anchor.suffix, + startOffset: suggestion.anchor.from, + }); + if (position == null) return null; return { from: position, to: position }; } @@ -880,6 +866,7 @@ interface VisualEditorProps { suggestions?: VisualEditorSuggestion[]; activeSuggestionId?: string | null; onActivateSuggestion?: (suggestionId: string) => void; + onSuggestionAnchorsChange?: (suggestionIds: string[]) => void; showCommentIndicators?: boolean; onJoinTitle?: (text: string) => void; notionPageLinks?: NotionPageLink[]; @@ -2213,6 +2200,7 @@ export function VisualEditor({ suggestions = [], activeSuggestionId, onActivateSuggestion, + onSuggestionAnchorsChange, showCommentIndicators = true, onJoinTitle, notionPageLinks = [], @@ -3045,11 +3033,20 @@ export function VisualEditor({ suggestionHighlightSpec(editor.state.doc, suggestion), ) .filter((spec): spec is SuggestionHighlightSpec => spec !== null); + onSuggestionAnchorsChange?.( + Array.from(new Set(specs.map((spec) => spec.suggestionId))), + ); setSuggestionHighlights(editor.view, { specs, activeId: activeSuggestionId ?? null, }); - }, [activeSuggestionId, editor, suggestions, suggestionsSignature]); + }, [ + activeSuggestionId, + editor, + onSuggestionAnchorsChange, + suggestions, + suggestionsSignature, + ]); useEffect(() => { if (!editor || editor.isDestroyed) return; @@ -3073,11 +3070,11 @@ export function VisualEditor({ event.preventDefault(); onActivateSuggestionRef.current?.(suggestion.dataset.suggestionId); }; - editor.view.dom.addEventListener("click", handleClick); - editor.view.dom.addEventListener("keydown", handleKeyDown); + editor.view.dom.addEventListener("click", handleClick, true); + editor.view.dom.addEventListener("keydown", handleKeyDown, true); return () => { - editor.view.dom.removeEventListener("click", handleClick); - editor.view.dom.removeEventListener("keydown", handleKeyDown); + editor.view.dom.removeEventListener("click", handleClick, true); + editor.view.dom.removeEventListener("keydown", handleKeyDown, true); }; }, [editor]); diff --git a/templates/content/app/components/editor/comment-anchors.spec.ts b/templates/content/app/components/editor/comment-anchors.spec.ts index feae99c32f9..a0a7532c237 100644 --- a/templates/content/app/components/editor/comment-anchors.spec.ts +++ b/templates/content/app/components/editor/comment-anchors.spec.ts @@ -1,7 +1,12 @@ import { Schema, type Node as PMNode } from "@tiptap/pm/model"; import { describe, it, expect } from "vitest"; -import { captureAnchor, resolveAnchor, buildDocText } from "./comment-anchors"; +import { + captureAnchor, + resolveAnchor, + resolveAnchorPoint, + buildDocText, +} from "./comment-anchors"; // Minimal doc/paragraph/text schema — enough to exercise the text-space anchor // math without pulling in the full editor. @@ -80,6 +85,28 @@ describe("comment-anchors", () => { expect(resolveAnchor(doc, { quotedText: null })).toBeNull(); }); + it("resolves a deleted draft at its ordered context boundary", () => { + const doc = mkDoc(["Alpha Gamma"]); + expect( + resolveAnchorPoint(doc, { + prefix: "Alpha ", + suffix: " Gamma", + startOffset: 6, + }), + ).toBe(7); + }); + + it("does not invent a top-of-document position for unresolved context", () => { + const doc = mkDoc(["Alpha Gamma"]); + expect( + resolveAnchorPoint(doc, { + prefix: "missing before", + suffix: "missing after", + startOffset: 20, + }), + ).toBeNull(); + }); + it("re-resolves against an edited document", () => { const original = mkDoc(["Intro. The target phrase lives here."]); const anchor = captureAnchor(original, 8, 25); diff --git a/templates/content/app/components/editor/comment-anchors.ts b/templates/content/app/components/editor/comment-anchors.ts index f5a9f961156..a1fc2f0a8e2 100644 --- a/templates/content/app/components/editor/comment-anchors.ts +++ b/templates/content/app/components/editor/comment-anchors.ts @@ -74,6 +74,47 @@ function offsetToPos(docText: DocText, offset: number): number | null { return last.pmFrom + last.length; } +/** Resolve one ordered zero-width context boundary without inventing a fallback. */ +export function resolveAnchorPoint( + doc: ProseMirrorNode, + anchor: { + prefix?: string; + suffix?: string; + startOffset?: number; + }, +): number | null { + const docText = buildDocText(doc); + const prefix = anchor.prefix ?? ""; + const suffix = anchor.suffix ?? ""; + if (!prefix && !suffix) return null; + + const candidates: number[] = []; + for (let offset = 0; offset <= docText.text.length; offset += 1) { + const beforeMatches = + !prefix || + docText.text.slice(Math.max(0, offset - prefix.length), offset) === + prefix; + const afterMatches = + !suffix || docText.text.slice(offset, offset + suffix.length) === suffix; + if (beforeMatches && afterMatches) candidates.push(offset); + } + if (candidates.length === 0) return null; + + let chosen = candidates[0]!; + if (candidates.length > 1) { + if (typeof anchor.startOffset !== "number") return null; + const ranked = candidates + .map((offset) => ({ + offset, + distance: Math.abs(offset - anchor.startOffset!), + })) + .sort((left, right) => left.distance - right.distance); + if (ranked[0]!.distance === ranked[1]!.distance) return null; + chosen = ranked[0]!.offset; + } + return offsetToPos(docText, chosen); +} + /** Map a ProseMirror position to a text-space offset (best-effort). */ function posToOffset(docText: DocText, pos: number): number { let best = 0; diff --git a/templates/content/app/global.css b/templates/content/app/global.css index edec5a83e90..6b356c4d494 100644 --- a/templates/content/app/global.css +++ b/templates/content/app/global.css @@ -38,6 +38,7 @@ --border: 0 0% 90%; --input: 0 0% 90%; --ring: 0 0% 15%; + --suggestion: 207 39% 46%; --radius: 0.375rem; @@ -82,6 +83,7 @@ --destructive: 0 91% 71%; --destructive-foreground: 0 0% 100%; + --suggestion: 207 55% 68%; --border: 0 0% 24%; --input: 0 0% 24%; @@ -2419,26 +2421,36 @@ audio.media-block__content { /* Pending suggested edits are presentation-only decorations over canonical text. */ .notion-editor .suggestion-delete { - background: hsl(var(--destructive) / 0.12); + background: hsl(var(--muted) / 0.7); + color: hsl(var(--muted-foreground)); text-decoration: line-through; - text-decoration-color: hsl(var(--destructive)); + text-decoration-color: hsl(var(--muted-foreground) / 0.8); } .notion-editor .suggestion-delete-widget { display: inline; border: 0; border-radius: 0.2em; - background: hsl(var(--destructive) / 0.12); - color: inherit; + background: hsl(var(--muted) / 0.7); + color: hsl(var(--muted-foreground)); font: inherit; padding: 0 0.1em; text-decoration: line-through; - text-decoration-color: hsl(var(--destructive)); + text-decoration-color: hsl(var(--muted-foreground) / 0.8); +} +.notion-editor .suggestion-delete:hover, +.notion-editor .suggestion-delete:focus-visible, +.notion-editor .suggestion-delete-widget:hover, +.notion-editor .suggestion-delete-widget:focus-visible { + background: hsl(var(--muted)); + color: hsl(var(--foreground)); + text-decoration: none; } .notion-editor .suggestion-change { - background: hsl(var(--primary) / 0.12); - box-shadow: inset 0 -2px hsl(var(--primary) / 0.55); + background: hsl(var(--suggestion) / 0.12); + box-shadow: inset 0 -2px hsl(var(--suggestion) / 0.55); + color: hsl(var(--suggestion)); } .notion-editor .suggestion-insert, @@ -2446,17 +2458,20 @@ audio.media-block__content { display: inline; border: 0; border-radius: 0.2em; - background: hsl(var(--primary) / 0.12); - box-shadow: inset 0 -2px hsl(var(--primary) / 0.55); - color: inherit; + background: hsl(var(--suggestion) / 0.12); + box-shadow: inset 0 -2px hsl(var(--suggestion) / 0.55); + color: hsl(var(--suggestion)); font: inherit; padding: 0 0.1em; white-space: pre-wrap; } .notion-editor .suggestion-highlight--active { - outline: 2px solid hsl(var(--primary)); - outline-offset: 2px; + background: hsl(var(--suggestion) / 0.16); + box-shadow: inset 0 -2px hsl(var(--suggestion)); + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 3px; } /* @mention tokens inside comment bodies */ diff --git a/templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md b/templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md index 67cc948ca26..bd13d40346c 100644 --- a/templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md +++ b/templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md @@ -3,8 +3,8 @@ title: "Content Suggested Edits parity shape" date: 2026-09-02 status: shape-complete authoritySchemaVersion: 3 -ledgerRevision: content-suggested-edits-shape-r3 -governingArtifactRevision: content-suggested-edits-shape-r3 +ledgerRevision: content-suggested-edits-shape-r4 +governingArtifactRevision: content-suggested-edits-shape-r4 --- # Content Suggested Edits parity @@ -21,6 +21,171 @@ Human QA of PR #4274 at `c820218ee5e811c0328d68e16085a0b1bcce2fc1` proved that t The firsthand Notion reference keeps every pending change visibly anchored in the ordinary document. Insertions, replacements, formatting, and new blocks have inline treatment and per-location counts; activating an anchor opens the exact suggestion thread with author, time, discussion, and Accept/Reject beside the affected material. The repair therefore restores the existing contract rather than adding optional polish. +## 2026-09-04 return-to-shape: calm Notion-parity interaction repair + +Human QA of PR #4274 after the r3 repair, recorded at +`https://clips.agent-native.com/r/HQTpXgtD7rxX`, proves that the implementation +still fails the parity contract. The author can now see some provisional edits, +but deletions can jump to the start of the Page, the page-actions trigger keeps a +stuck focus treatment, ending Suggesting opens Comments, and activating an inline +change does not reliably expose a usable discussion and decision surface. The +rail renders suggestions as a separate custom card family and offers no way to +hide them when the user wants ordinary comments only. + +The desired interaction is the observed Notion behavior: Suggesting is a quiet, +explicit toolbar mode with an adjacent close action; additions are blue; +deletions recede in gray with strikethrough until hover; exiting the mode does not +move the viewport or open a panel; and each selected suggestion behaves like a +normal anchored comment thread with replies and Accept/Reject. The Comments view +can include suggestions, exclude them, or show them alone without introducing a +second discussion system. + +### Root causes in the current implementation + +- `suggestionAnchorRange` reconstructs empty-range deletion positions from + independently searched prefix/suffix strings and falls back to ProseMirror + position `1`. That fallback converts an unresolved location into a plausible + top-of-document location. +- Draft operations are recomputed from whole-document Markdown after every + change. The derived deletion has no stable ProseMirror bookmark, so the + renderer tries to rediscover a position after the deleted text is already gone. +- `handleSuggestionModeChange(false)` persists the session and then explicitly + selects the first suggestion and opens Comments. Mode exit and review + navigation are incorrectly coupled. +- Suggestion activation updates selection and scroll state, but the desktop + suggestion cards are not integrated into the existing anchored comment layout. + A click can therefore select an ID without presenting the expected adjacent + thread. +- `CommentsSidebar` duplicates suggestion-card markup in inline and history + modes and nests a headerless `ReviewThreadPanel` inside it. That parallel + presentation bypasses the established comment card hierarchy, replies, and + filtering vocabulary. +- The active style uses an outline and destructive red treatment. That conflicts + with the requested calm gray-deletion/blue-addition visual language and makes + transient selection look like a persistent focus defect. + +### Implementation plan + +#### 1. Make suggestion locations structural and fail closed + +- Replace the UI's Markdown diff loop with a ProseMirror suggestion-capture + plugin backed by the existing `suggestions/model.ts` and `controller.ts`. + Capture insert, delete, replace, supported mark, and supported text-block + operations from transactions before the document loses the original range. +- Give every local operation a stable local ID and mapped ProseMirror bookmark. + Coalesce only adjacent keystrokes of the same operation kind; never merge + unrelated ranges merely because they happened in one Suggesting session. +- Persist the operation's typed before/after material and contextual anchor at + the edit boundary. Swap the local ID for the returned durable suggestion ID + without replacing the decoration or losing focus. +- For reloaded suggestions, resolve exact text plus bounded prefix and suffix as + one ordered context match. Zero or multiple matches produce an explicit + orphaned/stale presentation in the rail; delete the `{ from: 1, to: 1 }` + fallback. An unresolved proposal must never appear at a believable wrong + location. +- Keep `contentRevision` and `onBaseAwareReconcile` from current `main` active on + the canonical editor. Suggesting remains isolated from canonical autosave and + Yjs, while external canonical movement invalidates or explicitly rebases local + operations instead of silently shifting them. + +#### 2. Make Suggesting a calm editor mode + +- Replace the single secondary button with the compact observed control: pencil + icon plus **Suggesting**, followed by a separately focusable X with tooltip and + accessible name. The X changes authoring mode only. +- Entering from the page-actions menu must close the menu and return focus to the + editor. Remove persistent trigger highlighting that represents neither an open + menu nor keyboard focus; retain standards-compliant `focus-visible` treatment. +- Exiting Suggesting must not open Comments, select the first proposal, scroll, + or alter the current utility panel. Pending and failed writes remain visible in + place; failures expose retry without pretending the proposal exists durably. +- Render additions with the existing blue semantic accent. Render deletions as + muted gray strikethrough at rest, removing the strike and increasing contrast + on hover/focus so the text can be read. Use shape/line treatment in addition to + color and remove the persistent black/blue outline from active suggestions. + +#### 3. Use one anchored thread presentation for comments and suggestions + +- Extract the existing comment card's header, body, reply sequence, composer, + reactions, overflow menu, focus behavior, and anchored layout into a shared + thread shell. Ordinary comments keep their current behavior and identity. +- Add a suggestion-thread content adapter to that shell. It supplies typed + before/after material and pending Accept/Reject actions, then uses the same + chronological replies, mentions, reactions, unread/mute/deep-link controls, + spacing, and focus semantics as a normal comment. +- Remove both duplicated custom suggestion-card render loops and the nested + headerless `ReviewThreadPanel`. A selected inline suggestion should open or + focus its exact adjacent thread; selecting the thread should focus and center + the exact inline anchor. Orphaned suggestions remain reviewable in a clearly + labeled unanchored section without fabricating a document location. +- Keep decisions operation-specific and idempotent. During a pending decision, + disable only that suggestion's controls. On success, update the canonical + editor and thread status optimistically, reconcile from the action result, and + restore focus to the affected material. On stale/conflict failure, preserve + the thread and show the typed failure. + +#### 4. Add a real Comments content filter + +- Add a compact content-type filter with **All**, **Comments**, and + **Suggestions** to the existing Comments filter menu. Compose it with the + existing status and author filters rather than creating a second sidebar mode. +- Apply the filter consistently to anchored desktop cards, narrow sequential + cards, empty states, and All discussions history. Filtering suggestions out + must not remove their editor decorations or mutate their state. +- Persist the filter only if the ordinary Comments surface already persists + comparable view preferences; otherwise keep it local to avoid creating a new + settings contract for this repair. + +#### 5. Lock the behavior with focused proof + +- Unit-test transaction capture, keystroke coalescing, stable ID handoff, + deletion bookmarks, ordered-context reload resolution, and explicit + unresolved/ambiguous outcomes. Add a regression proving no path returns + position `1` after failed resolution. +- Component-test menu focus restoration, independent X behavior, mode exit with + every utility-panel state, visual class/state contracts, inline-to-thread and + thread-to-inline focus, per-thread pending decisions, and content-type filters. +- Preserve and extend action/database tests for permissions, canonical + isolation, idempotent decisions, stale bases, SQL/Yjs fencing, history, and + cross-client convergence. +- Run formatter, Content/Core focused suites, typecheck, i18n guards, repository + guards, and `pnpm test:content-product-impact`. +- Human-QA the exact head in the real Content editor at desktop and narrow + widths, using a fresh uniquely marked disposable Page. Compare against the + referenced Notion interactions for enter/edit/hover/exit/review/filter flows. + A second authenticated client verifies that pending edits do not change + canonical content and that accepted edits converge without reload. Record + visible latency in coarse honest bands and clean the fixture with independent + absence proof. + +### Sequencing and ownership + +1. **Editor semantics:** transaction capture, structural anchors, persistence + handoff, and canonical/base-aware isolation. +2. **Interaction shell:** toolbar mode, exit behavior, focus restoration, and + calm addition/deletion styling. +3. **Unified discussion:** shared thread shell, bidirectional anchoring, + operation-scoped decisions, and orphan/stale fallback. +4. **Filtering and accessibility:** content-type filter, narrow layout, + keyboard, screen-reader, and focus-return behavior. +5. **Proof and PR reconciliation:** automated suites, exact-head human QA, + cleanup, product-impact declaration, and PR description/evidence refresh. + +The first four steps are one coherent repair: shipping only the anchor fix would +leave the unusable parallel review model intact, while shipping only the thread +restyle would preserve data that can point at the wrong text. + +### Explicit exclusions retained + +- No title, Property, database, media, embed, local-file, or provider-owned + suggestions. +- No Accept all/Reject all, dependency-aware bulk review, AI summary, or general + typed-change graph. +- No new discussion store, REST route, permanent review dashboard, or canonical + mutation path. +- No claim that all of Content Feature 7 is available. This remains the + supported Page-body slice. + ### Bound repair 1. **Capture semantic operations, not one markdown session diff.** The suggesting editor must translate supported ProseMirror transactions into typed Add/Delete/Replace/Add block/Set mark operations with stable operation and thread identity. Keystrokes that form one continuous edit may coalesce, but distinct ranges and operation kinds remain independently reviewable. `markdownSuggestionOperation(base, finalDraft)` may remain a compatibility/test helper; it cannot be the UI's primary capture boundary. @@ -243,7 +408,7 @@ Automated coverage is required for model/store/action/permission/idempotency/sta ```yaml stage: shape -authority-source: "User invoked $shape on 2026-09-03 to shape the fixes after exact-head human QA of PR #4274." +authority-source: "User invoked $shape on 2026-09-04 after the second human QA pass of PR #4274 and requested an implementation plan against latest main." authorized-scope: repositories: - /Users/alicemoore/.codex/worktrees/5438/agent-native @@ -258,7 +423,7 @@ write-targets: - templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md governing-artifact: path: templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md - revision: content-suggested-edits-shape-r3 + revision: content-suggested-edits-shape-r4 architecture-fingerprint: outcome: Commenters, editors, and agents can propose supported page-body edits without changing canonical Content until an authorized reviewer accepts them. shipping-surfaces: @@ -280,9 +445,15 @@ architecture-fingerprint: summary: A commenter or agent proposes supported edits in the ordinary Content editor; an authorized editor discusses and decides each proposal; only accepted material reaches canonical Content and every state remains attributable and recoverable. required-assertions: - proposal creation preserves canonical content + - Suggesting uses a compact labeled mode control with an independent accessible exit action + - entering from page actions restores focus without leaving the trigger visually stuck - each supported semantic edit is visibly distinguished and independently anchored while authoring and after persistence or reload - - exiting Suggesting changes authoring mode without removing pending material from the Page + - unresolved or ambiguous anchors are explicit and never rendered at a plausible fallback location + - additions use blue provisional treatment and deletions use readable gray strikethrough that clarifies on hover and focus + - exiting Suggesting changes authoring mode without removing pending material, opening Comments, scrolling, or changing the utility panel - typed review renders exact before and after material without exposing serialized markdown syntax + - suggestions use the ordinary anchored comment-thread interaction with chronological replies and per-thread decisions + - Comments can show all discussion, ordinary comments only, or suggestions only without mutating editor state - selective accept/reject applies exactly the decided operation - discussion and resolved history persist with deep links - UI, agent, and action permission parity fails closed @@ -328,7 +499,7 @@ architecture-grounding: - accepted - rejected - stale - smallest-compatible-delta: Preserve the existing Core executable-suggestion lifecycle and canonical mutation coordinator; replace the UI's whole-session markdown diff with typed semantic operation capture, durable pending-operation hydration, in-place editor overlays and anchors, and typed rail rendering for the Content page-body slice. + smallest-compatible-delta: Preserve the existing Core executable-suggestion lifecycle, canonical mutation coordinator, and main's base-aware editor reconciliation; replace whole-session Markdown rediscovery and parallel suggestion cards with typed transaction capture, structural/fail-closed anchors, calm Notion-like mode and decorations, one ordinary anchored thread shell, and composable discussion filtering for the Content page-body slice. deferred-capabilities: - generic typed Property/Database/media proposals - filtered bulk review @@ -337,6 +508,8 @@ architecture-grounding: reversibility: A default-off app-owned feature flag keeps dormant code inactive; suggestion tables are additive; pending proposals never alter canonical content. direct-evidence: - verified Notion Suggested Edits interaction memo dated 2026-09-01 + - human QA recording HQTpXgtD7rxX dated 2026-09-04 + - origin/main e4364cd716 integrated locally at merge commit 713e8a7c29 - packages/core/src/review and packages/core/src/history - templates/content editor, actions, schema, comments, collaboration plugin - Content feature 7 and diff/history/access capability records @@ -349,45 +522,47 @@ architecture-grounding: delegation-ceiling: [] acceptance-state: status: blocked - summary: PR #4274 preserves canonical isolation and can accept, reject, and reload proposals, but exact-head human QA fails the frozen in-place authoring and review story because pending changes are detached from the Page and collapsed into one markdown diff. + summary: WORK PAUSED — RETURNING TO SHAPE. PR #4274 remains acceptance-blocked: the second human QA pass shows wrong-location deletions, stuck menu focus, coupled mode-exit navigation, unusable inline activation, a parallel suggestion-card UI, and no comments-only filter. evidence: - templates/content/docs/solutions/evidence/suggested-edits-accept-reject.png - focused Core and Content automated suites remaining: - - typed semantic operation capture in the real editor - - immediate and durable in-place decorations, markers, and bidirectional thread anchors - - typed before/after rail rendering without raw markdown leakage + - typed ProseMirror operation capture with stable structural/fail-closed anchors + - calm Suggesting control, focus restoration, independent exit, and Notion-like addition/deletion presentation + - one ordinary anchored thread shell for comments and suggestions with reliable bidirectional focus + - All, Comments, and Suggestions content filtering across desktop, narrow, and history views - commenter/editor role-separated acceptance - accountable agent proposal acceptance - two-client sync and narrow-width accessibility acceptance - independent technical review closure blockers: - - exact-head real-interface H1-H10 failed the in-place comparison and discoverability assertions + - exact-head real-interface QA in HQTpXgtD7rxX failed the anchoring, mode-control, visual treatment, thread interaction, and filtering assertions last-land-packet: null change-record: - from-revision: content-suggested-edits-shape-r2 - to-revision: content-suggested-edits-shape-r3 - trigger: Exact-head human QA proved that the staged plain-markdown draft and detached review card violate the already-frozen in-place parity contract. + from-revision: content-suggested-edits-shape-r3 + to-revision: content-suggested-edits-shape-r4 + trigger: The second human QA recording proved that r3's partial overlay repair still violates the Notion-parity interaction contract and that latest main adds reconciliation/comment seams the repair must preserve. preserved: - - outcome and exact Notion-parity product boundary + - outcome, shipping surfaces, and supported Page-body parity boundary - shipping surfaces and ownership split between Core and Content - default-off feature-flag risk strategy - prepare/commit/publish canonical mutation coordination changed: - - whole-session markdown diff is explicitly rejected as the editor capture boundary - - typed semantic operations must remain independently anchored and reviewable - - pending suggestions must render in place while authoring and after persistence or reload - - the rail must render typed material and maintain bidirectional focus with Page anchors - - narrow-width review must preserve affected-content context and return focus -ledger-revision: content-suggested-edits-shape-r3 + - structural and fail-closed anchor behavior is now explicit, including prohibition of top-of-document fallback + - mode exit is separated from persistence selection, scrolling, and Comments navigation + - the visual contract freezes blue additions, gray struck deletions, hover/focus legibility, and no persistent active outline + - suggestions must use the ordinary anchored comment-thread shell rather than custom nested review cards + - Comments gains a composable All, Comments, and Suggestions content filter + - current main's base-aware reconciliation is a preserved implementation seam +ledger-revision: content-suggested-edits-shape-r4 status: shape-complete ``` -## Active Work envelope +## Superseded Work envelope ```yaml -stage: work -authority-source: "User invoked $work on 2026-09-03 against content-suggested-edits-shape-r3." +stage: shape +authority-source: "The 2026-09-04 QA evidence materially changed the acceptance story and invalidated the prior r3 Work envelope." ledger-revision: content-suggested-edits-work-r3-1 governing-artifact: path: templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md @@ -429,9 +604,10 @@ test-resources: phase: work acceptance-reconciliation: consistent — schema-v3 real-interface, preferred independence, same-context-allowed custody task-attention: autonomous -status: implementation +status: return-to-shape +invalidation-banner: WORK PAUSED — RETURNING TO SHAPE ``` ## Next step -Invoke `/work templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md` to revise PR #4274 against r3. Work begins with the editor capture/rendering boundary: preserve the accepted Core lifecycle and canonical mutation coordinator, replace the whole-session markdown diff with typed semantic operations, and prove that proposed material stays visibly anchored before and after persistence. The PR remains acceptance-blocked until the complete H1-H10 story passes on the exact head; accept/reject persistence alone is not sufficient. +Invoke `/work templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md` to revise PR #4274 against r4. Work begins with typed ProseMirror capture and structural anchor resolution, then replaces the parallel suggestion cards with the ordinary anchored thread shell before visual polish or filtering. The PR remains acceptance-blocked until the complete exact-head real-interface story passes; a correct persistence action is insufficient when the edit appears in the wrong place or the discussion cannot be operated naturally. From e8676eee9e3614031590c82529b5cf2ee0bdbc04 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:34:54 -0400 Subject: [PATCH 06/15] fix: preserve suggested edit drafts and comment review behavior --- .changeset/pending-suggestion-amendments.md | 5 + packages/core/package.json | 1 + packages/core/src/client/review/index.ts | 2 + packages/core/src/client/review/use-review.ts | 15 + .../core/src/review/suggestions/actions.ts | 177 ++- .../actions/update-resource-suggestion.ts | 1 + .../src/review/suggestions/amendments.spec.ts | 323 +++++ .../core/src/review/suggestions/store.spec.ts | 3 + packages/core/src/review/suggestions/store.ts | 125 +- packages/core/src/review/suggestions/types.ts | 1 + packages/core/src/server/action-discovery.ts | 5 + .../editor/CommentComposer.test.tsx | 2 + .../app/components/editor/CommentComposer.tsx | 4 +- .../editor/CommentsSidebar.drafts.test.tsx | 682 +++++++++++ .../editor/CommentsSidebar.layout.test.ts | 181 ++- .../app/components/editor/CommentsSidebar.tsx | 1071 +++++++++++++---- .../editor/DocumentEditor.layout.test.ts | 142 ++- .../app/components/editor/DocumentEditor.tsx | 640 +++++++--- .../editor/VisualEditor.markdown.test.ts | 216 ++++ .../app/components/editor/VisualEditor.tsx | 272 ++++- .../components/editor/comment-anchors.spec.ts | 46 + .../app/components/editor/comment-anchors.ts | 17 +- .../components/editor/comment-lane.test.ts | 167 +++ .../app/components/editor/comment-lane.ts | 45 + .../extensions/CommentHighlight.spec.ts | 81 ++ .../editor/extensions/CommentHighlight.ts | 153 +-- .../extensions/SuggestionHighlight.spec.ts | 239 +++- .../editor/extensions/SuggestionHighlight.ts | 59 +- .../editor/suggestions/draft-session.test.ts | 883 ++++++++++++++ .../editor/suggestions/draft-session.ts | 337 ++++++ .../suggestions/markdown-operation.test.ts | 69 ++ .../editor/suggestions/markdown-operation.ts | 184 ++- templates/content/app/global.css | 76 +- templates/content/app/i18n-data.ts | 86 ++ templates/content/app/i18n/zh-TW.ts | 7 + .../content.revision.suggestions.md | 8 +- .../server/lib/suggested-edits.spec.ts | 175 +++ .../content/server/lib/suggested-edits.ts | 59 +- .../content/server/plugins/feature-flags.ts | 5 - templates/content/shared/feature-flags.ts | 9 - .../content/shared/suggestion-rebase.spec.ts | 113 ++ templates/content/shared/suggestion-rebase.ts | 95 ++ 42 files changed, 6130 insertions(+), 651 deletions(-) create mode 100644 .changeset/pending-suggestion-amendments.md create mode 100644 packages/core/src/review/suggestions/actions/update-resource-suggestion.ts create mode 100644 packages/core/src/review/suggestions/amendments.spec.ts create mode 100644 templates/content/app/components/editor/CommentsSidebar.drafts.test.tsx create mode 100644 templates/content/app/components/editor/comment-lane.test.ts create mode 100644 templates/content/app/components/editor/comment-lane.ts create mode 100644 templates/content/app/components/editor/extensions/CommentHighlight.spec.ts create mode 100644 templates/content/app/components/editor/suggestions/draft-session.test.ts create mode 100644 templates/content/app/components/editor/suggestions/draft-session.ts delete mode 100644 templates/content/server/plugins/feature-flags.ts delete mode 100644 templates/content/shared/feature-flags.ts create mode 100644 templates/content/shared/suggestion-rebase.spec.ts create mode 100644 templates/content/shared/suggestion-rebase.ts diff --git a/.changeset/pending-suggestion-amendments.md b/.changeset/pending-suggestion-amendments.md new file mode 100644 index 00000000000..192f9c2a019 --- /dev/null +++ b/.changeset/pending-suggestion-amendments.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Allow authors to amend pending suggestions while preserving discussion and durable revision history, with revision checks protecting concurrent review decisions. diff --git a/packages/core/package.json b/packages/core/package.json index 8495d9e39be..50e9ea89e9d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -251,6 +251,7 @@ "./review/actions/set-review-thread-unread": "./dist/review/actions/set-review-thread-unread.js", "./review/actions/set-review-thread-muted": "./dist/review/actions/set-review-thread-muted.js", "./review/suggestions/actions/create-resource-suggestion": "./dist/review/suggestions/actions/create-resource-suggestion.js", + "./review/suggestions/actions/update-resource-suggestion": "./dist/review/suggestions/actions/update-resource-suggestion.js", "./review/suggestions/actions/list-resource-suggestions": "./dist/review/suggestions/actions/list-resource-suggestions.js", "./review/suggestions/actions/get-resource-suggestion": "./dist/review/suggestions/actions/get-resource-suggestion.js", "./review/suggestions/actions/decide-resource-suggestion": "./dist/review/suggestions/actions/decide-resource-suggestion.js", diff --git a/packages/core/src/client/review/index.ts b/packages/core/src/client/review/index.ts index 9f6143c5362..f4a2e0967ad 100644 --- a/packages/core/src/client/review/index.ts +++ b/packages/core/src/client/review/index.ts @@ -26,6 +26,7 @@ export { useSendReviewThreadToAgent, useResourceSuggestions, useCreateResourceSuggestion, + useUpdateResourceSuggestion, useDecideResourceSuggestion, type ConsumeReviewFeedbackInput, type CreateReviewCommentInput, @@ -40,5 +41,6 @@ export { type SendReviewThreadToAgentInput, type ListResourceSuggestionsParams, type CreateResourceSuggestionInput, + type UpdateResourceSuggestionInput, type DecideResourceSuggestionInput, } from "./use-review.js"; diff --git a/packages/core/src/client/review/use-review.ts b/packages/core/src/client/review/use-review.ts index 0907df54793..ad0a091479e 100644 --- a/packages/core/src/client/review/use-review.ts +++ b/packages/core/src/client/review/use-review.ts @@ -123,6 +123,15 @@ export interface DecideResourceSuggestionInput { decision: SuggestionDecision; idempotencyKey: string; observedBase: string; + observedRevision?: number; +} + +export interface UpdateResourceSuggestionInput { + id: string; + observedRevision: number; + idempotencyKey: string; + operations: SuggestionOperation[]; + summary?: string; } export function useReviewComments( @@ -241,3 +250,9 @@ export function useDecideResourceSuggestion() { DecideResourceSuggestionInput >("decide-resource-suggestion"); } + +export function useUpdateResourceSuggestion() { + return useActionMutation( + "update-resource-suggestion", + ); +} diff --git a/packages/core/src/review/suggestions/actions.ts b/packages/core/src/review/suggestions/actions.ts index 330d15f6f0a..f3d097f344a 100644 --- a/packages/core/src/review/suggestions/actions.ts +++ b/packages/core/src/review/suggestions/actions.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { defineAction } from "../../action.js"; +import { defineAction, fail } from "../../action.js"; import { getDbExec } from "../../db/client.js"; import { notifyReviewComment } from "../notifications.js"; import { assertReviewableResourceAccess } from "../registry.js"; @@ -21,6 +21,8 @@ import { recordSuggestionCreation, replaceSuggestionStatus, updateSuggestionStatus, + getSuggestionAmendment, + amendSuggestion, } from "./store.js"; import type { ResourceSuggestion } from "./types.js"; @@ -160,6 +162,147 @@ export const createResourceSuggestion = defineAction({ }, }); +export const updateResourceSuggestion = defineAction({ + description: + "Amend your pending suggestion while preserving its discussion and history; returns the updated suggestion and revision.", + schema: z.object({ + id: z.string().min(1).describe("The existing pending suggestion to amend."), + observedRevision: z + .number() + .int() + .positive() + .describe( + "The suggestion revision you read; refresh after a conflict before editing again.", + ), + idempotencyKey: z + .string() + .min(1) + .max(200) + .describe( + "Reuse this key only for an exact retry of this amendment; use a new key for new edits.", + ), + operations: z + .array(operation) + .min(1) + .describe( + "Replacement proposal operations against the suggestion's existing canonical basis.", + ), + summary: z + .string() + .trim() + .min(1) + .max(500) + .optional() + .describe( + "Optional replacement summary; omission preserves the existing summary.", + ), + }), + run: async (args, ctx) => { + const initial = await getSuggestion(args.id); + if (!initial) + fail("Suggestion not found", { statusCode: 404, errorCode: "not_found" }); + await assertReviewableResourceAccess( + initial.resourceType, + initial.resourceId, + ctx as any, + "commenter", + ); + const author = (ctx as any)?.userEmail; + if (!author || author !== initial.authorEmail) { + fail("Only the author can amend this suggestion", { + statusCode: 403, + errorCode: "forbidden", + }); + } + const db = getDbExec(); + if (!db.transaction) + throw new Error( + "Suggestion amendments require an atomic database transaction", + ); + const request = JSON.stringify({ + id: args.id, + observedRevision: args.observedRevision, + operations: args.operations, + summary: args.summary ?? null, + }); + return db.transaction(async (tx) => { + const current = await getSuggestion(args.id, tx); + if (!current) + fail("Suggestion not found", { + statusCode: 404, + errorCode: "not_found", + }); + const access = await assertReviewableResourceAccess( + current.resourceType, + current.resourceId, + { ...(ctx as any), transaction: tx }, + "commenter", + ); + if (current.authorEmail !== author) + fail("Only the author can amend this suggestion", { + statusCode: 403, + errorCode: "forbidden", + }); + const prior = await getSuggestionAmendment(tx, args.idempotencyKey); + if (prior) { + if (prior.suggestionId !== current.id || prior.request !== request) { + fail("Idempotency key was already used for a different amendment", { + statusCode: 409, + errorCode: "idempotency_conflict", + }); + } + return prior.suggestion; + } + if ( + current.status !== "pending" || + current.revision !== args.observedRevision + ) { + fail("The suggestion changed; refresh before editing", { + statusCode: 409, + errorCode: "suggestion_conflict", + }); + } + const adapter = getSuggestionAdapter(current.adapterKind); + if (!adapter || adapter.version !== current.adapterVersion) + throw new Error("Suggestion adapter version is unavailable"); + const operations = + (await adapter.validateProposal({ + resourceType: current.resourceType, + resourceId: current.resourceId, + baseRevision: current.baseRevision, + operations: args.operations, + ctx: { ...(ctx as any), transaction: tx, suggestionAccess: access }, + })) ?? args.operations; + const updated = await amendSuggestion( + tx, + current, + operations, + args.summary ?? current.summary, + args.idempotencyKey, + request, + ); + if (!updated) + fail("The suggestion changed; refresh before editing", { + statusCode: 409, + errorCode: "suggestion_conflict", + }); + return updated; + }); + }, + audit: { + target: (_args, result) => { + const suggestion = result as ResourceSuggestion; + return { + type: suggestion.resourceType, + id: suggestion.resourceId, + ownerEmail: suggestion.ownerEmail, + orgId: suggestion.orgId, + visibility: suggestion.visibility, + }; + }, + }, +}); + export const listResourceSuggestions = defineAction({ description: "List typed suggestions for a resource.", schema: z.object({ @@ -220,6 +363,7 @@ export const decideResourceSuggestion = defineAction({ decision: z.enum(["accepted", "rejected"]), idempotencyKey: z.string().min(1), observedBase: z.string().min(1), + observedRevision: z.number().int().positive().optional(), }), run: async (args, ctx) => { const suggestion = await getSuggestion(args.id); @@ -260,17 +404,30 @@ export const decideResourceSuggestion = defineAction({ return { suggestion: current, decision }; } const currentAdapter = getSuggestionAdapter(current.adapterKind); + if ((args.observedRevision ?? 1) !== current.revision) { + fail("The suggestion changed; refresh before deciding", { + statusCode: 409, + errorCode: "suggestion_conflict", + }); + } if ( !currentAdapter || currentAdapter.version !== current.adapterVersion ) throw new Error("Suggestion adapter version is unavailable"); if (current.baseRevision !== args.observedBase) { - if (!(await updateSuggestionStatus(tx, current.id, "stale"))) - return { - suggestion: current, - decision: await getDecision(tx, args.idempotencyKey), - }; + if ( + !(await updateSuggestionStatus( + tx, + current.id, + "stale", + current.revision, + )) + ) + fail("The suggestion changed; refresh before deciding", { + statusCode: 409, + errorCode: "suggestion_conflict", + }); const decision = await recordDecision(tx, { suggestionId: current.id, idempotencyKey: args.idempotencyKey, @@ -289,11 +446,13 @@ export const decideResourceSuggestion = defineAction({ tx, current.id, args.decision, + current.revision, ); if (!claimed) - throw new Error( - "Suggestion was decided concurrently; retry to inspect it", - ); + fail("The suggestion changed; refresh before deciding", { + statusCode: 409, + errorCode: "suggestion_conflict", + }); const prior = await recordDecision(tx, { suggestionId: current.id, idempotencyKey: args.idempotencyKey, diff --git a/packages/core/src/review/suggestions/actions/update-resource-suggestion.ts b/packages/core/src/review/suggestions/actions/update-resource-suggestion.ts new file mode 100644 index 00000000000..8a41e79f1ee --- /dev/null +++ b/packages/core/src/review/suggestions/actions/update-resource-suggestion.ts @@ -0,0 +1 @@ +export { updateResourceSuggestion as default } from "../actions.js"; diff --git a/packages/core/src/review/suggestions/amendments.spec.ts b/packages/core/src/review/suggestions/amendments.spec.ts new file mode 100644 index 00000000000..30cc6b75fae --- /dev/null +++ b/packages/core/src/review/suggestions/amendments.spec.ts @@ -0,0 +1,323 @@ +import Database from "better-sqlite3"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +let sqlite: Database.Database; +const client = { + execute: async (input: string | { sql: string; args?: unknown[] }) => { + const sql = typeof input === "string" ? input : input.sql; + const args = typeof input === "string" ? [] : (input.args ?? []); + if (/^\s*(SELECT|PRAGMA)/i.test(sql)) { + return { rows: sqlite.prepare(sql).all(...args), rowsAffected: 0 }; + } + return { rows: [], rowsAffected: sqlite.prepare(sql).run(...args).changes }; + }, + transaction: async ( + run: (tx: typeof client) => Promise, + ): Promise => { + sqlite.exec("BEGIN"); + try { + const result = await run(client); + sqlite.exec("COMMIT"); + return result; + } catch (error) { + sqlite.exec("ROLLBACK"); + throw error; + } + }, +}; +const access = vi.fn(async () => ({ + role: "commenter", + ownerEmail: "owner@example.com", +})); +const validate = vi.fn(); +const apply = vi.fn(); +vi.mock("../../db/client.js", () => ({ + getDbExec: () => client, + isPostgres: () => false, +})); +vi.mock("../registry.js", () => ({ + assertReviewableResourceAccess: (...args: unknown[]) => access(...args), +})); +vi.mock("../notifications.js", () => ({ notifyReviewComment: vi.fn() })); +vi.mock("../store.js", () => ({ + ensureReviewTables: vi.fn(), + insertReviewCommentWithClient: vi.fn(), + resolveReviewThreadWithClient: vi.fn(), +})); +vi.mock("./registry.js", () => ({ + getSuggestionAdapter: () => ({ + kind: "test", + version: 1, + validateProposal: validate, + apply, + }), +})); + +const { updateResourceSuggestion, decideResourceSuggestion } = + await import("./actions.js"); +const { + insertSuggestion, + getSuggestion, + ensureSuggestionTables, + __resetSuggestionTablesForTests, + amendSuggestion, + updateSuggestionStatus, +} = await import("./store.js"); +const operation = { + ordinal: 0, + kind: "replace_text", + before: "old", + after: "new", + schemaVersion: 1, +}; +let suggestion: Awaited>; +const author = { userEmail: "author@example.com" }; +const request = () => ({ + id: suggestion.id, + observedRevision: 1, + idempotencyKey: "amend-1", + operations: [{ ...operation, after: "better" }], +}); +const decide = (observedRevision?: number) => + decideResourceSuggestion.run( + { + id: suggestion.id, + decision: "accepted", + observedBase: "base-1", + observedRevision, + idempotencyKey: "decision-1", + }, + author, + ); + +beforeEach(async () => { + sqlite = new Database(":memory:"); + __resetSuggestionTablesForTests(); + access + .mockReset() + .mockResolvedValue({ role: "commenter", ownerEmail: "owner@example.com" }); + validate.mockReset(); + apply.mockReset(); + await ensureSuggestionTables(); + suggestion = await insertSuggestion({ + resourceType: "document", + resourceId: "doc-1", + adapterKind: "test", + adapterVersion: 1, + threadId: "thread-1", + authorEmail: author.userEmail, + actorKind: "human", + baseRevision: "base-1", + status: "pending", + summary: "Original", + ownerEmail: "owner@example.com", + orgId: null, + visibility: "private", + metadata: null, + operations: [operation], + }); +}); +afterEach(() => sqlite.close()); + +describe("pending suggestion amendments", () => { + it("reuses additive schema without resetting existing proposal revisions", async () => { + await updateResourceSuggestion.run(request(), author); + __resetSuggestionTablesForTests(); + await ensureSuggestionTables(); + expect((await getSuggestion(suggestion.id))?.revision).toBe(2); + }); + + it("adds ownership columns to a preexisting amendment table without removing rows", async () => { + sqlite.close(); + sqlite = new Database(":memory:"); + sqlite.exec( + "CREATE TABLE agent_review_suggestion_amendments (idempotency_key TEXT PRIMARY KEY, suggestion_id TEXT NOT NULL, revision INTEGER NOT NULL, author_email TEXT NOT NULL, request_json TEXT NOT NULL, before_json TEXT NOT NULL, after_json TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE (suggestion_id, revision))", + ); + sqlite + .prepare( + "INSERT INTO agent_review_suggestion_amendments VALUES (?,?,?,?,?,?,?,?)", + ) + .run( + "existing", + "existing-suggestion", + 2, + "author@example.com", + "{}", + "{}", + "{}", + "now", + ); + __resetSuggestionTablesForTests(); + await ensureSuggestionTables(); + const row = sqlite + .prepare( + "SELECT * FROM agent_review_suggestion_amendments WHERE idempotency_key = ?", + ) + .get("existing"); + expect(row).toMatchObject({ + suggestion_id: "existing-suggestion", + revision: 2, + owner_email: null, + org_id: null, + visibility: "private", + }); + }); + it("preserves identity, increments revision and keeps append-only before/after history without applying canonical content", async () => { + const first = await updateResourceSuggestion.run(request(), author); + const second = await updateResourceSuggestion.run( + { + ...request(), + observedRevision: 2, + idempotencyKey: "amend-2", + operations: [{ ...operation, after: "best" }], + }, + author, + ); + expect(second).toMatchObject({ + id: suggestion.id, + threadId: suggestion.threadId, + authorEmail: suggestion.authorEmail, + createdAt: suggestion.createdAt, + revision: 3, + status: "pending", + }); + const rows = sqlite + .prepare( + "SELECT before_json, after_json FROM agent_review_suggestion_amendments ORDER BY revision", + ) + .all() as { before_json: string; after_json: string }[]; + expect(rows).toHaveLength(2); + expect(JSON.parse(rows[0]!.before_json).operations[0].after).toBe("new"); + expect(JSON.parse(rows[0]!.after_json)).toEqual(first); + expect(JSON.parse(rows[1]!.after_json)).toEqual(second); + expect(apply).not.toHaveBeenCalled(); + expect(validate).toHaveBeenCalledWith( + expect.objectContaining({ + baseRevision: "base-1", + ctx: expect.objectContaining({ transaction: client }), + }), + ); + }); + + it("replays an exact retry without adding history and rejects mismatched key reuse", async () => { + const first = await updateResourceSuggestion.run(request(), author); + expect(await updateResourceSuggestion.run(request(), author)).toEqual( + first, + ); + await expect( + updateResourceSuggestion.run( + { ...request(), summary: "Changed" }, + author, + ), + ).rejects.toMatchObject({ errorCode: "idempotency_conflict" }); + expect( + sqlite + .prepare( + "SELECT COUNT(*) AS count FROM agent_review_suggestion_amendments", + ) + .get(), + ).toEqual({ count: 1 }); + }); + + it.each([{}, { userEmail: "owner@example.com" }])( + "rejects a caller who is not the exact author: %s", + async (caller) => { + await expect( + updateResourceSuggestion.run(request(), caller), + ).rejects.toMatchObject({ errorCode: "forbidden" }); + expect((await getSuggestion(suggestion.id))?.revision).toBe(1); + }, + ); + + it("rechecks revoked access inside the transaction", async () => { + access + .mockResolvedValueOnce({ + role: "commenter", + ownerEmail: "owner@example.com", + }) + .mockRejectedValueOnce(new Error("Access revoked")); + await expect( + updateResourceSuggestion.run(request(), author), + ).rejects.toThrow("Access revoked"); + expect((await getSuggestion(suggestion.id))?.revision).toBe(1); + }); + + it("rejects a stale proposal revision without replacing content", async () => { + await updateResourceSuggestion.run(request(), author); + await expect( + updateResourceSuggestion.run( + { ...request(), idempotencyKey: "other" }, + author, + ), + ).rejects.toMatchObject({ errorCode: "suggestion_conflict" }); + expect((await getSuggestion(suggestion.id))?.operations[0]?.after).toBe( + "better", + ); + }); + + it("rejects changed canonical basis through adapter validation without an amendment", async () => { + validate.mockRejectedValueOnce(new Error("Canonical changed")); + await expect( + updateResourceSuggestion.run(request(), author), + ).rejects.toThrow("Canonical changed"); + expect((await getSuggestion(suggestion.id))?.revision).toBe(1); + expect( + sqlite + .prepare( + "SELECT COUNT(*) AS count FROM agent_review_suggestion_amendments", + ) + .get(), + ).toEqual({ count: 0 }); + }); + + it("rejects missing/stale decision revision after amendment and accepts the observed revision", async () => { + await updateResourceSuggestion.run(request(), author); + await expect(decide()).rejects.toMatchObject({ + errorCode: "suggestion_conflict", + }); + await expect(decide(1)).rejects.toMatchObject({ + errorCode: "suggestion_conflict", + }); + expect(apply).not.toHaveBeenCalled(); + await decide(2); + expect(apply).toHaveBeenCalledOnce(); + expect((await getSuggestion(suggestion.id))?.status).toBe("accepted"); + }); + + it("refuses an amendment after acceptance, while legacy revision-one decisions still work", async () => { + await decide(); + await expect( + updateResourceSuggestion.run(request(), author), + ).rejects.toMatchObject({ errorCode: "suggestion_conflict" }); + expect((await getSuggestion(suggestion.id))?.revision).toBe(1); + }); + + it("CAS refuses a decision based on an amendment's previous revision", async () => { + await updateResourceSuggestion.run(request(), author); + expect( + await updateSuggestionStatus(client, suggestion.id, "accepted", 1), + ).toBe(false); + expect((await getSuggestion(suggestion.id))?.status).toBe("pending"); + }); + + it("CAS refuses amendment if a decision wins after the author reads", async () => { + await decide(); + expect( + await client.transaction((tx) => + amendSuggestion(tx, suggestion, [operation], "Other", "late", "{}"), + ), + ).toBeNull(); + expect((await getSuggestion(suggestion.id))?.status).toBe("accepted"); + }); + + it("rolls the payload and revision back when the history receipt cannot be written", async () => { + await updateResourceSuggestion.run(request(), author); + const current = (await getSuggestion(suggestion.id))!; + await expect( + client.transaction((tx) => + amendSuggestion(tx, current, [operation], "Bad", "amend-1", "{}"), + ), + ).rejects.toThrow(); + expect(await getSuggestion(suggestion.id)).toEqual(current); + }); +}); diff --git a/packages/core/src/review/suggestions/store.spec.ts b/packages/core/src/review/suggestions/store.spec.ts index 8936e66c7d1..e980b7215d9 100644 --- a/packages/core/src/review/suggestions/store.spec.ts +++ b/packages/core/src/review/suggestions/store.spec.ts @@ -5,6 +5,9 @@ let sqlite: Database.Database; const rawClient = { execute: vi.fn(async (input: string | { sql: string; args?: unknown[] }) => { if (typeof input === "string") { + if (/^\s*PRAGMA/i.test(input)) { + return { rows: sqlite.prepare(input).all(), rowsAffected: 0 }; + } sqlite.exec(input); return { rows: [], rowsAffected: 0 }; } diff --git a/packages/core/src/review/suggestions/store.ts b/packages/core/src/review/suggestions/store.ts index 0e771dc9d39..aed68627713 100644 --- a/packages/core/src/review/suggestions/store.ts +++ b/packages/core/src/review/suggestions/store.ts @@ -1,10 +1,11 @@ import { getDbExec, isPostgres, type DbExec } from "../../db/client.js"; -import { ensureTableExists } from "../../db/ddl-guard.js"; +import { ensureColumnExists, ensureTableExists } from "../../db/ddl-guard.js"; import type { Visibility } from "../../sharing/schema.js"; import type { ResourceSuggestion, SuggestionDecision, SuggestionStatus, + SuggestionOperation, } from "./types.js"; let initialized: Promise | undefined; @@ -28,12 +29,44 @@ export async function ensureSuggestionTables( `CREATE TABLE IF NOT EXISTS agent_review_suggestion_operations (id TEXT PRIMARY KEY, suggestion_id TEXT NOT NULL, ordinal INTEGER NOT NULL, operation_kind TEXT NOT NULL, target_id TEXT, before_json TEXT, after_json TEXT, anchor_json TEXT, dependencies_json TEXT, schema_version INTEGER NOT NULL)`, `CREATE TABLE IF NOT EXISTS agent_review_suggestion_decisions (id TEXT PRIMARY KEY, suggestion_id TEXT NOT NULL, idempotency_key TEXT NOT NULL UNIQUE, reviewer TEXT, decision TEXT NOT NULL, observed_base TEXT, outcome TEXT NOT NULL, detail TEXT, created_at TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS agent_review_suggestion_creations (idempotency_key TEXT PRIMARY KEY, suggestion_id TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS agent_review_suggestion_amendments (idempotency_key TEXT PRIMARY KEY, suggestion_id TEXT NOT NULL, revision INTEGER NOT NULL, author_email TEXT NOT NULL, owner_email TEXT, org_id TEXT, visibility TEXT NOT NULL DEFAULT 'private', request_json TEXT NOT NULL, before_json TEXT NOT NULL, after_json TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE (suggestion_id, revision))`, ]; for (const sql of ddl) { const name = sql.match(/agent_review_[a-z_]+/)![0]; if (isPostgres()) await ensureTableExists(name, sql); else await client.execute(sql); } + for (const [table, definitions] of [ + [ + "agent_review_suggestions", + [["revision", "INTEGER NOT NULL DEFAULT 1"]], + ], + [ + "agent_review_suggestion_amendments", + [ + ["owner_email", "TEXT"], + ["org_id", "TEXT"], + ["visibility", "TEXT NOT NULL DEFAULT 'private'"], + ], + ], + ] as const) { + const existing = isPostgres() + ? null + : await client.execute(`PRAGMA table_info(${table})`); + for (const [column, type] of definitions) { + if (isPostgres()) { + await ensureColumnExists( + table, + column, + `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS ${column} ${type}`, + ); + } else if (!existing!.rows.some((field) => field.name === column)) { + await client.execute( + `ALTER TABLE ${table} ADD COLUMN ${column} ${type}`, + ); + } + } + } await client.execute( "CREATE INDEX IF NOT EXISTS idx_review_suggestions_resource ON agent_review_suggestions (resource_type, resource_id, created_at)", ); @@ -69,7 +102,10 @@ export async function recordSuggestionCreation( } export async function insertSuggestion( - input: Omit, + input: Omit< + ResourceSuggestion, + "id" | "revision" | "createdAt" | "updatedAt" + >, client = getDbExec(), ): Promise { await ensureSuggestionTables(client); @@ -97,7 +133,22 @@ export async function insertSuggestion( encode(input.metadata), ], }); - for (const operation of input.operations) + await insertSuggestionOperations(client, suggestionId, input.operations); + return { + ...input, + id: suggestionId, + revision: 1, + createdAt: now, + updatedAt: now, + }; +} + +async function insertSuggestionOperations( + client: DbExec, + suggestionId: string, + operations: SuggestionOperation[], +) { + for (const operation of operations) await client.execute({ sql: "INSERT INTO agent_review_suggestion_operations (id,suggestion_id,ordinal,operation_kind,target_id,before_json,after_json,anchor_json,dependencies_json,schema_version) VALUES (?,?,?,?,?,?,?,?,?,?)", args: [ @@ -113,7 +164,62 @@ export async function insertSuggestion( operation.schemaVersion, ], }); - return { ...input, id: suggestionId, createdAt: now, updatedAt: now }; +} + +export async function getSuggestionAmendment(client: DbExec, key: string) { + const row = ( + await client.execute({ + sql: "SELECT * FROM agent_review_suggestion_amendments WHERE idempotency_key = ?", + args: [key], + }) + ).rows[0]; + return row + ? { + suggestionId: String(row.suggestion_id), + request: String(row.request_json), + suggestion: decode(row.after_json), + } + : null; +} + +export async function amendSuggestion( + client: DbExec, + before: ResourceSuggestion, + operations: SuggestionOperation[], + summary: string, + idempotencyKey: string, + request: string, +): Promise { + const now = new Date().toISOString(); + const claimed = await client.execute({ + sql: "UPDATE agent_review_suggestions SET revision = revision + 1, summary = ?, updated_at = ? WHERE id = ? AND status = 'pending' AND revision = ?", + args: [summary, now, before.id, before.revision], + }); + if (claimed.rowsAffected !== 1) return null; + await client.execute({ + sql: "DELETE FROM agent_review_suggestion_operations WHERE suggestion_id = ?", + args: [before.id], + }); + await insertSuggestionOperations(client, before.id, operations); + const after = await getSuggestion(before.id, client); + if (!after) throw new Error("Amended suggestion disappeared"); + await client.execute({ + sql: "INSERT INTO agent_review_suggestion_amendments (idempotency_key,suggestion_id,revision,author_email,owner_email,org_id,visibility,request_json,before_json,after_json,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)", + args: [ + idempotencyKey, + before.id, + after.revision, + before.authorEmail, + before.ownerEmail, + before.orgId, + before.visibility, + request, + encode(before), + encode(after), + now, + ], + }); + return after; } export async function getSuggestion( @@ -136,6 +242,7 @@ export async function getSuggestion( ).rows; return { id: String(row.id), + revision: Number(row.revision), resourceType: String(row.resource_type), resourceId: String(row.resource_id), adapterKind: String(row.adapter_kind), @@ -282,10 +389,16 @@ export async function updateSuggestionStatus( client: DbExec, suggestionId: string, status: SuggestionStatus, + observedRevision?: number, ): Promise { const result = await client.execute({ - sql: "UPDATE agent_review_suggestions SET status = ?, updated_at = ? WHERE id = ? AND status = 'pending'", - args: [status, new Date().toISOString(), suggestionId], + sql: "UPDATE agent_review_suggestions SET status = ?, updated_at = ? WHERE id = ? AND status = 'pending' AND revision = ?", + args: [ + status, + new Date().toISOString(), + suggestionId, + observedRevision ?? 1, + ], }); return result.rowsAffected === 1; } diff --git a/packages/core/src/review/suggestions/types.ts b/packages/core/src/review/suggestions/types.ts index 414c4e4b61d..986e4528a8e 100644 --- a/packages/core/src/review/suggestions/types.ts +++ b/packages/core/src/review/suggestions/types.ts @@ -19,6 +19,7 @@ export interface SuggestionOperation { } export interface ResourceSuggestion { id: string; + revision: number; resourceType: string; resourceId: string; adapterKind: string; diff --git a/packages/core/src/server/action-discovery.ts b/packages/core/src/server/action-discovery.ts index c0d97b28e2a..de20ece0523 100644 --- a/packages/core/src/server/action-discovery.ts +++ b/packages/core/src/server/action-discovery.ts @@ -901,6 +901,11 @@ export async function mergeCoreSharingActions( () => import("../review/suggestions/actions/list-resource-suggestions.js"), ], + [ + "update-resource-suggestion", + () => + import("../review/suggestions/actions/update-resource-suggestion.js"), + ], [ "get-resource-suggestion", () => import("../review/suggestions/actions/get-resource-suggestion.js"), diff --git a/templates/content/app/components/editor/CommentComposer.test.tsx b/templates/content/app/components/editor/CommentComposer.test.tsx index 197d47654a4..03322781f60 100644 --- a/templates/content/app/components/editor/CommentComposer.test.tsx +++ b/templates/content/app/components/editor/CommentComposer.test.tsx @@ -47,5 +47,7 @@ describe("CommentComposer", () => { const textarea = render(false); expect(textarea.disabled).toBe(false); + expect(textarea.className).toContain("[field-sizing:content]"); + expect(textarea.className).toContain("max-h-48"); }); }); diff --git a/templates/content/app/components/editor/CommentComposer.tsx b/templates/content/app/components/editor/CommentComposer.tsx index 7bb859f393f..eff9c608944 100644 --- a/templates/content/app/components/editor/CommentComposer.tsx +++ b/templates/content/app/components/editor/CommentComposer.tsx @@ -175,10 +175,10 @@ export const CommentComposer = forwardRef< onBlur?.(); }} placeholder={placeholder} - className={ + className={`[field-sizing:content] max-h-48 ${ className ?? "w-full resize-none bg-transparent text-sm placeholder:text-muted-foreground focus:outline-none" - } + }`} /> {!disabled && menuOpen && (
diff --git a/templates/content/app/components/editor/CommentsSidebar.drafts.test.tsx b/templates/content/app/components/editor/CommentsSidebar.drafts.test.tsx new file mode 100644 index 00000000000..2f6e3fc2aa6 --- /dev/null +++ b/templates/content/app/components/editor/CommentsSidebar.drafts.test.tsx @@ -0,0 +1,682 @@ +// @vitest-environment happy-dom + +import type { ResourceSuggestion } from "@agent-native/core/review"; +import { act, useState, type ComponentProps, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vitest"; + +import { + CommentsSidebar, + suggestionTextForDisplay, + useCommentReplyDrafts, +} from "./CommentsSidebar"; +import type { DraftSuggestion } from "./suggestions/draft-session"; + +const { replyMutate } = vi.hoisted(() => ({ replyMutate: vi.fn() })); + +function suggestionFixture( + input: Pick< + ResourceSuggestion, + | "id" + | "threadId" + | "authorEmail" + | "actorKind" + | "createdAt" + | "status" + | "operations" + > & { revision: number }, +): ResourceSuggestion { + return { + resourceType: "document", + resourceId: "document-fixture", + adapterKind: "markdown", + adapterVersion: 1, + baseRevision: "fixture-revision", + summary: "", + ownerEmail: null, + orgId: null, + visibility: "private", + updatedAt: input.createdAt, + metadata: null, + ...input, + } as ResourceSuggestion; +} + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock("@agent-native/core/client/agent-chat", () => ({ + sendToAgentChat: vi.fn(), +})); +vi.mock("@agent-native/core/client/hooks", () => ({ + useAvatarUrl: () => null, +})); +vi.mock("@agent-native/core/client/i18n", () => ({ + useT: () => (key: string) => + ({ + "comments.suggestionAdd": "Add", + "comments.suggestionDelete": "Delete", + "comments.suggestionWith": "with", + "comments.suggestionReplace": "Replace", + })[key] ?? key, +})); +vi.mock("@agent-native/core/client/markdown", () => ({ + InlineMarkdown: ({ content }: { content: string }) => ( + <>{content.trimEnd()} + ), +})); +vi.mock("@agent-native/core/client/review", () => ({ + useReviewComments: ({ targetId }: { targetId: string }) => ({ + data: { + comments: /^(materialized|pending|accepted|rejected)-/.test(targetId) + ? [ + { + id: `root-${targetId}`, + threadId: `thread-${targetId}`, + parentCommentId: null, + status: + targetId.startsWith("accepted-") || + targetId.startsWith("rejected-") + ? "resolved" + : "open", + authorEmail: "reviewer@example.test", + authorName: "Reviewer", + createdAt: "2026-09-06T12:00:00.000Z", + createdBy: "reviewer@example.test", + body: "", + mentions: [], + }, + ...(targetId.startsWith("accepted-") || + targetId.startsWith("rejected-") + ? [ + { + id: `reply-${targetId}`, + threadId: `thread-${targetId}`, + parentCommentId: `root-${targetId}`, + status: "resolved", + authorEmail: "reviewer@example.test", + authorName: "Reviewer", + createdAt: "2026-09-06T12:01:00.000Z", + createdBy: "reviewer@example.test", + body: `existing reply ${targetId}`, + mentions: [], + }, + ] + : []), + ] + : [], + }, + isLoading: false, + }), + useReplyReviewComment: () => ({ + mutate: replyMutate, + isPending: false, + error: null, + }), +})); +vi.mock("@/hooks/use-comments", () => ({ + useCreateComment: () => ({ mutate: vi.fn(), isPending: false }), + useResolveComment: () => ({ mutate: vi.fn() }), +})); +vi.mock("@/hooks/use-mention-members", () => ({ + useMentionMembers: () => ({ data: [] }), +})); +vi.mock("@/components/ui/avatar", () => ({ + Avatar: ({ children }: { children: ReactNode }) =>
{children}
, + AvatarFallback: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + AvatarImage: () => null, +})); +vi.mock("@/components/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, +})); +vi.mock("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => ( + <>{children} + ), + DropdownMenuContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + DropdownMenuGroup: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + DropdownMenuLabel: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + DropdownMenuSeparator: () =>
, + DropdownMenuCheckboxItem: ({ + children, + onCheckedChange, + }: ComponentProps<"button"> & { + onCheckedChange?: (checked: boolean) => void; + }) => , +})); +vi.mock("./CommentComposer", async () => { + const { forwardRef } = await import("react"); + return { + CommentComposer: forwardRef< + HTMLTextAreaElement, + { value: string; placeholder?: string } + >(({ value, placeholder }, ref) => ( +