Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,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.

Expand Down
43 changes: 43 additions & 0 deletions src/client/PageDocument.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
Expand Down Expand Up @@ -32,6 +33,7 @@ export function PageDocument({
onDirty,
onSaved,
onRefresh,
onDeleted,
onSchedule,
onThread,
onSettings,
Expand All @@ -47,6 +49,7 @@ export function PageDocument({
onDirty: (value: boolean) => void;
onSaved: (page: Page) => void;
onRefresh: () => void;
onDeleted: (id: string) => void;
onSchedule: (id: string) => void;
onThread: (id: string) => void;
onSettings: () => void;
Expand All @@ -57,6 +60,13 @@ export function PageDocument({
const [source, setSource] = useState(false);
const [move, setMove] = useState(false);
const [notice, setNotice] = useState('');
const open = useRef(true);
useEffect(() => {
open.current = true;
return () => {
open.current = false;
};
}, []);
const [chatOpen, setChatOpen] = useState(false);
const safety = useMemo(() => inspectMarkdown(draft.content), [draft.content]);
const sourceMode = source || !safety.supported;
Expand Down Expand Up @@ -200,6 +210,39 @@ export function PageDocument({
},
]
: []),
{
label: 'Delete page',
action: async () => {
if (
!window.confirm(
`Delete "${draft.title || 'Untitled'}"? This can't be undone. Any subpages will move to this page's parent.`,
)
)
return;
try {
await api(
`/spaces/${page.spaceId}/pages/${page.id}`,
'DELETE',
);
onDeleted(page.id);
onRefresh();
// A slow DELETE can finish after the user opened another
// page: only leave, and drop the pending autosave, if this
// document is still the one on screen.
if (open.current) {
controller.dispose();
onDirty(false);
onHome();
}
} catch (error) {
setNotice(
error instanceof Error
? error.message
: 'Could not delete page.',
);
}
},
},
]}
/>
</header>
Expand Down
42 changes: 32 additions & 10 deletions src/client/PageReviewCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import remarkGfm from 'remark-gfm';
import { pageReviewSchema } from '../shared/page-review';
import {
decidePageReview,
isDeletedReview,
matchesReviewedDraft,
restorePageReview,
type DeletedReview,
} from './page-review-decision';
import { openPageLink } from './page-navigation';
import type { ReviewedPage } from '../server/pages';
Expand All @@ -28,25 +30,30 @@ export function PageReviewCard({
}) {
const draft = pageReviewSchema.safeParse(args);
const [savedPage, setSavedPage] = useState<ReviewedPage>();
const [deletedReview, setDeletedReview] = useState<DeletedReview>();
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const [receiptReady, setReceiptReady] = useState(false);
const [restoreAttempt, setRestoreAttempt] = useState(0);
const pending = useRef(false);
const finished = status === 'complete';
const conflict = !!savedPage && !matchesReviewedDraft(savedPage, args);
const saved = !!savedPage && !conflict;
const reviewed = savedPage ?? deletedReview;
const conflict = !!reviewed && !matchesReviewedDraft(reviewed, args);
const removed = !!deletedReview && !conflict;
const saved = (!!savedPage || removed) && !conflict;
const pageId = savedPage?.id ?? '';
const spaceId = savedPage?.spaceId ?? '';
useEffect(() => {
let active = true;
setReceiptReady(false);
setSavedPage(undefined);
setDeletedReview(undefined);
setError('');
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) => {
Expand Down Expand Up @@ -75,6 +82,19 @@ export function PageReviewCard({
});
return;
}
if (isDeletedReview(page)) {
setSavedPage(undefined);
setDeletedReview(page);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Clear savedPage when a refreshed receipt reports deletion. If this card already has a saved receipt and Continue conversation returns deleted:true, setDeletedReview leaves savedPage populated. The card then says Saved, then deleted while still offering Open page with a dead URL. Clear the saved-page state when accepting the tombstone.

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({
Expand Down Expand Up @@ -102,13 +122,15 @@ export function PageReviewCard({
<strong>
{conflict
? 'Review changed'
: saved
? 'Saved to your Space'
: !receiptReady
? 'Checking saved review…'
: finished
? 'Review ended'
: 'Ready for your review'}
: removed
? 'Saved, then deleted'
: saved
? 'Saved to your Space'
: !receiptReady
? 'Checking saved review…'
: finished
? 'Review ended'
: 'Ready for your review'}
</strong>
<span>
{conflict
Expand Down
18 changes: 16 additions & 2 deletions src/client/SpaceWorkspace.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { mergePageSnapshot } from './page-snapshots';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { Page } from '../server/pages';
import type { Space, WorkspaceState } from '../shared/types';
import { api } from './api';
Expand Down Expand Up @@ -32,6 +32,7 @@ export function SpaceWorkspace({
onCreateDot: () => void;
}) {
const [pages, setPages] = useState<Page[]>([]);
const removed = useRef(new Set<string>());
const [error, setError] = useState('');
const [loaded, setLoaded] = useState(false);
const [outline, setOutline] = useState(false);
Expand All @@ -41,7 +42,9 @@ export function SpaceWorkspace({
try {
const next = await api<Page[]>(`/spaces/${space.id}/pages`);
if (active) {
setPages((previous) => mergePageSnapshot(previous, next));
setPages((previous) =>
mergePageSnapshot(previous, next, removed.current),
);
setLoaded(true);
setError('');
}
Expand All @@ -67,6 +70,16 @@ export function SpaceWorkspace({
),
[],
);
const deleted = useCallback((id: string) => {
removed.current.add(id);
setPages((previous) => {
const parentId =
previous.find((item) => item.id === id)?.parentId ?? null;
return previous
.filter((item) => item.id !== id)
.map((item) => (item.parentId === id ? { ...item, parentId } : item));
});
}, []);
const create = async (parentId: string | null) => {
try {
const next = await api<Page>(`/spaces/${space.id}/pages`, 'POST', {
Expand Down Expand Up @@ -122,6 +135,7 @@ export function SpaceWorkspace({
onSubpage={() => void create(page.id)}
onDirty={onDirty}
onSaved={saved}
onDeleted={deleted}
onRefresh={onRefresh}
onSchedule={onSchedule}
onThread={onThread}
Expand Down
22 changes: 18 additions & 4 deletions src/client/page-review-decision.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,31 @@
import { pageReviewSchema } from '../shared/page-review';
import { pageReviewSchema, type PageReviewDraft } from '../shared/page-review';
import type { ReviewedPage } from '../server/pages';
import { api } from './api';

const reviewPath = (threadId: string) =>
`/conversations/${encodeURIComponent(threadId)}/reviewed-page`;

export type DeletedReview = {
deleted: true;
pageId: string;
spaceId: string;
reviewDraft: PageReviewDraft | null;
};
export const isDeletedReview = (value: unknown): value is DeletedReview =>
!!value &&
typeof value === 'object' &&
(value as DeletedReview).deleted === true;

export function restorePageReview(threadId: string, toolCallId: string) {
return api<ReviewedPage | null>(
return api<ReviewedPage | DeletedReview | null>(
`${reviewPath(threadId)}/${encodeURIComponent(toolCallId)}`,
);
}

export function matchesReviewedDraft(page: ReviewedPage, args: unknown) {
export function matchesReviewedDraft(
page: { reviewDraft: PageReviewDraft | null },
args: unknown,
) {
// Receipts created before draft binding have no original snapshot.
if (!page.reviewDraft) return true;
const draft = pageReviewSchema.safeParse(args);
Expand All @@ -28,7 +42,7 @@ export async function decidePageReview(
toolCallId: string,
args: unknown,
approved: boolean,
): Promise<ReviewedPage | null> {
): Promise<ReviewedPage | DeletedReview | null> {
// A previous save may have committed even if its response never arrived.
const previous = await restorePageReview(threadId, toolCallId);
if (previous) {
Expand Down
13 changes: 9 additions & 4 deletions src/client/page-snapshots.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import type { Page } from '../server/pages';
/** Pages currently have no delete operation. A missing row in an older poll
* must not unmount a newly created document. Deletion would require explicit
* tombstones or mutation ordering, rather than interpreting absence here. */
export function mergePageSnapshot(known: Page[], incoming: Page[]): Page[] {
/** A missing row in an older poll must not unmount a newly created document,
* so absence never removes a page. Deletions are explicit: ids in `removed`
* (tombstones kept by the caller) are ignored when a stale poll still lists them. */
export function mergePageSnapshot(
known: Page[],
incoming: Page[],
removed: ReadonlySet<string> = new Set(),
): Page[] {
const pages = new Map(known.map((page) => [page.id, page]));
for (const page of incoming) {
if (removed.has(page.id)) continue;
const previous = pages.get(page.id);
if (!previous || page.revision > previous.revision)
pages.set(page.id, page);
Expand Down
17 changes: 15 additions & 2 deletions src/server/page-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,13 @@ export function pageRoutes(platform: Platform) {
{ error: 'This Dot no longer has access to the selected Space.' },
403,
);
const { pageId, spaceId, draft: reviewDraft } = receipt;
// The receipt outlives its page so a retried approval cannot recreate it.
if (!platform.workspace.pages.exists(spaceId, pageId))
return c.json({ deleted: true, pageId, spaceId, reviewDraft });
return c.json({
...platform.workspace.pages.get(receipt.spaceId, receipt.pageId),
reviewDraft: receipt.draft,
...platform.workspace.pages.get(spaceId, pageId),
reviewDraft,
});
});
app.post('/conversations/:id/reviewed-page', async (c) => {
Expand Down Expand Up @@ -93,6 +97,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) })
Expand Down
35 changes: 35 additions & 0 deletions src/server/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ export class Pages {
if (!this.spaceExists(spaceId))
throw new PageError('Space not found.', 404);
}
exists(spaceId: string, id: string): boolean {
this.requireSpace(spaceId);
return !!this.db
.prepare('SELECT 1 FROM pages WHERE id=? AND spaceId=?')
.get(id, spaceId);
}
list(spaceId: string): Page[] {
this.requireSpace(spaceId);
return this.db
Expand Down Expand Up @@ -276,4 +282,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=?, revision=revision+1, updatedAt=? WHERE spaceId=? AND parentId=?',
)
.run(page.parentId, now, spaceId, 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=?')
.run(id, spaceId);
this.db.exec('COMMIT');
return true;
} catch (error) {
this.db.exec('ROLLBACK');
throw error;
}
}
}
Loading