Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions frontend/e2e/atlas-jump.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,51 @@ test('no matches shows the empty-result row instead of an empty list', async ({

await page.keyboard.press('Escape')
})

// Faceted search (goal 0086, shared/facetQuery.ts): vocabulary is every
// Kind's own Label + "area". "My space"/"Example area"/"Getting
// started"/"Scratchpad" are all Topic-kind (builtin.go's own seed
// comment: containment is a role, not a Kind); "Ada Lovelace" is
// Contact and "Project charter" is Document -- scoping to "Topic:"
// with empty text must list exactly the four, excluding both others.
test('"Topic: " lists every Topic-kind card, excluding other kinds', async ({ page }) => {
await page.goto('/')
await page.getByRole('link', { name: 'Atlas' }).click()
await expect(page.getByTestId('atlas-board')).toBeVisible()
await page.keyboard.press('Meta+k')
await expect(jumpDialog(page)).toBeVisible()

await page.getByTestId('atlas-jump-input').fill('Topic: ')

const results = jumpDialog(page).getByTestId('atlas-jump-result')
await expect(results).toHaveCount(4)
// Title-ascending, same order filterJumpCards' stableSortResults produces.
await expect(results).toContainText(['Example area', 'Getting started', 'My space', 'Scratchpad'])
await expect(jumpDialog(page)).not.toContainText('Ada Lovelace')
await expect(jumpDialog(page)).not.toContainText('Project charter')

await page.keyboard.press('Escape')
})

test('typing a Kind-label prefix offers a kind-glyph-colored suggestion chip; clicking it scopes the search', async ({ page }) => {
await page.goto('/')
await page.getByRole('link', { name: 'Atlas' }).click()
await expect(page.getByTestId('atlas-board')).toBeVisible()
await page.keyboard.press('Meta+k')
await expect(jumpDialog(page)).toBeVisible()

const input = page.getByTestId('atlas-jump-input')
await input.fill('to')

const chip = jumpDialog(page).getByRole('button', { name: 'Topic' })
await expect(chip).toBeVisible()
await expect(chip.getByTestId('facet-chip-dot')).toBeVisible()

await chip.click()
await expect(input).toHaveValue('Topic: ')
await expect(jumpDialog(page).getByTestId('atlas-jump-result')).toHaveCount(4)
// The chip row is a completion aid only -- gone once a scope is active.
await expect(jumpDialog(page).getByRole('button', { name: 'Topic' })).toHaveCount(0)

await page.keyboard.press('Escape')
})
59 changes: 59 additions & 0 deletions frontend/e2e/command-palette.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,3 +270,62 @@ test('Delete selection and Group into a new area are excluded from the palette (

await page.keyboard.press('Escape')
})

// Faceted search (goal 0086, shared/facetQuery.ts): the `<label>: <text>`
// grammar. "echo" is deliberately absent from any seeded command's own
// label/id, so a leaked non-workflow match would be a real regression,
// not a coincidental substring hit.
test('typing "workflow: <text>" scopes results to workflows only', async ({ page }) => {
const label = 'ZzE2eFacetEchoWorkflow'
await page.goto('/')
await createSimpleWorkflow(page, label)

await page.keyboard.press('Meta+k')
await expect(paletteDialog(page)).toBeVisible()
await paletteDialog(page).getByRole('combobox').fill(`workflow: ${label}`)

await expect(paletteDialog(page).getByRole('option', { name: new RegExp(`Run: ${label}`) })).toBeVisible()
await expect(paletteDialog(page).getByRole('option', { name: 'Open Settings' })).toHaveCount(0)

await page.keyboard.press('Escape')

// Cleanup.
await page.getByRole('link', { name: 'Workflows' }).click()
await clickRowAction(page, workflowRow(page, label), 'Delete')
await expect(workflowRow(page, label)).toHaveCount(0)
})

