Skip to content

Commit 5cce7bc

Browse files
alicodingclaude
andcommitted
feat: faceted search -- typed inline qualifier for palette, Quick Panel, Atlas jump (goal 0086)
Adds a shared `<label>: <text>` grammar (shared/facetQuery.ts) so the command palette, Quick Panel, and Atlas jump dialog all scope a search to one type the same way -- a suggestion-chip row (shared/FacetChipRow.tsx) completes the token on click, with typing the token itself as the primary path. filterJumpCards gains a scope parameter for kind/area scoping in the Atlas jump. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd
1 parent 3378c8f commit 5cce7bc

14 files changed

Lines changed: 632 additions & 14 deletions

frontend/e2e/atlas-jump.spec.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,51 @@ test('no matches shows the empty-result row instead of an empty list', async ({
151151

152152
await page.keyboard.press('Escape')
153153
})
154+
155+
// Faceted search (goal 0086, shared/facetQuery.ts): vocabulary is every
156+
// Kind's own Label + "area". "My space"/"Example area"/"Getting
157+
// started"/"Scratchpad" are all Topic-kind (builtin.go's own seed
158+
// comment: containment is a role, not a Kind); "Ada Lovelace" is
159+
// Contact and "Project charter" is Document -- scoping to "Topic:"
160+
// with empty text must list exactly the four, excluding both others.
161+
test('"Topic: " lists every Topic-kind card, excluding other kinds', async ({ page }) => {
162+
await page.goto('/')
163+
await page.getByRole('link', { name: 'Atlas' }).click()
164+
await expect(page.getByTestId('atlas-board')).toBeVisible()
165+
await page.keyboard.press('Meta+k')
166+
await expect(jumpDialog(page)).toBeVisible()
167+
168+
await page.getByTestId('atlas-jump-input').fill('Topic: ')
169+
170+
const results = jumpDialog(page).getByTestId('atlas-jump-result')
171+
await expect(results).toHaveCount(4)
172+
// Title-ascending, same order filterJumpCards' stableSortResults produces.
173+
await expect(results).toContainText(['Example area', 'Getting started', 'My space', 'Scratchpad'])
174+
await expect(jumpDialog(page)).not.toContainText('Ada Lovelace')
175+
await expect(jumpDialog(page)).not.toContainText('Project charter')
176+
177+
await page.keyboard.press('Escape')
178+
})
179+
180+
test('typing a Kind-label prefix offers a kind-glyph-colored suggestion chip; clicking it scopes the search', async ({ page }) => {
181+
await page.goto('/')
182+
await page.getByRole('link', { name: 'Atlas' }).click()
183+
await expect(page.getByTestId('atlas-board')).toBeVisible()
184+
await page.keyboard.press('Meta+k')
185+
await expect(jumpDialog(page)).toBeVisible()
186+
187+
const input = page.getByTestId('atlas-jump-input')
188+
await input.fill('to')
189+
190+
const chip = jumpDialog(page).getByRole('button', { name: 'Topic' })
191+
await expect(chip).toBeVisible()
192+
await expect(chip.getByTestId('facet-chip-dot')).toBeVisible()
193+
194+
await chip.click()
195+
await expect(input).toHaveValue('Topic: ')
196+
await expect(jumpDialog(page).getByTestId('atlas-jump-result')).toHaveCount(4)
197+
// The chip row is a completion aid only -- gone once a scope is active.
198+
await expect(jumpDialog(page).getByRole('button', { name: 'Topic' })).toHaveCount(0)
199+
200+
await page.keyboard.press('Escape')
201+
})

frontend/e2e/command-palette.spec.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,3 +270,62 @@ test('Delete selection and Group into a new area are excluded from the palette (
270270

271271
await page.keyboard.press('Escape')
272272
})
273+
274+
// Faceted search (goal 0086, shared/facetQuery.ts): the `<label>: <text>`
275+
// grammar. "echo" is deliberately absent from any seeded command's own
276+
// label/id, so a leaked non-workflow match would be a real regression,
277+
// not a coincidental substring hit.
278+
test('typing "workflow: <text>" scopes results to workflows only', async ({ page }) => {
279+
const label = 'ZzE2eFacetEchoWorkflow'
280+
await page.goto('/')
281+
await createSimpleWorkflow(page, label)
282+
283+
await page.keyboard.press('Meta+k')
284+
await expect(paletteDialog(page)).toBeVisible()
285+
await paletteDialog(page).getByRole('combobox').fill(`workflow: ${label}`)
286+
287+
await expect(paletteDialog(page).getByRole('option', { name: new RegExp(`Run: ${label}`) })).toBeVisible()
288+
await expect(paletteDialog(page).getByRole('option', { name: 'Open Settings' })).toHaveCount(0)
289+
290+
await page.keyboard.press('Escape')
291+
292+
// Cleanup.
293+
await page.getByRole('link', { name: 'Workflows' }).click()
294+
await clickRowAction(page, workflowRow(page, label), 'Delete')
295+
await expect(workflowRow(page, label)).toHaveCount(0)
296+
})
297+
298+
test('typing "work" offers a Workflow suggestion chip; clicking it scopes the search, and backspacing the token unscopes it', async ({ page }) => {
299+
const label = 'ZzE2eFacetChipTarget'
300+
await page.goto('/')
301+
await createSimpleWorkflow(page, label)
302+
303+
await page.keyboard.press('Meta+k')
304+
await expect(paletteDialog(page)).toBeVisible()
305+
const input = paletteDialog(page).getByRole('combobox')
306+
await input.fill('work')
307+
308+
const chip = paletteDialog(page).getByTestId('facet-chip-row').getByRole('button', { name: 'Workflow', exact: true })
309+
await expect(chip).toBeVisible()
310+
await chip.click()
311+
312+
await expect(input).toHaveValue('Workflow: ')
313+
await expect(paletteDialog(page).getByRole('option', { name: new RegExp(`Run: ${label}`) })).toBeVisible()
314+
// The chip row is a completion aid only -- once a scope is active,
315+
// there's nothing left to suggest.
316+
await expect(paletteDialog(page).getByTestId('facet-chip-row')).toHaveCount(0)
317+
318+
// Ordinary backspace over the token is the removal path (no special
319+
// key handling) -- clearing it back to empty returns the palette to
320+
// its bare rest state.
321+
for (let i = 0; i < 'Workflow: '.length; i++) await input.press('Backspace')
322+
await expect(input).toHaveValue('')
323+
await expect(paletteDialog(page).getByRole('option', { name: 'Open Settings' })).toBeVisible()
324+
325+
await page.keyboard.press('Escape')
326+
327+
// Cleanup.
328+
await page.getByRole('link', { name: 'Workflows' }).click()
329+
await clickRowAction(page, workflowRow(page, label), 'Delete')
330+
await expect(workflowRow(page, label)).toHaveCount(0)
331+
})

