Skip to content

Commit f3f4508

Browse files
committed
Search: close M10 gaps (stub copy, specs, a11y skips, empty-state tip)
- SearchFooterActions: drop "STUB"/"coming soon" from module header, JSDoc, and the tooltip; the M8b open-in-pane handler ships the real snapshot+nav+history flow. - CLAUDE.md: replace remaining "STUB" / "coming in M8" references in the file table, footer-actions section, and dependency list with the live behavior. - EmptyState: tip line now surfaces the in-dialog shortcuts (`⌘N`, `⌘H`, optional `⌘Enter` when AI is on) instead of the moot `⌘F`. Pinned with split AI-on/off test cases. - SearchResults a11y: convert the 5 `it.skip` cases (index-unavailable / scanning / loading / searching / no-results) back to real tests now that `role="listbox"` is conditionally rendered only when option rows exist. All 5 pass cleanly without rule disables. - 5 new Playwright specs covering plan §5.1 surface: - `search-dialog-open-close.spec.ts`: ⌘F mounts, Esc unmounts, reopen works. - `search-modes.spec.ts`: ⌘1/⌘2/⌘3 switch modes (handles AI-on and AI-off lanes); query preserved across switches. - `search-filters.spec.ts`: Size chip default → configure → confirm; Esc closes only the popover; × clears the chip. - `search-recent.spec.ts`: Open-in-pane persists to the backend `recent_searches` store (verified via direct IPC poll, bypassing the per-session cache). - `search-ai-prompt.spec.ts`: AI mode doesn't auto-apply within the debounce window (self-skips when AI is off in the fixture). - Shared helpers in `search-helpers.ts`: `openSearchDialog`, `closeSearchDialog`, `setSearchInputValue`, `getActiveMode`, `hasAiChip`, `pressMetaDigit`, `pollActiveMode`. Reused across all 5 new specs.
1 parent 84bf599 commit f3f4508

11 files changed

Lines changed: 511 additions & 25 deletions

apps/desktop/src/lib/search/CLAUDE.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ chip row and path-pill column landing in later milestones.
2525
| `EmptyState.svelte` | Pre-search "Try…" block: three example chips (AI prompts or filename patterns), index size, keyboard tip |
2626
| `RecentSearchesFooter.svelte` | Chip strip at the bottom of the dialog, up to 6 most recent entries plus an "All searches…" trailing chip |
2727
| `RecentSearchesPopover.svelte` | Fuzzy-searchable popover over the full recent-searches history (`⌘H` opens, ufuzzy under the hood) |
28-
| `SearchFooterActions.svelte` | Right-edge footer buttons: "Open in pane" (STUB in M7) and "Open in Finder" / "Open in file manager" |
28+
| `SearchFooterActions.svelte` | Right-edge footer buttons: "Open in pane" (live, M8b) and "Open in Finder" / "Open in file manager" |
2929
| `PathPills.svelte` | Clickable path-pill strip rendered inside each result row's path column (replaces flat `parentPath`) |
3030
| `SearchRowMenu.svelte` | Per-row `` button: always visible on cursor row, hover-revealed on other rows; opens native context menu |
3131
| `recent-searches-state.svelte.ts` | Module-level reactive store for the loaded recent-searches list; loads from backend once per session |
@@ -250,9 +250,10 @@ the recent-searches strip. It renders two buttons whenever `results.length > 0`:
250250
- **"Open in Finder" (macOS)** / **"Open in file manager" (Linux)**: reveals the cursor row in the platform's file
251251
manager via the existing `showInFinder` IPC (`open -R` on macOS, `xdg-open` on the parent on Linux). The dialog stays
252252
open so the user can keep browsing results.
253-
- **"Open in pane"**: the primary action. M7 ships this as a **STUB**: clicking closes the dialog and shows a "coming in
254-
M8" toast. M8 wires the real handoff (snapshot store + virtual-volume push). The stub keeps the affordance
255-
discoverable without overpromising.
253+
- **"Open in pane"**: the primary action. Wired live in M8b — the handler in `SearchDialog.svelte::openInPane` builds a
254+
`SearchSnapshot`, pins it via `setLastAttemptId`, adds the query to recent searches (the sole call site for that),
255+
hands the snapshot id to the host (`onOpenInPane`), and closes the dialog. The host routes the active pane to
256+
`search-results://<id>`. State is preserved across close + reopen, so `⌘F` lands back on the same results.
256257

