From 8d01466ede0742d3b4b403ab5d59312b35a2cc40 Mon Sep 17 00:00:00 2001 From: "codexbot.disnetdev.com (did:plc:hbonvqr5ysrscg5wdyb5klie)" Date: Tue, 11 Aug 2026 17:12:40 +0000 Subject: [PATCH] Hide shelved records from link autocomplete Co-Authored-By: codexbot.disnetdev.com (did:plc:hbonvqr5ysrscg5wdyb5klie) --- packages/ui/README.md | 5 ++-- packages/ui/src/lib/links.test.ts | 40 +++++++++++++++++++++---------- packages/ui/src/lib/links.ts | 32 +++++++++++++++---------- 3 files changed, 51 insertions(+), 26 deletions(-) diff --git a/packages/ui/README.md b/packages/ui/README.md index b90c5f9..5c8599b 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -305,13 +305,14 @@ as a result. ### Naming another record in a body -Typing `[[` in any of those fields offers the space's goals, artifacts, thread messages and projects, +Typing `[[` in any of those fields offers the space's active goals, artifacts, thread messages and projects, and picking one writes `[](at://did/collection/rkey)` — an ordinary markdown link naming an ordinary record (`links.ts`). Ctrl-Space opens the same list explicitly, filtered by the word already behind the caret and replacing it; Escape closes it and leaves the `[[` standing as the text it is, and a second Escape leaves the field, which is the app's one-layer-per-press rule. What is typed after `[[` filters by each row's OWN quick-find projection, so a record found by a word in a -list is found by that word here. +list is found by that word here. Ended goals and archived projects — including their artifacts and +messages — stay resolvable from links already written, but are not offered for new links. The filter is a substring test, so one wrong letter empties the list — and deleting it brings the list back. That takes work: an empty list is a closed one whatever the source returns, and CodeMirror diff --git a/packages/ui/src/lib/links.test.ts b/packages/ui/src/lib/links.test.ts index 5b6ae4f..1b67a91 100644 --- a/packages/ui/src/lib/links.test.ts +++ b/packages/ui/src/lib/links.test.ts @@ -13,6 +13,7 @@ import { type LinkCandidate, } from './links.js' import { projectByName, unitsOf } from './space.js' +import { liveTargets, liveUnits } from './units.js' // Naming a record from inside a body, in both directions: what a `[[` offers and writes, and where // the URI it wrote goes when the body is read back. @@ -55,10 +56,12 @@ const at = (text: string, explicit = false): CompletionQuery => ({ describe('linkCandidates', () => { it('offers every kind of record in the space', () => { - expect(of('goal').length).toBe(space.index.goals.length) - expect(of('project').length).toBe(space.index.projects.length) - expect(of('artifact').length).toBeGreaterThan(0) - expect(of('message').length).toBeGreaterThan(0) + expect(of('goal').length).toBe(liveTargets(space.index).filter((target) => 'ended' in target).length) + expect(of('project').length).toBe(liveTargets(space.index).filter((target) => 'archived' in target).length) + expect(of('artifact').length).toBe(liveUnits(space.index).length) + expect(of('message').length).toBe( + liveTargets(space.index).reduce((count, target) => count + target.messages.length, 0), + ) }) it('names a unit by its key, so what is written is what resolves', () => { @@ -80,11 +83,20 @@ describe('linkCandidates', () => { expect(lone.every((row) => row.detail.startsWith(`${row.title} · `))).toBe(true) }) - it('keeps ended goals, because a reference resolves wherever it points', () => { - // The lookup fold, not the live one. Shelving a goal must not turn a link into it back into an - // rkey — and the ending is in the detail, so the list says what it is offering. - const ended = of('goal').filter((row) => / · (completed|dropped|parked|superseded)$/.test(row.detail)) - expect(ended.length).toBeGreaterThan(0) + it('does not offer shelved targets or anything attached to them', () => { + const live = new Set(liveTargets(space.index).map((target) => target.target.uri)) + const shelved = [...space.index.goals, ...space.index.projects].filter( + (target) => !live.has(target.target.uri), + ) + expect(shelved.length).toBeGreaterThan(0) + + for (const target of shelved) { + expect(candidates.some((row) => row.uri === target.target.uri)).toBe(false) + expect(target.messages.some((message) => candidates.some((row) => row.uri === message.uri))).toBe(false) + for (const unit of unitsOf(space.index, target)) { + expect(candidates.some((row) => row.uri === unit.key)).toBe(false) + } + } }) it('ranks the thread last, so a busy space still offers its goals first', () => { @@ -284,9 +296,13 @@ describe('resolveAtLink', () => { }) it('sends a unit to its drawer, under the goal it hangs off', () => { - const unit = named('Six golden scenarios, shuffled 500 ways') - expect(where(unit.uri)).toContain(`unit=${encodeURIComponent(unit.uri)}`) - expect(where(unit.uri)).toContain('/g/') + // Resolution remains a lookup fold even though autocomplete offers only active work. In + // particular, an old link under an ended goal must keep opening after the goal is shelved. + const ended = space.index.goals.find((candidate) => candidate.ended) + const unit = ended && unitsOf(space.index, ended)[0] + expect(unit).toBeDefined() + expect(where(unit?.key ?? '')).toContain(`unit=${encodeURIComponent(unit?.key ?? '')}`) + expect(where(unit?.key ?? '')).toContain('/g/') }) it('sends a project-hung unit to System rather than to the project’s goal list', () => { diff --git a/packages/ui/src/lib/links.ts b/packages/ui/src/lib/links.ts index 98f865b..4f62946 100644 --- a/packages/ui/src/lib/links.ts +++ b/packages/ui/src/lib/links.ts @@ -25,7 +25,14 @@ // an ordinary interface, so the whole rule is testable in the `pure` project and `editor.ts` is left // holding nothing but the wiring. -import type { GoalView, MaterializedIndex, ProjectView, UnitView } from '@radial/core' +import { + activeGoals, + activeProjects, + type GoalView, + type MaterializedIndex, + type ProjectView, + type UnitView, +} from '@radial/core' import type { Directory } from './directory.js' import { goalSearchText } from './filters.js' import { artifactLabel, summarize } from './format.js' @@ -44,6 +51,8 @@ import { targetLabel, typeLabel, unitRowSearchText, + liveTargets, + liveUnits, unitsWithContext, } from './units.js' @@ -71,8 +80,10 @@ export interface LinkCandidate { } /** - * Everything in this space a body could name, in the order the list offers them: goals, then the - * units under them, then projects, then the thread. + * Everything still active in this space a body could name, in the order the list offers them: + * goals, then the units under them, then projects, then the thread. Ended goals and archived + * projects remain resolvable when an existing body names them, but autocomplete is an authoring + * surface: it does not invite somebody to attach new prose to work the space has shelved. * * Messages last and deliberately uncapped. They outnumber everything else in a busy space, so * offering them first would bury the goals — but capping the *candidates* would make an old message @@ -99,23 +110,20 @@ export function linkCandidates(space: Space): LinkCandidate[] { const cache = new WeakMap>() function goalCandidates(index: MaterializedIndex): LinkCandidate[] { - return index.goals.map((goal) => { + return activeGoals(index).map((goal) => { const project = projectOf(index, goal) - const ending = goal.ended ? ` · ${goal.disposition ?? 'ended'}` : '' return { kind: 'goal' as const, uri: goal.target.uri, title: goal.target.value.title, - detail: `goal · ${project?.name ?? 'no project'}${ending}`, + detail: `goal · ${project?.name ?? 'no project'}`, search: goalSearchText(goal), } }) } function unitCandidates(index: MaterializedIndex, directory: Directory): LinkCandidate[] { - // `unitsWithContext`, not `liveUnits`: this is the lookup fold. A plan under a goal somebody has - // ended is still the plan, and a reference to it is still worth writing down. - return unitsWithContext(index).map(({ unit, target }) => ({ + return liveUnits(index).map(({ unit, target }) => ({ kind: 'artifact' as const, uri: unit.key, title: unitTitle(unit), @@ -138,11 +146,11 @@ const unitTitle = (unit: UnitView): string => (unit.current ? artifactLabel(unit.current.artifact.value) : '') || typeLabel(unit.type) function projectCandidates(index: MaterializedIndex): LinkCandidate[] { - return index.projects.map((project) => ({ + return activeProjects(index).map((project) => ({ kind: 'project' as const, uri: project.target.uri, title: project.name, - detail: project.archived ? 'project · archived' : 'project', + detail: 'project', search: `${project.name} ${project.gitUrl}`, })) } @@ -150,7 +158,7 @@ function projectCandidates(index: MaterializedIndex): LinkCandidate[] { /** Newest first, so the exchange somebody is in the middle of is the part of it they are offered. */ function messageCandidates(index: MaterializedIndex, directory: Directory): LinkCandidate[] { const out: Array<{ at: string; candidate: LinkCandidate }> = [] - for (const target of [...index.goals, ...index.projects]) { + for (const target of liveTargets(index)) { for (const message of target.messages) { const who = directory.get(message.did) out.push({ -- 2.51.2