frontend/src/app/CommandPalette.tsx

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ import type { PaletteSearchable } from './paletteFilter'
1818
import { sortWorkflowsByPinnedAndFrecency } from './workflowFrecency'
1919
import { HotkeyHint } from '../shared/HotkeyHint'
2020
import { WorkflowRowTrailingVisual } from './WorkflowRowTrailingVisual'
21+
import { matchFacetSuggestions, parseFacetQuery } from '../shared/facetQuery'
22+
import type { FacetVocabEntry } from '../shared/facetQuery'
23+
import { FacetChipRow } from '../shared/FacetChipRow'
2124
import styles from './CommandPalette.module.css'
2225

2326
// The ⌘K command palette (docs/goals/0015-summon-quick-invoke.md): the
@@ -73,6 +76,31 @@ function groupMetadataFor(t: (key: string) => string) {
7376
]
7477
}
7578

79+
// Faceted search (goal 0086): vocabulary drawn straight from the
80+
// palette's own groups/types -- "command" covers both the 'commands'
81+
// and 'surface' groupIds (a surface-scoped command is still a
82+
// command, just ranked first), "setting" narrows further still, to
83+
// the per-section deep-link commands shared/settingsCommands.ts
84+
// registers (id `settings.open.<section>`) -- a strict subset of
85+
// "command". Quick Panel's own configure-entity-type facets don't
86+
// apply here: this surface has no Configure jump rows at all.
87+
function facetVocabularyFor(t: (key: string) => string): FacetVocabEntry[] {
88+
return [
89+
{ key: 'command', label: t('commandPalette.facets.command') },
90+
{ key: 'workflow', label: t('commandPalette.facets.workflow') },
91+
{ key: 'tab', label: t('commandPalette.facets.tab') },
92+
{ key: 'setting', label: t('commandPalette.facets.setting') },
93+
]
94+
}
95+
96+
function matchesPaletteFacet(scopeKey: string, entry: PaletteEntry): boolean {
97+
if (scopeKey === 'command') return entry.groupId === 'commands' || entry.groupId === 'surface'
98+
if (scopeKey === 'workflow') return entry.groupId === 'workflows'
99+
if (scopeKey === 'tab') return entry.groupId === 'tabs'
100+
if (scopeKey === 'setting') return entry.id === 'cmd:settings.open' || entry.id.startsWith('cmd:settings.open.')
101+
return true
102+
}
103+
76104
// Rest-state bound (design-wave-1 fix #2, Spotlight/Raycast/VS Code
77105
// convention: an empty query shows a short, useful default rather than
78106
// every command/workflow/tab at once). Nav commands are every
@@ -318,7 +346,29 @@ export function CommandPalette() {
318346
// eslint-disable-next-line react-hooks/exhaustive-deps -- commandEntry/workflowEntries/tabEntries close over workflows/nodeTypes/requests/workTabs/mostUsedRank/hotkeyCombos/pinnedWorkflowIds/togglePinnedWorkflow/t, already listed
319347
}, [restState, workflows, nodeTypes, requests, workTabs, mostUsedRank, hotkeyCombos, pinnedWorkflowIds, viewKind, t])
320348

