diff --git a/docs/SETUP.md b/docs/SETUP.md index 274b3151..dc326390 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -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. diff --git a/src/client/SpaceWorkspace.tsx b/src/client/SpaceWorkspace.tsx index 329c00bc..55c9ee86 100644 --- a/src/client/SpaceWorkspace.tsx +++ b/src/client/SpaceWorkspace.tsx @@ -33,44 +33,70 @@ export function SpaceWorkspace({ }) { const [pages, setPages] = useState([]); const removed = useRef(new Set()); + const mutations = useRef(0); + const [missingPageId, setMissingPageId] = useState(); 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(`/spaces/${space.id}/pages`); + const next = await api( + `/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 = @@ -87,6 +113,7 @@ export function SpaceWorkspace({ content: '', parentId, }); + mutations.current++; setPages((previous) => [...previous, next]); onPage(next.id); } catch (e) { @@ -103,6 +130,12 @@ export function SpaceWorkspace({ {error} )} + {page && missingPageId === pageId && ( +
+ This page was deleted elsewhere. Your draft is still available here; + download it before leaving. +
+ )} {!pageId ? ( = new Set(), + retained: ReadonlySet = 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); diff --git a/tests/page-snapshots.test.ts b/tests/page-snapshots.test.ts index 4d726249..ba8a4a4c 100644 --- a/tests/page-snapshots.test.ts +++ b/tests/page-snapshots.test.ts @@ -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; @@ -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([]); +}); diff --git a/tests/space-workspace.test.tsx b/tests/space-workspace.test.tsx new file mode 100644 index 00000000..45bcfc0d --- /dev/null +++ b/tests/space-workspace.test.tsx @@ -0,0 +1,254 @@ +import { createElement, useEffect, useState, type ComponentProps } from 'react'; +import { act, create, type ReactTestRenderer } from 'react-test-renderer'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import type { Page } from '../src/server/pages'; +const api = vi.hoisted(() => vi.fn()); +const unmount = vi.hoisted(() => vi.fn()); +vi.mock('../src/client/api', () => ({ api })); +vi.mock('../src/client/SpaceLibrary', () => ({ + SpaceLibrary: () => null, +})); +vi.mock('../src/client/PageOutline', () => ({ PageOutline: () => null })); +vi.mock('../src/client/PageDocument', () => ({ + PageDocument: () => { + const [draft, setDraft] = useState('Unsaved draft'); + useEffect(() => () => unmount(), []); + return createElement('input', { + 'aria-label': 'Fixture draft', + value: draft, + onChange: (event: { target: { value: string } }) => + setDraft(event.target.value), + }); + }, +})); +import { SpaceWorkspace } from '../src/client/SpaceWorkspace'; +import { SpaceLibrary } from '../src/client/SpaceLibrary'; +import { PageDocument } from '../src/client/PageDocument'; + +const space = { id: 'space', name: 'Space', description: '', createdAt: 0 }; +const base: Page = { + id: 'parent', + spaceId: space.id, + parentId: null, + title: 'Parent', + content: '', + revision: 1, + createdAt: 0, + updatedAt: 0, + sourceThreadId: null, +}; +const child: Page = { ...base, id: 'child', parentId: base.id, title: 'Child' }; +const props = (pageId?: string): ComponentProps => ({ + space, + pageId, + workspace: { + spaces: [space], + dots: [], + conversations: [], + calls: [], + setup: { + intelligence: false, + model: false, + browser: false, + voice: false, + slack: 'not_configured', + missing: [], + }, + }, + paused: false, + onPage: vi.fn(), + onDirty: vi.fn(), + onRefresh: vi.fn(), + onSchedule: vi.fn(), + onThread: vi.fn(), + onSettings: vi.fn(), + onCreateDot: vi.fn(), +}); +const roots: ReactTestRenderer[] = []; +async function mount(p = props()) { + let root!: ReactTestRenderer; + await act(async () => { + root = create(createElement(SpaceWorkspace, p)); + }); + roots.push(root); + return root; +} +const listed = (root: ReactTestRenderer): Page[] => + root.root.findByType(SpaceLibrary).props.pages; +async function poll() { + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); +} +function deferred() { + let resolve!: (pages: Page[]) => void; + const promise = new Promise((yes) => { + resolve = yes; + }); + return { promise, resolve }; +} +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + api.mockReset().mockResolvedValue([base, child]); + unmount.mockReset(); +}); +afterEach(async () => { + for (const root of roots.splice(0)) await act(async () => root.unmount()); + vi.restoreAllMocks(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +it('removes externally deleted pages and accepts reparented children on a fresh poll', async () => { + const root = await mount(); + expect(listed(root)).toEqual([base, child]); + const reparented = { ...child, parentId: null, revision: 2 }; + api.mockResolvedValueOnce([reparented]); + await poll(); + expect(listed(root)).toEqual([reparented]); +}); + +it('retains an open document and its draft, warns about deletion, then drops it on return to the library', async () => { + const p = props(base.id); + const root = await mount(p); + await act(async () => { + root.root.findByType('input').props.onChange({ + target: { value: 'Keep this edited draft' }, + }); + }); + api.mockResolvedValue([child]); + await poll(); + expect(root.root.findByType('input').props.value).toBe( + 'Keep this edited draft', + ); + expect(unmount).not.toHaveBeenCalled(); + expect(root.root.findByProps({ role: 'alert' }).props.children).toContain( + 'deleted elsewhere', + ); + await act(async () => { + root.update(createElement(SpaceWorkspace, { ...p, pageId: undefined })); + }); + await poll(); + expect(listed(root)).toEqual([child]); +}); + +it('keeps a newly created page against an older poll without keeping it forever', async () => { + const p = props(); + const root = await mount(p); + const older = deferred(); + api.mockReturnValueOnce(older.promise); + await poll(); + const created = { ...base, id: 'new-page' }; + api.mockResolvedValueOnce(created); + await act(async () => { + root.root.findByType(SpaceLibrary).props.onNew(); + }); + expect(p.onPage).toHaveBeenCalledWith(created.id); + await act(async () => older.resolve([base, child])); + expect(listed(root)).toContainEqual(created); + api.mockResolvedValueOnce([base, child, created]); + await poll(); + expect(listed(root)).toContainEqual(created); + await poll(); + expect(listed(root)).toEqual([base, child]); +}); + +it('does not overlap list polls while the previous response is pending', async () => { + const pending = deferred(); + api.mockReturnValueOnce(pending.promise); + const root = await mount(); + await poll(); + await poll(); + expect(api).toHaveBeenCalledTimes(1); + await act(async () => pending.resolve([base])); + expect(listed(root)).toEqual([base]); + await poll(); + expect(api).toHaveBeenCalledTimes(2); +}); + +it('preserves pages on poll failure and reconciles deletions when polling recovers', async () => { + const root = await mount(); + api.mockRejectedValueOnce(new Error('Temporary outage.')); + await poll(); + expect(listed(root)).toEqual([base, child]); + expect(root.root.findByProps({ role: 'alert' }).props.children).toBe( + 'Temporary outage.', + ); + api.mockResolvedValueOnce([]); + await poll(); + expect(listed(root)).toEqual([]); + expect(root.root.findAllByProps({ role: 'alert' })).toHaveLength(0); +}); + +it('keeps a newer local revision when an earlier poll arrives after a save', async () => { + const root = await mount(props(base.id)); + const older = deferred(); + api.mockReturnValueOnce(older.promise); + await poll(); + const saved = { ...base, revision: 3, content: 'Saved locally' }; + await act(async () => { + root.root.findByType(PageDocument).props.onSaved(saved); + }); + await act(async () => older.resolve([base, child])); + expect(root.root.findByType(PageDocument).props.page).toEqual(saved); +}); + +it('aborts and ignores an old list response after changing the selected page', async () => { + const p = props(); + const root = await mount(p); + const older = deferred(); + api.mockReturnValueOnce(older.promise); + await poll(); + const signal = api.mock.calls[1][3] as AbortSignal; + await act(async () => { + root.update(createElement(SpaceWorkspace, { ...p, pageId: base.id })); + }); + expect(signal.aborted).toBe(true); + await act(async () => older.resolve([])); + expect(root.root.findByType(PageDocument).props.page).toEqual(base); + expect(root.root.findAllByProps({ role: 'alert' })).toHaveLength(0); +}); + +it('bounds a stalled poll and resumes polling after its timeout', async () => { + const root = await mount(); + vi.spyOn(AbortSignal, 'timeout').mockImplementation((ms) => { + const controller = new AbortController(); + setTimeout(() => controller.abort(new Error('Fixture timeout.')), ms); + return controller.signal; + }); + api.mockImplementationOnce( + (_path, _method, _body, signal: AbortSignal) => + new Promise((_, reject) => + signal.addEventListener('abort', () => reject(signal.reason), { + once: true, + }), + ), + ); + await poll(); + await act(async () => { + await vi.advanceTimersByTimeAsync(10000); + }); + expect(api).toHaveBeenCalledTimes(2); + expect(listed(root)).toEqual([base, child]); + expect(root.root.findByProps({ role: 'alert' }).props.children).toBe( + 'Fixture timeout.', + ); + api.mockResolvedValueOnce([]); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(api).toHaveBeenCalledTimes(3); + expect(listed(root)).toEqual([]); +}); + +it('aborts the list request on unmount', async () => { + const pending = deferred(); + api.mockReturnValueOnce(pending.promise); + const root = await mount(); + const signal = api.mock.calls[0][3] as AbortSignal; + await act(async () => root.unmount()); + expect(signal.aborted).toBe(true); + await act(async () => pending.resolve([base])); + expect(root.toJSON()).toBeNull(); +});