test('typing "work" offers a Workflow suggestion chip; clicking it scopes the search, and backspacing the token unscopes it', async ({ page }) => {
const label = 'ZzE2eFacetChipTarget'
await page.goto('/')
await createSimpleWorkflow(page, label)

await page.keyboard.press('Meta+k')
await expect(paletteDialog(page)).toBeVisible()
const input = paletteDialog(page).getByRole('combobox')
await input.fill('work')

const chip = paletteDialog(page).getByTestId('facet-chip-row').getByRole('button', { name: 'Workflow', exact: true })
await expect(chip).toBeVisible()
await chip.click()

await expect(input).toHaveValue('Workflow: ')
await expect(paletteDialog(page).getByRole('option', { name: new RegExp(`Run: ${label}`) })).toBeVisible()
// The chip row is a completion aid only -- once a scope is active,
// there's nothing left to suggest.
await expect(paletteDialog(page).getByTestId('facet-chip-row')).toHaveCount(0)

// Ordinary backspace over the token is the removal path (no special
// key handling) -- clearing it back to empty returns the palette to
// its bare rest state.
for (let i = 0; i < 'Workflow: '.length; i++) await input.press('Backspace')
await expect(input).toHaveValue('')
await expect(paletteDialog(page).getByRole('option', { name: 'Open Settings' })).toBeVisible()

await page.keyboard.press('Escape')

// Cleanup.
await page.getByRole('link', { name: 'Workflows' }).click()
await clickRowAction(page, workflowRow(page, label), 'Delete')
await expect(workflowRow(page, label)).toHaveCount(0)
})
57 changes: 56 additions & 1 deletion frontend/src/app/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import type { PaletteSearchable } from './paletteFilter'
import { sortWorkflowsByPinnedAndFrecency } from './workflowFrecency'
import { HotkeyHint } from '../shared/HotkeyHint'
import { WorkflowRowTrailingVisual } from './WorkflowRowTrailingVisual'
import { matchFacetSuggestions, parseFacetQuery } from '../shared/facetQuery'
import type { FacetVocabEntry } from '../shared/facetQuery'
import { FacetChipRow } from '../shared/FacetChipRow'
import styles from './CommandPalette.module.css'

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

// Faceted search (goal 0086): vocabulary drawn straight from the
// palette's own groups/types -- "command" covers both the 'commands'
// and 'surface' groupIds (a surface-scoped command is still a
// command, just ranked first), "setting" narrows further still, to
// the per-section deep-link commands shared/settingsCommands.ts
// registers (id `settings.open.<section>`) -- a strict subset of
// "command". Quick Panel's own configure-entity-type facets don't
// apply here: this surface has no Configure jump rows at all.
function facetVocabularyFor(t: (key: string) => string): FacetVocabEntry[] {
return [
{ key: 'command', label: t('commandPalette.facets.command') },
{ key: 'workflow', label: t('commandPalette.facets.workflow') },
{ key: 'tab', label: t('commandPalette.facets.tab') },
{ key: 'setting', label: t('commandPalette.facets.setting') },
]
}

function matchesPaletteFacet(scopeKey: string, entry: PaletteEntry): boolean {
if (scopeKey === 'command') return entry.groupId === 'commands' || entry.groupId === 'surface'
if (scopeKey === 'workflow') return entry.groupId === 'workflows'
if (scopeKey === 'tab') return entry.groupId === 'tabs'
if (scopeKey === 'setting') return entry.id === 'cmd:settings.open' || entry.id.startsWith('cmd:settings.open.')
return true
}

// Rest-state bound (design-wave-1 fix #2, Spotlight/Raycast/VS Code
// convention: an empty query shows a short, useful default rather than
// every command/workflow/tab at once). Nav commands are every
Expand Down Expand Up @@ -318,7 +346,29 @@ export function CommandPalette() {
// eslint-disable-next-line react-hooks/exhaustive-deps -- commandEntry/workflowEntries/tabEntries close over workflows/nodeTypes/requests/workTabs/mostUsedRank/hotkeyCombos/pinnedWorkflowIds/togglePinnedWorkflow/t, already listed
}, [restState, workflows, nodeTypes, requests, workTabs, mostUsedRank, hotkeyCombos, pinnedWorkflowIds, viewKind, t])

const filtered = restState ? allEntries : filterPaletteEntries(allEntries, query)
// Faceted search (goal 0086): the scope narrows allEntries FIRST,
// then filterPaletteEntries ranks the remainder against the
// post-token text -- an empty text (just "workflow: ") already lists
// every entry in that scope, since filterPaletteEntries returns its
// input unranked for a blank query.
const facetVocab = useMemo(() => facetVocabularyFor(t), [t])
const parsed = useMemo(() => parseFacetQuery(query, facetVocab), [query, facetVocab])
const chipSuggestions = useMemo(
() => (parsed.scopeKey || !query.trim() ? [] : matchFacetSuggestions(query, facetVocab)),
[parsed.scopeKey, query, facetVocab],
)
const scopedEntries = useMemo(
() => (parsed.scopeKey ? allEntries.filter((e) => matchesPaletteFacet(parsed.scopeKey!, e)) : allEntries),
[allEntries, parsed.scopeKey],
)
const filtered = restState ? allEntries : filterPaletteEntries(scopedEntries, parsed.text)

