diff --git a/packages/ui/README.md b/packages/ui/README.md index 3d1aa91..fa237d5 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -27,7 +27,7 @@ if a `node:*` import creeps back onto that path. | `src/lib/auth.svelte.ts`, `write.ts` | The OAuth session, and `runCli()` over a `RepoWriter` built on it. The only two modules that write. | | `src/lib/store.ts`, `src/lib/idb.ts` | `RecordStore` and `SyncStateStore` over an in-memory mirror hydrated from IndexedDB. Synchronous reads, background writes, one database per space. | | `src/lib/identity.ts` | DID → PDS and DID → handle resolution, cached. A handle is shown only when it resolves back to the DID that claimed it. | -| `src/lib/units.ts` | Presentation over `timeline()`: a row's text, its badges, its status disc, and the two cross-target capture relations no single target's index can see. | +| `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. | | `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/diagnostics.ts` | `index.ignored` and `index.edits`, grouped for display. | | `src/lib/keys.ts`, `focus.ts` | The keyboard map, and focus restoration. | @@ -99,6 +99,28 @@ Escape unwinds one layer per press — picker, account popover, ⊕ menu, compos Inside a text field it blurs instead of closing, so a stray press never discards a half-written brief. Closing any layer returns focus to the control that opened it. +### Quick find + +`/` puts the caret in the pane bar's box, and what is typed there narrows **the view you are +looking at** — the rows on a list, the units and the thread on a goal, the members, types and +projects on the Space page. It changes no URL, writes no record and touches no index: it is a way of +reading one screen. + +A row is searched by what it shows — its type, the current version's title, where it hangs off, +the brief of a request still open on it, and whoever it is with — plus the artifact's full body, +which is how anything written before `title` existed is still findable. The projections live beside +the row models in `units.ts` and `replies.ts` so a string a row draws and a string quick find reads +cannot drift apart. + +While it is narrowing, counts and empty copy describe what is on screen: a section heading counts +the rows under it, and a list that a query emptied says so rather than claiming the desk is clear. +Goal-wide facts do not move — the pie, "3 of 5 reviewed" and the ending badge are about the goal, +not about the search — and neither do the request bar, the composer or the goal's own actions, +because a filter must not take away the way out of itself. + +It is per-tab and per-view: nothing persists it, it is never a search parameter, and the layout +clears it on the way to another route or another space. Escape clears it too. + ### Fixture mode `fixtureSpace()` from `@radial/core/fixture` — the comp's dataset as real `com.disnetdev.radial.*` diff --git a/packages/ui/src/lib/components/AskRow.svelte b/packages/ui/src/lib/components/AskRow.svelte index 539db8c..017da60 100644 --- a/packages/ui/src/lib/components/AskRow.svelte +++ b/packages/ui/src/lib/components/AskRow.svelte @@ -1,7 +1,7 @@ diff --git a/packages/ui/src/lib/components/SmartList.svelte b/packages/ui/src/lib/components/SmartList.svelte index aa248f5..ff51327 100644 --- a/packages/ui/src/lib/components/SmartList.svelte +++ b/packages/ui/src/lib/components/SmartList.svelte @@ -3,7 +3,8 @@ import CrossRow from './CrossRow.svelte' import Glyph from './Glyph.svelte' import type { Space } from '$lib/space.js' - import { isAskRow, type SmartGroup, type SmartRow } from '$lib/units.js' + import { filterSmartGroups, isAskRow, smartRowSearchText, type SmartGroup, type SmartRow } from '$lib/units.js' + import { filtering, matches } from '$lib/ui.svelte.js' interface Props { space: Space @@ -16,7 +17,19 @@ } const { space, title, glyph, color, notes, groups, empty }: Props = $props() - const filled = $derived(groups.filter((group) => group.rows.length > 0)) + // Quick find, for the three pages made of this component. It is applied here rather than in each + // route so "For me", "Awaiting input" and "With an agent" cannot come to search by different rules + // — and the counts beside the headings are computed from what survives, because a section that + // says 7 over 2 rows is a section lying about the page it is on. + // + // `filtering()` first so an unfiltered list composes no search text at all: these are whole-space + // folds, and the projections carry artifact bodies. + const filled = $derived( + filterSmartGroups( + groups, + (row) => !filtering() || matches(smartRowSearchText(row, space.directory)), + ), + ) // Two rows can appear in one list, and the difference is whether the thing has a unit at all: an // ask — a review or a reply somebody asked for — never becomes one, and is with an agent or @@ -36,9 +49,17 @@ {#if filled.length === 0} +
-

{empty[0]}

-

{empty[1]}

+ {#if filtering()} +

Nothing in this view matches that.

+

Clear quick find, or press Escape, to see the whole list again.

+ {:else} +

{empty[0]}

+

{empty[1]}

+ {/if}
{:else} {#each filled as group (group.title)} diff --git a/packages/ui/src/lib/components/Thread.svelte b/packages/ui/src/lib/components/Thread.svelte index 59f1f45..e643d24 100644 --- a/packages/ui/src/lib/components/Thread.svelte +++ b/packages/ui/src/lib/components/Thread.svelte @@ -6,13 +6,16 @@ import { retractArgs, retractable } from '$lib/requests.js' import { answerState, + filterThreadRows, + messageSearchText, pendingAnswers, pendingFor, + threadMessageCount, threadRows, type ThreadMessage, } from '$lib/replies.js' import type { Space } from '$lib/space.js' - import { toast } from '$lib/ui.svelte.js' + import { filtering, matches, toast } from '$lib/ui.svelte.js' import { write } from '$lib/write.js' import AskAnswer from './AskAnswer.svelte' import Badge from './Badge.svelte' @@ -34,8 +37,17 @@ } const { view, space, onReply }: Props = $props() - const rows = $derived(threadRows(view)) - const count = $derived(view.messages.length) + // Quick find narrows the exchange too — a long thread is exactly where looking for one thing said + // once is worth doing. What it does NOT touch is the composer below it or the actions on each row: + // a filter is a way of reading, and a reader who searches must still be able to answer what they + // found. + const rows = $derived( + filterThreadRows(threadRows(view), (message) => + !filtering() || matches(messageSearchText(message, space.directory)), + ), + ) + // What is on screen, which with no query is every message the view holds. + const count = $derived(threadMessageCount(rows)) const awaiting = $derived(new Set(view.awaitingInput)) const pending = $derived(pendingAnswers(view)) // Requests that are conversation rather than work. They are deliberately not units, so they have @@ -140,7 +152,7 @@
Thread {#if count}{count}{/if}
{#if rows.length === 0}
-

Nothing has been said on this goal yet.

+

{filtering() ? 'No message in this thread matches that.' : 'Nothing has been said on this goal yet.'}

{:else}
diff --git a/packages/ui/src/lib/quick-find.test.ts b/packages/ui/src/lib/quick-find.test.ts new file mode 100644 index 0000000..e1047ab --- /dev/null +++ b/packages/ui/src/lib/quick-find.test.ts @@ -0,0 +1,337 @@ +import type { GoalView } from '@radial/core' +import { beforeEach, describe, expect, it } from 'vitest' +import { asks } from './asks.js' +import { buildFixtureSpace } from './fixture.js' +import { filterThreadRows, messageSearchText, threadMessageCount, threadRows } from './replies.js' +import { projectByName, unitsOf } from './space.js' +import { + askRowSearchText, + filterSmartGroups, + isAskRow, + liveUnits, + smartRowSearchText, + targetLabel, + unitDays, + unitRowSearchText, + unitSearchText, + type AskContext, + type SmartGroup, + type SmartRow, + type UnitContext, +} from './units.js' +import { filtering, matches, ui } from './ui.svelte.js' + +// Quick find, end to end as pure functions: what a row is searched by, what a filtered list keeps, +// and what a filtered thread keeps. It is written against the fixture rather than against invented +// records, because the whole claim being tested is that a word a reader can SEE on a row is a word +// that finds it — and the fixture is the dataset those rows are drawn from. +// +// Nothing here renders anything. The components are three lines each on top of these functions +// (`SmartList.svelte`, `Thread.svelte`, the goal route), and `test/quick-find.test.mjs` checks that +// they are wired to them. +const space = buildFixtureSpace() +const { directory } = space + +const goalNamed = (title: string): GoalView => { + const found = space.index.goals.find((goal) => goal.target.value.title === title) + if (!found) throw new Error(`fixture goal missing: ${title}`) + return found +} + +const rows = liveUnits(space.index) + +const unitRow = (type: string, where: string): UnitContext => { + const found = rows.find((row) => row.unit.type === type && targetLabel(row.target) === where) + if (!found) throw new Error(`fixture unit missing: ${type} on ${where}`) + return found +} + +const askRow = (kind: 'review' | 'answer', where: string): AskContext => { + const found = asks(space.index).find((ask) => ask.kind === kind && targetLabel(ask.target) === where) + if (!found) throw new Error(`fixture ask missing: ${kind} on ${where}`) + return found +} + +/** What a page does with the query in the box: the predicate `SmartList` and the thread both use. */ +const keep = (text: string): boolean => !filtering() || matches(text) + +beforeEach(() => { + ui.query = '' +}) + +describe('matches', () => { + it('is off entirely until something is typed', () => { + expect(filtering()).toBe(false) + expect(matches('anything at all')).toBe(true) + // Whitespace is not a query. If it were, a stray space would empty every list on screen and the + // copy explaining why would disagree with the filter that did it. + ui.query = ' ' + expect(filtering()).toBe(false) + expect(matches('anything at all')).toBe(true) + }) + + it('normalizes case and surrounding whitespace once, for every page', () => { + ui.query = ' JETSTREAM ' + expect(filtering()).toBe(true) + expect(matches('Jetstream subscription with polling fallback')).toBe(true) + expect(matches('nothing of the sort')).toBe(false) + }) +}) + +describe('unitRowSearchText', () => { + it('finds a unit by its type', () => { + const row = unitRow('security-review', 'Review queue in the web UI') + ui.query = 'security-review' + expect(matches(unitRowSearchText(row.unit, row.target, directory))).toBe(true) + }) + + it('finds a landed artifact by the short title on its row', () => { + const row = unitRow('implementation', 'Surface the records the fold ignored') + ui.query = 'ignored records in a closed disclosure' + expect(matches(unitRowSearchText(row.unit, row.target, directory))).toBe(true) + }) + + it('still finds an artifact written before titles existed, by its body', () => { + // ADR 0004 does not make an untitled record unfindable: full-text search was the only way to + // find anything before `title` existed, and adding a title must not have taken it away. + const row = unitRow('plan', 'Surface the records the fold ignored') + expect(row.unit.current?.artifact.value.title).toBeUndefined() + ui.query = 'one disclosure at the foot of the pane' + expect(matches(unitRowSearchText(row.unit, row.target, directory))).toBe(true) + }) + + it('finds a unit by the goal it hangs off, and a system record by its project', () => { + const goalUnit = unitRow('plan', 'Operator onboarding page') + const systemUnit = unitRow('architecture', 'System · radial-ng') + ui.query = 'operator onboarding' + expect(matches(unitRowSearchText(goalUnit.unit, goalUnit.target, directory))).toBe(true) + ui.query = 'system · radial-ng' + expect(matches(unitRowSearchText(systemUnit.unit, systemUnit.target, directory))).toBe(true) + }) + + it('finds an unanswered request by the brief its row actually reads', () => { + const row = unitRow('plan', 'Capture requests should distill, not copy') + ui.query = 'scaffolding' + expect(matches(unitRowSearchText(row.unit, row.target, directory))).toBe(true) + }) + + it('finds a row by whoever the work is with, however that identity is spelled', () => { + const row = unitRow('plan', 'Operator onboarding page') + for (const spelling of ['planner', 'planner.radial.bot', 'did:plc:']) { + ui.query = spelling + expect(matches(unitRowSearchText(row.unit, row.target, directory))).toBe(true) + } + }) + + it('rejects text that is nowhere on the row', () => { + const row = unitRow('plan', 'Operator onboarding page') + ui.query = 'kubernetes' + expect(matches(unitRowSearchText(row.unit, row.target, directory))).toBe(false) + }) +}) + +describe('askRowSearchText', () => { + it('searches a review by the word the row prints, its target, and its brief', () => { + const ask = askRow('review', 'Surface the records the fold ignored') + for (const query of ['review', 'surface the records']) { + ui.query = query + expect(matches(askRowSearchText(ask, directory))).toBe(true) + } + }) + + it('searches a review by the exact version it pins — type, title and body', () => { + const ask = asks(space.index).find( + (row) => row.kind === 'review' && row.pinned?.unit.type === 'architecture', + ) + expect(ask).toBeDefined() + if (!ask) return + for (const query of ['architecture', 'four moving parts', 'write boundary']) { + ui.query = query + expect(matches(askRowSearchText(ask, directory))).toBe(true) + } + }) + + it('searches a reply by the word the row prints and by the message it pins', () => { + const ask = askRow('answer', 'Review queue in the web UI') + for (const query of ['reply', 'stale-view guard']) { + ui.query = query + expect(matches(askRowSearchText(ask, directory))).toBe(true) + } + // Never by the lexicon's type name. `answer` is not a word on the row, and a reader cannot + // search for something they have not been shown. + ui.query = 'answer' + expect(matches(askRowSearchText(ask, directory))).toBe(false) + }) + + it('is what smartRowSearchText picks for an ask, and the unit projection for a unit', () => { + const ask: SmartRow = askRow('answer', 'Review queue in the web UI') + const unit = unitRow('security-review', 'Review queue in the web UI') + expect(isAskRow(ask)).toBe(true) + expect(smartRowSearchText(ask, directory)).toBe(askRowSearchText(ask as AskContext, directory)) + expect(smartRowSearchText(unit, directory)).toBe( + unitRowSearchText(unit.unit, unit.target, directory), + ) + }) +}) + +describe('filterSmartGroups', () => { + const groups = (): SmartGroup[] => [ + { title: 'Asked of you', hint: 'Owed on one exact version.', rows: [...rows] }, + { title: 'Under way', rows: [unitRow('plan', 'Operator onboarding page')] }, + ] + const filtered = (): SmartGroup[] => + filterSmartGroups(groups(), (row) => keep(smartRowSearchText(row, directory))) + + it('keeps every group in order, with its heading and hint, when nothing is typed', () => { + const out = filtered() + expect(out.map((group) => group.title)).toEqual(['Asked of you', 'Under way']) + expect(out[0]?.hint).toBe('Owed on one exact version.') + expect(out[0]?.rows.length).toBe(rows.length) + }) + + it('counts what it draws — the heading number is the filtered length', () => { + ui.query = 'operator onboarding' + const out = filtered() + expect(out[0]?.rows.length).toBe(1) + expect(out[0]?.rows.length).toBeLessThan(rows.length) + }) + + it('drops a group nothing survived in rather than drawing an empty heading', () => { + ui.query = 'architecture' + const out = filtered() + // The architecture record is in the first group and not in the second, which held one plan. + expect(out.map((group) => group.title)).toEqual(['Asked of you']) + }) + + it('draws nothing at all when the query matches nothing — the page says so instead', () => { + ui.query = 'kubernetes' + expect(filtered()).toEqual([]) + }) + + it('restores the whole list the moment the box is cleared', () => { + ui.query = 'kubernetes' + expect(filtered()).toEqual([]) + ui.query = '' + expect(filtered()[0]?.rows.length).toBe(rows.length) + }) + + it('filters asks and units alike, in one list', () => { + const mixed: SmartGroup[] = [ + { title: 'Under way', rows: [...asks(space.index), ...rows] }, + ] + ui.query = 'reply' + const out = filterSmartGroups(mixed, (row) => keep(smartRowSearchText(row, directory))) + expect(out[0]?.rows.every((row) => isAskRow(row) && row.kind === 'answer')).toBe(true) + expect(out[0]?.rows.length).toBeGreaterThan(0) + }) + + it('never sorts, splices or empties the arrays it was handed', () => { + const given = groups() + const before = given.map((group) => [...group.rows]) + ui.query = 'architecture' + filterSmartGroups(given, (row) => keep(smartRowSearchText(row, directory))) + expect(given.length).toBe(2) + given.forEach((group, index) => expect(group.rows).toEqual(before[index])) + }) +}) + +describe('filterThreadRows', () => { + const view = goalNamed('Review queue in the web UI') + const exchange = () => threadRows(view) + const narrowed = () => + filterThreadRows(exchange(), (message) => keep(messageSearchText(message, directory))) + + it('is the whole exchange, and the whole count, until something is typed', () => { + expect(narrowed()).toEqual(exchange()) + expect(threadMessageCount(narrowed())).toBe(view.messages.length) + }) + + it('keeps a matching message and drops the replies under it that match nothing', () => { + ui.query = 'someone will add a lock' + const out = narrowed() + expect(out.length).toBe(1) + expect(out[0]?.replies).toEqual([]) + expect(threadMessageCount(out)).toBe(1) + }) + + it('keeps a matching reply under the message it answers, never as an orphan', () => { + ui.query = 'materializes a second time' + const out = narrowed() + expect(out.length).toBe(1) + expect(out[0]?.replies.length).toBe(1) + // The parent is retained as context: it matches nothing on its own. + expect(matches(messageSearchText(out[0]!.message, directory))).toBe(false) + expect(threadMessageCount(out)).toBe(2) + }) + + it('finds a message by who said it', () => { + ui.query = 'tim.disnetdev.com' + const out = narrowed() + expect(out.length).toBeGreaterThan(0) + expect(threadMessageCount(out)).toBeLessThan(view.messages.length) + }) + + it('is empty when nothing was said that matches — the thread says so, the composer stays', () => { + ui.query = 'kubernetes' + expect(narrowed()).toEqual([]) + expect(threadMessageCount([])).toBe(0) + }) +}) + +describe('unitDays', () => { + const view = goalNamed('Review queue in the web UI') + const units = unitsOf(space.index, view) + const shown = () => + units.filter((unit) => keep(unitRowSearchText(unit, view, directory))) + + it('groups a goal’s units into the days they were asked for', () => { + const days = unitDays(units, space.asOf) + expect(days.map((day) => day.units.map((unit) => unit.type))).toEqual([ + ['plan'], + ['implementation', 'security-review'], + ]) + expect(days.at(-1)?.label).toBe('Today') + }) + + it('loses a day heading whose whole day was filtered out', () => { + ui.query = 'CSRF' + const days = unitDays(shown(), space.asOf) + expect(days.length).toBe(1) + expect(days[0]?.units.map((unit) => unit.type)).toEqual(['security-review']) + }) + + it('leaves the goal’s own totals alone — they describe the goal, not the search', () => { + ui.query = 'CSRF' + expect(shown().length).toBe(1) + expect(units.length).toBe(3) + }) + + it('never mutates the list it was handed', () => { + const given = [...units] + unitDays(given, space.asOf) + expect(given).toEqual(units) + }) +}) + +describe('the pages that already filtered', () => { + it('still find a system record by type, title and body — unchanged', () => { + const project = projectByName(space.index, 'radial-ng') + expect(project).toBeDefined() + if (!project) return + const system = unitsOf(space.index, project) + ui.query = 'jetstream first' + expect(system.filter((unit) => matches(unitSearchText(unit))).length).toBe(1) + }) + + it('are a subset of what the wider row projection finds, never a different answer', () => { + // `unitRowSearchText` is `unitSearchText` plus where the row hangs off, its open brief and its + // actor. Anything the narrower one finds the wider one finds too, so no page loses a hit by + // moving between them. + const row = unitRow('architecture', 'System · radial-ng') + for (const query of ['architecture', 'four moving parts', 'write boundary']) { + ui.query = query + expect(matches(unitSearchText(row.unit))).toBe(true) + expect(matches(unitRowSearchText(row.unit, row.target, directory))).toBe(true) + } + }) +}) diff --git a/packages/ui/src/lib/replies.ts b/packages/ui/src/lib/replies.ts index a66d45e..fcb6815 100644 --- a/packages/ui/src/lib/replies.ts +++ b/packages/ui/src/lib/replies.ts @@ -25,6 +25,7 @@ import type { MaterializedIndex, StrongRef, ThreadMessage, + ThreadRow, } from '@radial/core' import { openRequestState } from '@radial/core' import type { Actor, Directory } from './directory.js' @@ -262,3 +263,35 @@ export function answerAsks(index: MaterializedIndex): AskContext[] { * thread keep reading it from one module. */ export { threadRows, type ThreadReply, type ThreadRow } from '@radial/core' + +/** What a message is searched by: what it says, and who said it however that name is spelled. */ +export function messageSearchText(message: ThreadMessage, directory: Directory): string { + const actor = directory.get(message.did) + return [message.value.body, actor.name, actor.handle ?? '', actor.did].join(' ') +} + +/** + * The exchange, narrowed — the messages that match, plus the ones they are answers to. + * + * A reply is only legible under what it answers, so a matching reply keeps its parent as context + * even where the parent matches nothing. The reverse does not hold: a matching parent does not drag + * its replies in, because a filter that pulls in whole conversations around one hit is not a filter. + * + * Nothing is mutated and no row is reordered — `threadRows()` already sorted this, and the caller's + * array is `$derived` from the fold. + */ +export function filterThreadRows( + rows: ThreadRow[], + keep: (message: ThreadMessage) => boolean, +): ThreadRow[] { + const out: ThreadRow[] = [] + for (const row of rows) { + const replies = row.replies.filter((reply) => keep(reply.message)) + if (replies.length > 0 || keep(row.message)) out.push({ message: row.message, replies }) + } + return out +} + +/** How many messages an exchange puts on screen — what the Thread heading counts. */ +export const threadMessageCount = (rows: ThreadRow[]): number => + rows.reduce((total, row) => total + 1 + row.replies.length, 0) diff --git a/packages/ui/src/lib/ui.svelte.ts b/packages/ui/src/lib/ui.svelte.ts index 07d81b0..6f1139d 100644 --- a/packages/ui/src/lib/ui.svelte.ts +++ b/packages/ui/src/lib/ui.svelte.ts @@ -28,8 +28,25 @@ export function toast(message: string): void { }, 2600) } +/** + * Quick find, applied to one string. Every page shares this so trim and case mean one thing. + * + * It filters the view it is typed in and nothing else: no URL, no record, no index. A page narrowed + * by it says so in its counts and its empty copy, and the layout clears it on the way to anywhere + * else — a filter carried to a page whose box the reader is no longer looking at is a page that + * silently hides rows. + */ export const matches = (haystack: string): boolean => - ui.query.trim() === '' || haystack.toLowerCase().includes(ui.query.trim().toLowerCase()) + !filtering() || haystack.toLowerCase().includes(ui.query.trim().toLowerCase()) + +/** + * Whether quick find is narrowing anything at all. + * + * What a page's empty copy reads, and the cheap way to skip composing search text for rows nothing + * is going to reject. Whitespace is not a query — `matches()` trims — so neither is it here, and the + * two cannot disagree about whether a list is filtered. + */ +export const filtering = (): boolean => ui.query.trim() !== '' export function toggleTheme(): void { const root = document.documentElement diff --git a/packages/ui/src/lib/units.ts b/packages/ui/src/lib/units.ts index 9e37dde..a06809a 100644 --- a/packages/ui/src/lib/units.ts +++ b/packages/ui/src/lib/units.ts @@ -23,7 +23,7 @@ import type { } from '@radial/core' import { activeGoals, activeProjects, artifactTypes, claimDeadline, staleness, timeline } from '@radial/core' import type { Directory } from './directory.js' -import { artifactLabel, relativeTime } from './format.js' +import { artifactLabel, dayKey, dayLabel, relativeTime } from './format.js' export type BadgeKind = 'ok' | 'bad' | 'warn' | 'flat' | 'accent' @@ -133,6 +133,16 @@ export const isGoalView = (view: GoalView | ProjectView): view is GoalView => */ export const typeLabel = (name: string): string => (name === 'adr' ? 'ADR' : name) +/** + * Where a row hangs off, as the row says it: the goal's title, or `System · `. + * + * One function rather than the same conditional in each row component, because quick find searches + * what a row *shows* — a label composed separately in two components is a label one of them will + * eventually spell differently, and the difference would be a word a reader can see and not find. + */ +export const targetLabel = (target: GoalView | ProjectView): string => + isGoalView(target) ? target.target.value.title : `System · ${target.name}` + /** "plan v2", or just "plan" when there has only ever been one — what a provenance line reads. */ export const versionLabel = (unit: UnitView, version: UnitVersion): string => unit.versions.length > 1 ? `${typeLabel(unit.type)} v${version.version}` : typeLabel(unit.type) @@ -284,6 +294,114 @@ export const versionTitle = (version: UnitVersion | undefined): string => export const unitSearchText = (unit: UnitView): string => [unit.type, versionTitle(unit.current), unit.current?.artifact.value.body ?? ''].join(' ') +// ── what quick find searches ──────────────────────────────────────────────────────────────────── +// +// Quick find narrows the view it is typed in, so what it searches is what that view SHOWS. Each row +// shape therefore has one projection, next to the model the row is drawn from, rather than a +// per-page guess at what the row put on screen — the failure mode otherwise is a word visibly on a +// row that typing it makes disappear. +// +// A projection is deliberately wider than the row's one visible line in one direction only: the +// artifact BODY. A short title is how a record is found by name, and it must not cost the full-text +// search that was the only way to find anything before titles existed (`unitSearchText` above). + +/** A person as their row can be searched for: what the disc stands for, however it is spelled. */ +const actorSearchText = (did: string | undefined, directory: Directory): string => { + if (!did) return '' + const actor = directory.get(did) + return [actor.name, actor.handle ?? '', actor.did].join(' ') +} + +/** + * A unit row: the type, its current version's title and body, where it hangs off, the brief of the + * request that is still open on it, and whoever it is with. + * + * The brief is what an unanswered request's row actually reads (`unitSummary`), so leaving it out + * would make every open request on a page findable only by its type. + */ +export const unitRowSearchText = ( + unit: UnitView, + target: GoalView | ProjectView, + directory: Directory, +): string => + [ + unitSearchText(unit), + targetLabel(target), + unit.openRequest?.value.brief ?? '', + actorSearchText(unitActorDid(unit), directory), + ].join(' ') + +/** + * An ask row: the two words the product calls these turns, where it hangs off, its brief, and what + * it pins — the artifact version for a review, the message for a reply. + * + * `review` and `reply` are searched as the words `AskRow` prints, never as `answer`: the lexicon's + * type name is not on screen, and a reader searching for a word they cannot see is not something to + * design for. + */ +export const askRowSearchText = (ask: AskContext, directory: Directory): string => + [ + ask.kind === 'review' ? 'review' : 'reply', + targetLabel(ask.target), + ask.request.value.brief ?? '', + ask.pinned + ? [ + ask.pinned.unit.type, + versionTitle(ask.pinned.version), + ask.pinned.version.artifact.value.body, + ].join(' ') + : '', + ask.subject?.value.body ?? '', + actorSearchText(ask.actor, directory), + ].join(' ') + +/** Either row shape, for the lists that hold both. */ +export const smartRowSearchText = (row: SmartRow, directory: Directory): string => + isAskRow(row) ? askRowSearchText(row, directory) : unitRowSearchText(row.unit, row.target, directory) + +/** + * The groups a smart list draws: the same groups in the same order, each holding only the rows that + * survive, and none of the ones left empty. + * + * New arrays throughout. The caller's groups are `$derived` from the fold and shared with whatever + * else reads it, so filtering in place would be one page quietly editing another's data. + */ +export function filterSmartGroups( + groups: SmartGroup[], + keep: (row: SmartRow) => boolean, +): SmartGroup[] { + const out: SmartGroup[] = [] + for (const group of groups) { + const rows = group.rows.filter(keep) + if (rows.length > 0) out.push({ ...group, rows }) + } + return out +} + +/** One day's worth of a goal's units — the comp's "Today / Yesterday / Jul 21" headings. */ +export interface UnitDay { + key: string + label: string + units: UnitView[] +} + +/** + * Units grouped into the days they were asked for, in the order they arrive. + * + * Built from whatever list it is handed, which is what makes a filtered goal page drop a heading + * whose whole day was filtered out rather than draw an empty one. + */ +export function unitDays(units: UnitView[], now: string): UnitDay[] { + const days: UnitDay[] = [] + for (const unit of units) { + const key = dayKey(unit.createdAt) + const last = days.at(-1) + if (last && last.key === key) last.units.push(unit) + else days.push({ key, label: dayLabel(unit.createdAt, now), units: [unit] }) + } + return days +} + export function checkTally(version: UnitVersion | undefined): { pass: number; total: number } | undefined { if (!version || version.checkruns.length === 0) return undefined const results = version.checkruns.flatMap((run) => run.value.results) diff --git a/packages/ui/src/routes/+layout.svelte b/packages/ui/src/routes/+layout.svelte index 37934af..d428276 100644 --- a/packages/ui/src/routes/+layout.svelte +++ b/packages/ui/src/routes/+layout.svelte @@ -70,6 +70,23 @@ closeMenu() }) + // Quick find belongs to the view it was typed in, and so does its filter. Carried to the next page + // it would hide rows there with the reason sitting in a box at the top of the pane that the reader + // has no cause to look at — a list that says "nothing is waiting on you" because of something typed + // on a different page is the worst thing this app can say. + // + // Compared rather than merely tracked: a live space is rebuilt on every poll, so an effect that + // reacted to `session.space` itself would wipe the box mid-word every ten seconds. It is the + // *place* changing that clears it. Not persisted, and never a search parameter — this narrows + // what is drawn, and nothing that narrows what is drawn should survive a link being shared. + let showing = '' + $effect(() => { + const here = `${space?.uri ?? ''} ${page.url.pathname}` + if (here === showing) return + showing = here + ui.query = '' + }) + // The picker opens over an open space, from the rail's own name button. It is the one surface here // that covers the whole app, so closing it puts the reader back on the control they left. let back: (() => boolean) | undefined diff --git a/packages/ui/src/routes/g/[did]/[rkey]/+page.svelte b/packages/ui/src/routes/g/[did]/[rkey]/+page.svelte index 24984bf..88f9884 100644 --- a/packages/ui/src/routes/g/[did]/[rkey]/+page.svelte +++ b/packages/ui/src/routes/g/[did]/[rkey]/+page.svelte @@ -11,16 +11,20 @@ import UnitRow from '$lib/components/UnitRow.svelte' import UriChip from '$lib/components/UriChip.svelte' import Who from '$lib/components/Who.svelte' - import { dayKey, dayLabel, shortDate } from '$lib/format.js' + import { shortDate } from '$lib/format.js' import { currentSpace } from '$lib/session.svelte.js' import type { ThreadMessage } from '$lib/replies.js' import { goalByRoute, unitsOf } from '$lib/space.js' - import { endingBadge, needsVerdict } from '$lib/units.js' + import { endingBadge, needsVerdict, unitDays, unitRowSearchText } from '$lib/units.js' + import { filtering, matches } from '$lib/ui.svelte.js' // The heart of the product: one goal, its work units in the order they happened, and the thread. const space = $derived(currentSpace()) const goal = $derived(goalByRoute(space.index, page.params.did ?? '', page.params.rkey ?? '')) const units = $derived(goal ? unitsOf(space.index, goal) : []) + // Deliberately over ALL units, never over what a query left showing. How much of this goal has been + // reviewed and how it ended are facts about the goal; a pie that moved while somebody typed would + // be reporting the search box back to them as progress. const counts = $derived(tally(units)) const toJudge = $derived(units.filter(needsVerdict).length) const author = $derived(goal ? space.directory.get(goal.target.did) : undefined) @@ -35,17 +39,17 @@ /** …and, when the row stood for one exact version — a review owed — with that version showing. */ const openedVersion = $derived(Number.parseInt(page.url.searchParams.get('v') ?? '', 10)) - // Units group into the days they were asked for — the comp's "Today / Yesterday / Jul 21". - const days = $derived.by(() => { - const groups: Array<{ key: string; label: string; units: typeof units }> = [] - for (const unit of units) { - const key = dayKey(unit.createdAt) - const last = groups.at(-1) - if (last && last.key === key) last.units.push(unit) - else groups.push({ key, label: dayLabel(unit.createdAt, space.asOf), units: [unit] }) - } - return groups - }) + // What quick find leaves showing — the rows, and only the rows. Everything else on this page stays + // where it is: the heading, the request bar, the composer and the closing actions are how a reader + // gets out of a search, and a filter that took them away would be one nobody could undo. + const shown = $derived( + goal && filtering() + ? units.filter((unit) => matches(unitRowSearchText(unit, goal, space.directory))) + : units, + ) + // Units group into the days they were asked for — the comp's "Today / Yesterday / Jul 21". Built + // from what is shown, so a day nothing survived in loses its heading rather than keeping an empty one. + const days = $derived(unitDays(shown, space.asOf)) {#if !goal} @@ -85,9 +89,13 @@ - {#if units.length === 0} + {#if shown.length === 0}
-

Nothing requested against this goal yet.

+

+ {units.length === 0 + ? 'Nothing requested against this goal yet.' + : 'Nothing requested against this goal matches that.'} +

{:else} {#each days as day (day.key)} diff --git a/packages/ui/src/routes/logbook/+page.svelte b/packages/ui/src/routes/logbook/+page.svelte index acb1cd5..2b23989 100644 --- a/packages/ui/src/routes/logbook/+page.svelte +++ b/packages/ui/src/routes/logbook/+page.svelte @@ -4,7 +4,7 @@ import GoalRow from '$lib/components/GoalRow.svelte' import { currentSpace } from '$lib/session.svelte.js' import { isEnded } from '$lib/space.js' - import { matches, ui } from '$lib/ui.svelte.js' + import { filtering, matches } from '$lib/ui.svelte.js' // Every goal that has ended, however it ended, newest ending first — the list `GoalView.ended` is // the definition of. It is wider than it used to be: a goal an old client archived was on no list at @@ -34,7 +34,7 @@ {#if ended.length === 0}
-

{ui.query ? 'No ended goal matches that.' : 'Nothing ended yet.'}

+

{filtering() ? 'No ended goal matches that.' : 'Nothing ended yet.'}

{:else}
    diff --git a/packages/ui/src/routes/p/[project]/+page.svelte b/packages/ui/src/routes/p/[project]/+page.svelte index 70f7169..3b81abd 100644 --- a/packages/ui/src/routes/p/[project]/+page.svelte +++ b/packages/ui/src/routes/p/[project]/+page.svelte @@ -6,7 +6,7 @@ import Glyph from '$lib/components/Glyph.svelte' import { currentSpace } from '$lib/session.svelte.js' import { goalsOf, isEnded, projectByName, settingsHref } from '$lib/space.js' - import { matches, ui } from '$lib/ui.svelte.js' + import { filtering, matches } from '$lib/ui.svelte.js' const space = $derived(currentSpace()) const project = $derived(projectByName(space.index, page.params.project ?? '')) @@ -77,7 +77,7 @@
{:else}
-

{ui.query ? 'No goal matches that.' : 'No open goals in this project.'}

+

{filtering() ? 'No goal matches that.' : 'No open goals in this project.'}

Press ⊕ to write one.

{/if} diff --git a/packages/ui/src/routes/p/[project]/system/+page.svelte b/packages/ui/src/routes/p/[project]/system/+page.svelte index 474c01b..5d8aa1b 100644 --- a/packages/ui/src/routes/p/[project]/system/+page.svelte +++ b/packages/ui/src/routes/p/[project]/system/+page.svelte @@ -8,7 +8,7 @@ import { currentSpace } from '$lib/session.svelte.js' import { projectByName, unitsOf } from '$lib/space.js' import { unitSearchText } from '$lib/units.js' - import { matches, ui } from '$lib/ui.svelte.js' + import { filtering, matches } from '$lib/ui.svelte.js' // The project's standing memory (design §8). The current version of each of these rides in every // turn's bundle, which is the real reason the section exists. @@ -71,11 +71,11 @@ {#if units.length === 0}
-

{ui.query ? 'No system record matches that.' : 'This project has no system artifacts yet.'}

+

{filtering() ? 'No system record matches that.' : 'This project has no system artifacts yet.'}

- {#if !ui.query} + {#if !filtering()}

One can also be captured from a goal artifact's own card.

{/if}
diff --git a/packages/ui/src/routes/space/+page.svelte b/packages/ui/src/routes/space/+page.svelte index c477010..a46262a 100644 --- a/packages/ui/src/routes/space/+page.svelte +++ b/packages/ui/src/routes/space/+page.svelte @@ -18,7 +18,7 @@ import { shortDate } from '$lib/format.js' import { currentSpace } from '$lib/session.svelte.js' import { goalsOf, projectHref, projects, settingsHref } from '$lib/space.js' - import { matches, toast, ui } from '$lib/ui.svelte.js' + import { filtering, matches, toast } from '$lib/ui.svelte.js' import { write } from '$lib/write.js' // The space's own shape, on one page. @@ -196,7 +196,7 @@ {#if members.length === 0} -

{ui.query ? 'No member matches that.' : 'Nobody has been granted membership yet.'}

+

{filtering() ? 'No member matches that.' : 'Nobody has been granted membership yet.'}

{/if} @@ -252,7 +252,7 @@ {#if registry.length === 0}
-

{ui.query ? 'No type matches that.' : 'This space has no artifact types.'}

+

{filtering() ? 'No type matches that.' : 'This space has no artifact types.'}

Register one and it appears in ⊕.

{/if} @@ -300,7 +300,7 @@ {#if repos.length === 0}
-

{ui.query ? 'No project matches that.' : 'This space has no projects.'}

+

{filtering() ? 'No project matches that.' : 'This space has no projects.'}

Create one to write goals against it.

{/if} diff --git a/packages/ui/test/quick-find.test.mjs b/packages/ui/test/quick-find.test.mjs new file mode 100644 index 0000000..c2c040e --- /dev/null +++ b/packages/ui/test/quick-find.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { describe, it } from 'node:test' + +// Quick find is one box in the pane bar and one predicate (`ui.svelte.ts`), and every view it +// narrows narrows through the same two functions: a search projection beside the row model, and a +// filter that returns new arrays. What those functions DO is tested in `src/lib/quick-find.test.ts`, +// against the fixture. What is checked here is the wiring — which is where this feature was broken +// before: the projections existed and three pages simply never called them. + +const read = (path) => readFileSync(new URL(`../src/${path}`, import.meta.url), 'utf8') + +describe('quick find is wired to every list it claims to narrow', () => { + it('filters the smart lists in the component all three routes share', () => { + const list = read('lib/components/SmartList.svelte') + assert.match(list, /filterSmartGroups\(\s*groups,/) + assert.match(list, /smartRowSearchText\(row, space\.directory\)/) + // The heading number is read off the filtered group, so a section cannot claim rows it is not + // drawing. + assert.match(list, /\{#each filled as group[\s\S]*?\{group\.rows\.length\}<\/span>/) + // …and the three routes hand it groups without filtering anything themselves: one rule, applied + // once, so "For me", "Awaiting input" and "With an agent" cannot come to search differently. + for (const route of ['routes/me/+page.svelte', 'routes/awaiting/+page.svelte', 'routes/inflight/+page.svelte']) { + assert.match(read(route), / { + const goal = read('routes/g/[did]/[rkey]/+page.svelte') + assert.match(goal, /unitRowSearchText\(unit, goal, space\.directory\)/) + assert.match(goal, /unitDays\(shown, space\.asOf\)/) + // The pie and the review badge are goal-wide facts, so they are computed from every unit. + assert.match(goal, /const counts = \$derived\(tally\(units\)\)/) + assert.match(goal, /const toJudge = \$derived\(units\.filter\(needsVerdict\)\.length\)/) + // Reading is narrowed; writing and recovery are not. + assert.match(goal, / { + assert.match(read('lib/components/SmartList.svelte'), /\{#if filtering\(\)\}[\s\S]*?Nothing in this view matches that\./) + assert.match(read('lib/components/Thread.svelte'), /filtering\(\) \? 'No message in this thread matches that\.'/) + assert.match(read('routes/g/[did]/[rkey]/+page.svelte'), /Nothing requested against this goal matches that\./) + // The pages that already filtered ask the same question the same way, rather than testing the + // raw box — a query of spaces filters nothing, so it must not read as one that did. + for (const route of [ + 'routes/logbook/+page.svelte', + 'routes/p/[project]/+page.svelte', + 'routes/p/[project]/system/+page.svelte', + 'routes/space/+page.svelte', + ]) { + assert.match(read(route), /filtering\(\)/) + assert.doesNotMatch(read(route), /ui\.query/) + } + }) + + it('clears the query on the way to another view, and never persists it', () => { + const layout = read('routes/+layout.svelte') + // Keyed on the place being shown — the space and the path — and compared rather than merely + // tracked, because a live space is rebuilt on every poll and an effect that reacted to the + // object itself would empty the box mid-word every ten seconds. + assert.match(layout, /const here = `\$\{space\?\.uri \?\? ''\} \$\{page\.url\.pathname\}`/) + assert.match(layout, /if \(here === showing\) return[\s\S]*?ui\.query = ''/) + // Per-tab and transient: nothing writes it to storage or to the URL. + assert.doesNotMatch(read('lib/ui.svelte.ts'), /localStorage[\s\S]{0,80}query|query[\s\S]{0,80}localStorage/) + assert.doesNotMatch(read('lib/components/PaneBar.svelte'), /goto|searchParams/) + }) + + it('leaves the key map alone: / focuses the box and Escape empties it', () => { + const keys = read('lib/keys.ts') + assert.match(keys, /if \(target === quickFind\) ui\.query = ''/) + assert.match(keys, /if \(ui\.query !== ''\) \{\s*ui\.query = ''/) + }) +})