Skip to content
Open
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
71 changes: 71 additions & 0 deletions __tests__/unit/merged-search-filter.test.ts
Original file line number Diff line number Diff line change
@@ -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<MultiConversation>): 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<MultiSession>): 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)
})
})
37 changes: 24 additions & 13 deletions app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -473,6 +491,7 @@ export default function ProjectsHub() {
onRefresh={handleRefresh}
searchOpen={searchOpen}
searchQuery={classicConvSearch}
conversationsFromServer={Boolean(debouncedConvSearch)}
onSearchChange={setClassicConvSearch}
isBackgroundRefreshing={isBackgroundRefreshing}
/>
Expand Down Expand Up @@ -610,6 +629,7 @@ const MergedClassicList = React.memo(function MergedClassicList({
onRefresh,
searchOpen,
searchQuery,
conversationsFromServer,
onSearchChange,
isBackgroundRefreshing,
}: {
Expand All @@ -618,6 +638,7 @@ const MergedClassicList = React.memo(function MergedClassicList({
onRefresh: () => void
searchOpen: boolean
searchQuery: string
conversationsFromServer: boolean
onSearchChange: (q: string) => void
isBackgroundRefreshing?: boolean
}) {
Expand Down Expand Up @@ -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,
Expand Down
Loading