const selectChip = (key: string) => {
const entry = facetVocab.find((v) => v.key === key)
if (!entry) return
setQuery(`${entry.label}: `)
inputRef.current?.focus()
}

const items = filtered.map((entry) => ({
key: entry.id,
Expand All @@ -345,6 +395,11 @@ export function CommandPalette() {
height="auto"
initialFocusRef={inputRef}
>
<FacetChipRow
items={chipSuggestions.map((entry) => ({ key: entry.key, label: entry.label }))}
onSelect={selectChip}
ariaLabel={t('commandPalette.facets.suggestionsAriaLabel')}
/>
<FilteredActionList
className={styles.list}
items={items}
Expand Down
12 changes: 10 additions & 2 deletions frontend/src/app/QuickPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ import {
} from '../shared/configureEntityStore'
import { useAtlasStore, refreshAtlasCards, refreshAtlasKinds, refreshAtlasNotes } from '../atlas/atlasStore'
import { findRootNode } from '../composition/triggerRowInfo'
import { filterPaletteEntries } from './paletteFilter'
import { sortWorkflowsByPinnedAndFrecency } from './workflowFrecency'
import { WorkflowRowTrailingVisual } from './WorkflowRowTrailingVisual'
import { buildConfigureAndActionEntries } from './quickPanelActionEntries'
import type { PanelEntry } from './quickPanelActionEntries'
import { cascadeNotePosition, resolveNoteParentID } from './quickPanelCapture'
import { QuickPanelClipboardApply } from './QuickPanelClipboardApply'
import { FacetChipRow } from '../shared/FacetChipRow'
import { useQuickPanelFacetSearch } from './quickPanelFacets'
import styles from './QuickPanel.module.css'

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

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

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

return (
<div className={styles.panel} data-testid="quick-panel">
<FacetChipRow
items={chipSuggestions.map((entry) => ({ key: entry.key, label: entry.label }))}
onSelect={selectChip}
ariaLabel={t('quickPanel.facets.suggestionsAriaLabel')}
/>
<FilteredActionList
items={items}
groupMetadata={GROUP_METADATA}
Expand Down
81 changes: 81 additions & 0 deletions frontend/src/app/quickPanelFacets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { useMemo, type RefObject } from 'react'
import { filterPaletteEntries } from './paletteFilter'
import { matchFacetSuggestions, parseFacetQuery } from '../shared/facetQuery'
import type { FacetVocabEntry } from '../shared/facetQuery'
import type { PanelEntry } from './quickPanelActionEntries'

// Split out of QuickPanel.tsx (architecture.md's 500-line convention),
// same seam quickPanelActionEntries.tsx was split along: the panel's
// own faceted-search vocabulary (goal 0086) is a pure derivation from
// the entry shapes quickPanelActionEntries.tsx already builds, with no
// hook state of its own.
//
// Vocabulary is "workflow" plus one keyword per Configure entity type
// this panel actually renders as jump rows (quickPanelActionEntries.tsx's
// own `configure:<tab>:<id>` id shape drives the match below). 'atlas'
// and 'actions' stay unscoped on purpose -- Atlas cards already have
// their own dedicated jump surface (atlas/AtlasJumpDialog.tsx), and
// 'actions' is a handful of fixed rows with nothing worth narrowing.
export function facetVocabularyFor(t: (key: string) => string): FacetVocabEntry[] {
return [
{ key: 'workflow', label: t('quickPanel.facets.workflow') },
{ key: 'integration', label: t('quickPanel.facets.integration') },
{ key: 'list', label: t('quickPanel.facets.list') },
{ key: 'mcpServer', label: t('quickPanel.facets.mcpServer') },
{ key: 'decision', label: t('quickPanel.facets.decision') },
{ key: 'execEnv', label: t('quickPanel.facets.execEnv') },
{ key: 'aiProvider', label: t('quickPanel.facets.aiProvider') },
{ key: 'stepType', label: t('quickPanel.facets.stepType') },
]
}

const CONFIGURE_TAB_BY_FACET: Record<string, string> = {
integration: 'integration',
list: 'lists',
mcpServer: 'mcpservers',
decision: 'decisions',
execEnv: 'execenvs',
aiProvider: 'aiproviders',
stepType: 'steptypes',
}

function matchesPanelFacet(scopeKey: string, entry: PanelEntry): boolean {
if (scopeKey === 'workflow') return entry.groupId === 'workflows'
const tab = CONFIGURE_TAB_BY_FACET[scopeKey]
return tab ? entry.id.startsWith(`configure:${tab}:`) : true
}

// The scope-then-rank pipeline QuickPanel.tsx's own filtered-entries
// step delegates to (same shape app/CommandPalette.tsx runs inline) --
// pulled into a hook so QuickPanel.tsx's render body stays a single
// call instead of five separate useMemos (architecture.md's 500-line
// convention).
export function useQuickPanelFacetSearch(params: {
t: (key: string) => string
allEntries: PanelEntry[]
query: string
setQuery: (query: string) => void
inputRef: RefObject<HTMLInputElement | null>
}): { filtered: PanelEntry[]; chipSuggestions: FacetVocabEntry[]; selectChip: (key: string) => void } {
const { t, allEntries, query, setQuery, inputRef } = params
const facetVocab = useMemo(() => facetVocabularyFor(t), [t])
const parsed = useMemo(() => parseFacetQuery(query, facetVocab), [query, facetVocab])
const chipSuggestions = useMemo(
() => (parsed.scopeKey || !query.trim() ? [] : matchFacetSuggestions(query, facetVocab)),
[parsed.scopeKey, query, facetVocab],
)
const scopedEntries = useMemo(
() => (parsed.scopeKey ? allEntries.filter((e) => matchesPanelFacet(parsed.scopeKey!, e)) : allEntries),
[allEntries, parsed.scopeKey],
)
const filtered = filterPaletteEntries(scopedEntries, parsed.text)

const selectChip = (key: string) => {
const entry = facetVocab.find((v) => v.key === key)
if (!entry) return
setQuery(`${entry.label}: `)
inputRef.current?.focus()
}

return { filtered, chipSuggestions, selectChip }
}
37 changes: 35 additions & 2 deletions frontend/src/atlas/AtlasJumpDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { useTranslation } from 'react-i18next'
import { ActionList, Dialog, TextInput } from '@primer/react'
import type { Card, Kind } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models'
import { kindColorTokens } from './atlasKindColor'
import { filterJumpCards } from './atlasJumpFilter'
import { AREA_FACET_KEY, filterJumpCards } from './atlasJumpFilter'
import { matchFacetSuggestions, parseFacetQuery } from '../shared/facetQuery'
import type { FacetVocabEntry } from '../shared/facetQuery'
import { FacetChipRow } from '../shared/FacetChipRow'
import monoStyles from '../shared/monoText.module.css'
import styles from './AtlasJumpDialog.module.css'

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

const results = useMemo(() => filterJumpCards(cards, kinds, query), [cards, kinds, query])
// Faceted search (goal 0086): vocabulary is every Kind's own Label
// plus the "area" role (group cards, orthogonal to Kind -- ADR-0038
// Decision 3). parseFacetQuery/matchFacetSuggestions are the same
// shared grammar the command palette and Quick Panel use.
const vocabulary = useMemo<FacetVocabEntry[]>(
() => [...kinds.map((k) => ({ key: k.ID, label: k.Label })), { key: AREA_FACET_KEY, label: t('jump.areaFacetLabel') }],
[kinds, t],
)
const parsed = useMemo(() => parseFacetQuery(query, vocabulary), [query, vocabulary])
const results = useMemo(() => filterJumpCards(cards, kinds, parsed.text, parsed.scopeKey), [cards, kinds, parsed])
const chipSuggestions = useMemo(
() => (parsed.scopeKey || !query.trim() ? [] : matchFacetSuggestions(query, vocabulary)),
[parsed.scopeKey, query, vocabulary],
)

const selectChip = (key: string) => {
const entry = vocabulary.find((v) => v.key === key)
if (!entry) return
setQuery(`${entry.label}: `)
setActiveIndex(0)
inputRef.current?.focus()
}

const go = (card: Card) => {
onClose()
Expand Down Expand Up @@ -86,6 +110,15 @@ export function AtlasJumpDialog({ open, onClose, cards, kinds, onJump }: {
onKeyDown={onInputKeyDown}
data-testid="atlas-jump-input"
/>
<FacetChipRow
items={chipSuggestions.map((entry) => ({
key: entry.key,
label: entry.label,
dotColorToken: entry.key === AREA_FACET_KEY ? undefined : kindColorTokens(entry.key).emphasis,
}))}
onSelect={selectChip}
ariaLabel={t('jump.suggestionsAriaLabel')}
/>
<ActionList selectionVariant="single" data-testid="atlas-jump-results">
{results.map((r, i) => {
const tokens = kindColorTokens(r.card.KindID)
Expand Down
Loading
Loading