diff --git a/packages/ui/README.md b/packages/ui/README.md index 716f2a5..cbe803c 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -39,7 +39,7 @@ if a `node:*` import creeps back onto that path. | `src/lib/units.ts` | Presentation over `timeline()`: a row's text, what quick find searches it by, its badges, its status disc, and the two cross-target capture relations no single target's index can see. Also the tip/open-request split — `requestState()` and the `isClaimed`/`isAssigned`/`isOpen`/`isAwaiting` predicates every list groups and counts by, because `UnitView.state` describes what LANDED and stays `judged` while a successor runs. | | `src/lib/requests.ts`, `verdicts.ts`, `admin.ts` | What each surface may offer and what it writes, as pure functions: the ⊕ menu, the review form and its findings, space administration. Tested without rendering anything. | | `src/lib/docs.ts` | The tiered doc viewer: which tier a unit is drawn in, the second drift signal beside `staleness()`, the roadmap tier's derived sections, and the two commands editing a page writes. A tier is a REGISTRY NAME and this is the only module that knows the two — `user-doc` and `roadmap-doc` are artifact types, so the feature adds no record type and every other project-scoped type falls into the dev tier with no code change. Two rules live here rather than in a component: an edit request always names its own author as assignee (an unassigned one is claimable by any operator's daemon), and a new version re-pins its sources to their current heads, which is the only thing that clears source drift. | -| `src/routes/p/[project]/docs/`, `src/lib/components/DocReader.svelte`, `DocEditor.svelte` | The surface: tier navigation, a page index, and the reader — prose first, then the provenance that makes the page checkable, then the roadmap's road-ahead and road-travelled sections derived from live goal state. The editor is two ordinary `runCli` commands (`request create`, `artifact post`) through `write.ts`, so a private space gets doc editing through the substituted writer with no branch anywhere; a save that failed between them is offered as a resume rather than written twice. Where a tier's type is not registered the pane is a setup card, because `materialize()` drops a request no registry entry names — the entry is load-bearing, not decorative. | +| `src/routes/p/[project]/docs/`, `src/lib/components/DocReader.svelte`, `DocEditor.svelte` | The surface: tier navigation, a page index, and the reader — prose first, then the provenance that makes the page checkable, then the roadmap's road-ahead and road-travelled sections derived from live goal state. The editor is two ordinary `runCli` commands (`request create`, `artifact post`) through `write.ts`, so a private space gets doc editing through the substituted writer with no branch anywhere; a save that failed between them is offered as a resume rather than written twice. A unit holds at most one open request, so Edit and “Finish it” are the SAME action — Edit continues the open request rather than forking a second one that would leave the first open forever — and the way out is a `request retract` tombstone, which is also how a member changes sources recovery cannot. Where a tier's type is not registered the pane is a setup card, because `materialize()` drops a request no registry entry names — the entry is load-bearing, not decorative. | | `src/lib/labels.ts` | The reading side of goal labels: the argv a label editor writes (always `--set`, always the whole set — the record has no add or remove), the space's label vocabulary with counts (which IS the registry: there is none on-protocol), and a chip's hue as a pure function of its text. The normalization *rule* is `@radial/core`'s, shared with the sidecar so a label typed here and one typed at a shell cannot differ. | | `src/lib/filters.ts`, `filters.svelte.ts` | Narrowing a goal list by label and state, and the URL round-trip that makes a narrowed list a link. Pure derivation — nothing here writes, and nothing reads prose: the predicate reads `GoalView.labels` and `GoalView.ended` and nothing else. Composed *with* quick find rather than replacing it, and the labels' own text joins the corpus `matches()` searches. | | `src/lib/grouping.ts` | Arranging that same list once the filter has decided what is in it: one section per label, in the vocabulary's own order, with the unlabelled goals last. A goal stands under *every* label it carries — a set has no primary member for this module to invent one from — so the sections can hold more rows than the list, and each is counted where it stands. Grouping is not narrowing: it stays per-tab and out of the URL, because a `group` parameter would be one more thing `viewHref` has to reproduce exactly for the rail's active-view highlight to keep matching. | diff --git a/packages/ui/src/lib/components/DocEditor.svelte b/packages/ui/src/lib/components/DocEditor.svelte index f2fbc14..4503e84 100644 --- a/packages/ui/src/lib/components/DocEditor.svelte +++ b/packages/ui/src/lib/components/DocEditor.svelte @@ -165,7 +165,8 @@

