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}
+
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 = ''/)
+ })
+})