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: 2 additions & 0 deletions docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ Select a Space to open its page library. Search for a document, switch between g

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.

Pages deleted in another tab disappear from the library on a fresh successful poll. If the deleted page is open, its editor stays available with a warning: download your draft before leaving. Keeping that draft on screen does not restore the deleted page on the server.

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.

Use the conversation's save-to-page action to create a document from its saved text history. This requires a working conversation service. Pages retain a link to the source conversation, and page links in chat open the document workspace.
Expand Down
59 changes: 46 additions & 13 deletions src/client/SpaceWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,44 +33,70 @@ export function SpaceWorkspace({
}) {
const [pages, setPages] = useState<Page[]>([]);
const removed = useRef(new Set<string>());
const mutations = useRef(0);
const [missingPageId, setMissingPageId] = useState<string>();
const [error, setError] = useState('');
const [loaded, setLoaded] = useState(false);
const [outline, setOutline] = useState(false);
useEffect(() => {
let active = true;
let loading = false;
const controller = new AbortController();
const load = async () => {
// Serialize polls so an older snapshot cannot arrive after a newer one.
if (loading) return;
loading = true;
const startedAt = mutations.current;
try {
const next = await api<Page[]>(`/spaces/${space.id}/pages`);
const next = await api<Page[]>(
`/spaces/${space.id}/pages`,
'GET',
undefined,
AbortSignal.any([controller.signal, AbortSignal.timeout(10000)]),
);
if (active) {
setPages((previous) =>
mergePageSnapshot(previous, next, removed.current),
);
setPages((previous) => {
const retained = new Set(pageId ? [pageId] : []);
// A poll begun before a local mutation is not proof of absence.
if (mutations.current !== startedAt)
for (const page of previous) retained.add(page.id);
return mergePageSnapshot(previous, next, removed.current, retained);
});
if (mutations.current === startedAt)
setMissingPageId(
pageId && !next.some((page) => page.id === pageId)
? pageId
: undefined,
);
setLoaded(true);
setError('');
}
} catch (e) {
if (active)
setError(e instanceof Error ? e.message : 'Could not load pages.');
} finally {
loading = false;
}
};
void load();
const timer = setInterval(() => void load(), 3000);
return () => {
active = false;
controller.abort();
clearInterval(timer);
};
}, [space.id]);
}, [space.id, pageId]);
const page = pages.find((item) => item.id === pageId);
const saved = useCallback(
(next: Page) =>
setPages((previous) =>
previous.map((item) =>
item.id === next.id && item.revision < next.revision ? next : item,
),
const saved = useCallback((next: Page) => {
mutations.current++;
setPages((previous) =>
previous.map((item) =>
item.id === next.id && item.revision < next.revision ? next : item,
),
[],
);
);
}, []);
const deleted = useCallback((id: string) => {
mutations.current++;
removed.current.add(id);
setPages((previous) => {
const parentId =
Expand All @@ -87,6 +113,7 @@ export function SpaceWorkspace({
content: '',
parentId,
});
mutations.current++;
setPages((previous) => [...previous, next]);
onPage(next.id);
} catch (e) {
Expand All @@ -103,6 +130,12 @@ export function SpaceWorkspace({
{error}
</div>
)}
{page && missingPageId === pageId && (
<div className="document-load-error" role="alert">
This page was deleted elsewhere. Your draft is still available here;
download it before leaving.
</div>
)}
{!pageId ? (
<SpaceLibrary
space={space}
Expand Down
18 changes: 14 additions & 4 deletions src/client/page-snapshots.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import type { Page } from '../server/pages';
/** 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. */
/** Reconcile a complete list, retaining only explicitly protected missing rows.
* The caller protects an open document or local changes newer than this poll.
* Local deletion tombstones always win, including over protected rows. */
export function mergePageSnapshot(
known: Page[],
incoming: Page[],
removed: ReadonlySet<string> = new Set(),
retained: ReadonlySet<string> = new Set(),
): Page[] {
const pages = new Map(known.map((page) => [page.id, page]));
const listed = new Set(incoming.map((page) => page.id));
const pages = new Map(
known
.filter(
(page) =>
!removed.has(page.id) &&
(listed.has(page.id) || retained.has(page.id)),
)
.map((page) => [page.id, page]),
);
for (const page of incoming) {
if (removed.has(page.id)) continue;
const previous = pages.get(page.id);
Expand Down
20 changes: 19 additions & 1 deletion tests/page-snapshots.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ it('retains a newly created document when an earlier list response arrives after
let pages = [base];
const created = { ...base, id: 'new-page', title: 'New page' };
const pending = olderRead.then((snapshot) => {
pages = mergePageSnapshot(pages, snapshot);
pages = mergePageSnapshot(
pages,
snapshot,
new Set(),
new Set([created.id]),
);
});
pages = [...pages, created];
const selectedId = created.id;
Expand All @@ -45,3 +50,16 @@ it('ignores a stale poll that still lists a deleted page', () => {
base,
]);
});

it('removes pages absent from a fresh complete snapshot', () => {
expect(mergePageSnapshot([base], [])).toEqual([]);
});

it('retains an explicitly protected open page but never resurrects a tombstone', () => {
expect(mergePageSnapshot([base], [], new Set(), new Set([base.id]))).toEqual([
base,
]);
expect(
mergePageSnapshot([base], [base], new Set([base.id]), new Set([base.id])),
).toEqual([]);
});
Loading