Finishing an edit that was started and never posted. This writes the page against the request that is already open, rather than opening a second one. Its sources are already recorded and - cannot be changed while recovering this save. + cannot be changed while recovering this save — cancel and discard the unfinished edit if you + need different ones.

{/if} {#if forked} @@ -203,7 +204,7 @@

{#if resume} These are the sources recorded on the existing request. Finish this save before making a new - version with different sources. + version with different sources, or discard the unfinished edit and start it again. {:else} These travel as strongrefs on the request, pinned at the version shown. Re-pinning them is what clears this page’s “sources moved on” badge, so tick what you have actually read. diff --git a/packages/ui/src/routes/p/[project]/docs/+page.svelte b/packages/ui/src/routes/p/[project]/docs/+page.svelte index 2c545a8..15b8a37 100644 --- a/packages/ui/src/routes/p/[project]/docs/+page.svelte +++ b/packages/ui/src/routes/p/[project]/docs/+page.svelte @@ -24,6 +24,7 @@ TIERS, type TierId, } from '$lib/docs.js' + import { retractArgs } from '$lib/requests.js' import { currentSpace } from '$lib/session.svelte.js' import { projectByName, systemHref } from '$lib/space.js' import { toast } from '$lib/ui.svelte.js' @@ -67,6 +68,46 @@ let editing = $state(undefined) let settingUp = $state(false) let setupError = $state('') + let discarding = $state(false) + let discardError = $state('') + + /** + * Editing the page on screen — and it is the SAME action as "Finish it", because a unit has at + * most one open request (`timeline.ts` attaches the first successor and pushes any second one to + * `standalone`). An Edit that ignored `resumableHere` would write a second request for the same + * edit and leave the first standing: the abandoned one re-attaches as `unit.openRequest` on the + * next sync, so this hint comes back forever, the System page keeps a request nobody is answering, + * and the edit actually being written shows up as a second doc in the tier until it lands. + * + * The way OUT of a resume is `discard()` below, not a second request. + */ + const edit = (): void => { + if (!current) return + editing = { unit: current, ...(resumableHere ? { resume: resumableHere } : {}) } + } + + /** + * Withdraw the unfinished edit instead of finishing it — the protocol-backed exit from the resume, + * and what a member reaches for when the sources they want are not the ones the open request + * pinned (recovery cannot change those: they are already signed). + * + * A tombstone only counts from the request's own author or an active admin, and `resumableEdit` + * yields nothing but this reader's own requests, so the fold honours every retraction offered + * here. Retracting takes it out of `openRequests`, which is what clears the hint. + */ + async function discard(): Promise { + if (!resumableHere || discarding) return + discarding = true + discardError = '' + try { + await write(retractArgs(resumableHere)) + toast('Unfinished edit withdrawn — nothing was deleted') + } catch (failure) { + discardError = failure instanceof Error ? failure.message : String(failure) + } finally { + discarding = false + } + } // Closing the editor whenever the reader navigates: an open form over a different document than // the one the URL names is the classic way an edit lands on the wrong page. @@ -74,6 +115,7 @@ void tier void opened editing = undefined + discardError = '' }) async function setUp(): Promise { @@ -198,19 +240,23 @@ {:else if current} {#if resumableHere}

- You started an edit of this page and never posted it. + You started an edit of this page and never posted it. Editing continues that save rather + than opening a second request; its sources were fixed when the request was written. + + disabled={!writable || discarding} + onclick={discard} + >{discarding ? 'Discarding…' : 'Discard it'}

+ {#if discardError}

{discardError}

{/if} {/if} (editing = { unit: current }) : undefined} + onEdit={writable && isEditable(tier) ? edit : undefined} /> {:else}
diff --git a/packages/ui/src/routes/p/[project]/docs/docs-page.svelte.test.ts b/packages/ui/src/routes/p/[project]/docs/docs-page.svelte.test.ts new file mode 100644 index 0000000..0d02435 --- /dev/null +++ b/packages/ui/src/routes/p/[project]/docs/docs-page.svelte.test.ts @@ -0,0 +1,206 @@ +// @vitest-environment jsdom +import { COLLECTIONS, materialize, MemoryRecordStore, type StoredRecord } from '@radial/core' +import { FIXTURE_DIDS, fixtureSpace } from '@radial/core/fixture' +import { flushSync, mount, unmount } from 'svelte' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildDirectory } from '$lib/directory.js' +import { docTitle, docsOf } from '$lib/docs.js' +import { loadEditor } from '$lib/editor.js' +import { projectByName, type Space } from '$lib/space.js' +import Page from './+page.svelte' + +// The docs page, mounted, with an edit somebody started and never posted standing in the fold. +// +// The claim under test is a page-level one and cannot be made against either component alone: a unit +// has at most ONE open request (`timeline.ts` attaches the first successor and pushes any second to +// `standalone`), so the reader's Edit button and the resume hint's "Finish it" must be the same +// action. An Edit that opened a fresh request would leave the abandoned one open forever — it +// re-attaches on the next sync, so the hint never goes away, the System page keeps a request nobody +// is answering, and the edit actually being written draws as a second doc until it lands. +// +// And because recovery cannot change the sources a signed request already pinned, the resume needs +// an exit: discarding writes the `request retract` tombstone the fold honours from its own author. + +const writes = vi.hoisted(() => vi.fn()) +vi.mock('$lib/write.js', () => ({ write: writes, uploadImage: vi.fn() })) +vi.mock('$lib/auth.svelte.js', () => ({ + account: { status: 'signed-in', did: FIXTURE_DIDS.tim }, +})) +vi.mock('$app/navigation', () => ({ goto: vi.fn() })) +vi.mock('$app/state', () => ({ + page: { + get params() { + return { project: 'radial-ng' } + }, + get url() { + return url + }, + }, +})) +vi.mock('$lib/session.svelte.js', () => ({ currentSpace: () => space })) + +const RESUME_RKEY = '3lbs1dq0resume' +const RESUME_URI = `at://${FIXTURE_DIDS.tim}/${COLLECTIONS.artifactRequest}/${RESUME_RKEY}` + +let url = new URL('http://127.0.0.1/p/radial-ng/docs?tier=user') +let space: Space +let host: HTMLDivElement +let component: Record | undefined + +/** + * The fixture space, plus one open request of Tim's against the "Review queue" page with nothing + * posted against it: the half of a two-write save that survives a dropped connection. It is a real + * record through the real fold, so `resumableEdit` finds it the way it would in a browser. + * + * Its sources deliberately differ from the ones the landed version was written from — that is the + * state recovery has to be honest about, since they were signed when the request was written. + */ +function spaceWithUnfinishedEdit(): Space { + const fixture = fixtureSpace() + const store = new MemoryRecordStore() + for (const record of fixture.records) store.put(record) + const base = materialize(store, { spaceUri: fixture.spaceUri, asOf: fixture.asOf }) + const project = projectByName(base, 'radial-ng') + if (!project) throw new Error('fixture project missing') + const docs = docsOf(base, project) + const reviewQueue = docs.user.find((unit) => docTitle(unit) === 'Review queue') + const conventions = docs.dev.find((unit) => unit.type === 'conventions')?.current?.artifact + const landed = reviewQueue?.current?.artifact + if (!landed || !conventions) throw new Error('fixture is missing the doc this test edits') + + const open: StoredRecord = { + did: FIXTURE_DIDS.tim, + collection: COLLECTIONS.artifactRequest, + rkey: RESUME_RKEY, + uri: RESUME_URI, + cid: 'cid-resume', + rev: '9999999999999', + value: { + $type: COLLECTIONS.artifactRequest, + project: { uri: project.target.uri, cid: project.target.cid }, + type: 'user-doc', + basedOn: [ + { uri: landed.uri, cid: landed.cid }, + { uri: conventions.uri, cid: conventions.cid }, + ], + assignee: FIXTURE_DIDS.tim, + createdAt: '2026-07-26T12:40:00Z', + }, + } + store.put(open) + const index = materialize(store, { spaceUri: fixture.spaceUri, asOf: fixture.asOf }) + const withEdit = projectByName(index, 'radial-ng') + if (!withEdit) throw new Error('fixture project missing') + const unit = docsOf(index, withEdit).user.find((candidate) => docTitle(candidate) === 'Review queue') + if (unit?.openRequest?.uri !== RESUME_URI) throw new Error('the unfinished edit did not attach') + url = new URL( + `http://127.0.0.1/p/radial-ng/docs?tier=user&doc=${encodeURIComponent(unit.key)}`, + ) + return { + uri: fixture.spaceUri, + index, + directory: buildDirectory(index, {}), + asOf: fixture.asOf, + fixture: false, + } +} + +async function settle(): Promise { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + flushSync() +} + +const button = (label: string): HTMLButtonElement | undefined => + [...host.querySelectorAll('button')].find((entry) => entry.textContent?.trim().startsWith(label)) + +async function render(): Promise { + await loadEditor() + component = mount(Page, { target: host, props: {} }) as Record + await settle() +} + +beforeEach(() => { + host = document.createElement('div') + document.body.append(host) + writes.mockReset() + writes.mockResolvedValue({ + primary: { uri: `at://${FIXTURE_DIDS.tim}/com.disnetdev.radial.artifact/posted`, cid: 'cid-posted' }, + refs: {}, + }) + space = spaceWithUnfinishedEdit() +}) + +afterEach(() => { + if (component) unmount(component) + component = undefined + host.remove() +}) + +describe('the docs page with an unfinished edit', () => { + it('continues the open request from Edit, rather than opening a second one', async () => { + await render() + expect(host.textContent).toContain('You started an edit of this page and never posted it') + + // The reader's own button, not the hint's: the two entry points must not diverge. + button('Edit')?.click() + await settle() + + // The editor came up recovering: the sources are the request's, and they are not up for change. + expect(host.textContent).toContain('Finishing an edit that was started and never posted') + const sources = [...host.querySelectorAll('.sources input')] + expect(sources.length).toBeGreaterThan(0) + expect(sources.every((input) => input.disabled)).toBe(true) + + button('Save version')?.click() + await settle() + await Promise.resolve() + + // One write, against the request that was already open. A second `request create` here is the + // fork this whole path exists to avoid. + expect(writes).toHaveBeenCalledTimes(1) + const [post] = writes.mock.calls[0] as [string[]] + expect(post.slice(0, 3)).toEqual(['artifact', 'post', '--request']) + expect(post[3]).toBe(`${RESUME_URI}#cid-resume`) + }) + + it('discards the unfinished edit with a retraction, which is the only way out of it', async () => { + await render() + + button('Discard it')?.click() + await settle() + + expect(writes).toHaveBeenCalledTimes(1) + const [retract] = writes.mock.calls[0] as [string[]] + expect(retract).toEqual(['request', 'retract', '--request', `${RESUME_URI}#cid-resume`]) + }) + + it('opens a fresh request from Edit once nothing is left open', async () => { + // The same button on a page with no unfinished edit: the ordinary two-write save, unchanged. + space = { ...space, index: materializeWithout(space) } + await render() + expect(host.textContent).not.toContain('You started an edit of this page and never posted it') + + button('Edit')?.click() + await settle() + expect(host.textContent).not.toContain('Finishing an edit that was started and never posted') + + button('Save version')?.click() + await settle() + await Promise.resolve() + + expect(writes).toHaveBeenCalledTimes(2) + const [request] = writes.mock.calls[0] as [string[]] + expect(request.slice(0, 2)).toEqual(['request', 'create']) + expect(request[request.indexOf('--assignee') + 1]).toBe(FIXTURE_DIDS.tim) + }) +}) + +/** The same space with the unfinished edit retracted — what a discard leaves behind. */ +function materializeWithout(current: Space): Space['index'] { + const fixture = fixtureSpace() + const store = new MemoryRecordStore() + for (const record of fixture.records) store.put(record) + return materialize(store, { spaceUri: current.uri, asOf: fixture.asOf }) +}