From 7df9338a1068914b3f48a73f52e6a5a6bca88d21 Mon Sep 17 00:00:00 2001 From: antony Date: Sun, 11 Oct 2026 22:02:07 +1000 Subject: [PATCH] fix(search): the dependency group appears when the feed lands after the query was typed Live check of #919 in the founder's app (2026-10-11): with the query set in the same tick the field opened, "Your dependencies" stayed empty until the next keystroke. The provider is sync and reads the store's Preemption feed; opening the field starts that load, and results are only recomputed on input. A pasted query, or a slow first load, therefore missed the group. CommandSearch now re-runs the current query when `preemptionFeed` changes while the field is open and non-empty (the intelligence tier answers from its LRU cache, so no extra backend search). Test: CommandSearch.test.tsx stages "typed first, feed later" with a manually released get_preemption_alerts promise. It fails without the effect (group absent) and passes with it; a feed landing on an empty field adds no rows. Search suite 34/34, tsc and eslint clean. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CY6KandRe2fYACyoAHwRPe --- src/components/search/CommandSearch.test.tsx | 88 ++++++++++++++++++++ src/components/search/CommandSearch.tsx | 9 ++ 2 files changed, 97 insertions(+) create mode 100644 src/components/search/CommandSearch.test.tsx diff --git a/src/components/search/CommandSearch.test.tsx b/src/components/search/CommandSearch.test.tsx new file mode 100644 index 000000000..716ae74cc --- /dev/null +++ b/src/components/search/CommandSearch.test.tsx @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: FSL-1.1-Apache-2.0 +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; + +import { CommandSearch } from './CommandSearch'; +import { useAppStore } from '../../store'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string | Record) => + typeof fallback === 'string' ? fallback : key, + }), +})); +vi.mock('./search-warm', () => ({ warmSearchBackend: () => false })); +vi.mock('../../lib/frecency', () => ({ frecencyBoost: () => 0, recordPick: () => {} })); + +// The Preemption feed resolves only when the test says so, to stage "the user +// typed before the feed arrived". +let releaseFeed: (() => void) | null = null; +const SHARP_FEED = { + items: [ + { + id: 'upgrade-plan:npm:sharp', + kind: 'alert', + title: 'Upgrade sharp to >= 0.35.5 — clears 1 advisory across 1 project', + explanation: '', + confidence: { value: 0.9, provenance: 'heuristic' }, + urgency: 'high', + evidence: [], + affected_projects: ['d:/work/navcal'], + affected_deps: ['sharp'], + suggested_actions: [], + lens_hints: { preemption: true }, + created_at: 0, + }, + ], + total: 1, + critical_count: 0, + high_count: 1, + score: null, +}; + +vi.mock('../../lib/commands', () => ({ + cmd: (name: string) => { + if (name === 'get_preemption_alerts') { + return new Promise(resolve => { + releaseFeed = () => resolve(SHARP_FEED); + }); + } + if (name === 'natural_language_query') { + return Promise.resolve({ items: [], ghost_preview: null, is_pro: true, total_count: 0, semantic_pending: false }); + } + return Promise.resolve(null); + }, +})); + +describe('CommandSearch — Your dependencies', () => { + beforeEach(() => { + releaseFeed = null; + useAppStore.setState({ preemptionFeed: null, preemptionLoading: false, preemptionPaywalled: false }); + }); + + it('shows the group when the feed lands after the query was typed, with no further keystroke', async () => { + render( {}} onOpenSettings={() => {}} />); + const input = screen.getByRole('combobox'); + fireEvent.focus(input); // opening the field starts the feed load + fireEvent.change(input, { target: { value: 'sharp vulnerability' } }); + await waitFor(() => expect(releaseFeed).not.toBeNull()); + expect(screen.queryByText('Your dependencies')).toBeNull(); + + await act(async () => { + releaseFeed?.(); + }); + + await waitFor(() => expect(screen.getByText('Your dependencies')).toBeTruthy()); + expect(screen.getByText(/Upgrade sharp to >= 0\.35\.5/)).toBeTruthy(); + }); + + it('a feed landing with an empty field adds no rows', async () => { + render( {}} onOpenSettings={() => {}} />); + fireEvent.focus(screen.getByRole('combobox')); + await waitFor(() => expect(releaseFeed).not.toBeNull()); + await act(async () => { + releaseFeed?.(); + }); + expect(screen.queryByText('Your dependencies')).toBeNull(); + }); +}); diff --git a/src/components/search/CommandSearch.tsx b/src/components/search/CommandSearch.tsx index ccad30922..83df6d0d6 100644 --- a/src/components/search/CommandSearch.tsx +++ b/src/components/search/CommandSearch.tsx @@ -93,6 +93,15 @@ export const CommandSearch = memo(function CommandSearch({ onAnalyze, onOpenSett if (!s.preemptionFeed && !s.preemptionLoading && !s.preemptionPaywalled) void s.loadPreemption(); }, [open]); + // When that feed lands after the user already typed (a pasted query, a slow + // first load), re-run the query so the group appears without another + // keystroke. The intelligence tier answers from its cache. + const preemptionFeed = useAppStore(s => s.preemptionFeed); + useEffect(() => { + if (open && query.trim()) setQuery(query); + // eslint-disable-next-line react-hooks/exhaustive-deps -- only a newly loaded feed re-runs the query, not each keystroke + }, [preemptionFeed]); + // Global Cmd/Ctrl+K — platform-correct (⌘ on macOS, Ctrl on Windows/Linux). // The `/` focus shortcut is handled by the app's keyboard layer via the // [data-search-input] hook target, which fires this input's onFocus.