Skip to content

Commit df81950

Browse files
committed
Query dialogs: reopening Search/Select shows the same results right away
Reopening the Search or Select dialog restored the session's mode, term, and filters, but the result list didn't come back: the content area sat on the empty state until the user made one edit or pressed Enter, which re-ran the query. The fix makes reopen show "the same results in the same order" immediately. Root cause: `hasSearched` in `QueryDialog.svelte` was a component-local `$state(false)`, reset on every mount, and nothing re-triggered a run on reopen. So even though the surviving `QueryFilterState` singleton still held the prior results, the render chain fell back to the idle/empty state. Two levers, both in `QueryDialog.svelte`: - Seed `hasSearched` from `getLastRunQuery() !== null` (the precise "a prior run exists" flag, nulled by `⌘N`/`clearCore`) so persisted results render on mount. - For a restored NON-AI session (prior run + a non-empty query or active filter), `onMount` sets the existing `runOnMount` one-shot so the query re-runs: Select re-derives the matcher against the freshly-snapshotted current folder (more correct than rendering rows from the old folder), Search re-hits the index. AI restored sessions are deliberately excluded from the re-run gate so reopen never re-calls the cloud (cost); the seeded `hasSearched` renders the persisted results as-is. A first-ever open (no prior run) still rests on the empty state, and `⌘N` still clears to the empty state. Unified the prefill and reopen "is there anything to run?" check behind one `hasRestorableQuery()` predicate (now also counts a type-only filter, matching Selection's `hasActiveFilter`). Tests: `SearchDialog` reopen re-runs once on mount, AI reopen makes no translate call, first-ever open and post-`⌘N` reopen don't auto-run; `SelectionDialog` reopen re-derives against the CURRENT folder (proven by reopening on a different folder and asserting the committed indices), plus a first-open empty-state guard. Red verified by reverting the `QueryDialog` change.
1 parent 2328f46 commit df81950

5 files changed

Lines changed: 215 additions & 7 deletions

File tree

apps/desktop/src/lib/query-ui/CLAUDE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ popover, and the `createQueryFilterState()` factory. Filter-chip internals live
4242
- **Don't wipe state from `onDestroy` / any lifecycle hook.** The dialog mounts on open and unmounts on close; state
4343
survives unmount by design. The ONLY sanctioned reset is `⌘N` (the consumer's clear hook). Wiping on unmount turns
4444
every close+reopen into lost work.
45+
- **Reopen re-derives results so they show immediately, not the empty state.** `hasSearched` is component-local (resets
46+
to `false` each mount), so it's seeded from `getLastRunQuery() !== null` (the precise "a prior run exists" flag,
47+
nulled by `⌘N`/`clearCore`) so persisted results render on mount. For a restored NON-AI session (a prior run + a
48+
non-empty query or active filter, via `hasRestorableQuery()`), `onMount` sets `runOnMount` so the query re-runs:
49+
Select re-derives against the freshly-snapshotted current folder (more correct than showing rows from the old folder),
50+
Search re-hits the index. AI restored sessions must NOT re-run (cloud cost): the `onMount` gate excludes
51+
`mode === 'ai'`, so the seeded `hasSearched` renders the persisted results without re-calling translate. A first-ever
52+
open (no prior run) still rests on the empty state.
4553
- **⌘⏎ and ⇧⏎ are explicit no-ops** (swallowed with `preventDefault`); bare Enter is the only key that runs a search or
4654
opens the cursor row, dispatched via `enterAction`. `⌘N` is captured before the dialog's `stopPropagation` so it
4755
doesn't reach the route-level new-tab handler.

apps/desktop/src/lib/query-ui/DETAILS.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,18 @@ thin centered bar; it's NOT a `<header>` landmark, which would collide with the
8181
The orchestrator's `$effect` block on `state.getRunOnMount()` consumes the one-shot prefill flag. It clears the flag
8282
BEFORE dispatching so downstream state writes can't re-trigger the effect. Cold-open (dialog mounts with the flag
8383
pre-set, e.g. MCP `open_search_dialog`) and hot-prefill (the flag flips while the dialog is already open, e.g. a
84-
recent-search activation) flow through the same path. AI mode honors the explicit-trigger contract because the prefill
85-
caller's `autoRun: true` IS the explicit trigger.
84+
recent-search activation) flow through the same path. The effect dispatches when there's anything runnable, via the
85+
shared `hasRestorableQuery()` predicate (non-empty query OR size/date/type filter active). AI mode honors the
86+
explicit-trigger contract because the prefill caller's `autoRun: true` IS the explicit trigger.
87+
88+
A third producer of `runOnMount` is the reopen path. `onMount` sets the flag when the surviving state holds a restorable
89+
NON-AI session (`getLastRunQuery() !== null` AND `hasRestorableQuery()` AND `mode !== 'ai'`), so the dialog re-derives
90+
results on reopen instead of resting on the empty state: Select re-runs the matcher against the freshly-snapshotted
91+
current folder (more correct than rendering rows from the old folder), Search re-hits the index. AI restored sessions
92+
are excluded from this gate (cloud cost); they render the persisted results because `hasSearched` is seeded from
93+
`getLastRunQuery() !== null` at component init. For Search the index may not be ready when `onMount` fires; the effect's
94+
`config.isIndexReady` guard skips the run, and Search's own `search-index-ready` listener re-sets `runOnMount` once the
95+
index loads, so the re-run still lands.
8696

