From a37cdabe81939ae82172af79d37ff3c0509d34c9 Mon Sep 17 00:00:00 2001 From: Tim Disney Date: Wed, 29 Jul 2026 13:00:29 -0700 Subject: [PATCH] Show the reviews and replies agents are working on in "With an agent" (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `review` request and an `answer` request are deliberately never units — neither produces an artifact, so counting them would move a goal's pie every time somebody asked a question — and every cross-goal list was a fold over units. So the most ordinary shape of "an agent is working right now" (the review request auto-review writes under the daemon's own reviewer identity, and any open one an agent has claimed) was the one shape "With an agent" could not draw, and an open answer request appeared nowhere but its thread. One row type (`AskContext`), two folds (`reviewAsks`, `answerAsks`), one union module (`asks.ts`), and `AnswerRow` generalised into `AskRow`. An ask is drawn in exactly one of three lists — with an agent, awaiting input, or owed by a person — decided by one predicate pair (`askWithAgent` / `askWithPerson`), so no row and no rail badge is counted twice. A review ask links to the drawer at the exact version it pins, project-scoped ones included; an answer ask links to the thread. Per-goal badges count asks too, and the state ladder for an open request is now one exported function in core (`openRequestState`) rather than a closure inside `timeline()`. Units are untouched: no timeline, tally or pie moved. Co-authored-by: claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) --- CHANGELOG.md | 14 + packages/core/src/fixture.ts | 66 +++- packages/core/src/timeline.ts | 35 +- packages/core/test/fixture.test.mjs | 49 ++- packages/core/test/timeline.test.mjs | 97 +++++ packages/sidecar/test/thread-list.test.mjs | 10 +- packages/ui/src/lib/asks.test.ts | 341 ++++++++++++++++++ packages/ui/src/lib/asks.ts | 57 +++ .../ui/src/lib/components/AnswerRow.svelte | 41 --- .../ui/src/lib/components/AskReview.svelte | 3 +- packages/ui/src/lib/components/AskRow.svelte | 59 +++ packages/ui/src/lib/components/GoalRow.svelte | 30 +- packages/ui/src/lib/components/Rail.svelte | 19 +- .../ui/src/lib/components/SmartList.svelte | 19 +- packages/ui/src/lib/components/Verdict.svelte | 2 +- packages/ui/src/lib/replies.test.ts | 88 +++-- packages/ui/src/lib/replies.ts | 48 ++- packages/ui/src/lib/units.ts | 111 +++++- packages/ui/src/lib/verdicts.test.ts | 7 +- packages/ui/src/lib/verdicts.ts | 99 +++-- packages/ui/src/lib/write.test.ts | 8 +- packages/ui/src/routes/awaiting/+page.svelte | 11 +- packages/ui/src/routes/inflight/+page.svelte | 24 +- packages/ui/src/routes/logbook/+page.svelte | 5 +- packages/ui/src/routes/me/+page.svelte | 7 +- .../ui/src/routes/p/[project]/+page.svelte | 7 +- 26 files changed, 1050 insertions(+), 207 deletions(-) create mode 100644 packages/ui/src/lib/asks.test.ts create mode 100644 packages/ui/src/lib/asks.ts delete mode 100644 packages/ui/src/lib/components/AnswerRow.svelte create mode 100644 packages/ui/src/lib/components/AskRow.svelte diff --git a/CHANGELOG.md b/CHANGELOG.md index 376cc04..fa322ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -165,6 +165,20 @@ including multi-operator hardening. ### Fixed +- **Reviews and replies an agent is working on now appear in "With an agent"**, + and in the count beside it. A `review` request and an `answer` request are + deliberately never units — neither produces an artifact, so counting them would + move a goal's pie every time somebody asked a question — and every cross-goal + list was a fold over units, so the most ordinary shape of "an agent is working + right now" (the review request auto-review writes under the daemon's own + reviewer identity, and any open one an agent has claimed) was the one shape + that list could not draw. Both are now folded beside the units, by assignment + or by live claim, with the version a review pins named on the row and linked to + the drawer at that version. A built-in request whose turn **asks a question + back** now appears in "Awaiting input" and in "For me" rather than vanishing + from every list; and one predicate decides which of the three lists a row is + in, so none is drawn — or counted — twice. Units are untouched: no timeline, + tally or pie moved. - **A claim's lease no longer depends on whose clock is right** (design §6, `docs/operators.md` §3). Three exposures, in descending severity. A forward clock jump between two renewals used to write a lease years out that *nobody, diff --git a/packages/core/src/fixture.ts b/packages/core/src/fixture.ts index 5a6a28a..4740aa6 100644 --- a/packages/core/src/fixture.ts +++ b/packages/core/src/fixture.ts @@ -19,6 +19,10 @@ // - Living vs append-only is registry data (§3.3), so `architecture` and `conventions` declare // `durability: "living"` and `adr` declares `append-only`, rather than the UI knowing names. // - ADR numbers stay prose in the body (§3.4): the chain carries the real relationship. +// - Two of the things agents work on are never units — a `review` somebody asked for and an +// `answer` — because neither produces an artifact. The fixture carries one of each with an agent +// (an auto-review assigned to the reviewer, a claimed review of a system document, and a message +// ask) so "With an agent" has the rows it exists to draw. import { COLLECTIONS, type RadialRecord, type StrongRef } from './generated/records.js' import { joinRkey } from './records.js' @@ -557,6 +561,28 @@ export function fixtureSpace(): FixtureSpace { // `parent` threads it, `re` closes the request: the two fields a daemon-written reply carries. { parent: u1q, re: u1ans, mentions: [tim] }, ) + // A second question, still with the agent. The exchange above is the COMPLETED case — request + // fulfilled by the reply threaded under its subject — and this is the same shape mid-flight: an + // open `answer` request naming one exact message and one agent. It is not a unit and never will + // be, which is exactly why "With an agent" could not see it. + const u1q2 = message( + tim, + '3lbq2msg0005', + g1Ref, + 'Separate thing: does the stale-view guard hold if the second operator’s daemon fulfils the request between our render and our submit, rather than another tab?', + '2026-07-25T14:09:00Z', + { mentions: [reviewer] }, + ) + request({ + author: tim, + rkey: '3lbq2ans0002', + anchor: g1Ref, + type: 'answer', + subject: ref(u1q2), + assignee: reviewer, + at: '2026-07-25T14:09:20Z', + basedOn: [ref(u1v2)], + }) // ── g2 · Capture requests should distil ───────────────────────────────────────────────────── // The blocked-on-a-human case: the assignee asked a question and nobody has answered it. @@ -922,6 +948,21 @@ export function fixtureSpace(): FixtureSpace { // The other flavour: a verdict owed by a named human on one exact version. `review` is not in the // registry — it is a built-in of the turn layer — so this request carries a `subject` rather than a // `basedOn`, and it never becomes a row of its own. + // Written by the daemon, not by a person: `autoReview` is on for `implementation` on this project, + // so the moment the artifact landed the daemon wrote a review request under its own reviewer + // identity and assigned it there. This is the most ordinary shape of "an agent is working right + // now" in a live space — and, being a review, it is not a unit, so it was the one shape the + // in-flight list could not draw. Two asks on one version are two asks: the row count below is + // unchanged, and so is the goal's pie. + request({ + author: reviewer, + rkey: '3lbr7ii8jj50k', + anchor: g8Ref, + type: 'review', + subject: ref(u14v1), + assignee: reviewer, + at: '2026-07-25T11:13:00Z', + }) request({ author: ana, rkey: '3lbr7gg7hh50j', @@ -994,7 +1035,7 @@ export function fixtureSpace(): FixtureSpace { arch1, [{ uri: arch1.uri, cid: arch1.cid }], ) - sysVersion( + const arch3 = sysVersion( '3lbg1aa2mm60p', '3lbg2ee5pp60q', 'architecture', @@ -1005,6 +1046,29 @@ export function fixtureSpace(): FixtureSpace { arch2, [{ uri: arch2.uri, cid: arch2.cid }], ) + // Three implementations have merged since v3 landed, so the document has drifted and tim asked for + // a fresh read — open to any member rather than named to anybody, which is what makes it claimable. + // An agent took it under a live lease, so this is the one ask the index genuinely KNOWS a turn is + // running on. Project-scoped too: an ask hangs off a system document as readily as off a goal's. + const archReview = request({ + author: tim, + rkey: '3lbg3rq6ss60r', + anchor: ngRef, + type: 'review', + subject: ref(arch3), + at: '2026-07-25T13:48:00Z', + brief: + 'Three implementations have merged since this landed. Say what is now wrong in it rather than rewriting it.', + }) + // Dated off FIXTURE_NOW like the g7 claim: claimed half an hour ago, renewed two minutes ago, and + // the ten minutes to `expiresAt` is the lease itself. + put(reviewer, '3lbg3clm0002', { + $type: COLLECTIONS.claim, + request: ref(archReview), + expiresAt: '2026-07-25T14:26:00Z', + renewedAt: '2026-07-25T14:16:00Z', + createdAt: '2026-07-25T13:49:00Z', + }) // conventions — a second living document, two versions. const conv1 = sysVersion( diff --git a/packages/core/src/timeline.ts b/packages/core/src/timeline.ts index bf89f36..d5524b0 100644 --- a/packages/core/src/timeline.ts +++ b/packages/core/src/timeline.ts @@ -63,6 +63,33 @@ export type UnitState = | 'merged' | 'retracted' +/** + * Where an OPEN request stands: the four rungs of the ladder above that a request which has produced + * nothing yet can be on. + * + * Split out of `UnitState` because a `review` or an `answer` request is never a unit (see + * `isBuiltinRequest`) and yet is in exactly one of these states, and the lists that draw one must not + * recompute the ladder in their own words. + */ +export type OpenRequestState = Extract + +/** + * Where an open request stands: blocked on a person, held under a live lease, named to somebody, or + * free for anyone that produces the type. + * + * The order is the ladder and not a preference: a claim is only ever taken on an unassigned request + * (`materialize` skips the rest), and a request whose assignee asked a question is blocked whoever + * holds it. Retraction is the caller's business — a retracted request is not open. + */ +export function openRequestState( + view: GoalView | ProjectView, + request: IndexedRecord, +): OpenRequestState { + if (view.awaitingInput.includes(request.uri)) return 'awaiting' + if (view.winningClaims[request.uri]) return 'claimed' + return request.value.assignee ? 'assigned' : 'open' +} + export type Verdict = 'approve' | 'request_changes' export interface UnitVersion { @@ -301,12 +328,8 @@ export function timeline(index: MaterializedIndex, targetUri: string): UnitView[ claimedRequestUris.add(request.uri) } - const stateOfRequest = (request: IndexedRecord): UnitState => { - if (retractedUris.has(request.uri)) return 'retracted' - if (awaitingUris.has(request.uri)) return 'awaiting' - if (view.winningClaims[request.uri]) return 'claimed' - return request.value.assignee ? 'assigned' : 'open' - } + const stateOfRequest = (request: IndexedRecord): UnitState => + retractedUris.has(request.uri) ? 'retracted' : openRequestState(view, request) // A retracted request that never produced anything is still a row: a tombstone withdrew it, // nothing was deleted, and hiding it would misreport the history. diff --git a/packages/core/test/fixture.test.mjs b/packages/core/test/fixture.test.mjs index 21bc549..88826c3 100644 --- a/packages/core/test/fixture.test.mjs +++ b/packages/core/test/fixture.test.mjs @@ -116,19 +116,25 @@ describe('the comp, as records', () => { { uri: plan.current.artifact.uri, cid: plan.current.artifact.cid }, ]) - // The goal thread is four messages, oldest first: two standalone, then one exchange — a human's - // reply asking something, and the agent's answer to it, which an `answer` request commissioned. + // The goal thread is five messages, oldest first: two standalone, then one exchange — a human's + // reply asking something, and the agent's answer to it, which an `answer` request commissioned — + // and then a second question that is still with the agent. const goalView = view.goals.find((candidate) => candidate.target.uri === fixture.goals.reviewQueue) - assert.equal(goalView.messages.length, 4) - const reply = goalView.messages.at(-1) - assert.ok(reply.value.parent, 'the last message is threaded under the question it answers') + assert.equal(goalView.messages.length, 5) + const reply = goalView.messages[3] + assert.ok(reply.value.parent, 'the answered question is threaded under what it answers') assert.ok(reply.value.re, 'and it names the request that asked for it') - // The answer request is fulfilled by that reply, so nothing is owed… - assert.equal( - goalView.openRequests.some((request) => request.value.type === 'answer'), - false, + // The first answer request is fulfilled by that reply, and the second is not — an agent is + // writing it now. Both are `answer` requests naming one exact message. + const answers = goalView.requests.filter((request) => request.value.type === 'answer') + const openAnswers = goalView.openRequests.filter((request) => request.value.type === 'answer') + assert.equal(answers.length, 2) + assert.deepEqual( + openAnswers.map((request) => request.value.assignee), + ['did:plc:r3d5xnp0uq7w'], ) - // …and a conversation is never a unit: the goal's rows are the three above and no more. + // …and a conversation is never a unit, answered or not: the goal's rows are the three above and + // no more. assert.equal(units.length, 3) }) @@ -147,16 +153,21 @@ describe('the comp, as records', () => { // Nobody was asked about the plan. Landing is not done, so it is owed a verdict anyway. assert.deepEqual(plan.current.reviewRequests, []) - // The implementation was explicitly put in front of a human. A review request is a verdict owed - // on one exact version, not a unit — it hangs off the version it pins and adds no row. + // The implementation was put in front of two readers: the daemon's own reviewer the moment it + // landed (auto-review is on for `implementation` here), and then a named human. A review request + // is a verdict owed on one exact version, not a unit — it hangs off the version it pins, so two + // of them add no rows at all. assert.equal(units.length, 2) - assert.equal(implementation.current.reviewRequests.length, 1) - const owed = implementation.current.reviewRequests[0] - assert.equal(owed.value.assignee, 'did:plc:qv7hjr2mzk4x') - assert.deepEqual(owed.value.subject, { - uri: implementation.current.artifact.uri, - cid: implementation.current.artifact.cid, - }) + assert.deepEqual( + implementation.current.reviewRequests.map((request) => request.value.assignee), + ['did:plc:r3d5xnp0uq7w', 'did:plc:qv7hjr2mzk4x'], + ) + for (const owed of implementation.current.reviewRequests) { + assert.deepEqual(owed.value.subject, { + uri: implementation.current.artifact.uri, + cid: implementation.current.artifact.cid, + }) + } }) it('blocks the capture goal on an unanswered question', () => { diff --git a/packages/core/test/timeline.test.mjs b/packages/core/test/timeline.test.mjs index b151d66..9bb1d41 100644 --- a/packages/core/test/timeline.test.mjs +++ b/packages/core/test/timeline.test.mjs @@ -6,6 +6,7 @@ import { allUnits, durabilityOf, mergesSince, + openRequestState, staleness, tally, timeline, @@ -314,3 +315,99 @@ describe('timeline over the golden scenario', () => { assert.deepEqual(timeline(index, 'at://did:plc:nobody/x/y'), []) }) }) + +describe('where an open request stands', () => { + // `timeline()` computes this ladder for the requests that ARE units and nothing else. A `review` + // and an `answer` request never become units, and the lists that draw one need the same answer — + // so the rungs are one exported function, and this is the test that they agree. + const goalOf = (index, uri) => index.goals.find((goal) => goal.target.uri === uri) + const requestBy = (view, rkey) => view.openRequests.find((request) => request.rkey === rkey) + + const reviewRequest = (scenario, rkey, extra) => { + const goal = scenario.records.find((record) => record.uri === scenario.goalUri) + const planV2 = scenario.records.find((record) => record.rkey === 'plan-v2') + return { + did: 'did:plc:human', + collection: COLLECTIONS.artifactRequest, + rkey, + uri: `at://did:plc:human/${COLLECTIONS.artifactRequest}/${rkey}`, + cid: `cid-${rkey}`, + rev: '0000000000060', + value: { + $type: COLLECTIONS.artifactRequest, + goal: { uri: goal.uri, cid: scenario.originalGoalCid }, + type: 'review', + subject: { uri: planV2.uri, cid: planV2.cid }, + basedOn: [], + createdAt: '2026-01-01T00:23:00Z', + ...extra, + }, + } + } + + it('reads the four rungs off a request that is not, and never will be, a unit', () => { + const scenario = goldenScenario() + const records = structuredClone(scenario.records) + const open = reviewRequest(scenario, 'review-open') + const assigned = reviewRequest(scenario, 'review-assigned', { assignee: 'did:plc:agentb' }) + const claimed = reviewRequest(scenario, 'review-claimed') + const parked = reviewRequest(scenario, 'review-parked', { assignee: 'did:plc:agentb' }) + records.push(open, assigned, claimed, parked, { + did: 'did:plc:agentb', + collection: COLLECTIONS.claim, + rkey: 'claim-review', + uri: `at://did:plc:agentb/${COLLECTIONS.claim}/claim-review`, + cid: 'cid-claim-review', + rev: '0000000000061', + value: { + $type: COLLECTIONS.claim, + request: { uri: claimed.uri, cid: claimed.cid }, + expiresAt: '2026-02-01T01:00:00Z', + createdAt: '2026-01-31T23:50:00Z', + }, + }, { + // The assignee asked a question of its own and stopped. A review turn can do this exactly as + // any other turn can, which is the hole that made a review ask vanish from every list. + did: 'did:plc:agentb', + collection: COLLECTIONS.message, + rkey: 'review-question', + uri: `at://did:plc:agentb/${COLLECTIONS.message}/review-question`, + cid: 'cid-review-question', + rev: '0000000000062', + value: { + $type: COLLECTIONS.message, + goal: { uri: scenario.goalUri, cid: scenario.originalGoalCid }, + body: 'Which of the two criteria am I judging this against?', + mentions: ['did:plc:human'], + re: { uri: parked.uri, cid: parked.cid }, + createdAt: '2026-01-01T00:23:30Z', + }, + }) + + const index = build(records) + const view = goalOf(index, scenario.goalUri) + assert.equal(openRequestState(view, requestBy(view, 'review-open')), 'open') + assert.equal(openRequestState(view, requestBy(view, 'review-assigned')), 'assigned') + assert.equal(openRequestState(view, requestBy(view, 'review-claimed')), 'claimed') + // Blocked on a person outranks who it is named to: the assignee is not going to run it again + // until somebody answers. + assert.equal(openRequestState(view, requestBy(view, 'review-parked')), 'awaiting') + // And none of the four is a unit, so none of them moved the goal's pie. + assert.equal(timeline(index, scenario.goalUri).some((unit) => unit.type === 'review'), false) + assert.deepEqual( + tally(timeline(index, scenario.goalUri)), + tally(timeline(build(scenario.records), scenario.goalUri)), + ) + }) + + it('is the same ladder `timeline()` puts a unit’s own request on', () => { + const scenario = goldenScenario() + const index = build(scenario.records) + const view = goalOf(index, scenario.goalUri) + const openRequest = requestBy(view, 'request-open') + // The golden open request is unassigned and claimed, and `timeline()` hangs it off the plan it + // continues — so the unit reads `judged` (its TIP) while the request itself reads `claimed`. + assert.equal(openRequestState(view, openRequest), 'claimed') + assert.equal(view.winningClaims[openRequest.uri].uri, scenario.claimWinnerUri) + }) +}) diff --git a/packages/sidecar/test/thread-list.test.mjs b/packages/sidecar/test/thread-list.test.mjs index 2f1ca1f..863ac98 100644 --- a/packages/sidecar/test/thread-list.test.mjs +++ b/packages/sidecar/test/thread-list.test.mjs @@ -21,9 +21,10 @@ const goal = (uri) => index.goals.find((view) => view.target.uri === uri) describe('message list', () => { it('flattens the exchange the UI draws: a row, then its replies, in that order', () => { - // The review queue's thread is one conversation in four messages: a reviewer's note, a human's + // The review queue's thread is one conversation in five messages: a reviewer's note, a human's // question threaded under it, and the agent's answer threaded under THAT — a second-level reply, - // which the fold flattens onto the row and marks with the message it was actually answering. + // which the fold flattens onto the row and marks with the message it was actually answering — + // and then a later top-level question an agent has been asked to reply to but has not yet. const lines = threadListing(goal(fixture.goals.reviewQueue)) assert.deepEqual( lines.map((line) => [line.depth, named[line.did], threadFlags(line)]), @@ -32,11 +33,12 @@ describe('message list', () => { [0, 'reviewer', '-'], [1, 'tim', 'reply'], [1, 'reviewer', 'reply'], + [0, 'tim', '-'], ], ) // The reply order is the thread's, not the flat list's: the agent's answer follows the question - // it answers even though a later top-level message would sort between them. - const answer = lines.at(-1) + // it answers even though a later top-level message sorts between them. + const answer = lines[3] assert.equal(answer.inReplyTo, lines[2].locator) // It carries `re` — an answer turn's reply closes its request — but it is not a question: the // `parent` half is what tells the two apart, and getting that wrong is what would make a turn diff --git a/packages/ui/src/lib/asks.test.ts b/packages/ui/src/lib/asks.test.ts new file mode 100644 index 0000000..7140a4c --- /dev/null +++ b/packages/ui/src/lib/asks.test.ts @@ -0,0 +1,341 @@ +import { + COLLECTIONS, + materialize, + MemoryRecordStore, + tally, + type MaterializedIndex, +} from '@radial/core' +import { FIXTURE_DIDS, fixtureSpace } from '@radial/core/fixture' +import { describe, expect, it } from 'vitest' +import { asks, asksByTarget, asksWithAgent, parkedAsks } from './asks.js' +import { buildFixtureSpace } from './fixture.js' +import { goalHref, systemHref, unitHref, unitsOf } from './space.js' +import { agentDids, askBadges, askWithAgent, isGoalView } from './units.js' +import { reviewQueue, unjudged } from './verdicts.js' + +// A `review` and an `answer` are the two things an agent works on that never become units. Everything +// here is one rule seen from the sides the lists touch: an ask is with an agent when it is claimed, or +// assigned to an active agent member; it is awaiting input when its turn asked something back; and it +// is with a person otherwise. No row may be in two of those at once, and none of them is work. +const space = buildFixtureSpace() + +const IGNORED = 'Surface the records the fold ignored' +const QUEUE = 'Review queue in the web UI' + +const goal = (title: string) => { + const found = space.index.goals.find((entry) => entry.target.value.title === title) + if (!found) throw new Error(`fixture goal missing: ${title}`) + return found +} + +const ask = (index: MaterializedIndex, rkey: string) => { + const found = asks(index).find((row) => row.request.rkey === rkey) + if (!found) throw new Error(`fixture ask missing: ${rkey}`) + return found +} + +/** The daemon's auto-review of the g8 implementation: written by the reviewer, assigned to it. */ +const AUTO_REVIEW = '3lbr7ii8jj50k' +/** Ana's review of the same version, asked of tim — a person. */ +const HUMAN_REVIEW = '3lbr7gg7hh50j' +/** Tim's re-read of the architecture: open to any member, and claimed by the reviewer. */ +const CLAIMED_REVIEW = '3lbg3rq6ss60r' +/** The open message ask in g1, assigned to the reviewer. */ +const MESSAGE_ASK = '3lbq2ans0002' + +/** + * The fixture as it reads once that ask's turn has asked a question back and stopped. + * + * Index surgery rather than new records, the way `replies.test.ts` builds its parked case: nothing + * here is a state the fold could not produce — `computeAwaitingInput` puts a request's uri in this + * list the moment its assignee posts an unanswered question — and the fixture deliberately does not + * carry a fourth ask just to hold one. + */ +function parkedOn(rkey: string): MaterializedIndex { + const blocked = ; awaitingInput: string[] }>( + target: T, + ): T => { + const held = target.openRequests.filter((request) => request.rkey === rkey) + return held.length === 0 + ? target + : { ...target, awaitingInput: [...target.awaitingInput, ...held.map((request) => request.uri)] } + } + return { + ...space.index, + goals: space.index.goals.map(blocked), + projects: space.index.projects.map(blocked), + } +} + +describe('what an ask is with', () => { + it('is with an agent when an agent was named, and stays out of the human queue', () => { + const auto = ask(space.index, AUTO_REVIEW) + expect(auto.kind).toBe('review') + expect(auto.state).toBe('assigned') + expect(auto.actor).toBe(FIXTURE_DIDS.reviewer) + expect(askWithAgent(auto, agentDids(space.index))).toBe(true) + expect(asksWithAgent(space.index).map((row) => row.request.rkey)).toContain(AUTO_REVIEW) + + // The version it pins is the implementation's v1, so the row can open the drawer there rather + // than following whatever lands next. + expect(auto.pinned?.unit.type).toBe('implementation') + expect(auto.pinned?.version.artifact.uri).toBe( + unitsOf(space.index, goal(IGNORED)).find((unit) => unit.type === 'implementation')?.current + ?.artifact.uri, + ) + + // …and it is not owed by a person, so the signed-out queue does not claim it too. This is the + // whole of the double-counting rule: one predicate, and the rail's badge is the sum of the + // lists its pages draw. + expect(reviewQueue(space.index).map((row) => row.unit.type)).toEqual(['implementation']) + expect(reviewQueue(space.index)).toHaveLength(1) + }) + + it('is with a person when a person was named, however many agents are also asked', () => { + const human = ask(space.index, HUMAN_REVIEW) + expect(human.actor).toBe(FIXTURE_DIDS.tim) + expect(askWithAgent(human, agentDids(space.index))).toBe(false) + expect(asksWithAgent(space.index).map((row) => row.request.rkey)).not.toContain(HUMAN_REVIEW) + // Two asks on one version are two rows: this is the one in tim's queue, and the daemon's is in + // nobody's — a queue is what a person is waiting to do, whoever is signed in. + expect(reviewQueue(space.index, FIXTURE_DIDS.tim)).toHaveLength(1) + expect(reviewQueue(space.index, FIXTURE_DIDS.reviewer)).toHaveLength(0) + }) + + it('is with an agent when one holds a live lease on an unassigned ask, and says for how long', () => { + const claimed = ask(space.index, CLAIMED_REVIEW) + expect(claimed.state).toBe('claimed') + // Nobody was named; the claim is what says an agent has it, and who. + expect(claimed.request.value.assignee).toBeUndefined() + expect(claimed.claim?.did).toBe(FIXTURE_DIDS.reviewer) + expect(claimed.actor).toBe(FIXTURE_DIDS.reviewer) + expect(askWithAgent(claimed, agentDids(space.index))).toBe(true) + + // Project-scoped: an ask hangs off a system document as readily as off a goal's artifact. + expect(claimed.target.target.uri).toBe(fixtureSpace().projects.radialNg) + expect(claimed.pinned?.unit.type).toBe('architecture') + + const badges = askBadges(claimed, { now: space.asOf }) + expect(badges.map((badge) => badge.text)).toEqual(['claimed', 'architecture v3']) + expect(badges[0]?.title).toBe('lease expires in 8 min') + }) + + it('is with an agent when an agent was asked to reply', () => { + const reply = ask(space.index, MESSAGE_ASK) + expect(reply.kind).toBe('answer') + expect(reply.state).toBe('assigned') + expect(reply.actor).toBe(FIXTURE_DIDS.reviewer) + expect(reply.pinned).toBeUndefined() + expect(reply.subject?.value.body).toMatch(/stale-view guard/) + expect(asksWithAgent(space.index).map((row) => row.request.rkey)).toContain(MESSAGE_ASK) + }) + + it('is with nobody once its assignee is no longer a member', () => { + // `agentDids` counts ACTIVE members only. An ask named to a removed agent still reads + // `assigned` — the record says so — but nothing is going to run it, so it is not with an agent. + // It stays in the signed-out queue, where a human can see it and retract it. + const removed = new Set() + const auto = ask(space.index, AUTO_REVIEW) + expect(askWithAgent(auto, removed)).toBe(false) + const withoutReviewer: MaterializedIndex = { + ...space.index, + members: space.index.members.map((member) => + member.did === FIXTURE_DIDS.reviewer ? { ...member, active: false } : member, + ), + } + expect(agentDids(withoutReviewer).has(FIXTURE_DIDS.reviewer)).toBe(false) + expect(asksWithAgent(withoutReviewer).map((row) => row.request.rkey)).not.toContain(AUTO_REVIEW) + expect(reviewQueue(withoutReviewer).map((row) => row.unit.type)).toEqual([ + 'implementation', + 'implementation', + ]) + }) +}) + +describe('where a row goes', () => { + it('opens a review on the version being judged, wherever that version lives', () => { + // `AskRow` builds this: the base is the target's own page, and the version is carried so the + // drawer opens on what is being read rather than following the tip of whatever lands next. + const auto = ask(space.index, AUTO_REVIEW) + if (!auto.pinned) throw new Error('fixture ask lost its version') + expect(unitHref(goalHref(goal(IGNORED)), auto.pinned.unit, auto.pinned.version)).toMatch( + /^\/g\/[^/]+\/[^/]+\?unit=.+&v=1$/, + ) + + const claimed = ask(space.index, CLAIMED_REVIEW) + const project = space.index.projects.find( + (entry) => entry.target.uri === fixtureSpace().projects.radialNg, + ) + if (!claimed.pinned || !project) throw new Error('fixture ask lost its version') + // Its third version, and the System page rather than a goal's. + expect(claimed.pinned.version.version).toBe(3) + expect(unitHref(systemHref(project), claimed.pinned.unit, claimed.pinned.version)).toBe( + `${systemHref(project)}?unit=${encodeURIComponent(claimed.pinned.unit.key)}&v=3`, + ) + }) + + it('opens a reply in the thread, because that is where the box to answer it is', () => { + // An answer ask pins no version, so `AskRow` falls back to the target's own page — the goal's + // thread, where both the question and the composer are. + const reply = ask(space.index, MESSAGE_ASK) + expect(reply.pinned).toBeUndefined() + expect(isGoalView(reply.target)).toBe(true) + expect(goalHref({ uri: reply.target.target.uri })).toBe(goalHref(goal(QUEUE))) + }) +}) + +describe('an ask whose turn asked a question back', () => { + it('leaves the in-flight list for the awaiting one rather than for nowhere', () => { + // The failure this guards: if asks join "With an agent" and parked ones are not added to + // "Awaiting input", a review vanishes from every list the moment its agent asks something. + expect(parkedAsks(space.index)).toEqual([]) + + const index = parkedOn(AUTO_REVIEW) + const parked = ask(index, AUTO_REVIEW) + expect(parked.state).toBe('awaiting') + expect(parkedAsks(index).map((row) => row.request.rkey)).toEqual([AUTO_REVIEW]) + expect(asksWithAgent(index).map((row) => row.request.rkey)).not.toContain(AUTO_REVIEW) + // …and it does not fall into the review queue on the way past: "Waiting on you" is a group on + // that same page, so a row in both would be drawn twice and counted twice. + expect(reviewQueue(index)).toHaveLength(1) + expect(askBadges(parked).map((badge) => badge.text)).toContain('awaiting you') + }) + + it('does the same for a reply', () => { + const index = parkedOn(MESSAGE_ASK) + expect(parkedAsks(index).map((row) => row.request.rkey)).toEqual([MESSAGE_ASK]) + expect(asksWithAgent(index).map((row) => row.request.rkey)).not.toContain(MESSAGE_ASK) + }) +}) + +describe('an ask that has been answered', () => { + it('is in no list at all', () => { + // Both fulfillment rules, as the fixture already carries them: a review naming the request it + // answers, and a reply threaded under the message its request pinned. Neither request is open, + // so neither is an ask. + const rkeys = asks(space.index).map((row) => row.request.rkey) + // The g8 plan's review request… there is none; the g1 answer exchange completed. + expect(rkeys).not.toContain('3lbq2ans0001') + // Nothing in the space has more asks than the four the fixture writes. + expect(rkeys).toEqual([AUTO_REVIEW, HUMAN_REVIEW, CLAIMED_REVIEW, MESSAGE_ASK]) + }) +}) + +describe('shelving', () => { + const shelved = (collection: string, rkey: string, subject: { uri: string; cid: string }) => { + const fixture = fixtureSpace() + const store = new MemoryRecordStore() + for (const record of fixture.records) store.put(record) + store.put({ + did: FIXTURE_DIDS.tim, + collection, + rkey, + uri: `at://${FIXTURE_DIDS.tim}/${collection}/${rkey}`, + cid: `cid-${rkey}`, + rev: '9999999999999', + value: { + $type: collection, + ...(collection === COLLECTIONS.archiveGoal ? { goal: subject } : { project: subject }), + archived: true, + createdAt: fixture.asOf, + }, + } as never) + return materialize(store, { spaceUri: fixture.spaceUri, asOf: fixture.asOf }) + } + + it('takes a goal’s asks out of every list, exactly as it takes its units', () => { + const fixture = fixtureSpace() + const record = fixture.records.find((entry) => entry.uri === fixture.goals.ignored) + if (!record) throw new Error('fixture goal missing') + const index = shelved(COLLECTIONS.archiveGoal, '3lba0archive001', { + uri: record.uri, + cid: record.cid, + }) + const rkeys = asks(index).map((row) => row.request.rkey) + expect(rkeys).not.toContain(AUTO_REVIEW) + expect(rkeys).not.toContain(HUMAN_REVIEW) + expect(reviewQueue(index)).toEqual([]) + expect(unjudged(index)).toEqual([]) + }) + + it('takes a project’s asks with it, including the project-scoped ones', () => { + const fixture = fixtureSpace() + const record = fixture.records.find((entry) => entry.uri === fixture.projects.radialNg) + if (!record) throw new Error('fixture project missing') + const index = shelved(COLLECTIONS.archiveProject, '3lba0archive002', { + uri: record.uri, + cid: record.cid, + }) + // Archiving a project shelves its own system units and every goal under it, so all four asks go. + expect(asks(index)).toEqual([]) + expect(asksWithAgent(index)).toEqual([]) + }) +}) + +describe('an ask is still not a unit', () => { + it('adds no row and moves no pie, on either target it can hang off', () => { + const ignored = unitsOf(space.index, goal(IGNORED)) + expect(ignored.some((unit) => unit.type === 'review')).toBe(false) + expect(ignored).toHaveLength(2) + expect(tally(ignored)).toEqual({ total: 2, judged: 0 }) + + const queue = unitsOf(space.index, goal(QUEUE)) + expect(queue.some((unit) => unit.type === 'answer')).toBe(false) + expect(queue).toHaveLength(3) + expect(tally(queue)).toEqual({ total: 3, judged: 1 }) + + // The project-scoped one is the same claim on the System page: six documents, six rows. + const project = space.index.projects.find( + (entry) => entry.target.uri === fixtureSpace().projects.radialNg, + ) + if (!project) throw new Error('fixture project missing') + expect(unitsOf(space.index, project)).toHaveLength(6) + }) +}) + +describe('the rail’s arithmetic', () => { + it('counts exactly the rows “With an agent” draws', () => { + // `Rail.svelte` adds `asksWithAgent` to the units it already counted; the page draws the same + // union, split into its two groups. The comment on that count is a constraint — the badge is the + // sum of the lists it links to — so the two are asserted against each other here. + const rows = asksWithAgent(space.index) + const drawn = [ + ...rows.filter((row) => row.state === 'claimed'), + ...rows.filter((row) => row.state === 'assigned'), + ] + expect(drawn).toHaveLength(rows.length) + expect(rows).toHaveLength(3) + // And no row is in both halves of the split "For me"/"With an agent" rule. + const agents = agentDids(space.index) + for (const row of asks(space.index)) { + const withAgent = askWithAgent(row, agents) + const parked = row.state === 'awaiting' + expect(withAgent && parked).toBe(false) + } + }) + + it('groups by the target a per-goal badge reads', () => { + const byTarget = asksByTarget(asks(space.index)) + expect(byTarget.get(goal(IGNORED).target.uri)?.map((row) => row.request.rkey)).toEqual([ + AUTO_REVIEW, + HUMAN_REVIEW, + ]) + expect(byTarget.get(goal(QUEUE).target.uri)?.map((row) => row.request.rkey)).toEqual([ + MESSAGE_ASK, + ]) + // A project-scoped ask never matches a goal uri, which is why the rail's amber-dot set needs no + // guard for it. + expect(byTarget.get(fixtureSpace().projects.radialNg)?.map((row) => row.request.rkey)).toEqual([ + CLAIMED_REVIEW, + ]) + }) + + it('is oldest first, matching the queue it sits beside', () => { + expect(asks(space.index).map((row) => row.request.value.createdAt)).toEqual([ + '2026-07-25T11:13:00Z', + '2026-07-25T11:40:00Z', + '2026-07-25T13:48:00Z', + '2026-07-25T14:09:20Z', + ]) + }) +}) diff --git a/packages/ui/src/lib/asks.ts b/packages/ui/src/lib/asks.ts new file mode 100644 index 0000000..f12f0ca --- /dev/null +++ b/packages/ui/src/lib/asks.ts @@ -0,0 +1,57 @@ +// The two built-in requests, as one list. +// +// A `review` and an `answer` are the two things an agent works on that are never units: neither +// produces an artifact, so `timeline()` skips both and a goal's pie does not move when somebody asks +// for a judgement or a reply. That was always right about the pie and always wrong about the smart +// lists — the daemon runs real turns for both (`dispatch.ts`), auto-review writes one assigned to its +// own reviewer agent, and the claim manager claims unassigned ones — so the most common shape of "an +// agent is working right now" was the one shape "With an agent" could not see. +// +// This module exists so the two pages and the rail read ONE list rather than each unioning two +// folds; it cannot live in `units.ts`, which both folds already import. The row shape is +// `AskContext` there, and the predicate that decides which list a row belongs in is `askWithAgent` +// there too — one predicate, so no row can be drawn in two lists at once and no count can overstate +// what its page draws. + +import type { MaterializedIndex } from '@radial/core' +import { answerAsks } from './replies.js' +import { agentDids, askWithAgent, type AskContext } from './units.js' +import { reviewAsks } from './verdicts.js' + +/** Oldest first, matching `reviewQueue` — the request's own instant, then its uri to break ties. */ +const byAge = (left: AskContext, right: AskContext): number => + left.request.value.createdAt.localeCompare(right.request.value.createdAt) || + left.request.uri.localeCompare(right.request.uri) + +/** Every open review and answer request in the space, for the lists that do not care which it is. */ +export const asks = (index: MaterializedIndex): AskContext[] => + [...reviewAsks(index), ...answerAsks(index)].sort(byAge) + +/** With an agent, one way or the other — what "With an agent" draws beside its units. */ +export function asksWithAgent(index: MaterializedIndex): AskContext[] { + const agents = agentDids(index) + return asks(index).filter((ask) => askWithAgent(ask, agents)) +} + +/** + * Stopped on a person: an ask whose turn asked a question back and ended. + * + * The pair to `asksWithAgent`, and not optional beside it. An ask that appears in "With an agent" + * and then vanishes the moment its agent asks something would be a worse bug than the one this + * fixes: the row would be in no list at all, and the person it is waiting on would never know. + */ +export const parkedAsks = (index: MaterializedIndex): AskContext[] => + asks(index).filter((ask) => ask.state === 'awaiting') + +/** + * Asks grouped by the target they hang off — what a per-goal badge reads, so a page folds the space + * once and every row on it reads its own slice rather than re-folding. + */ +export function asksByTarget(rows: AskContext[]): Map { + const byTarget = new Map() + for (const ask of rows) { + const key = ask.target.target.uri + byTarget.set(key, [...(byTarget.get(key) ?? []), ask]) + } + return byTarget +} diff --git a/packages/ui/src/lib/components/AnswerRow.svelte b/packages/ui/src/lib/components/AnswerRow.svelte deleted file mode 100644 index 0b4890b..0000000 --- a/packages/ui/src/lib/components/AnswerRow.svelte +++ /dev/null @@ -1,41 +0,0 @@ - - -
  • - - - reply - - - {entry.target.target.value.title}{entry.subject - ? ` · ${summarize(entry.subject.value.body, 80)}` - : ''} - - - - {#if assignee}{/if} - {relativeTime(entry.request.value.createdAt, space.asOf)} - - -
  • diff --git a/packages/ui/src/lib/components/AskReview.svelte b/packages/ui/src/lib/components/AskReview.svelte index 981712f..9fa3aaa 100644 --- a/packages/ui/src/lib/components/AskReview.svelte +++ b/packages/ui/src/lib/components/AskReview.svelte @@ -3,7 +3,8 @@ import { account } from '$lib/auth.svelte.js' import { toast, ui } from '$lib/ui.svelte.js' import type { Space } from '$lib/space.js' - import { askReviewArgs, askedForReview, pinnedLabel, possibleReviewers } from '$lib/verdicts.js' + import { pinnedLabel } from '$lib/units.js' + import { askReviewArgs, askedForReview, possibleReviewers } from '$lib/verdicts.js' import { write } from '$lib/write.js' import Glyph from './Glyph.svelte' import UriChip from './UriChip.svelte' diff --git a/packages/ui/src/lib/components/AskRow.svelte b/packages/ui/src/lib/components/AskRow.svelte new file mode 100644 index 0000000..d7469e1 --- /dev/null +++ b/packages/ui/src/lib/components/AskRow.svelte @@ -0,0 +1,59 @@ + + +
  • + + + {name} + + {#if entry.pinned && entry.pinned.unit.versions.length > 1} + v{entry.pinned.version.version} + {/if} + + + {where}{entry.subject ? ` · ${summarize(entry.subject.value.body, 80)}` : ''} + + + {#each askBadges(entry, { now: space.asOf }) as badge (badge.text)} + + {/each} + {#if actor}{/if} + {relativeTime(entry.request.value.createdAt, space.asOf)} + + +
  • diff --git a/packages/ui/src/lib/components/GoalRow.svelte b/packages/ui/src/lib/components/GoalRow.svelte index ec28a17..bd4eb68 100644 --- a/packages/ui/src/lib/components/GoalRow.svelte +++ b/packages/ui/src/lib/components/GoalRow.svelte @@ -3,7 +3,15 @@ import { tally } from '@radial/core' import { shortDate } from '$lib/format.js' import { goalHref, isClosed, unitsOf, type Space } from '$lib/space.js' - import { isAwaiting, isMoving, needsVerdict, unitParticipants } from '$lib/units.js' + import { + agentDids, + askWithAgent, + isAwaiting, + isMoving, + needsVerdict, + unitParticipants, + type AskContext, + } from '$lib/units.js' import Badge from './Badge.svelte' import Disc from './Disc.svelte' import Pie from './Pie.svelte' @@ -11,13 +19,27 @@ interface Props { goal: GoalView space: Space + /** + * The asks hanging off this goal — a review or a reply somebody asked an agent for. Narrowed by + * the caller (`asksByTarget`) rather than folded here, because a page draws many of these rows + * and the fold walks the whole space. + */ + asks?: AskContext[] } - const { goal, space }: Props = $props() + const { goal, space, asks = [] }: Props = $props() const units = $derived(unitsOf(space.index, goal)) const counts = $derived(tally(units)) - const awaiting = $derived(units.filter(isAwaiting).length) - const moving = $derived(units.filter(isMoving).length) + const agents = $derived(agentDids(space.index)) + // An ask is not work — it adds nothing to `tally()` and does not move the pie — but it is + // something an agent is doing, and a goal whose only live work is an agent writing a review used + // to read as idle. + const awaiting = $derived( + units.filter(isAwaiting).length + asks.filter((ask) => ask.state === 'awaiting').length, + ) + const moving = $derived( + units.filter(isMoving).length + asks.filter((ask) => askWithAgent(ask, agents)).length, + ) const open = $derived(units.filter((unit) => unit.state === 'open').length) const toJudge = $derived(units.filter(needsVerdict).length) // Three discs is a crowd; past that the row is about the goal, not its cast. diff --git a/packages/ui/src/lib/components/Rail.svelte b/packages/ui/src/lib/components/Rail.svelte index 8de5afc..5956338 100644 --- a/packages/ui/src/lib/components/Rail.svelte +++ b/packages/ui/src/lib/components/Rail.svelte @@ -2,8 +2,8 @@ import { page } from '$app/state' import { SvelteSet } from 'svelte/reactivity' import { durabilityOf, staleness, tally } from '@radial/core' + import { asks } from '$lib/asks.js' import { account } from '$lib/auth.svelte.js' - import { parkedAnswers } from '$lib/replies.js' import { session } from '$lib/session.svelte.js' import { goalHref, @@ -16,7 +16,7 @@ unitsOf, type Space, } from '$lib/space.js' - import { isAwaiting, isMoving, liveUnits } from '$lib/units.js' + import { agentDids, askWithAgent, isAwaiting, isMoving, liveUnits } from '$lib/units.js' import { reviewQueue, unjudged } from '$lib/verdicts.js' import { ui } from '$lib/ui.svelte.js' import Glyph from './Glyph.svelte' @@ -31,10 +31,15 @@ // The badge is the sum of the three lists `/me` actually draws, so the number and the page cannot // disagree — including once there is a session and the first of them narrows to one person. const me = $derived(account.status === 'signed-in' ? account.did : undefined) - // A parked answer request is stopped on a person like any awaiting unit, and is not a unit — both - // lists draw it, so both counts include it, and so does the amber dot on its goal. - const parkedRows = $derived(parkedAnswers(space.index)) + // An ask — a review or a reply somebody asked an agent for — is never a unit, and is with an agent + // or stopped on a person exactly like one. Every list that draws them derives from this one fold, + // so the space is walked once per render rather than once per number. + const askRows = $derived(asks(space.index)) + const agentSet = $derived(agentDids(space.index)) + const parkedRows = $derived(askRows.filter((ask) => ask.state === 'awaiting')) const parked = $derived(parkedRows.length) + // A project-scoped review ask simply never matches a goal uri, so it drops out of this set without + // a guard: the amber dot is a fact about a goal in the rail, and the System page has no row here. const parkedGoals = $derived(new Set(parkedRows.map((row) => row.target.target.uri))) const counts = $derived({ me: @@ -43,7 +48,9 @@ all.filter(({ unit }) => isAwaiting(unit)).length + parked, awaiting: all.filter(({ unit }) => isAwaiting(unit)).length + parked, - moving: all.filter(({ unit }) => isMoving(unit)).length, + moving: + all.filter(({ unit }) => isMoving(unit)).length + + askRows.filter((ask) => askWithAgent(ask, agentSet)).length, logbook: space.index.goals.filter(isClosed).length, }) diff --git a/packages/ui/src/lib/components/SmartList.svelte b/packages/ui/src/lib/components/SmartList.svelte index fe3d7fb..aa248f5 100644 --- a/packages/ui/src/lib/components/SmartList.svelte +++ b/packages/ui/src/lib/components/SmartList.svelte @@ -1,9 +1,9 @@
    @@ -47,8 +48,8 @@ {/if}
      {#each group.rows as entry (keyOf(entry))} - {#if isAnswerRow(entry)} - + {#if isAskRow(entry)} + {:else} {/if} diff --git a/packages/ui/src/lib/components/Verdict.svelte b/packages/ui/src/lib/components/Verdict.svelte index c67b634..abd7ebd 100644 --- a/packages/ui/src/lib/components/Verdict.svelte +++ b/packages/ui/src/lib/components/Verdict.svelte @@ -4,13 +4,13 @@ import { account } from '$lib/auth.svelte.js' import { toast, ui } from '$lib/ui.svelte.js' import type { Space } from '$lib/space.js' + import { pinnedLabel } from '$lib/units.js' import { SEVERITIES, blankFinding, findingsOf, mayJudge, owedBy, - pinnedLabel, verdictArgs, wroteVerdict, type FindingDraft, diff --git a/packages/ui/src/lib/replies.test.ts b/packages/ui/src/lib/replies.test.ts index 968548a..b33c97f 100644 --- a/packages/ui/src/lib/replies.test.ts +++ b/packages/ui/src/lib/replies.test.ts @@ -3,12 +3,12 @@ import { FIXTURE_DIDS } from '@radial/core/fixture' import { describe, expect, it } from 'vitest' import { buildFixtureSpace } from './fixture.js' import { + answerAsks, answerState, askAnswerArgs, askedForAnswer, defaultResponder, messageArgs, - parkedAnswers, pendingAnswers, pendingFor, possibleResponders, @@ -204,9 +204,9 @@ describe('what the thread reads as', () => { it('nests a reply under what it answers, and keeps the row count to top-level messages', () => { const view = goal(QUEUE) const rows = threadRows(view) - // Four messages: two standalone, and one exchange of two under the second. - expect(view.messages).toHaveLength(4) - expect(rows).toHaveLength(2) + // Five messages: three standalone, and one exchange of two under the second. + expect(view.messages).toHaveLength(5) + expect(rows).toHaveLength(3) expect(rows[1]?.replies).toHaveLength(2) // The agent's answer replies to the human's question, not to the row it is drawn under, so it // says so rather than pretending the thread is deeper than one level. @@ -235,19 +235,22 @@ describe('what the thread reads as', () => { describe('a request somebody owes a reply on', () => { it('hangs off the exact message it pins, and is gone once the reply lands', () => { const view = goal(QUEUE) - // The fixture's exchange is complete, so nothing is pending on it. - expect(pendingAnswers(view).size).toBe(0) + const answered = messageBy(view, 'Reads the index twice how?') + const pending = messageBy(view, 'Separate thing: does the stale-view guard hold') - const question = messageBy(view, 'Reads the index twice how?') - const request = view.requests.find((entry) => entry.value.type === 'answer') + // One row, under the message it pins rather than on the goal. The fixture's first exchange + // completed — the reply threaded under its subject closed the request — so that message has + // nothing pending on it at all, which is the whole of how a pending row disappears. + const rows = pendingAnswers(view) + expect(rows.size).toBe(1) + expect(pendingFor(rows, answered)).toEqual([]) + const [request] = pendingFor(rows, pending) if (!request) throw new Error('fixture answer request missing') - // Re-read as if the reply had not landed yet: the row is under the message, not on the goal. - const open = { ...view, openRequests: [...view.openRequests, request] } - const pending = pendingAnswers(open) - expect(pendingFor(pending, question).map((entry) => entry.uri)).toEqual([request.uri]) - expect(answerState(open, request)).toBe('assigned') + expect(request.value.assignee).toBe(FIXTURE_DIDS.reviewer) + + expect(answerState(view, request)).toBe('assigned') // …and amber when the answer turn asked a question of its own instead. - expect(answerState({ ...open, awaitingInput: [request.uri] }, request)).toBe('awaiting') + expect(answerState({ ...view, awaitingInput: [request.uri] }, request)).toBe('awaiting') }) it('offers Retract only to whoever the fold would honour a tombstone from', () => { @@ -269,40 +272,51 @@ describe('a request somebody owes a reply on', () => { expect(retractable(byAna, space.directory, FIXTURE_DIDS.ana)).toBe(true) }) - it('shows up in the awaiting-input fold once its own turn asks a question back', () => { + it('is a row outside the thread too, carrying the message it pins and whoever it is with', () => { const view = goal(QUEUE) - const request = view.requests.find((entry) => entry.value.type === 'answer') - if (!request) throw new Error('fixture answer request missing') - const question = messageBy(view, 'Reads the index twice how?') + const question = messageBy(view, 'Separate thing: does the stale-view guard hold') + + // Every OPEN one, whatever state it is in — not just the parked ones. An ordinary open answer + // request is with an agent, and the list that says so is the one it belongs in; the awaiting + // lists take it from here only once its turn asks something back. + const rows = answerAsks(space.index) + expect(rows).toHaveLength(1) + expect(rows[0]?.kind).toBe('answer') + expect(rows[0]?.state).toBe('assigned') + expect(rows[0]?.actor).toBe(FIXTURE_DIDS.reviewer) + expect(rows[0]?.target.target.uri).toBe(view.target.uri) + // It carries the message it pins, which is the whole of what a row outside the thread can say + // about what was asked. + expect(rows[0]?.subject?.uri).toBe(question.uri) + // And the completed exchange is in no list at all: its request is closed, so it is not open. + expect(rows.map((row) => row.request.rkey)).toEqual(['3lbq2ans0002']) + }) - // Nothing is parked in the fixture — the exchange completed — so the state is rebuilt from it: - // the request open, and blocked because the answer turn asked something of its own. - expect(parkedAnswers(space.index)).toEqual([]) - const parked = (open: boolean, blocked: boolean): MaterializedIndex => ({ + it('is not dropped when it is a person who was asked', () => { + // Nothing in the UI writes an answer request naming a human — asking a person to reply is what + // the thread already is — but a record can say it, and such a request is parked on that person + // when it blocks. Agent-ness is applied by the lists (`askWithAgent`), never inside this fold, + // so a human-assigned ask cannot fall out of every list at once. + const view = goal(QUEUE) + const humanised: MaterializedIndex = { ...space.index, goals: space.index.goals.map((entry) => entry === view ? { ...entry, - openRequests: open ? [...entry.openRequests, request] : entry.openRequests, - awaitingInput: blocked ? [request.uri] : [], + openRequests: entry.openRequests.map((request) => + request.value.type === 'answer' + ? { ...request, value: { ...request.value, assignee: FIXTURE_DIDS.ana } } + : request, + ), } : entry, ), - }) - - const rows = parkedAnswers(parked(true, true)) + } + const rows = answerAsks(humanised) expect(rows).toHaveLength(1) - expect(rows[0]?.request.uri).toBe(request.uri) - expect(rows[0]?.target.target.uri).toBe(view.target.uri) - // It carries the message it pins, which is the whole of what a row outside the thread can say - // about what was asked. - expect(rows[0]?.subject?.uri).toBe(question.uri) - - // With an agent is not the same as with a person: an open answer request nobody is blocked on - // belongs in the in-flight list, not this one. And a landed reply takes it out of both. - expect(parkedAnswers(parked(true, false))).toEqual([]) - expect(parkedAnswers(parked(false, true))).toEqual([]) + expect(rows[0]?.state).toBe('assigned') + expect(rows[0]?.actor).toBe(FIXTURE_DIDS.ana) }) it('is not a unit: it adds no row and does not move the goal’s tally', () => { diff --git a/packages/ui/src/lib/replies.ts b/packages/ui/src/lib/replies.ts index ac0ec70..a66d45e 100644 --- a/packages/ui/src/lib/replies.ts +++ b/packages/ui/src/lib/replies.ts @@ -26,9 +26,10 @@ import type { StrongRef, ThreadMessage, } from '@radial/core' +import { openRequestState } from '@radial/core' import type { Actor, Directory } from './directory.js' import { locator } from './requests.js' -import { isGoalView, liveTargets, type AnswerContext } from './units.js' +import { isGoalView, liveTargets, type AskContext } from './units.js' /** The built-in turn type that produces a thread reply. Not registry data — see `verdicts.ts` on * `review`, which is the same kind of built-in and is absent from `index.artifactTypes` for the same @@ -212,36 +213,45 @@ export const answerState = ( ): 'assigned' | 'awaiting' => (view.awaitingInput.includes(request.uri) ? 'awaiting' : 'assigned') /** - * Every open answer request across the space whose turn asked a question back and stopped. + * Every OPEN answer request across the space, with the message it pins and whoever it is with. * - * The awaiting-input lists are folds over units, and an answer request is never a unit — so without - * this one, an agent that stopped mid-reply is visible nowhere but the goal's own thread, and the - * list that promises "everything blocked on you" quietly is not. It is the same `awaitingInput` set - * every other awaiting row is drawn from; only the row shape differs, because there is no unit to - * hang it on. + * Every cross-goal list is a fold over units, and an answer request is never a unit — so without this + * one, an agent writing a reply, or one that stopped mid-reply to ask something back, is visible + * nowhere but the goal's own thread. "With an agent" would read empty while an agent worked, and + * "everything blocked on you" would quietly not be. The state is the same `awaitingInput` / + * `winningClaims` ladder every other row is drawn from (`openRequestState`); only the row shape + * differs, because there is no unit to hang it on. Shelving is honoured as `liveUnits` honours it. * - * Only PARKED ones: an ordinary open answer request is with an agent, not with a person, and belongs - * in that list rather than this one. Shelving is honoured exactly as `liveUnits` honours it. + * Deliberately NOT filtered to agent assignees. Nothing in the UI writes an answer request naming a + * person — asking a human to reply is what the thread already is — but a record can say it, and such + * a request is parked on that person when it blocks. Agent-ness is `askWithAgent`'s business alone, + * applied by the lists rather than by the fold, so a human-assigned ask cannot fall out of both. */ -export function parkedAnswers(index: MaterializedIndex): AnswerContext[] { - const rows: AnswerContext[] = [] +export function answerAsks(index: MaterializedIndex): AskContext[] { + const rows: AskContext[] = [] for (const target of liveTargets(index)) { + // An answer request carries a `goal`: it names a message, and messages hang off goals. if (!isGoalView(target)) continue - const blocked = new Set(target.awaitingInput) for (const request of target.openRequests) { - if (request.value.type !== ANSWER_TYPE || !blocked.has(request.uri)) continue + if (request.value.type !== ANSWER_TYPE) continue const subject = request.value.subject const pinned = subject ? target.messages.find((message) => message.uri === subject.uri && message.cid === subject.cid) : undefined - rows.push({ request, target, ...(pinned ? { subject: pinned } : {}) }) + const claim = target.winningClaims[request.uri] + const actor = request.value.assignee ?? claim?.did + rows.push({ + kind: 'answer', + request, + target, + state: openRequestState(target, request), + ...(actor ? { actor } : {}), + ...(claim ? { claim } : {}), + ...(pinned ? { subject: pinned } : {}), + }) } } - return rows.sort( - (left, right) => - left.request.value.createdAt.localeCompare(right.request.value.createdAt) || - left.request.uri.localeCompare(right.request.uri), - ) + return rows } /** diff --git a/packages/ui/src/lib/units.ts b/packages/ui/src/lib/units.ts index 1aa1f83..d96ea11 100644 --- a/packages/ui/src/lib/units.ts +++ b/packages/ui/src/lib/units.ts @@ -9,10 +9,12 @@ import type { ArtifactRequestRecord, CheckrunRecord, + ClaimRecord, GoalView, IndexedRecord, MaterializedIndex, MessageRecord, + OpenRequestState, ProjectView, StrongRef, UnitState, @@ -47,25 +49,72 @@ export interface UnitContext { } /** - * An answer request stopped on a person, seen from outside the thread it lives in. + * An **ask** — a built-in request, `review` or `answer` — seen from outside the thing it hangs off. * - * Conversation is not work, so an answer request is deliberately never a unit — it adds no row to a - * goal and no fraction to its pie (`timeline()` skips it). But a parked one is still blocked on a - * human exactly like any other turn's question, and the list whose whole job is "what is stopped on - * me" would read empty while an agent waited. So it is a row shape of its own rather than a unit. - * `parkedAnswers` in `replies.ts` is what produces these. + * Neither is work in the sense a row stands for: a review produces a judgement and an answer + * produces conversation, so `timeline()` skips both and neither adds a row to a goal or a fraction + * to its pie. But an agent really does run turns for both, and a turn of either kind can stop on a + * question exactly like any other. So an ask is a row shape of its own rather than a unit, and the + * lists that say "with an agent" and "stopped on you" draw it beside the units they fold. + * + * `reviewAsks` in `verdicts.ts` and `answerAsks` in `replies.ts` produce these; `asks.ts` is where + * the two become one list. */ -export interface AnswerContext { +export interface AskContext { + kind: 'review' | 'answer' request: IndexedRecord - target: GoalView - /** The message the request pins, when the view still holds that exact version. */ + /** A review ask may be PROJECT-scoped (a system artifact); an answer ask is always in a goal. */ + target: GoalView | ProjectView + state: OpenRequestState + /** Whose disc ends the row: the assignee, or whoever's claim won. */ + actor?: string + /** The live lease, when that is what says an agent has it. */ + claim?: IndexedRecord + /** `answer`: the exact message version it pins, when the view still holds it. */ subject?: IndexedRecord + /** `review`: the unit and exact version it pins, via `findVersion`. */ + pinned?: FoundVersion } -/** What a smart list can hold: a unit, or an answer request that never became one. */ -export type SmartRow = UnitContext | AnswerContext +/** What a smart list can hold: a unit, or an ask that never became one. */ +export type SmartRow = UnitContext | AskContext + +export const isAskRow = (row: SmartRow): row is AskContext => 'request' in row + +/** + * The DIDs this space treats as agents: active members whose grant says `agent`. + * + * Read off `index.members` rather than `index.agents`, because membership is what makes a DID an + * agent here — an agent that has never published a self-description is still one, and a request + * assigned to it is still with an agent rather than with a person. + */ +export const agentDids = (index: MaterializedIndex): Set => + new Set( + index.members.filter((member) => member.active && member.kind === 'agent').map((member) => member.did), + ) -export const isAnswerRow = (row: SmartRow): row is AnswerContext => 'request' in row +/** + * With an agent, one way or the other — the ask half of `isMoving`. + * + * One predicate, read by "With an agent" and — through `askWithPerson` below — by the review queue + * too, so the two cannot both claim a row: the rail's badge is the sum of the lists it links to, and + * a row counted twice overstates it. An ask assigned to a *human*, or to an agent whose membership + * has been removed, is not this — the first is a person's to answer and the second is nobody's to + * run, and it stays visible in the queue where a human can retract it. + */ +export const askWithAgent = (ask: AskContext, agents: Set): boolean => + ask.state === 'claimed' || (ask.state === 'assigned' && agents.has(ask.actor ?? '')) + +/** + * Owed by a person, and nothing has started: the third of the three places an ask can be. + * + * The partition is total and disjoint — with an agent, blocked on a person, or waiting for one — and + * that is the property the rail depends on. "For me" draws a parked ask under "Waiting on you", so a + * queue that also listed it under "Asked of someone" would put one row twice on one page and count + * it twice in the badge above it. + */ +export const askWithPerson = (ask: AskContext, agents: Set): boolean => + ask.state !== 'awaiting' && !askWithAgent(ask, agents) /** One block of a smart list: a heading, why it exists, and the rows under it. */ export interface SmartGroup { @@ -88,6 +137,18 @@ export const typeLabel = (name: string): string => (name === 'adr' ? 'ADR' : nam export const versionLabel = (unit: UnitView, version: UnitVersion): string => unit.versions.length > 1 ? `${typeLabel(unit.type)} v${version.version}` : typeLabel(unit.type) +/** + * What a review calls the thing it is judging: the type and the version, always — `implementation + * v1`, not `implementation`. + * + * `versionLabel` above drops the number where there has only ever been one version, because "plan + * v1" is noise in a provenance line. Here it is the whole point: a review pins one uri#cid, and the + * card — or the row standing for the review owed — has to say which one even when saying it is + * redundant today and will not be tomorrow. + */ +export const pinnedLabel = (unit: UnitView, version: UnitVersion): string => + `${typeLabel(unit.type)} v${version.version}` + /** * Every unit in the space, each still holding the view it came from — including the ones under a * goal somebody has shelved. This is the lookup fold: a reference is resolvable wherever it points, @@ -170,7 +231,11 @@ export function unitParticipants(unit: UnitView): string[] { * another hour while `materialize()` has already let it go. */ export function claimExpiry(unit: UnitView, now: string): string | undefined { - const claim = unit.claim + return leaseExpiry(unit.claim, now) +} + +/** The same sentence, for the rows that hold a claim without holding a unit (`AskContext`). */ +export function leaseExpiry(claim: IndexedRecord | undefined, now: string): string | undefined { const expires = claim?.value.expiresAt if (!claim || !expires) return undefined const observed = claimDeadline(claim.value, claim.firstSeenAt) @@ -305,6 +370,26 @@ export function unitBadges(unit: UnitView, options: { now: string } = { now: '' return badges } +/** + * The tail of an ask row, mirroring `unitBadges`: state first, then what it pins. + * + * Shorter than a unit's tail because an ask has less to say — nothing lands, so there is no verdict, + * no checks and no PR. What it does have that a unit row does not is the version it is a review OF, + * which is the only thing distinguishing two reviews owed on two versions of one document. + */ +export function askBadges(ask: AskContext, options: { now: string } = { now: '' }): RowBadge[] { + const badges: RowBadge[] = [] + if (ask.state === 'open') badges.push({ kind: 'flat', glyph: '○', text: 'open' }) + if (ask.state === 'assigned') + badges.push({ kind: 'accent', glyph: '◍', text: 'assigned', title: 'named to an agent, and not claimed yet' }) + if (ask.state === 'claimed') + badges.push({ kind: 'accent', glyph: '◌', text: 'claimed', title: leaseExpiry(ask.claim, options.now) ?? 'an agent holds a live lease' }) + if (ask.state === 'awaiting') badges.push({ kind: 'warn', glyph: '◉', text: 'awaiting you' }) + if (ask.pinned) + badges.push({ kind: 'flat', glyph: '◇', text: pinnedLabel(ask.pinned.unit, ask.pinned.version) }) + return badges +} + /** The state circle's shape. `landed` is the tip's state when nothing has reviewed it yet. */ export const dotState = (unit: UnitView): UnitState => unit.state diff --git a/packages/ui/src/lib/verdicts.test.ts b/packages/ui/src/lib/verdicts.test.ts index 2e88a43..899e5be 100644 --- a/packages/ui/src/lib/verdicts.test.ts +++ b/packages/ui/src/lib/verdicts.test.ts @@ -151,8 +151,13 @@ describe('the request a review answers', () => { it('is never one naming somebody else', () => { // The fold marks a review request fulfilled by any trusted review that pins the same subject — // it does not check who wrote it. Naming another member's request would cancel their work. + // Nobody asked ana for anything here, so she has nothing to answer even though there are two + // open asks on this exact version. + expect(version().reviewRequests).toHaveLength(2) expect(owedBy(version(), FIXTURE_DIDS.ana)).toBeUndefined() - expect(owedBy(version(), FIXTURE_DIDS.reviewer)).toBeUndefined() + // …and the daemon's own auto-review ask is the reviewer's to answer, never tim's and never hers. + expect(owedBy(version(), FIXTURE_DIDS.reviewer)?.value.assignee).toBe(FIXTURE_DIDS.reviewer) + expect(owedBy(version(), FIXTURE_DIDS.tim)?.value.assignee).toBe(FIXTURE_DIDS.tim) }) it('is nothing at all where nobody asked', () => { diff --git a/packages/ui/src/lib/verdicts.ts b/packages/ui/src/lib/verdicts.ts index a5d875e..8ace580 100644 --- a/packages/ui/src/lib/verdicts.ts +++ b/packages/ui/src/lib/verdicts.ts @@ -35,20 +35,21 @@ import type { UnitView, Verdict, } from '@radial/core' +import { openRequestState, REVIEW_TYPE_NAME } from '@radial/core' import type { Actor, Directory } from './directory.js' import { locator } from './requests.js' -import { liveUnits, typeLabel, type UnitContext } from './units.js' +import { + agentDids, + askWithPerson, + findVersion, + liveTargets, + liveUnits, + type AskContext, + type UnitContext, +} from './units.js' -/** - * What a review calls the thing it is judging: the type and the version, always — `implementation - * v1`, not `implementation`. - * - * Everywhere else a lone version is written without its number, because "plan v1" where there has - * only ever been one plan is noise. Here it is the whole point: a review pins one uri#cid, and the - * card has to say which one even when saying it is redundant today and will not be tomorrow. - */ -export const pinnedLabel = (unit: UnitView, version: UnitVersion): string => - `${typeLabel(unit.type)} v${version.version}` +// `pinnedLabel` — what a review calls the version it judges — lives in `units.ts` beside +// `versionLabel`, the same label for a provenance line. Import it from there. /** The lexicon's three, in the order a human reads them: least consequential first. */ export const SEVERITIES = ['info', 'warning', 'error'] as const @@ -226,31 +227,71 @@ export const askedForReview = (assignee: Actor | undefined, version: UnitVersion // ── the queue ─────────────────────────────────────────────────────────────────────────────────── /** - * Reviews somebody was explicitly asked for: open review requests, resolved back to the unit and - * the exact version each one pins. + * Every OPEN review request in the space, resolved back to the version it pins and to whoever it is + * with. * - * A fold over versions rather than over units, because that is where a review request lives. Two - * requests on one version, or requests on two versions of one unit, are separate rows — each is a - * separate review owed. + * A fold over open requests rather than over units, because that is where a review ask lives: a + * review request is a judgement owed on one exact version and never becomes a unit of its own. Two + * asks on one version are two rows — each is a separate review owed. Shelving is honoured exactly as + * `liveUnits` honours it, and a project-scoped ask (a review of a system document) is folded beside + * the goal-scoped ones. + */ +export function reviewAsks(index: MaterializedIndex): AskContext[] { + const rows: AskContext[] = [] + for (const target of liveTargets(index)) { + for (const request of target.openRequests) { + const subject = request.value.subject + if (request.value.type !== REVIEW_TYPE_NAME || !subject) continue + // Can't-happen: the materializer only files a review request under the target its subject + // resolves in. An ask whose version cannot be found has nothing a row could name, so it is + // dropped rather than drawn as a review of nothing. + const pinned = findVersion(index, subject) + if (!pinned) continue + const claim = target.winningClaims[request.uri] + const actor = request.value.assignee ?? claim?.did + rows.push({ + kind: 'review', + request, + target, + state: openRequestState(target, request), + ...(actor ? { actor } : {}), + ...(claim ? { claim } : {}), + pinned, + }) + } + } + return rows +} + +/** + * Reviews a PERSON was explicitly asked for and has not started: the asks above, minus the ones an + * agent is working on and the ones already stopped on a question, resolved back to the unit and the + * exact version each one pins. * * With a DID it is that person's queue. Without one — nobody signed in — it is every review owed by * anyone, because a tab with no session still knows what this space is waiting on a human for, and * saying nothing would be less true than saying "these, by someone". + * + * `askWithPerson` either way, and that is the whole of the no-double-counting rule: an ask with an + * agent is drawn by "With an agent", a parked one by "Waiting on you" — which is a group on this + * same page — and only what is left is a queue. Three lists, three states, no row in two of them. */ export function reviewQueue(index: MaterializedIndex, did?: string): UnitContext[] { - const rows: UnitContext[] = [] - for (const { unit, target } of liveUnits(index)) { - for (const version of unit.versions) { - for (const request of version.reviewRequests) { - if (did === undefined || request.value.assignee === did) rows.push({ unit, target, version }) - } - } - } - return rows.sort( - (left, right) => - left.unit.createdAt.localeCompare(right.unit.createdAt) || - left.unit.key.localeCompare(right.unit.key), - ) + const agents = agentDids(index) + return reviewAsks(index) + .filter( + (ask) => + askWithPerson(ask, agents) && + (did === undefined || ask.request.value.assignee === did), + ) + .flatMap((ask) => + ask.pinned ? [{ unit: ask.pinned.unit, target: ask.target, version: ask.pinned.version }] : [], + ) + .sort( + (left, right) => + left.unit.createdAt.localeCompare(right.unit.createdAt) || + left.unit.key.localeCompare(right.unit.key), + ) } /** diff --git a/packages/ui/src/lib/write.test.ts b/packages/ui/src/lib/write.test.ts index 6a043ab..5977fae 100644 --- a/packages/ui/src/lib/write.test.ts +++ b/packages/ui/src/lib/write.test.ts @@ -536,10 +536,14 @@ describe('judging a version from the browser', () => { await write(verdictArgs(version, { verdict: 'approve', findings: [], request: owed })) // Naming the request is what closes it: the fold marks a review request fulfilled when a trusted - // review carries its ref and pins the same subject. Nothing was deleted and nothing was flagged. + // review carries its ref and pins the same subject — that one, and only it. The daemon's + // auto-review ask on the same version is untouched, which is the point of naming: answering + // somebody else's request would cancel work they are doing. Nothing was deleted, nothing flagged. expect(reviewQueue(space().index, FIXTURE_DIDS.tim)).toHaveLength(0) const after = unitOf('implementation') - expect(after.current?.reviewRequests).toEqual([]) + expect(after.current?.reviewRequests.map((request) => request.value.assignee)).toEqual([ + FIXTURE_DIDS.reviewer, + ]) expect(after.verdict).toBe('approve') expect(space().index.ignored).toEqual([]) }) diff --git a/packages/ui/src/routes/awaiting/+page.svelte b/packages/ui/src/routes/awaiting/+page.svelte index fdcfd86..95aaa88 100644 --- a/packages/ui/src/routes/awaiting/+page.svelte +++ b/packages/ui/src/routes/awaiting/+page.svelte @@ -1,13 +1,14 @@ @@ -21,7 +22,7 @@ { title: 'Awaiting input', hint: 'The turn posted a question and stopped. Answer in the thread and it can be picked up again.', - rows: [...all.filter(({ unit }) => isAwaiting(unit)), ...parkedAnswers(space.index)], + rows: [...all.filter(({ unit }) => isAwaiting(unit)), ...parkedAsks(space.index)], }, ]} empty={['Nothing is blocked on an answer.', 'A request lands here when its assignee asks a question nobody has answered.']} diff --git a/packages/ui/src/routes/inflight/+page.svelte b/packages/ui/src/routes/inflight/+page.svelte index ae71d3c..0f3a775 100644 --- a/packages/ui/src/routes/inflight/+page.svelte +++ b/packages/ui/src/routes/inflight/+page.svelte @@ -1,4 +1,5 @@ unit.state === 'claimed'), + rows: [ + ...all.filter(({ unit }) => unit.state === 'claimed'), + ...agentAsks.filter((ask) => ask.state === 'claimed'), + ], }, { title: 'Assigned', hint: 'Named to one agent, but not claimed yet.', - rows: all.filter(({ unit }) => unit.state === 'assigned'), + rows: [ + ...all.filter(({ unit }) => unit.state === 'assigned'), + ...agentAsks.filter((ask) => ask.state === 'assigned'), + ], }, ]} - empty={['Nothing is with an agent right now.', 'A request moves here when it is assigned, or when an agent claims an open one.']} + empty={[ + 'Nothing is with an agent right now.', + 'A request moves here when it is assigned, or when an agent claims an open one — including a review or a reply somebody asked an agent for.', + ]} /> diff --git a/packages/ui/src/routes/logbook/+page.svelte b/packages/ui/src/routes/logbook/+page.svelte index 1f954fb..52ccb7f 100644 --- a/packages/ui/src/routes/logbook/+page.svelte +++ b/packages/ui/src/routes/logbook/+page.svelte @@ -1,4 +1,5 @@