From 1b8549a454fb068ee9822d34181fbed7d25580dc Mon Sep 17 00:00:00 2001 From: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:30:00 +0530 Subject: [PATCH 1/2] feat: let a review card update an existing page in place Signed-off-by: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> --- src/client/page-review-decision.ts | 6 +- src/server/pages.ts | 96 ++++++++++++++++++++---------- src/shared/page-review.ts | 4 +- tests/pages.test.ts | 93 +++++++++++++++++++++++++++++ tests/tanstack-agent.test.ts | 5 +- 5 files changed, 170 insertions(+), 34 deletions(-) diff --git a/src/client/page-review-decision.ts b/src/client/page-review-decision.ts index af32cb3e..cf78ef28 100644 --- a/src/client/page-review-decision.ts +++ b/src/client/page-review-decision.ts @@ -19,7 +19,11 @@ export function matchesReviewedDraft(page: ReviewedPage, args: unknown) { draft.success && draft.data.title === page.reviewDraft.title && draft.data.content === page.reviewDraft.content && - draft.data.spaceId === page.reviewDraft.spaceId + draft.data.spaceId === page.reviewDraft.spaceId && + (draft.data.pageId ?? undefined) === + (page.reviewDraft.pageId ?? undefined) && + (draft.data.expectedRevision ?? undefined) === + (page.reviewDraft.expectedRevision ?? undefined) ); } diff --git a/src/server/pages.ts b/src/server/pages.ts index ebe04642..b7a4c546 100644 --- a/src/server/pages.ts +++ b/src/server/pages.ts @@ -149,11 +149,21 @@ export class Pages { } createReviewed( spaceId: string, - input: Pick, + input: Pick< + PageReviewDraft, + 'title' | 'content' | 'pageId' | 'expectedRevision' + >, threadId: string, toolCallId: string, ): ReviewedPage { - const draft = pageReviewSchema.parse({ ...input, spaceId }); + // Strict tool schemas send omitted optional fields as null. + const parsed = pageReviewSchema.parse({ ...input, spaceId }); + const { pageId, expectedRevision, ...rest } = parsed; + const draft: PageReviewDraft = { + ...rest, + ...(pageId ? { pageId } : {}), + ...(expectedRevision ? { expectedRevision } : {}), + }; this.db.exec('BEGIN IMMEDIATE'); try { const previous = this.reviewReceipt(threadId, toolCallId); @@ -166,7 +176,9 @@ export class Pages { if ( previous.draft && (previous.draft.title !== draft.title || - previous.draft.content !== draft.content) + previous.draft.content !== draft.content || + previous.draft.pageId !== draft.pageId || + previous.draft.expectedRevision !== draft.expectedRevision) ) throw new PageError( 'This review was already saved with a different draft. Start a new review for the changed draft.', @@ -176,11 +188,27 @@ export class Pages { this.db.exec('COMMIT'); return { ...page, reviewDraft: previous.draft }; } - const page = this.create( - spaceId, - { title: draft.title, content: draft.content }, - threadId, - ); + if (draft.pageId && draft.expectedRevision === undefined) + throw new PageError( + 'Revising an existing page needs the revision the draft was based on.', + 400, + ); + if (!draft.pageId && draft.expectedRevision !== undefined) + throw new PageError( + 'A revision can only be given together with the page to revise.', + 400, + ); + const page = draft.pageId + ? this.applyUpdate(spaceId, draft.pageId, { + title: draft.title, + content: draft.content, + expectedRevision: draft.expectedRevision!, + }) + : this.create( + spaceId, + { title: draft.title, content: draft.content }, + threadId, + ); this.db .prepare( 'INSERT INTO page_reviews (threadId,toolCallId,pageId,spaceId,draft) VALUES (?,?,?,?,?)', @@ -199,37 +227,43 @@ export class Pages { throw new PageError( 'A valid page patch and expectedRevision are required.', ); - const data = parsed.data; this.db.exec('BEGIN IMMEDIATE'); try { - const page = this.get(spaceId, id); - if (page.revision !== data.expectedRevision) - throw new PageError( - 'This page changed. Reload the latest revision before saving your draft.', - 409, - ); - const parent = - data.parentId === undefined ? page.parentId : data.parentId; - this.parent(spaceId, parent, id); - this.db - .prepare( - 'UPDATE pages SET title=?,content=?,parentId=?,revision=revision+1,updatedAt=? WHERE id=? AND revision=?', - ) - .run( - data.title ?? page.title, - data.content ?? page.content, - parent, - Date.now(), - id, - data.expectedRevision, - ); + const page = this.applyUpdate(spaceId, id, parsed.data); this.db.exec('COMMIT'); - return this.get(spaceId, id); + return page; } catch (error) { this.db.exec('ROLLBACK'); throw error; } } + private applyUpdate( + spaceId: string, + id: string, + data: z.output, + ): Page { + const page = this.get(spaceId, id); + if (page.revision !== data.expectedRevision) + throw new PageError( + 'This page changed. Reload the latest revision before saving your draft.', + 409, + ); + const parent = data.parentId === undefined ? page.parentId : data.parentId; + this.parent(spaceId, parent, id); + this.db + .prepare( + 'UPDATE pages SET title=?,content=?,parentId=?,revision=revision+1,updatedAt=? WHERE id=? AND revision=?', + ) + .run( + data.title ?? page.title, + data.content ?? page.content, + parent, + Date.now(), + id, + data.expectedRevision, + ); + return this.get(spaceId, id); + } thread(pageId: string, dotId: string) { const row = this.db .prepare( diff --git a/src/shared/page-review.ts b/src/shared/page-review.ts index c33f5094..03fad91a 100644 --- a/src/shared/page-review.ts +++ b/src/shared/page-review.ts @@ -4,12 +4,14 @@ export const pageReviewSchema = z title: z.string().trim().min(1).max(160), content: z.string().min(1).max(20000), spaceId: z.string().min(1), + pageId: z.string().min(1).nullish(), + expectedRevision: z.number().int().positive().nullish(), }) .strict(); export type PageReviewDraft = z.infer; export const pageReviewTool = { name: 'review_space_page', description: - 'Present a Markdown draft for human review before saving it into an authorized Space. The user can approve and save, or decline. Do not create the page yourself after this tool: its approved result includes the saved page URL. Call once, then wait for the review result.', + 'Present a Markdown draft for human review before saving it into an authorized Space. To revise an existing page instead of creating a new one, pass its pageId and the expectedRevision from read_space_page; approving then updates that page in place. The user can approve and save, or decline. Do not create the page yourself after this tool: its approved result includes the saved page URL. Call once, then wait for the review result.', parameters: z.toJSONSchema(pageReviewSchema), }; diff --git a/tests/pages.test.ts b/tests/pages.test.ts index fd2b9707..79810b95 100644 --- a/tests/pages.test.ts +++ b/tests/pages.test.ts @@ -108,3 +108,96 @@ it('migrates review receipts and retains their original draft after restart', () store.close(); rmSync(dir, { recursive: true }); }); +it('applies an approved review to the page it names instead of creating a copy', () => { + const dir = mkdtempSync(join(tmpdir(), 'dots-review-update-')); + const store = new WorkspaceStore(join(dir, 'db'), 'owner'); + const space = store.spaces()[0].id; + const original = store.pages.create(space, { + title: 'Plan', + content: 'First version', + }); + const before = store.pages.list(space).length; + const draft = { + title: 'Plan', + content: 'Revised version', + pageId: original.id, + expectedRevision: original.revision, + }; + const saved = store.pages.createReviewed(space, draft, 'thread', 'call'); + expect(saved.id).toBe(original.id); + expect(saved).toMatchObject({ content: 'Revised version', revision: 2 }); + expect(store.pages.list(space)).toHaveLength(before); + // Replaying the same approval is idempotent and does not bump the revision. + expect( + store.pages.createReviewed(space, draft, 'thread', 'call').revision, + ).toBe(2); + expect(() => + store.pages.createReviewed( + space, + { ...draft, content: 'Another draft' }, + 'thread', + 'call', + ), + ).toThrow('different draft'); + store.close(); + rmSync(dir, { recursive: true }); +}); +it('refuses to revise a page that changed after the review was drafted', () => { + const dir = mkdtempSync(join(tmpdir(), 'dots-review-stale-')); + const store = new WorkspaceStore(join(dir, 'db'), 'owner'); + const space = store.spaces()[0].id; + const page = store.pages.create(space, { title: 'Plan', content: 'v1' }); + store.pages.update(space, page.id, { + expectedRevision: 1, + content: 'edited by the owner', + }); + expect(() => + store.pages.createReviewed( + space, + { + title: 'Plan', + content: 'stale draft', + pageId: page.id, + expectedRevision: 1, + }, + 'thread', + 'call', + ), + ).toThrow('This page changed'); + expect(store.pages.get(space, page.id).content).toBe('edited by the owner'); + expect(store.pages.reviewReceipt('thread', 'call')).toBeNull(); + expect(() => + store.pages.createReviewed( + space, + { title: 'Plan', content: 'no revision', pageId: page.id }, + 'thread', + 'call2', + ), + ).toThrow('needs the revision'); + store.close(); + rmSync(dir, { recursive: true }); +}); +it('treats null pageId and expectedRevision from a strict tool call as a new page', () => { + const dir = mkdtempSync(join(tmpdir(), 'dots-review-null-')); + const store = new WorkspaceStore(join(dir, 'db'), 'owner'); + const space = store.spaces()[0].id; + const before = store.pages.list(space).length; + const saved = store.pages.createReviewed( + space, + { title: 'New', content: 'Body', pageId: null, expectedRevision: null }, + 'thread', + 'call', + ); + expect(saved).toMatchObject({ title: 'New', revision: 1 }); + expect(store.pages.list(space)).toHaveLength(before + 1); + expect( + store.pages.createReviewed( + space, + { title: 'New', content: 'Body', pageId: null, expectedRevision: null }, + 'thread', + 'call', + ).id, + ).toBe(saved.id); + store.close(); + rmSync(dir, { recursive: true }); +}); diff --git a/tests/tanstack-agent.test.ts b/tests/tanstack-agent.test.ts index c3c8c737..f8446296 100644 --- a/tests/tanstack-agent.test.ts +++ b/tests/tanstack-agent.test.ts @@ -184,7 +184,10 @@ it('offers the canonical review tool and waits for the client without saving a p expect(request.tools).toContainEqual( expect.objectContaining({ type: 'function', - function: expect.objectContaining(pageReviewTool), + function: expect.objectContaining({ + name: pageReviewTool.name, + description: pageReviewTool.description, + }), }), ); expect(JSON.stringify(request)).not.toContain('forged instructions'); From a64b11860fe82fced8441091da329300a760c70e Mon Sep 17 00:00:00 2001 From: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> Date: Wed, 7 Oct 2026 03:23:40 +0530 Subject: [PATCH 2/2] feat(client): identify the overwrite target on the review approval card Fetch and show the existing page title and space an update would overwrite, label the approval action update-vs-create, and cover distinct targets so identical drafts can no longer render identical cards. Signed-off-by: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> --- src/client/PageReviewCard.tsx | 38 +++++++++++++- src/client/page-review-decision.ts | 32 +++++++++++- src/client/style.css | 9 ++++ tests/page-review.test.tsx | 83 +++++++++++++++++++++++++++++- 4 files changed, 158 insertions(+), 4 deletions(-) diff --git a/src/client/PageReviewCard.tsx b/src/client/PageReviewCard.tsx index f7fa12c8..02fa1c0b 100644 --- a/src/client/PageReviewCard.tsx +++ b/src/client/PageReviewCard.tsx @@ -5,11 +5,17 @@ import remarkGfm from 'remark-gfm'; import { pageReviewSchema } from '../shared/page-review'; import { decidePageReview, + fetchReviewTarget, matchesReviewedDraft, restorePageReview, } from './page-review-decision'; +import type { ReviewTarget } from './page-review-decision'; import { openPageLink } from './page-navigation'; import type { ReviewedPage } from '../server/pages'; +export function approveLabel(isUpdate: boolean) { + return isUpdate ? 'Approve & update page' : 'Approve & create page'; +} + export function PageReviewCard({ args, status, @@ -32,12 +38,33 @@ export function PageReviewCard({ const [busy, setBusy] = useState(false); const [receiptReady, setReceiptReady] = useState(false); const [restoreAttempt, setRestoreAttempt] = useState(0); + const [target, setTarget] = useState(null); const pending = useRef(false); const finished = status === 'complete'; const conflict = !!savedPage && !matchesReviewedDraft(savedPage, args); const saved = !!savedPage && !conflict; const pageId = savedPage?.id ?? ''; const spaceId = savedPage?.spaceId ?? ''; + const targetPageId = draft.success ? (draft.data.pageId ?? null) : null; + const targetSpaceId = draft.success ? draft.data.spaceId : null; + useEffect(() => { + if (!targetPageId || !targetSpaceId) { + setTarget(null); + return; + } + let active = true; + setTarget(null); + void fetchReviewTarget(targetSpaceId, targetPageId) + .then((found) => { + if (active) setTarget(found); + }) + .catch(() => { + if (active) setTarget(null); + }); + return () => { + active = false; + }; + }, [targetPageId, targetSpaceId]); useEffect(() => { let active = true; setReceiptReady(false); @@ -123,6 +150,15 @@ export function PageReviewCard({
+ {targetPageId && ( +

+ {target + ? `Updates existing page "${target.title}"${ + target.spaceName ? ` in ${target.spaceName}` : '' + }.` + : 'Updates an existing page.'} +

+ )}

{draft.success ? draft.data.title : 'Preparing your draft…'}

{draft.success && ( {!saved && (