mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 16:02:29 +00:00
Prepare the Activity search query once per filtered list (#24701)
Lazily reuse a call-local search predicate inside the existing filter, using unchanged byte gates and thread text cache.
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { toSshExecutionHostId } from '../../../../shared/execution-host'
|
||||
import * as clipboard from '../../../../shared/clipboard-text'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
LEAF_ID,
|
||||
makeRepo,
|
||||
makeTabWithIds,
|
||||
makeWorktree
|
||||
} from './ActivityPrototypePage-test-fixtures'
|
||||
import {
|
||||
activityThreadMatchesSearchQuery,
|
||||
createActivityThreadSearchMatcher,
|
||||
getThreadSearchTextComputeCount
|
||||
} from './activity-thread-grouping'
|
||||
import { useAgentPaneThreads } from './use-agent-pane-threads'
|
||||
|
||||
vi.mock('@/store', async () => {
|
||||
const { createTestStore } = await import('@/store/slices/store-test-helpers')
|
||||
return { useAppStore: createTestStore() }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function installThreads(count: number, prompt = 'Task PROJECT ☃ progress'): string[] {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(100_000)
|
||||
const repo = makeRepo()
|
||||
const worktree = makeWorktree()
|
||||
const tabs = Array.from({ length: count }, (_, index) =>
|
||||
makeTabWithIds(`tab-${index}`, worktree.id)
|
||||
)
|
||||
const entries: Record<string, AgentStatusEntry> = {}
|
||||
for (const tab of tabs) {
|
||||
const paneKey = makePaneKey(tab.id, LEAF_ID)
|
||||
entries[paneKey] = {
|
||||
paneKey,
|
||||
state: 'working',
|
||||
prompt,
|
||||
agentType: 'claude',
|
||||
updatedAt: 100_000,
|
||||
stateStartedAt: 99_000,
|
||||
stateHistory: []
|
||||
}
|
||||
}
|
||||
useAppStore.setState({
|
||||
agentStatusByPaneKey: entries,
|
||||
retainedAgentsByPaneKey: {},
|
||||
migrationUnsupportedByPtyId: {},
|
||||
runtimeAgentOrchestrationByPaneKey: {},
|
||||
acknowledgedAgentsByPaneKey: {},
|
||||
activityClearedAtByPaneKey: {},
|
||||
repos: [repo],
|
||||
worktreesByRepo: { [repo.id]: [worktree] },
|
||||
folderWorkspaces: [],
|
||||
detectedWorktreesByRepo: {},
|
||||
tabsByWorktree: { [worktree.id]: tabs },
|
||||
unifiedTabsByWorktree: {},
|
||||
agentsVisibleHostIds: null,
|
||||
agentsFilterRepoIds: []
|
||||
})
|
||||
return Object.keys(entries)
|
||||
}
|
||||
|
||||
function queryWork(query: string) {
|
||||
const texts = new Set([query, query.trim(), query.trim().toLowerCase()].filter(Boolean))
|
||||
const originalBudget = clipboard.isClipboardTextByteLengthOverLimit
|
||||
const originalTrim = String.prototype.trim
|
||||
const originalLower = String.prototype.toLowerCase
|
||||
const counts = { budget: 0, trim: 0, lower: 0 }
|
||||
const budget = vi
|
||||
.spyOn(clipboard, 'isClipboardTextByteLengthOverLimit')
|
||||
.mockImplementation((text, maxBytes) => {
|
||||
if (texts.has(text)) {
|
||||
counts.budget += 1
|
||||
}
|
||||
return originalBudget(text, maxBytes)
|
||||
})
|
||||
const trim = vi.spyOn(String.prototype, 'trim').mockImplementation(function (this: string) {
|
||||
if (texts.has(String(this))) {
|
||||
counts.trim += 1
|
||||
}
|
||||
return originalTrim.call(this)
|
||||
})
|
||||
const lower = vi.spyOn(String.prototype, 'toLowerCase').mockImplementation(function (
|
||||
this: string
|
||||
) {
|
||||
if (texts.has(String(this))) {
|
||||
counts.lower += 1
|
||||
}
|
||||
return originalLower.call(this)
|
||||
})
|
||||
return {
|
||||
counts,
|
||||
restore() {
|
||||
budget.mockRestore()
|
||||
trim.mockRestore()
|
||||
lower.mockRestore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function options(query: string): Parameters<typeof useAgentPaneThreads>[0] {
|
||||
return { query, readFilter: 'all', groupBy: 'none', selectedPaneKey: null, showChildAgents: true }
|
||||
}
|
||||
|
||||
describe('Activity search query work in the real hook', () => {
|
||||
it.each([12, 128, 1000])('prepares one query for %i live threads', (count) => {
|
||||
installThreads(count)
|
||||
const work = queryWork(' PROJECT ☃ ')
|
||||
const activity = renderHook(() => useAgentPaneThreads(options(' PROJECT ☃ ')))
|
||||
work.restore()
|
||||
expect(activity.result.current.allThreads).toHaveLength(count)
|
||||
expect(activity.result.current.visibleThreads).toEqual(activity.result.current.allThreads)
|
||||
for (const [index, thread] of activity.result.current.visibleThreads.entries()) {
|
||||
expect(thread).toBe(activity.result.current.allThreads[index])
|
||||
}
|
||||
expect(work.counts).toEqual({ budget: 2, trim: 2, lower: 2 })
|
||||
})
|
||||
|
||||
it.each(['', ' \t\n '])('keeps an empty query lazy: %j', (query) => {
|
||||
installThreads(12)
|
||||
const before = getThreadSearchTextComputeCount()
|
||||
const activity = renderHook(() => useAgentPaneThreads(options(query)))
|
||||
expect(activity.result.current.visibleThreads).toEqual(activity.result.current.allThreads)
|
||||
expect(getThreadSearchTextComputeCount()).toBe(before)
|
||||
})
|
||||
|
||||
it('retains both original and normalized UTF-8 byte limits', () => {
|
||||
const query = 'İ'.repeat(700)
|
||||
installThreads(12, query)
|
||||
const before = getThreadSearchTextComputeCount()
|
||||
const work = queryWork(query)
|
||||
const activity = renderHook(() => useAgentPaneThreads(options(query)))
|
||||
work.restore()
|
||||
expect(activity.result.current.allThreads).toHaveLength(12)
|
||||
expect(activity.result.current.visibleThreads).toEqual([])
|
||||
expect(getThreadSearchTextComputeCount()).toBe(before)
|
||||
expect(work.counts.budget).toBe(2)
|
||||
})
|
||||
|
||||
it('does not prepare a row query before read/child/scope filters admit a row', () => {
|
||||
const panes = installThreads(12)
|
||||
useAppStore.setState({
|
||||
acknowledgedAgentsByPaneKey: Object.fromEntries(panes.map((paneKey) => [paneKey, 100_000]))
|
||||
})
|
||||
const work = queryWork(' PROJECT ☃ ')
|
||||
const activity = renderHook(() =>
|
||||
useAgentPaneThreads({ ...options(' PROJECT ☃ '), readFilter: 'unread' })
|
||||
)
|
||||
work.restore()
|
||||
expect(activity.result.current.allThreads).toHaveLength(12)
|
||||
expect(activity.result.current.visibleThreads).toEqual([])
|
||||
expect(work.counts).toEqual({ budget: 1, trim: 1, lower: 1 })
|
||||
})
|
||||
|
||||
it('keeps selection, child classification and scoped row order', () => {
|
||||
const panes = installThreads(8)
|
||||
const parent = panes[0]
|
||||
const child = panes[1]
|
||||
useAppStore.setState({
|
||||
runtimeAgentOrchestrationByPaneKey: {
|
||||
[child]: { parentPaneKey: parent, taskId: 'task', dispatchId: 'dispatch' }
|
||||
},
|
||||
acknowledgedAgentsByPaneKey: { [child]: 100_000 }
|
||||
})
|
||||
const activity = renderHook((input) => useAgentPaneThreads(input), {
|
||||
initialProps: {
|
||||
...options('PROJECT'),
|
||||
readFilter: 'unread' as const,
|
||||
showChildAgents: false,
|
||||
selectedPaneKey: child
|
||||
}
|
||||
})
|
||||
expect(activity.result.current.visibleThreads.map((thread) => thread.paneKey)).toEqual(panes)
|
||||
act(() => useAppStore.setState({ agentsVisibleHostIds: [toSshExecutionHostId('other')] }))
|
||||
expect(activity.result.current.visibleThreads.map((thread) => thread.paneKey)).toEqual([child])
|
||||
activity.rerender({
|
||||
...options('NO MATCH'),
|
||||
readFilter: 'unread',
|
||||
showChildAgents: false,
|
||||
selectedPaneKey: child
|
||||
})
|
||||
expect(activity.result.current.visibleThreads).toEqual([])
|
||||
expect(activity.result.current.effectiveSelectedPaneKey).toBe(child)
|
||||
})
|
||||
|
||||
it('uses a changed query and new thread data without a retained query cache', () => {
|
||||
const panes = installThreads(12, 'first prompt')
|
||||
const activity = renderHook((query) => useAgentPaneThreads(options(query)), {
|
||||
initialProps: 'first'
|
||||
})
|
||||
expect(activity.result.current.visibleThreads).toHaveLength(12)
|
||||
activity.rerender('second')
|
||||
expect(activity.result.current.visibleThreads).toEqual([])
|
||||
act(() => {
|
||||
const entries = useAppStore.getState().agentStatusByPaneKey
|
||||
useAppStore.setState({
|
||||
agentStatusByPaneKey: {
|
||||
...entries,
|
||||
[panes[2]]: { ...entries[panes[2]], prompt: 'second prompt' }
|
||||
}
|
||||
})
|
||||
})
|
||||
expect(activity.result.current.visibleThreads.map((thread) => thread.paneKey)).toEqual([
|
||||
panes[2]
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps original oversized query rejection ahead of normalization/search', () => {
|
||||
installThreads(12)
|
||||
const query = ' '.repeat(2049)
|
||||
const before = getThreadSearchTextComputeCount()
|
||||
const work = queryWork(query)
|
||||
const activity = renderHook(() => useAgentPaneThreads(options(query)))
|
||||
work.restore()
|
||||
expect(activity.result.current.visibleThreads).toEqual([])
|
||||
expect(getThreadSearchTextComputeCount()).toBe(before)
|
||||
expect(work.counts).toEqual({ budget: 1, trim: 0, lower: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepared Activity search preserves the public one-thread matcher', () => {
|
||||
it('matches original output and lazy text for Unicode, boundary and empty queries', () => {
|
||||
installThreads(8, 'Unicode İ 👩🏽💻 and BILLING')
|
||||
const activity = renderHook(() => useAgentPaneThreads(options('')))
|
||||
const queries = [
|
||||
'',
|
||||
'\t\n ',
|
||||
'BILLING',
|
||||
' Unicode İ ',
|
||||
'👩🏽💻',
|
||||
'\ud800',
|
||||
'x'.repeat(2048),
|
||||
'é'.repeat(1024),
|
||||
'é'.repeat(1025),
|
||||
'İ'.repeat(700)
|
||||
]
|
||||
for (const searchQuery of queries) {
|
||||
const matches = createActivityThreadSearchMatcher(searchQuery)
|
||||
expect(activity.result.current.allThreads.map(matches)).toEqual(
|
||||
activity.result.current.allThreads.map((thread) =>
|
||||
activityThreadMatchesSearchQuery({ thread, searchQuery })
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -134,6 +134,20 @@ export function isActivitySearchQueryTooLarge(
|
||||
return isClipboardTextByteLengthOverLimit(query, maxBytes)
|
||||
}
|
||||
|
||||
export function createActivityThreadSearchMatcher(
|
||||
searchQuery: string
|
||||
): (thread: AgentPaneThread) => boolean {
|
||||
if (isActivitySearchQueryTooLarge(searchQuery)) {
|
||||
return () => false
|
||||
}
|
||||
const trimmedQuery = searchQuery.trim()
|
||||
if (!trimmedQuery) {
|
||||
return () => true
|
||||
}
|
||||
const normalizedQuery = trimmedQuery.toLowerCase()
|
||||
return (thread) => threadSearchText(thread).includes(normalizedQuery)
|
||||
}
|
||||
|
||||
export function activityThreadMatchesSearchQuery({
|
||||
thread,
|
||||
searchQuery
|
||||
|
||||
@@ -15,7 +15,7 @@ import { collectChildAgentPaneKeys } from './activity-thread-child-agent'
|
||||
const EMPTY_PANE_KEYS: ReadonlySet<string> = new Set()
|
||||
import { filterThreadsByActivityScope, resolveActivityScopeRepoIds } from './activity-scope-filter'
|
||||
import {
|
||||
activityThreadMatchesSearchQuery,
|
||||
createActivityThreadSearchMatcher,
|
||||
buildActivityThreadGroups,
|
||||
isActivitySearchQueryTooLarge
|
||||
} from './activity-thread-grouping'
|
||||
@@ -212,6 +212,7 @@ export function useAgentPaneThreads(args: {
|
||||
const normalizedQuery = isActivitySearchQueryTooLarge(deferredQuery)
|
||||
? null
|
||||
: deferredQuery.trim().toLowerCase()
|
||||
let matchesSearchQuery: ReturnType<typeof createActivityThreadSearchMatcher> | undefined
|
||||
return scopeVisibleThreads.filter((thread) => {
|
||||
// Why: keep the just-selected thread visible after auto-mark-read flips it to read, else unread-only mode makes the clicked row vanish from the list.
|
||||
if (
|
||||
@@ -232,7 +233,8 @@ export function useAgentPaneThreads(args: {
|
||||
if (normalizedQuery === null) {
|
||||
return false
|
||||
}
|
||||
return activityThreadMatchesSearchQuery({ thread, searchQuery: normalizedQuery })
|
||||
matchesSearchQuery ??= createActivityThreadSearchMatcher(normalizedQuery)
|
||||
return matchesSearchQuery(thread)
|
||||
})
|
||||
}, [
|
||||
scopeVisibleThreads,
|
||||
|
||||
Reference in New Issue
Block a user