diff --git a/__tests__/unit/merged-search-filter.test.ts b/__tests__/unit/merged-search-filter.test.ts new file mode 100644 index 00000000..0d49b610 --- /dev/null +++ b/__tests__/unit/merged-search-filter.test.ts @@ -0,0 +1,71 @@ +/** + * Regression guard for #646: the hub's merged list must not re-filter results + * that came from /api/search. + * + * /api/search matches message bodies; the client only ever sees title and + * preview, so re-running the predicate on server results can only discard + * conversations the server correctly matched — exactly the "I know we + * discussed it, no idea where" case server-side search exists to serve. + * + * Sessions keep filtering client-side in both regimes, because /api/search + * does not cover them. + */ +import { mergedItemMatchesQuery, type MergedItem } from '@/app/index' +import type { MultiConversation, MultiSession } from '@/types/api' + +const conversation = (over: Partial): MergedItem => ({ + kind: 'conversation', + ms: 0, + item: { + id: 'conv-1', + title: 'Retry loop refactor', + projectPath: '/tmp/p', + messageCount: 12, + lastActivity: '2026-08-12T10:00:00Z', + serverId: 'srv-1', + ...over, + }, +}) + +const session = (over: Partial): MergedItem => ({ + kind: 'session', + ms: 0, + item: { + id: 'sess-1', + status: 'running', + ptyAttached: true, + projectPath: '/tmp/p', + projectName: 'wombat-api', + lastOutput: 'compiling', + elapsedMs: 1000, + promptCount: 2, + startedAt: '2026-08-12T09:00:00Z', + serverId: 'srv-1', + ...over, + }, +}) + +describe('mergedItemMatchesQuery', () => { + // The defect: body-only matches have nothing to match on client-side. + const bodyOnlyMatch = conversation({ title: 'Retry loop refactor', preview: 'Let us ship it' }) + + it('keeps a server-matched conversation whose query hits neither title nor preview', () => { + expect(mergedItemMatchesQuery(bodyOnlyMatch, 'wombat', true)).toBe(true) + }) + + it('still filters conversations on title and preview when they came from the paged set', () => { + expect(mergedItemMatchesQuery(bodyOnlyMatch, 'wombat', false)).toBe(false) + expect(mergedItemMatchesQuery(bodyOnlyMatch, 'retry', false)).toBe(true) + expect( + mergedItemMatchesQuery(conversation({ preview: 'the wombat timeout' }), 'wombat', false), + ).toBe(true) + }) + + it('filters session rows client-side even while conversations are server-backed', () => { + expect(mergedItemMatchesQuery(session({}), 'wombat', true)).toBe(true) + expect(mergedItemMatchesQuery(session({ projectName: 'billing' }), 'wombat', true)).toBe(false) + expect( + mergedItemMatchesQuery(session({ projectName: 'billing', lastOutput: 'wombat timed out' }), 'wombat', true), + ).toBe(true) + }) +}) diff --git a/app/index.tsx b/app/index.tsx index 2f1c221e..153ee9a3 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -67,10 +67,28 @@ const EMPTY_CONVERSATIONS: MultiConversation[] = [] type ClassicTab = 'sessions' | 'history' -type MergedItem = +export type MergedItem = | { kind: 'session'; ms: number; item: MultiSession } | { kind: 'conversation'; ms: number; item: MultiConversation } +// Sessions always match client-side — /api/search does not cover them. +// Conversations only do when they came from the paged set: server results are +// already matched on message bodies, so re-checking title/preview here could +// only drop rows the server correctly found. +export function mergedItemMatchesQuery( + item: MergedItem, + q: string, + conversationsFromServer: boolean, +): boolean { + if (item.kind === 'session') { + return Boolean( + item.item.projectName?.toLowerCase().includes(q) || item.item.lastOutput?.toLowerCase().includes(q), + ) + } + if (conversationsFromServer) return true + return Boolean(item.item.title?.toLowerCase().includes(q) || item.item.preview?.toLowerCase().includes(q)) +} + function lastActivityMs(s: MultiSession): number { if (s.completedAt) return Date.parse(s.completedAt) return Date.parse(s.startedAt) + (s.elapsedMs ?? 0) @@ -473,6 +491,7 @@ export default function ProjectsHub() { onRefresh={handleRefresh} searchOpen={searchOpen} searchQuery={classicConvSearch} + conversationsFromServer={Boolean(debouncedConvSearch)} onSearchChange={setClassicConvSearch} isBackgroundRefreshing={isBackgroundRefreshing} /> @@ -610,6 +629,7 @@ const MergedClassicList = React.memo(function MergedClassicList({ onRefresh, searchOpen, searchQuery, + conversationsFromServer, onSearchChange, isBackgroundRefreshing, }: { @@ -618,6 +638,7 @@ const MergedClassicList = React.memo(function MergedClassicList({ onRefresh: () => void searchOpen: boolean searchQuery: string + conversationsFromServer: boolean onSearchChange: (q: string) => void isBackgroundRefreshing?: boolean }) { @@ -646,18 +667,8 @@ const MergedClassicList = React.memo(function MergedClassicList({ const filteredItems = useMemo(() => { if (!searchQuery) return items const q = searchQuery.toLowerCase() - return items.filter((item) => { - if (item.kind === 'session') { - const s = item.item as MultiSession - return s.projectName?.toLowerCase().includes(q) || s.lastOutput?.toLowerCase().includes(q) - } - const c = item.item as MultiConversation - return ( - c.title?.toLowerCase().includes(q) || - c.preview?.toLowerCase().includes(q) - ) - }) - }, [searchQuery, items]) + return items.filter((item) => mergedItemMatchesQuery(item, q, conversationsFromServer)) + }, [searchQuery, items, conversationsFromServer]) const visibleServerCount = useMemo( () => new Set(filteredItems.map((it) => it.item.serverId)).size,