From 83ca6c850fe66ffa2d1706f69812c70b4b27f2ee Mon Sep 17 00:00:00 2001 From: Pioneer113 Date: Sun, 4 Oct 2026 01:05:38 +0300 Subject: [PATCH 1/5] feat(pages): add page deletion with safe subpage reparenting --- src/client/PageDocument.tsx | 25 +++++++++++++++++++++++++ src/server/page-routes.ts | 9 +++++++++ src/server/page-tools.ts | 2 ++ src/server/pages.ts | 29 +++++++++++++++++++++++++++++ tests/page-routes.test.ts | 25 +++++++++++++++++++++++++ tests/pages.test.ts | 32 ++++++++++++++++++++++++++++++++ 6 files changed, 122 insertions(+) diff --git a/src/client/PageDocument.tsx b/src/client/PageDocument.tsx index 164b3bf..e6a677b 100644 --- a/src/client/PageDocument.tsx +++ b/src/client/PageDocument.tsx @@ -200,6 +200,31 @@ export function PageDocument({ }, ] : []), + { + label: 'Delete page', + action: async () => { + if ( + !window.confirm( + `Delete "${draft.title || 'Untitled'}"? Any subpages will move to this page's parent.`, + ) + ) + return; + try { + await api( + `/spaces/${page.spaceId}/pages/${page.id}`, + 'DELETE', + ); + onRefresh(); + onHome(); + } catch (error) { + setNotice( + error instanceof Error + ? error.message + : 'Could not delete page.', + ); + } + }, + }, ]} /> diff --git a/src/server/page-routes.ts b/src/server/page-routes.ts index c496367..fad4b07 100644 --- a/src/server/page-routes.ts +++ b/src/server/page-routes.ts @@ -92,6 +92,15 @@ export function pageRoutes(platform: Platform) { ), ); }); + app.delete('/spaces/:spaceId/pages/:id', (c) => { + const deleted = platform.workspace.pages.delete( + c.req.param('spaceId'), + c.req.param('id'), + ); + if (!deleted) + return c.json({ error: 'Page not found in this Space.' }, 404); + return c.json({ ok: true }); + }); app.post('/spaces/:spaceId/pages/:id/conversation', async (c) => { const data = z .object({ dotId: z.string().min(1) }) diff --git a/src/server/page-tools.ts b/src/server/page-tools.ts index 6336a73..3fc9398 100644 --- a/src/server/page-tools.ts +++ b/src/server/page-tools.ts @@ -41,6 +41,8 @@ export function pageAccess( linked(workspace.pages.create(resolve(requested), input)), edit: (id: string, input: z.input, requested?: string) => linked(workspace.pages.update(resolve(requested), id, input)), + delete: (id: string, requested?: string) => + workspace.pages.delete(resolve(requested), id), }; } diff --git a/src/server/pages.ts b/src/server/pages.ts index 128f756..a600d5e 100644 --- a/src/server/pages.ts +++ b/src/server/pages.ts @@ -244,4 +244,33 @@ export class Pages { ? this.get(spaceId ?? String(row.spaceId), String(row.pageId)) : undefined; } + delete(spaceId: string, id: string): boolean { + this.requireSpace(spaceId); + this.db.exec('BEGIN IMMEDIATE'); + try { + const page = this.db + .prepare('SELECT parentId FROM pages WHERE id=? AND spaceId=?') + .get(id, spaceId) as { parentId: string | null } | undefined; + if (!page) { + this.db.exec('COMMIT'); + return false; + } + const now = Date.now(); + this.db + .prepare( + 'UPDATE pages SET parentId=?, updatedAt=? WHERE spaceId=? AND parentId=?', + ) + .run(page.parentId, now, spaceId, id); + this.db.prepare('DELETE FROM page_reviews WHERE pageId=?').run(id); + this.db.prepare('DELETE FROM page_threads WHERE pageId=?').run(id); + this.db + .prepare('DELETE FROM pages WHERE id=? AND spaceId=?') + .run(id, spaceId); + this.db.exec('COMMIT'); + return true; + } catch (error) { + this.db.exec('ROLLBACK'); + throw error; + } + } } diff --git a/tests/page-routes.test.ts b/tests/page-routes.test.ts index 9b0f8fe..1f1d7a8 100644 --- a/tests/page-routes.test.ts +++ b/tests/page-routes.test.ts @@ -221,3 +221,28 @@ it('restores review receipts through the owner API with current thread and Space ws.updateDot(dot.id, { ...dot, spaceId: other.id, spaceIds: [other.id] }); expect((await app.request(`${base}/call`, { headers })).status).toBe(403); }); + +it('deletes pages through the API and returns 404 for missing pages or spaces', async () => { + const { ws, app } = fixture(); + const space = ws.spaces()[0].id; + const page = ws.pages.create(space, { title: 'To Delete' }); + const path = `/api/spaces/${space}/pages/${page.id}`; + + const res = await app.request(path, request(undefined, 'DELETE')); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + expect(ws.pages.list(space)).toHaveLength(0); + + const missing = await app.request(path, request(undefined, 'DELETE')); + expect(missing.status).toBe(404); + expect(await missing.json()).toEqual({ + error: 'Page not found in this Space.', + }); + + const missingSpace = await app.request( + `/api/spaces/missing-space/pages/${page.id}`, + request(undefined, 'DELETE'), + ); + expect(missingSpace.status).toBe(404); + expect(await missingSpace.json()).toEqual({ error: 'Space not found.' }); +}); diff --git a/tests/pages.test.ts b/tests/pages.test.ts index 4dd83d2..846206b 100644 --- a/tests/pages.test.ts +++ b/tests/pages.test.ts @@ -51,3 +51,35 @@ it('rejects cross-space parents, cycles and stale writes without losing content' expect(() => store.pages.get(b, root.id)).toThrow(); store.close(); }); + +it('deletes a page, cleans up threads and reviews, and reparents descendants safely', () => { + const store = new WorkspaceStore(':memory:', 'owner'); + const space = store.spaces()[0].id; + const root = store.pages.create(space, { title: 'Root' }); + const child = store.pages.create(space, { + title: 'Child', + parentId: root.id, + }); + const grandChild = store.pages.create(space, { + title: 'Grandchild', + parentId: child.id, + }); + + // Verify deletion of child: grandChild should be reparented to root + expect(store.pages.delete(space, child.id)).toBe(true); + expect(store.pages.delete(space, child.id)).toBe(false); + expect(() => store.pages.get(space, child.id)).toThrow(); + expect(store.pages.get(space, grandChild.id).parentId).toBe(root.id); + + // Verify deletion of root: grandChild should be reparented to null (root level) + expect(store.pages.delete(space, root.id)).toBe(true); + expect(store.pages.get(space, grandChild.id).parentId).toBeNull(); + expect(store.pages.list(space)).toHaveLength(1); + expect(store.pages.list(space)[0].id).toBe(grandChild.id); + + // Missing space throws + expect(() => store.pages.delete('non-existent-space', grandChild.id)).toThrow( + /Space not found/, + ); + store.close(); +}); From 4c11ce6d8f639c4201aa28bc64f27611649165c3 Mon Sep 17 00:00:00 2001 From: Pioneer113 Date: Sun, 4 Oct 2026 01:16:43 +0300 Subject: [PATCH 2/5] fix(pages): keep review receipts on delete, drop unused delete tool, cover cleanup in tests --- src/server/page-tools.ts | 2 -- src/server/pages.ts | 2 +- tests/pages.test.ts | 35 ++++++++++++++++++++++++++++++++++- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/server/page-tools.ts b/src/server/page-tools.ts index 3fc9398..6336a73 100644 --- a/src/server/page-tools.ts +++ b/src/server/page-tools.ts @@ -41,8 +41,6 @@ export function pageAccess( linked(workspace.pages.create(resolve(requested), input)), edit: (id: string, input: z.input, requested?: string) => linked(workspace.pages.update(resolve(requested), id, input)), - delete: (id: string, requested?: string) => - workspace.pages.delete(resolve(requested), id), }; } diff --git a/src/server/pages.ts b/src/server/pages.ts index a600d5e..57b23ce 100644 --- a/src/server/pages.ts +++ b/src/server/pages.ts @@ -261,7 +261,7 @@ export class Pages { 'UPDATE pages SET parentId=?, updatedAt=? WHERE spaceId=? AND parentId=?', ) .run(page.parentId, now, spaceId, id); - this.db.prepare('DELETE FROM page_reviews WHERE pageId=?').run(id); + // page_reviews rows stay: a retried approval must not recreate this page. this.db.prepare('DELETE FROM page_threads WHERE pageId=?').run(id); this.db .prepare('DELETE FROM pages WHERE id=? AND spaceId=?') diff --git a/tests/pages.test.ts b/tests/pages.test.ts index 846206b..0f5f720 100644 --- a/tests/pages.test.ts +++ b/tests/pages.test.ts @@ -52,7 +52,7 @@ it('rejects cross-space parents, cycles and stale writes without losing content' store.close(); }); -it('deletes a page, cleans up threads and reviews, and reparents descendants safely', () => { +it('deletes a page and reparents descendants safely', () => { const store = new WorkspaceStore(':memory:', 'owner'); const space = store.spaces()[0].id; const root = store.pages.create(space, { title: 'Root' }); @@ -83,3 +83,36 @@ it('deletes a page, cleans up threads and reviews, and reparents descendants saf ); store.close(); }); + +it('drops page thread bindings on delete and never recreates a page from a retried review', () => { + const store = new WorkspaceStore(':memory:', 'owner'); + const space = store.spaces()[0].id; + const page = store.pages.create(space, { title: 'Draft' }); + store.pages.reserveThread(page.id, 'dot', 'thread-1'); + store.pages.finishThread(page.id, 'dot'); + expect(store.pages.thread(page.id, 'dot')).toBeDefined(); + expect(store.pages.delete(space, page.id)).toBe(true); + expect(store.pages.thread(page.id, 'dot')).toBeUndefined(); + + const reviewed = store.pages.createReviewed( + space, + { title: 'Reviewed' }, + 'review-thread', + 'call-1', + ); + expect(store.pages.delete(space, reviewed.id)).toBe(true); + expect(store.pages.reviewReceipt('review-thread', 'call-1')).toEqual({ + pageId: reviewed.id, + spaceId: space, + }); + expect(() => + store.pages.createReviewed( + space, + { title: 'Reviewed' }, + 'review-thread', + 'call-1', + ), + ).toThrow(/Page not found/); + expect(store.pages.list(space)).toHaveLength(0); + store.close(); +}); From dc60b91fcf5737182b77b2664ec0bfb93c121c83 Mon Sep 17 00:00:00 2001 From: Pioneer113 Date: Sun, 4 Oct 2026 01:25:38 +0300 Subject: [PATCH 3/5] fix(pages): handle deleted reviewed pages, avoid stale draft prompt, document deletion --- docs/SETUP.md | 2 +- src/client/PageDocument.tsx | 4 +++ src/client/PageReviewCard.tsx | 43 ++++++++++++++++++++++-------- src/client/page-review-decision.ts | 10 +++++-- src/server/page-routes.ts | 8 +++--- src/server/pages.ts | 6 +++++ tests/page-review.test.tsx | 18 ++++++++++++- tests/page-routes.test.ts | 33 +++++++++++++++++++++++ 8 files changed, 106 insertions(+), 18 deletions(-) diff --git a/docs/SETUP.md b/docs/SETUP.md index 077c0ac..00b69ec 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -44,7 +44,7 @@ The model environment variable names follow the configured provider adapter. Pro Select a Space to open its page library. Search for a document, switch between grid and list views, or create a new page. The visual editor supports formatting, headings, lists, checklists, tables, and slash commands. Use `/` to insert a block and Cmd/Ctrl+S to save immediately. Pages autosave after editing pauses; the save status tells you whether changes reached the server. -Page actions include creating subpages, moving a page within its Space, and editing Markdown source. Existing documents with unsupported visual-editor syntax stay in source mode to preserve their content. Manual editing works without conversation credentials. +Page actions include creating subpages, moving a page within its Space, deleting a page (its subpages move up to the deleted page's parent), and editing Markdown source. Existing documents with unsupported visual-editor syntax stay in source mode to preserve their content. Manual editing works without conversation credentials. Open a page's chat and choose a specialist with access to that Space. Grant access from the Dot’s settings in the sidebar. The server creates or reuses a CopilotKit Thread for that page and specialist. The Dot receives the current saved page as context and can read, create, and edit pages in its authorized Spaces. The page conversation uses that page’s Space by default; other chats use the Dot’s default page destination. Save your manual edits before asking it to revise the document. Revision checks reject stale writes; a conflict keeps your local draft available for recovery. Failed saves stop automatic retries until you retry or resolve the conflict, so a disconnected session does not silently replace newer content. diff --git a/src/client/PageDocument.tsx b/src/client/PageDocument.tsx index e6a677b..10b80ef 100644 --- a/src/client/PageDocument.tsx +++ b/src/client/PageDocument.tsx @@ -214,6 +214,10 @@ export function PageDocument({ `/spaces/${page.spaceId}/pages/${page.id}`, 'DELETE', ); + // Drop pending autosave so navigation is not blocked by the + // unsaved-draft prompt for a page that no longer exists. + controller.dispose(); + onDirty(false); onRefresh(); onHome(); } catch (error) { diff --git a/src/client/PageReviewCard.tsx b/src/client/PageReviewCard.tsx index 9962ce4..626f5c1 100644 --- a/src/client/PageReviewCard.tsx +++ b/src/client/PageReviewCard.tsx @@ -2,7 +2,12 @@ import { useEffect, useRef, useState } from 'react'; import { Check, FileText, ArrowUpRight } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import { pageReviewSchema } from '../shared/page-review'; -import { decidePageReview, restorePageReview } from './page-review-decision'; +import { + decidePageReview, + isDeletedReview, + restorePageReview, + type DeletedReview, +} from './page-review-decision'; import { computerToolResult } from './ComputerToolCard'; import { openPageLink } from './page-navigation'; import type { Page } from '../server/pages'; @@ -26,6 +31,7 @@ export function PageReviewCard({ const draft = pageReviewSchema.safeParse(args); const outcome = computerToolResult(result); const [savedPage, setSavedPage] = useState(); + const [deletedReview, setDeletedReview] = useState(); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); const [receiptReady, setReceiptReady] = useState(false); @@ -33,7 +39,7 @@ export function PageReviewCard({ const pending = useRef(false); const finished = status === 'complete'; const recordedApproval = outcome.approved === true; - const saved = !!savedPage || recordedApproval; + const saved = !!savedPage || recordedApproval || !!deletedReview; const pageId = savedPage?.id ?? (typeof outcome.pageId === 'string' ? outcome.pageId : ''); const spaceId = @@ -47,7 +53,8 @@ export function PageReviewCard({ void restorePageReview(threadId, toolCallId) .then((page) => { if (!active) return; - setSavedPage(page ?? undefined); + if (isDeletedReview(page)) setDeletedReview(page); + else setSavedPage(page ?? undefined); setReceiptReady(true); }) .catch((cause) => { @@ -76,6 +83,18 @@ export function PageReviewCard({ }); return; } + if (isDeletedReview(page)) { + setDeletedReview(page); + await respond({ + approved: true, + pageId: page.pageId, + spaceId: page.spaceId, + deleted: true, + message: + 'The draft was saved, then the owner deleted the page. Do not link it.', + }); + return; + } setSavedPage(page); onSaved(); await respond({ @@ -101,13 +120,15 @@ export function PageReviewCard({
- {saved - ? 'Saved to your Space' - : !receiptReady - ? 'Checking saved review…' - : finished - ? 'Review ended' - : 'Ready for your review'} + {deletedReview + ? 'Saved, then deleted' + : saved + ? 'Saved to your Space' + : !receiptReady + ? 'Checking saved review…' + : finished + ? 'Review ended' + : 'Ready for your review'} {saved @@ -146,7 +167,7 @@ export function PageReviewCard({ )}