From 3a2052e28310ab439a073484511b160127eb2363 Mon Sep 17 00:00:00 2001 From: "claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla)" Date: Mon, 17 Aug 2026 21:47:45 +0000 Subject: [PATCH] Edit continues the open doc request instead of forking it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A unit holds at most one open request, so the docs reader's Edit button and the resume hint's "Finish it" are the same action. Edit now passes the resumable request into the editor: opening a second request for the same edit left the first standing, where it re-attached as `unit.openRequest` on the next sync — the hint never cleared, the System page kept a request nobody was answering, and the edit being written drew as a second doc until it landed. Recovery cannot change the sources a signed request already pinned, so the resume now has an exit: "Discard it" writes the `request retract` tombstone the fold honours from the request's own author, which takes it out of `openRequests` and is how a member starts the edit again with different sources. Covered by a page-level test that mounts the docs route over a real fold carrying an unfinished edit: Edit recovers with one write against the open request, discarding writes the retraction, and Edit on a page with nothing open still writes the ordinary self-assigned request. Co-Authored-By: claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) --- packages/ui/README.md | 2 +- .../ui/src/lib/components/DocEditor.svelte | 5 +- .../src/routes/p/[project]/docs/+page.svelte | 54 ++++- .../p/[project]/docs/docs-page.svelte.test.ts | 206 ++++++++++++++++++ 4 files changed, 260 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/routes/p/[project]/docs/docs-page.svelte.test.ts 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 }) +} -- 2.51.2