8797
### Test coverage
8898

@@ -344,7 +354,10 @@ still typing their query.
344354

345355
**State preservation across close + reopen**: The factory's `$state` survives dialog unmount. Closing the dialog (Escape
346356
or overlay click) does NOT wipe query, mode, filters, scope, results, or cursor. The only reset path is `⌘N` inside the
347-
dialog, which calls the consumer's clear hook.
357+
dialog, which calls the consumer's clear hook. On reopen the dialog shows those results immediately rather than the
358+
empty state: `hasSearched` is seeded from `getLastRunQuery() !== null`, and a restored non-AI session re-runs on mount
359+
(see the `runOnMount` consumer section) so the rows reflect the folder open now. AI sessions render the persisted
360+
results without re-calling the cloud.
348361

349362
**`⌘N` shortcut**: Hard-coded in the dialog's `handleModifierShortcuts`. Captured before the dialog's global
350363
`stopPropagation` would let it reach the route-level `⌘N` (new tab) handler. The choice of `⌘N` matches the macOS "new

apps/desktop/src/lib/query-ui/QueryDialog.svelte

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,16 @@
7272
let debounceTimer: ReturnType<typeof setTimeout> | undefined
7373
let unlistenAutoApply: (() => void) | undefined
7474
let highlightedFields: SvelteSet<string> = new SvelteSet<string>()
75-
let hasSearched = $state(false)
75+
/**
76+
* Whether a run's results are the current content. Seeded from the surviving state so a
77+
* close + reopen shows the SAME results immediately, not the empty state. `lastRunQuery`
78+
* is non-null exactly when a run has landed and hasn't been cleared by `⌘N` (`clearCore`
79+
* nulls it), so it's the precise "a prior run exists" signal. On a first-ever open it's
80+
* null, so we still start on the empty state. For non-AI restored sessions we additionally
81+
* re-run on mount (see `onMount`) so Select re-derives against the current folder; AI
82+
* restored sessions render these persisted results WITHOUT re-calling the cloud.
83+
*/
84+
let hasSearched = $state(config.state.getLastRunQuery() !== null)
7685
/**
7786
* IME composition flag. While true, `scheduleSearch` is a no-op so we don't fire
7887
* mid-character on Chinese/Japanese/Korean input. On `compositionend` the bar
@@ -185,6 +194,21 @@
185194
}
186195
})
187196
197+
/**
198+
* Whether the current state has anything runnable: a non-empty query OR an active filter
199+
* (size ≠ any, date ≠ any, or type ≠ both). The single source of truth for "is there a
200+
* session worth running?", shared by the `runOnMount` effect and the reopen re-run gate in
201+
* `onMount`. Type counts: a "Folders"-only Selection run is a valid filter-only query.
202+
*/
203+
function hasRestorableQuery(): boolean {
204+
return (
205+
config.state.getQuery().trim() !== '' ||
206+
config.state.getSizeFilter() !== 'any' ||
207+
config.state.getDateFilter() !== 'any' ||
208+
config.state.getTypeFilter() !== 'both'
209+
)
210+
}
211+
188212
/**
189213
* Single consumer for the `runOnMount` one-shot flag. Fires both on cold-open
190214
* (dialog mounts with the flag pre-set, e.g. MCP `open_search_dialog`) and on
@@ -203,11 +227,9 @@
203227
// the prefilled query runs.
204228
hasSearched = false
205229
const trimmed = config.state.getQuery().trim()
206-
const hasFilters =
207-
config.state.getSizeFilter() !== 'any' || config.state.getDateFilter() !== 'any'
208230
if (trimmed && config.state.getMode() === 'ai' && config.aiEnabled) {
209231
void runAiSearch(trimmed)
210-
} else if (config.isIndexReady && (trimmed || hasFilters)) {
232+
} else if (config.isIndexReady && hasRestorableQuery()) {
211233
void executeQuery()
212234
}
213235
// Otherwise: prefill arrived but nothing to run. The dialog rests on the empty
@@ -254,6 +276,21 @@
254276
// until the user edits the query/filters or results arrive.
255277
config.state.setLastDialogEvent('opened')
256278
279+
// Reopen-with-results: when the surviving state holds a restorable session (a prior
280+
// run, plus a non-empty query or an active filter) re-derive it on mount so the user
281+
// sees the same results immediately, not the empty state. For non-AI modes we re-run
282+
// the query (cheap: Select re-derives against the freshly-snapshotted current folder,
283+
// which is MORE correct than showing rows from the old folder; Search re-hits the
284+
// index). AI mode never auto-runs (cloud cost): `hasSearched` was already seeded from
285+
// the prior run, so its persisted results render as-is without re-calling translate.
286+
if (
287+
config.state.getLastRunQuery() !== null &&
288+
config.state.getMode() !== 'ai' &&
289+
hasRestorableQuery()
290+
) {
291+
config.state.setRunOnMount(true)
292+
}
293+
257294
// Live-mirror `search.autoApply`. Shared key across consumers (no separate
258295
// `selection.autoApply` setting; the auto-apply contract is the same one).
259296
unlistenAutoApply = onSpecificSettingChange('search.autoApply', (_id, value) => {

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

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,108 @@ describe('SearchDialog state preservation and ⌘N', () => {
213213
})
214214
})
215215

216+
describe('SearchDialog reopen re-runs so results show', () => {
217+
beforeEach(() => {
218+
clearSearchState()
219+
aiProvider = 'off'
220+
autoApplySetting = false // run only on explicit Enter so we count runs precisely
221+
autoApplyListeners.clear()
222+
searchFilesMock.mockClear()
223+
translateSearchQueryMock.mockClear()
224+
})
225+
226+
it('a restored non-AI session re-runs the query on reopen (results, not the empty state)', async () => {
227+
// First open: type a query and run it once.
228+
const first = await mountDialog()
229+
setQuery('*.png')
230+
dispatchKey(first.overlay, 'Enter')
231+
await tick()
232+
await new Promise((r) => setTimeout(r, 0))
233+
await tick()
234+
expect(searchFilesMock).toHaveBeenCalledTimes(1)
235+
236+
// Close and reopen. The reopen must re-derive results on mount WITHOUT the user
237+
// touching anything: pre-fix, `hasSearched` reset to false and nothing re-ran, so the
238+
// content area sat on the empty state until a manual edit / Enter.
239+
first.cleanup()
240+
searchFilesMock.mockClear()
241+
const second = await mountDialog()
242+
await tick()
243+
await new Promise((r) => setTimeout(r, 0))
244+
await tick()
245+
expect(searchFilesMock).toHaveBeenCalledTimes(1)
246+
second.cleanup()
247+
})
248+
249+
it('a restored AI session shows persisted results WITHOUT re-calling the cloud on reopen', async () => {
250+
aiProvider = 'cloud'
251+
translateSearchQueryMock.mockResolvedValueOnce({
252+
display: { namePattern: '*.png', patternType: 'glob' },
253+
query: {},
254+
caveat: null,
255+
} as unknown as TranslateResult)
256+
// First open: run an AI search (one translate + one searchFiles).
257+
const first = await mountDialog()
258+
setMode('ai')
259+
setQuery('all screenshots')
260+
dispatchKey(first.overlay, 'Enter')
261+
await new Promise((r) => setTimeout(r, 0))
262+
await tick()
263+
await new Promise((r) => setTimeout(r, 0))
264+
await tick()
265+
expect(translateSearchQueryMock).toHaveBeenCalledTimes(1)
266+
267+
// Reopen. AI mode must NOT re-call translate (cloud cost); the persisted results render
268+
// from the surviving state instead.
269+
first.cleanup()
270+
translateSearchQueryMock.mockClear()
271+
searchFilesMock.mockClear()
272+
const second = await mountDialog()
273+
await tick()
274+
await new Promise((r) => setTimeout(r, 0))
275+
await tick()
276+
expect(translateSearchQueryMock).not.toHaveBeenCalled()
277+
expect(searchFilesMock).not.toHaveBeenCalled()
278+
second.cleanup()
279+
})
280+
281+
it('a first-ever open (no prior run) shows the empty state and does not auto-run', async () => {
282+
const { overlay, cleanup } = await mountDialog()
283+
await tick()
284+
await new Promise((r) => setTimeout(r, 0))
285+
await tick()
286+
// Nothing ran, and the empty state is visible.
287+
expect(searchFilesMock).not.toHaveBeenCalled()
288+
expect(overlay.querySelector('.empty-state, [data-testid="empty-state"]') ?? overlay.textContent).toBeTruthy()
289+
cleanup()
290+
})
291+
292+
it('⌘N returns to the empty state and clears the prior-run marker (no re-run on next reopen)', async () => {
293+
const first = await mountDialog()
294+
setQuery('*.png')
295+
dispatchKey(first.overlay, 'Enter')
296+
await tick()
297+
await new Promise((r) => setTimeout(r, 0))
298+
await tick()
299+
expect(searchFilesMock).toHaveBeenCalledTimes(1)
300+
301+
// ⌘N clears the session (query + the prior-run marker `lastRunQuery`).
302+
dispatchKey(first.overlay, 'n', true)
303+
await tick()
304+
expect(getQuery()).toBe('')
305+
306+
// Reopen: with no query and no prior run, nothing re-runs and the empty state stands.
307+
first.cleanup()
308+
searchFilesMock.mockClear()
309+
const second = await mountDialog()
310+
await tick()
311+
await new Promise((r) => setTimeout(r, 0))
312+
await tick()
313+
expect(searchFilesMock).not.toHaveBeenCalled()
314+
second.cleanup()
315+
})
316+
})
317+
216318
describe('SearchDialog mode shortcuts (AI on)', () => {
217319
beforeEach(() => {
218320
clearSearchState()

apps/desktop/src/lib/selection-dialog/SelectionDialog.svelte.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,54 @@ describe('SelectionDialog', () => {
605605
second.cleanup()
606606
})
607607

608+
it('reopen re-derives results against the CURRENT folder (not stale rows from the first folder)', async () => {
609+
// Live-smoke fix: reopening a restored session must show the same query's results
610+
// immediately, re-derived against the folder open NOW. We prove the re-run happened by
611+
// reopening on a DIFFERENT folder and asserting Enter commits indices computed against
612+
// the new folder. Pre-fix, nothing re-ran on mount: the content sat idle until an edit.
613+
const matched: number[][] = []
614+
615+
// First folder: two PNGs at indices 0 and 2.
616+
const first = await mountDialog({
617+
entries: [buildEntry('a.png'), buildEntry('b.txt'), buildEntry('c.png')],
618+
onCommit: (idxs) => matched.push(idxs),
619+
})
620+
const firstInput = first.overlay.querySelector('input[type="text"], input:not([type])') as HTMLInputElement
621+
firstInput.value = '*.png'
622+
firstInput.dispatchEvent(new Event('input', { bubbles: true }))
623+
await tick()
624+
await new Promise((r) => setTimeout(r, 1100)) // auto-apply debounce → a run lands
625+
await tick()
626+
first.cleanup()
627+
628+
// Second folder, DIFFERENT shape: a single PNG, now at index 1.
629+
const second = await mountDialog({
630+
entries: [buildEntry('x.txt'), buildEntry('y.png'), buildEntry('z.txt')],
631+
onCommit: (idxs) => matched.push(idxs),
632+
})
633+
// Let the reopen re-run settle (no typing).
634+
await tick()
635+
await new Promise((r) => setTimeout(r, 0))
636+
await tick()
637+
// Enter commits immediately — the result set is already re-derived against the new folder.
638+
dispatchKey(second.overlay, 'Enter')
639+
await tick()
640+
expect(matched).toHaveLength(1)
641+
expect(matched[0]).toEqual([1]) // y.png in the SECOND folder, not [0, 2] from the first
642+
second.cleanup()
643+
})
644+
645+
it('first-ever open shows the empty state and does not auto-run', async () => {
646+
// A clean session (after ⌘N / first launch) must rest on the empty state with examples,
647+
// never an auto-run. `clearSelectionState()` in beforeEach gives us the clean slate.
648+
const { overlay, cleanup } = await mountDialog()
649+
await tick()
650+
await new Promise((r) => setTimeout(r, 0))
651+
await tick()
652+
expect(overlay.querySelector('.empty-state')).toBeTruthy()
653+
cleanup()
654+
})
655+
608656
it('matches on a size filter alone when the name bar is empty', async () => {
609657
// The headline M2 fix: a `≥ 1 MB` size filter with an EMPTY name pattern must
610658
// select every file ≥ 1 MB. Before the fix, `buildMatchQuery` returned null on

0 commit comments

Comments
 (0)