321-
const filtered = restState ? allEntries : filterPaletteEntries(allEntries, query)
349+
// Faceted search (goal 0086): the scope narrows allEntries FIRST,
350+
// then filterPaletteEntries ranks the remainder against the
351+
// post-token text -- an empty text (just "workflow: ") already lists
352+
// every entry in that scope, since filterPaletteEntries returns its
353+
// input unranked for a blank query.
354+
const facetVocab = useMemo(() => facetVocabularyFor(t), [t])
355+
const parsed = useMemo(() => parseFacetQuery(query, facetVocab), [query, facetVocab])
356+
const chipSuggestions = useMemo(
357+
() => (parsed.scopeKey || !query.trim() ? [] : matchFacetSuggestions(query, facetVocab)),
358+
[parsed.scopeKey, query, facetVocab],
359+
)
360+
const scopedEntries = useMemo(
361+
() => (parsed.scopeKey ? allEntries.filter((e) => matchesPaletteFacet(parsed.scopeKey!, e)) : allEntries),
362+
[allEntries, parsed.scopeKey],
363+
)
364+
const filtered = restState ? allEntries : filterPaletteEntries(scopedEntries, parsed.text)
365+
366+
const selectChip = (key: string) => {
367+
const entry = facetVocab.find((v) => v.key === key)
368+
if (!entry) return
369+
setQuery(`${entry.label}: `)
370+
inputRef.current?.focus()
371+
}
322372