257258
Both buttons are hidden (not just disabled) on empty/idle state, because they have nothing to act on. Empty + idle
258259
inputs disable both (index not ready). The platform branch uses `isMacOS()` from `$lib/shortcuts/key-capture`.
@@ -504,7 +505,6 @@ The label shown in the pane breadcrumb (and the snapshot's `label` field) is bui
504505
`parseSearchScope`, `getRecentSearches`, `addRecentSearch`, `removeRecentSearch`, `clearRecentSearches`,
505506
`applyRecentSearchesMaxCount`, `showFileContextMenu` (row context menu), `showInFinder` (footer Open in Finder / file
506507
manager)
507-
- `$lib/ui/toast/toast-store.svelte` -- `addToast` for the M7 "Open in pane: coming in M8" stub
508508
- `$lib/shortcuts/key-capture` -- `isMacOS()` for the footer action's macOS/Linux label fork
509509
- `@leeoniya/ufuzzy` -- fuzzy filtering inside `RecentSearchesPopover`
510510
- `$lib/indexing` -- `isScanning`, `getEntriesScanned` (scan progress for unavailable state)

apps/desktop/src/lib/search/EmptyState.svelte

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,9 @@
7272
</div>
7373
<p class="index-status">Index ready · {formattedCount} {pluralize(indexEntryCount, 'entry', 'entries')}</p>
7474
<p class="tip">
75-
Tip: <kbd>⌘F</kbd> opens search, <kbd>⌘N</kbd> starts fresh, <kbd>⌘H</kbd> shows recent searches.
75+
Tip: <kbd>⌘N</kbd> starts fresh, <kbd>⌘H</kbd> shows recent searches{#if aiEnabled}, <kbd
76+
>⌘Enter</kbd
77+
> runs an AI search{/if}.
7678
</p>
7779
</div>
7880

apps/desktop/src/lib/search/EmptyState.svelte.test.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ describe('EmptyState', () => {
4848
target.remove()
4949
})
5050

51-
it('shows the keyboard tip line with the three documented shortcuts', async () => {
51+
it('shows the in-dialog keyboard tip line (AI off: ⌘N and ⌘H, no ⌘Enter)', async () => {
5252
const target = document.createElement('div')
5353
document.body.appendChild(target)
5454
mount(EmptyState, {
@@ -57,9 +57,29 @@ describe('EmptyState', () => {
5757
})
5858
await tick()
5959
const tip = target.querySelector('.tip')?.textContent ?? ''
60-
expect(tip).toContain('⌘F')
6160
expect(tip).toContain('⌘N')
6261
expect(tip).toContain('⌘H')
62+
// ⌘Enter is AI-gated and AI is off here.
63+
expect(tip).not.toContain('⌘Enter')
64+
// ⌘F opens the dialog from the explorer; once the dialog is open the
65+
// shortcut is moot, so we explicitly do NOT advertise it inside the
66+
// empty state.
67+
expect(tip).not.toContain('⌘F')
68+
target.remove()
69+
})
70+
71+
it('adds the ⌘Enter AI hint when AI is enabled', async () => {
72+
const target = document.createElement('div')
73+
document.body.appendChild(target)
74+
mount(EmptyState, {
75+
target,
76+
props: { aiEnabled: true, indexEntryCount: 1, onPick: () => {} },
77+
})
78+
await tick()
79+
const tip = target.querySelector('.tip')?.textContent ?? ''
80+
expect(tip).toContain('⌘N')
81+
expect(tip).toContain('⌘H')
82+
expect(tip).toContain('⌘Enter')
6383
target.remove()
6484
})
6585

apps/desktop/src/lib/search/SearchFooterActions.svelte

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
* SearchFooterActions: the right-edge action buttons in the dialog footer.
44
*
55
* Per search-redesign-plan §3.9:
6-
* - "Open in pane" (primary): visible whenever results exist. M7 ships this button
7-
* as a STUB — clicking closes the dialog and shows a "coming in M8" toast. M8
8-
* wires the snapshot store + virtual-volume push, at which point the toast goes
9-
* away and the handler does the real navigation.
6+
* - "Open in pane" (primary): visible whenever results exist. The handler in
7+
* `SearchDialog.svelte::openInPane` creates a `SearchSnapshot`, pins it via
8+
* `setLastAttemptId`, adds the query to recent searches, navigates the active
9+
* pane to `search-results://<id>`, and closes the dialog. State is preserved
10+
* so reopening (`⌘F`) lands back on the same results.
1011
* - "Open in Finder" (macOS) / "Open in file manager" (Linux): opens the parent
1112
* folder of the cursor row in the platform's file manager. Wired here via the
1213
* existing `showInFinder` IPC (which already calls `open -R` on macOS and
@@ -27,7 +28,8 @@
2728
* would otherwise jump the layout while the user is mid-thought.
2829
*/
2930
disabled: boolean
30-
/** Click handler for "Open in pane". Parent owns the (currently stub) navigation. */
31+
/** Click handler for "Open in pane". Parent creates the snapshot, navigates the
32+
* active pane to `search-results://<id>`, and closes the dialog. */
3133
onOpenInPane: () => void
3234
/** Click handler for "Open in Finder / file manager". Parent owns the IPC. */
3335
onOpenInFileManager: () => void
@@ -57,7 +59,7 @@
5759
onclick={onOpenInPane}
5860
aria-label="Open in pane"
5961
>
60-
<span use:tooltip={'Open the results in a pane (coming soon)'}>Open in pane</span>
62+
<span use:tooltip={'Open the results in a pane'}>Open in pane</span>
6163
</Button>
6264
</div>
6365
{/if}

apps/desktop/src/lib/search/SearchResults.a11y.test.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,12 @@ const defaultProps = {
5151
}
5252

5353
describe('SearchResults a11y', () => {
54-
// TODO: `.results-container` is always `role="listbox"`, but every
55-
// non-populated state (index-unavailable message, loading, searching,
56-
// no-results) replaces the option rows with a plain `<div>` message.
57-
// Axe flags `aria-required-children` (listbox requires option/group).
58-
// Fix: either drop `role="listbox"` when results aren't rendered, or
59-
// render the empty-state messages outside the listbox container.
54+
// `.results-container` only gets `role="listbox"` when there are option rows
55+
// to host. Every non-populated state (index-unavailable message, loading,
56+
// searching, no-results, empty-state) renders a plain message container with
57+
// no role — sidestepping `aria-required-children` cleanly. The tests below
58+
// exercise each of those states so any regression in the role-gating logic
59+
// (e.g. someone forcing `role="listbox"` back on) trips immediately.
6060
it('index ready, no search yet has no a11y violations', async () => {
6161
const target = document.createElement('div')
6262
document.body.appendChild(target)
@@ -65,7 +65,7 @@ describe('SearchResults a11y', () => {
6565
await expectNoA11yViolations(target)
6666
})
6767

68-
it.skip('index unavailable (not scanning) has no a11y violations (BLOCKED: aria-required-children)', async () => {
68+
it('index unavailable (not scanning) has no a11y violations', async () => {
6969
const target = document.createElement('div')
7070
document.body.appendChild(target)
7171
mount(SearchResults, {
@@ -76,7 +76,7 @@ describe('SearchResults a11y', () => {
7676
await expectNoA11yViolations(target)
7777
})
7878

79-
it.skip('index unavailable with scan in progress has no a11y violations (BLOCKED: aria-required-children)', async () => {
79+
it('index unavailable with scan in progress has no a11y violations', async () => {
8080
const target = document.createElement('div')
8181
document.body.appendChild(target)
8282
mount(SearchResults, {
@@ -93,7 +93,7 @@ describe('SearchResults a11y', () => {
9393
await expectNoA11yViolations(target)
9494
})
9595

96-
it.skip('index loading after search has no a11y violations (BLOCKED: aria-required-children)', async () => {
96+
it('index loading after search has no a11y violations', async () => {
9797
const target = document.createElement('div')
9898
document.body.appendChild(target)
9999
mount(SearchResults, {
@@ -109,7 +109,7 @@ describe('SearchResults a11y', () => {
109109
await expectNoA11yViolations(target)
110110
})
111111

112-
it.skip('searching (no results yet) has no a11y violations (BLOCKED: aria-required-children)', async () => {
112+
it('searching (no results yet) has no a11y violations', async () => {
113113
const target = document.createElement('div')
114114
document.body.appendChild(target)
115115
mount(SearchResults, {
@@ -125,7 +125,7 @@ describe('SearchResults a11y', () => {
125125
await expectNoA11yViolations(target)
126126
})
127127

128-
it.skip('no results (search finished, empty) has no a11y violations (BLOCKED: aria-required-children)', async () => {
128+
it('no results (search finished, empty) has no a11y violations', async () => {
129129
const target = document.createElement('div')
130130
document.body.appendChild(target)
131131
mount(SearchResults, {
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* Search dialog: AI mode auto-apply contract.
3+
*
4+
* Per plan §3.6 and lib/search/CLAUDE.md's "AI single-pass flow": AI mode
5+
* NEVER auto-applies. The user must press Enter / ⌘Enter / click the ⏎ run
6+
* button. Filename + Regex modes auto-apply on a 1 s debounce (gated by the
7+
* `search.autoApply` setting, default on).
8+
*
9+
* Mocking the AI provider end-to-end in the Playwright fixture is non-trivial
10+
* (the AI server lives in a separate process and the dialog calls
11+
* `translateSearchQuery` IPC which routes through the registry-driven cloud
12+
* config). So we test the explicit-trigger contract directly: type into the
13+
* AI input, wait past the debounce window, and assert that no AI translation
14+
* happened — observable as the input value still matching what we typed (an
15+
* AI run would overwrite `query` with the translated pattern, per the
16+
* "AI overwrites the bar" decision documented in lib/search/CLAUDE.md).
17+
*
18+
* When AI is off in the test fixture (the only chip is Filename / Regex), the
19+
* test self-skips with a clear note: there's nothing meaningful to test.
20+
*/
21+
22+
import { test } from './fixtures.js'
23+
import { ensureAppReady, sleep } from './helpers.js'
24+
import { ensureMcpClient } from '../e2e-shared/mcp-client.js'
25+
import {
26+
closeSearchDialog,
27+
getActiveMode,
28+
getSearchInputValue,
29+
hasAiChip,
30+
openSearchDialog,
31+
pollActiveMode,
32+
pressMetaDigit,
33+
setSearchInputValue,
34+
} from './search-helpers.js'
35+
36+
test.describe('Search dialog: AI mode never auto-applies', () => {
37+
test('typing in AI mode does not run a search until the user explicitly triggers it', async ({ tauriPage }) => {
38+
await ensureAppReady(tauriPage)
39+
await ensureMcpClient(tauriPage)
40+
await openSearchDialog(tauriPage)
41+
42+
const aiOn = await hasAiChip(tauriPage)
43+
test.skip(!aiOn, 'AI provider is off in this fixture; AI-mode contract has no observable surface')
44+
45+
// Switch to AI mode (⌘1 with AI on per modeForShortcutNumber). If the
46+
// dialog already opened on AI, the press is a no-op.
47+
if ((await getActiveMode(tauriPage)) !== 'ai') {
48+
await pressMetaDigit(tauriPage, 1)
49+
const switched = await pollActiveMode(tauriPage, 'ai')
50+
test.skip(!switched, 'failed to switch to AI mode; cannot validate the contract')
51+
}
52+
53+
const prompt = 'find very large pdf files from last week'
54+
await setSearchInputValue(tauriPage, prompt)
55+
56+
// Wait past the auto-apply debounce window (1 s per
57+
// SEARCH_AUTO_APPLY_DEBOUNCE_MS) plus a small buffer. This is the rare
58+
// legitimate fixed wait: we're asserting a *negative* (nothing fires),
59+
// and "nothing fires" has no observable signal to poll on. We pick the
60+
// shortest sleep that's strictly larger than the debounce window.
61+
// eslint-disable-next-line cmdr/no-arbitrary-sleep-in-e2e -- asserting a negative; nothing to poll for
62+
await sleep(1500)
63+
64+
const after = await getSearchInputValue(tauriPage)
65+
// We don't assert anything about the result set — that needs a real or
66+
// stubbed AI provider. We do assert the input wasn't mutated, which is
67+
// the observable signature of an AI run that landed.
68+
if (after !== prompt) {
69+
throw new Error(
70+
`AI mode auto-applied: input was rewritten from ${JSON.stringify(prompt)} to ${JSON.stringify(after)}`,
71+
)
72+
}
73+
74+
await closeSearchDialog(tauriPage)
75+
})
76+
})
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Search dialog: open + close lifecycle.
3+
*
4+
* The smallest possible smoke for the dialog. Confirms that:
5+
* 1. The registry's `search.open` command (the same one the menu and `⌘F`
6+
* menu accelerator wire) mounts `.search-overlay`.
7+
* 2. Escape unmounts it.
8+
*
9+
* Anything richer (mode chips, results, filters) is covered by sibling specs.
10+
* This one is intentionally cheap (~600 ms wall-clock) so it acts as the
11+
* canary if either the registry wiring or the dialog's Escape handler
12+
* regresses.
13+
*/
14+
15+
import { test, expect } from './fixtures.js'
16+
import { ensureAppReady, pollUntil } from './helpers.js'
17+
import { ensureMcpClient } from '../e2e-shared/mcp-client.js'
18+
import { SEARCH_OVERLAY, closeSearchDialog, openSearchDialog } from './search-helpers.js'
19+
20+
test.describe('Search dialog: open and close', () => {
21+
test('search.open mounts the overlay, Escape closes it', async ({ tauriPage }) => {
22+
await ensureAppReady(tauriPage)
23+
await ensureMcpClient(tauriPage)
24+
25+
await openSearchDialog(tauriPage)
26+
expect(await tauriPage.count(SEARCH_OVERLAY)).toBe(1)
27+
28+
await closeSearchDialog(tauriPage)
29+
expect(await tauriPage.count(SEARCH_OVERLAY)).toBe(0)
30+
31+
// Reopening after close should work uneventfully (state preservation lives
32+
// in `search-state.svelte.ts`, but the overlay element itself is fresh).
33+
await openSearchDialog(tauriPage)
34+
const reopened = await pollUntil(tauriPage, async () => (await tauriPage.count(SEARCH_OVERLAY)) === 1, 2000)
35+
expect(reopened).toBe(true)
36+
await closeSearchDialog(tauriPage)
37+
})
38+
})
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* Search dialog: filter chips.
3+
*
4+
* Exercises the Size chip (the simplest configured-state shape): default
5+
* state → open popover → set min value → confirm via input change → chip
6+
* shows the configured summary → × clears it. Also pins the Escape-scoped
7+
* close contract (`SearchFilterChips.svelte` documents this: Esc inside the
8+
* popover closes only the popover, not the dialog).
9+
*
10+
* The Modified and Search-in chips share the same popover primitive, so
11+
* covering one chip end-to-end is enough at the E2E tier; the per-chip shape
12+
* differences are pinned by the Vitest unit tests in
13+
* `SearchFilterChips.svelte.test.ts`.
14+
*/
15+
16+
import { test, expect } from './fixtures.js'
17+
import { ensureAppReady, pollUntil, pressKey } from './helpers.js'
18+
import { ensureMcpClient } from '../e2e-shared/mcp-client.js'
19+
import { SEARCH_OVERLAY, closeSearchDialog, openSearchDialog } from './search-helpers.js'
20+
21+
const SIZE_CHIP_DEFAULT = '.search-overlay .filter-chip[aria-label="Size"]'
22+
const SIZE_CHIP_CONFIGURED = '.search-overlay .filter-chip.is-configured'
23+
const SIZE_CHIP_CLEAR = '.search-overlay .filter-chip.is-configured .chip-clear'
24+
const FILTER_POPOVER = '.search-overlay .filter-chip-popover'
25+
const SIZE_MIN_INPUT = '.search-overlay .filter-chip-popover input[aria-label="Minimum size value"]'
26+
27+
test.describe('Search dialog: filter chips', () => {
28+
test('Size chip: open popover, set min, confirm, clear via ×', async ({ tauriPage }) => {
29+
await ensureAppReady(tauriPage)
30+
await ensureMcpClient(tauriPage)
31+
await openSearchDialog(tauriPage)
32+
33+
// Default state: chip exists, no `.is-configured` modifier.
34+
expect(await tauriPage.count(SIZE_CHIP_DEFAULT)).toBe(1)
35+
expect(await tauriPage.count(SIZE_CHIP_CONFIGURED)).toBe(0)
36+
37+
// Click the Size chip → popover opens. The popover renders the comparator
38+
// select; setting it to `gte` from default `any` exposes the value input.
39+
await tauriPage.click(SIZE_CHIP_DEFAULT)
40+
await tauriPage.waitForSelector(FILTER_POPOVER, 2000)
41+
await tauriPage.evaluate(`(function(){
42+
var sel = document.querySelector('.search-overlay .filter-chip-popover select[aria-label="Size comparator"]');
43+
if (!sel) return;
44+
sel.value = 'gte';
45+
sel.dispatchEvent(new Event('change', { bubbles: true }));
46+
})()`)
47+
48+
// Fill the min-size input. The chip's configured summary reads back from
49+
// the same state, so we poll the chip class once the input lands.
50+
await tauriPage.waitForSelector(SIZE_MIN_INPUT, 2000)
51+
await tauriPage.evaluate(`(function(){
52+
var el = document.querySelector(${JSON.stringify(SIZE_MIN_INPUT)});
53+
if (!el) return;
54+
el.focus();
55+
el.value = '100';
56+
el.dispatchEvent(new Event('input', { bubbles: true }));
57+
})()`)
58+
const configured = await pollUntil(tauriPage, async () => (await tauriPage.count(SIZE_CHIP_CONFIGURED)) === 1, 2000)
59+
expect(configured).toBe(true)
60+
61+
// Esc closes ONLY the popover (`SearchFilterChips.svelte` capture-phase
62+
// guard, see lib/search/CLAUDE.md). The dialog must stay open.
63+
await pressKey(tauriPage, 'Escape')
64+
const popoverGone = await pollUntil(tauriPage, async () => (await tauriPage.count(FILTER_POPOVER)) === 0, 2000)
65+
expect(popoverGone).toBe(true)
66+
expect(await tauriPage.count(SEARCH_OVERLAY)).toBe(1)
67+
68+
// Click × to clear. The chip drops `is-configured` and the value vanishes
69+
// from the popover state (re-opening would show comparator `any`).
70+
await tauriPage.evaluate(`(function(){
71+
var x = document.querySelector(${JSON.stringify(SIZE_CHIP_CLEAR)});
72+
if (x) x.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
73+
})()`)
74+
const cleared = await pollUntil(tauriPage, async () => (await tauriPage.count(SIZE_CHIP_CONFIGURED)) === 0, 2000)
75+
expect(cleared).toBe(true)
76+
77+
await closeSearchDialog(tauriPage)
78+
})
79+
})

0 commit comments

Comments
 (0)