323373
const items = filtered.map((entry) => ({
324374
key: entry.id,
@@ -345,6 +395,11 @@ export function CommandPalette() {
345395
height="auto"
346396
initialFocusRef={inputRef}
347397
>
398+
<FacetChipRow
399+
items={chipSuggestions.map((entry) => ({ key: entry.key, label: entry.label }))}
400+
onSelect={selectChip}
401+
ariaLabel={t('commandPalette.facets.suggestionsAriaLabel')}
402+
/>
348403
<FilteredActionList
349404
className={styles.list}
350405
items={items}

frontend/src/app/QuickPanel.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,14 @@ import {
1313
} from '../shared/configureEntityStore'
1414
import { useAtlasStore, refreshAtlasCards, refreshAtlasKinds, refreshAtlasNotes } from '../atlas/atlasStore'
1515
import { findRootNode } from '../composition/triggerRowInfo'
16-
import { filterPaletteEntries } from './paletteFilter'
1716
import { sortWorkflowsByPinnedAndFrecency } from './workflowFrecency'
1817
import { WorkflowRowTrailingVisual } from './WorkflowRowTrailingVisual'
1918
import { buildConfigureAndActionEntries } from './quickPanelActionEntries'
2019
import type { PanelEntry } from './quickPanelActionEntries'
2120
import { cascadeNotePosition, resolveNoteParentID } from './quickPanelCapture'
2221
import { QuickPanelClipboardApply } from './QuickPanelClipboardApply'
22+
import { FacetChipRow } from '../shared/FacetChipRow'
23+
import { useQuickPanelFacetSearch } from './quickPanelFacets'
2324
import styles from './QuickPanel.module.css'
2425

2526
// docs/adr/0033-quick-panel-second-window.md: the search+run surface
@@ -410,7 +411,9 @@ export function QuickPanel() {
410411
decisions, execEnvs, aiProviders, declaredStepTypes, atlasCards, atlasKinds, reviewPendingCount,
411412
])
412413

413-
const filtered = filterPaletteEntries(allEntries, query)
414+
// Faceted search (goal 0086) -- quickPanelFacets.ts's own hook, same
415+
// scope-then-rank shape app/CommandPalette.tsx runs inline.
416+
const { filtered, chipSuggestions, selectChip } = useQuickPanelFacetSearch({ t, allEntries, query, setQuery, inputRef })
414417

415418
// The save-note row (docs/goals/0090) never goes through
416419
// filterPaletteEntries -- it isn't a match against the typed text,
@@ -468,6 +471,11 @@ export function QuickPanel() {
468471

469472
return (
470473
<div className={styles.panel} data-testid="quick-panel">
474+
<FacetChipRow
475+
items={chipSuggestions.map((entry) => ({ key: entry.key, label: entry.label }))}
476+
onSelect={selectChip}
477+
ariaLabel={t('quickPanel.facets.suggestionsAriaLabel')}
478+
/>
471479
<FilteredActionList
472480
items={items}
473481
groupMetadata={GROUP_METADATA}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { useMemo, type RefObject } from 'react'
2+
import { filterPaletteEntries } from './paletteFilter'
3+
import { matchFacetSuggestions, parseFacetQuery } from '../shared/facetQuery'
4+
import type { FacetVocabEntry } from '../shared/facetQuery'
5+
import type { PanelEntry } from './quickPanelActionEntries'
6+
7+
// Split out of QuickPanel.tsx (architecture.md's 500-line convention),
8+
// same seam quickPanelActionEntries.tsx was split along: the panel's
9+
// own faceted-search vocabulary (goal 0086) is a pure derivation from
10+
// the entry shapes quickPanelActionEntries.tsx already builds, with no
11+
// hook state of its own.
12+
//
13+
// Vocabulary is "workflow" plus one keyword per Configure entity type
14+
// this panel actually renders as jump rows (quickPanelActionEntries.tsx's
15+
// own `configure:<tab>:<id>` id shape drives the match below). 'atlas'
16+
// and 'actions' stay unscoped on purpose -- Atlas cards already have
17+
// their own dedicated jump surface (atlas/AtlasJumpDialog.tsx), and
18+
// 'actions' is a handful of fixed rows with nothing worth narrowing.
19+
export function facetVocabularyFor(t: (key: string) => string): FacetVocabEntry[] {
20+
return [
21+
{ key: 'workflow', label: t('quickPanel.facets.workflow') },
22+
{ key: 'integration', label: t('quickPanel.facets.integration') },
23+
{ key: 'list', label: t('quickPanel.facets.list') },
24+
{ key: 'mcpServer', label: t('quickPanel.facets.mcpServer') },
25+
{ key: 'decision', label: t('quickPanel.facets.decision') },
26+
{ key: 'execEnv', label: t('quickPanel.facets.execEnv') },
27+
{ key: 'aiProvider', label: t('quickPanel.facets.aiProvider') },
28+
{ key: 'stepType', label: t('quickPanel.facets.stepType') },
29+
]
30+
}
31+
32+
const CONFIGURE_TAB_BY_FACET: Record<string, string> = {
33+
integration: 'integration',
34+
list: 'lists',
35+
mcpServer: 'mcpservers',
36+
decision: 'decisions',
37+
execEnv: 'execenvs',
38+
aiProvider: 'aiproviders',
39+
stepType: 'steptypes',
40+
}
41+
42+
function matchesPanelFacet(scopeKey: string, entry: PanelEntry): boolean {
43+
if (scopeKey === 'workflow') return entry.groupId === 'workflows'
44+
const tab = CONFIGURE_TAB_BY_FACET[scopeKey]
45+
return tab ? entry.id.startsWith(`configure:${tab}:`) : true
46+
}
47+
48+
// The scope-then-rank pipeline QuickPanel.tsx's own filtered-entries
49+
// step delegates to (same shape app/CommandPalette.tsx runs inline) --
50+
// pulled into a hook so QuickPanel.tsx's render body stays a single
51+
// call instead of five separate useMemos (architecture.md's 500-line
52+
// convention).
53+
export function useQuickPanelFacetSearch(params: {
54+
t: (key: string) => string
55+
allEntries: PanelEntry[]
56+
query: string
57+
setQuery: (query: string) => void
58+
inputRef: RefObject<HTMLInputElement | null>
59+
}): { filtered: PanelEntry[]; chipSuggestions: FacetVocabEntry[]; selectChip: (key: string) => void } {
60+
const { t, allEntries, query, setQuery, inputRef } = params
61+
const facetVocab = useMemo(() => facetVocabularyFor(t), [t])
62+
const parsed = useMemo(() => parseFacetQuery(query, facetVocab), [query, facetVocab])
63+
const chipSuggestions = useMemo(
64+
() => (parsed.scopeKey || !query.trim() ? [] : matchFacetSuggestions(query, facetVocab)),
65+
[parsed.scopeKey, query, facetVocab],
66+
)
67+
const scopedEntries = useMemo(
68+
() => (parsed.scopeKey ? allEntries.filter((e) => matchesPanelFacet(parsed.scopeKey!, e)) : allEntries),
69+
[allEntries, parsed.scopeKey],
70+
)
71+
const filtered = filterPaletteEntries(scopedEntries, parsed.text)
72+
73+
const selectChip = (key: string) => {
74+
const entry = facetVocab.find((v) => v.key === key)
75+
if (!entry) return
76+
setQuery(`${entry.label}: `)
77+
inputRef.current?.focus()
78+
}
79+
80+
return { filtered, chipSuggestions, selectChip }
81+
}

frontend/src/atlas/AtlasJumpDialog.tsx

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import { useTranslation } from 'react-i18next'
33
import { ActionList, Dialog, TextInput } from '@primer/react'
44
import type { Card, Kind } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models'
55
import { kindColorTokens } from './atlasKindColor'
6-
import { filterJumpCards } from './atlasJumpFilter'
6+
import { AREA_FACET_KEY, filterJumpCards } from './atlasJumpFilter'
7+
import { matchFacetSuggestions, parseFacetQuery } from '../shared/facetQuery'
8+
import type { FacetVocabEntry } from '../shared/facetQuery'
9+
import { FacetChipRow } from '../shared/FacetChipRow'
710
import monoStyles from '../shared/monoText.module.css'
811
import styles from './AtlasJumpDialog.module.css'
912

@@ -39,7 +42,28 @@ export function AtlasJumpDialog({ open, onClose, cards, kinds, onJump }: {
3942
setActiveIndex(0)
4043
}, [open])
4144

42-
const results = useMemo(() => filterJumpCards(cards, kinds, query), [cards, kinds, query])
45+
// Faceted search (goal 0086): vocabulary is every Kind's own Label
46+
// plus the "area" role (group cards, orthogonal to Kind -- ADR-0038
47+
// Decision 3). parseFacetQuery/matchFacetSuggestions are the same
48+
// shared grammar the command palette and Quick Panel use.
49+
const vocabulary = useMemo<FacetVocabEntry[]>(
50+
() => [...kinds.map((k) => ({ key: k.ID, label: k.Label })), { key: AREA_FACET_KEY, label: t('jump.areaFacetLabel') }],
51+
[kinds, t],
52+
)
53+
const parsed = useMemo(() => parseFacetQuery(query, vocabulary), [query, vocabulary])
54+
const results = useMemo(() => filterJumpCards(cards, kinds, parsed.text, parsed.scopeKey), [cards, kinds, parsed])
55+
const chipSuggestions = useMemo(
56+
() => (parsed.scopeKey || !query.trim() ? [] : matchFacetSuggestions(query, vocabulary)),
57+
[parsed.scopeKey, query, vocabulary],
58+
)
59+
60+
const selectChip = (key: string) => {
61+
const entry = vocabulary.find((v) => v.key === key)
62+
if (!entry) return
63+
setQuery(`${entry.label}: `)
64+
setActiveIndex(0)
65+
inputRef.current?.focus()
66+
}
4367

4468
const go = (card: Card) => {
4569
onClose()
@@ -86,6 +110,15 @@ export function AtlasJumpDialog({ open, onClose, cards, kinds, onJump }: {
86110
onKeyDown={onInputKeyDown}
87111
data-testid="atlas-jump-input"
88112
/>
113+
<FacetChipRow
114+
items={chipSuggestions.map((entry) => ({
115+
key: entry.key,
116+
label: entry.label,
117+
dotColorToken: entry.key === AREA_FACET_KEY ? undefined : kindColorTokens(entry.key).emphasis,
118+
}))}
119+
onSelect={selectChip}
120+
ariaLabel={t('jump.suggestionsAriaLabel')}
121+
/>
89122
<ActionList selectionVariant="single" data-testid="atlas-jump-results">
90123
{results.map((r, i) => {
91124
const tokens = kindColorTokens(r.card.KindID)

0 commit comments

Comments
 (0)