From fcab77051806097978b080aaa37980c3498c12f9 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 11 Apr 2026 06:49:23 -0500 Subject: [PATCH 1/3] Add inbox issue search fallback --- ui/src/lib/inbox.test.ts | 60 +++++++++++++++++++++++++++++++++++++++ ui/src/lib/inbox.ts | 25 ++++++++++++++++ ui/src/pages/Inbox.tsx | 61 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 140 insertions(+), 6 deletions(-) diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 2bd2d74b..002946b4 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -20,6 +20,7 @@ import { getApprovalsForTab, getInboxWorkItems, getInboxKeyboardSelectionIndex, + getInboxSearchFallbackIssues, getRecentTouchedIssues, getUnreadTouchedIssues, groupInboxWorkItems, @@ -611,6 +612,65 @@ describe("inbox helpers", () => { ).toEqual(["newer", "older"]); }); + it("uses remote issue results only when local inbox search has no matches", () => { + const remoteMatch = makeIssue("remote-match", false); + remoteMatch.status = "in_progress"; + + expect( + getInboxSearchFallbackIssues({ + query: "pull/3303", + filteredWorkItems: [], + archivedSearchIssues: [], + remoteIssues: [remoteMatch], + issueFilters: { + statuses: ["in_progress"], + priorities: [], + assignees: [], + labels: [], + projects: [], + workspaces: [], + showRoutineExecutions: false, + }, + }).map((issue) => issue.id), + ).toEqual(["remote-match"]); + + expect( + getInboxSearchFallbackIssues({ + query: "pull/3303", + filteredWorkItems: [{ kind: "issue", timestamp: 1, issue: makeIssue("local", false) }], + archivedSearchIssues: [], + remoteIssues: [remoteMatch], + issueFilters: { + statuses: [], + priorities: [], + assignees: [], + labels: [], + projects: [], + workspaces: [], + showRoutineExecutions: false, + }, + }), + ).toEqual([]); + + expect( + getInboxSearchFallbackIssues({ + query: "pull/3303", + filteredWorkItems: [], + archivedSearchIssues: [makeIssue("archived", false)], + remoteIssues: [remoteMatch], + issueFilters: { + statuses: [], + priorities: [], + assignees: [], + labels: [], + projects: [], + workspaces: [], + showRoutineExecutions: false, + }, + }), + ).toEqual([]); + }); + it("defaults the remembered inbox tab to mine and persists all", () => { localStorage.clear(); expect(loadLastInboxTab()).toBe("mine"); diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index 5e496ee4..f31adf6e 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -7,6 +7,7 @@ import type { JoinRequest, } from "@paperclipai/shared"; import { + applyIssueFilters, defaultIssueFilterState, type IssueFilterState, } from "./issue-filters"; @@ -370,6 +371,30 @@ export function getArchivedInboxSearchIssues({ .sort(sortIssuesByMostRecentActivity); } +export function getInboxSearchFallbackIssues({ + query, + filteredWorkItems, + archivedSearchIssues, + remoteIssues, + issueFilters, + currentUserId, + enableRoutineVisibilityFilter = false, +}: { + query: string; + filteredWorkItems: InboxWorkItem[]; + archivedSearchIssues: Issue[]; + remoteIssues: Issue[]; + issueFilters: IssueFilterState; + currentUserId?: string | null; + enableRoutineVisibilityFilter?: boolean; +}): Issue[] { + const normalizedQuery = query.trim(); + if (!normalizedQuery) return []; + if (filteredWorkItems.length > 0) return []; + if (archivedSearchIssues.length > 0) return []; + return applyIssueFilters(remoteIssues, issueFilters, currentUserId, enableRoutineVisibilityFilter); +} + export function resolveIssueWorkspaceName( issue: Pick, { diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index b28938ca..30891363 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -98,6 +98,7 @@ import { getArchivedInboxSearchIssues, getInboxWorkItems, getInboxKeyboardSelectionIndex, + getInboxSearchFallbackIssues, getLatestFailedRunsByAgent, matchesInboxIssueSearch, getRecentTouchedIssues, @@ -642,6 +643,7 @@ export function Inbox() { retry: false, }); const [searchQuery, setSearchQuery] = useState(""); + const normalizedSearchQuery = searchQuery.trim(); const [filterPreferences, setFilterPreferences] = useState( () => loadInboxFilterPreferences(selectedCompanyId), ); @@ -945,7 +947,7 @@ export function Inbox() { ); const filteredWorkItems = useMemo(() => { - const q = searchQuery.trim().toLowerCase(); + const q = normalizedSearchQuery.toLowerCase(); if (!q) return workItemsToRender; return workItemsToRender.filter((item) => { if (item.kind === "issue") { @@ -987,12 +989,12 @@ export function Inbox() { }); }, [ workItemsToRender, - searchQuery, agentById, defaultProjectWorkspaceIdByProjectId, executionWorkspaceById, issueById, isolatedWorkspacesEnabled, + normalizedSearchQuery, projectWorkspaceById, ]); @@ -1002,7 +1004,7 @@ export function Inbox() { ? getArchivedInboxSearchIssues({ visibleIssues: visibleMineIssues, searchableIssues: visibleTouchedIssues, - query: searchQuery, + query: normalizedSearchQuery, isolatedWorkspacesEnabled, executionWorkspaceById, projectWorkspaceById, @@ -1013,13 +1015,60 @@ export function Inbox() { defaultProjectWorkspaceIdByProjectId, executionWorkspaceById, isolatedWorkspacesEnabled, + normalizedSearchQuery, projectWorkspaceById, - searchQuery, tab, visibleMineIssues, visibleTouchedIssues, ], ); + const shouldUseIssueSearchFallback = + !!selectedCompanyId + && normalizedSearchQuery.length > 0 + && filteredWorkItems.length === 0 + && archivedSearchIssues.length === 0; + const { data: remoteIssueSearchResults = [] } = useQuery({ + queryKey: [ + ...queryKeys.issues.search(selectedCompanyId!, normalizedSearchQuery, undefined, 25), + "inbox-fallback", + issueFilters, + ], + queryFn: () => + issuesApi.list(selectedCompanyId!, { + q: normalizedSearchQuery, + limit: 25, + includeRoutineExecutions: true, + }), + enabled: shouldUseIssueSearchFallback, + placeholderData: (previousData) => previousData, + }); + const issueSearchFallbackResults = useMemo( + () => + getInboxSearchFallbackIssues({ + query: normalizedSearchQuery, + filteredWorkItems, + archivedSearchIssues, + remoteIssues: remoteIssueSearchResults, + issueFilters, + currentUserId, + enableRoutineVisibilityFilter: true, + }), + [ + archivedSearchIssues, + currentUserId, + filteredWorkItems, + issueFilters, + normalizedSearchQuery, + remoteIssueSearchResults, + ], + ); + const effectiveWorkItems = useMemo( + () => + issueSearchFallbackResults.length > 0 + ? getInboxWorkItems({ issues: issueSearchFallbackResults, approvals: [] }) + : filteredWorkItems, + [filteredWorkItems, issueSearchFallbackResults], + ); const archivedSearchIssueIds = useMemo( () => new Set(archivedSearchIssues.map((issue) => issue.id)), [archivedSearchIssues], @@ -1037,14 +1086,14 @@ export function Inbox() { }, []); const [collapsedInboxParents, setCollapsedInboxParents] = useState>(new Set()); const groupedSections = useMemo(() => [ - ...buildGroupedInboxSections(filteredWorkItems, groupBy, nestingEnabled), + ...buildGroupedInboxSections(effectiveWorkItems, groupBy, nestingEnabled), ...buildGroupedInboxSections( getInboxWorkItems({ issues: archivedSearchIssues, approvals: [] }), groupBy, nestingEnabled, { keyPrefix: "archived-search:", isArchivedSearch: true }, ), - ], [archivedSearchIssues, filteredWorkItems, groupBy, nestingEnabled]); + ], [archivedSearchIssues, effectiveWorkItems, groupBy, nestingEnabled]); const totalVisibleWorkItems = useMemo( () => groupedSections.reduce((count, group) => count + group.displayItems.length, 0), [groupedSections], From 1f78e5507238664974735a4c54b187949c3248ea Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 11 Apr 2026 06:57:49 -0500 Subject: [PATCH 2/3] Broaden comment matches in issue search --- server/src/__tests__/issues-service.test.ts | 45 +++++ server/src/services/issues.ts | 4 +- ui/src/components/CommandPalette.test.tsx | 190 ++++++++++++++++++++ ui/src/components/CommandPalette.tsx | 2 +- ui/src/lib/inbox.test.ts | 14 +- ui/src/lib/inbox.ts | 13 +- ui/src/pages/Inbox.tsx | 25 +-- 7 files changed, 267 insertions(+), 26 deletions(-) create mode 100644 ui/src/components/CommandPalette.test.tsx diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index a434b418..0629686a 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -298,6 +298,51 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { expect(result.map((issue) => issue.id)).toEqual([titleMatchId, descriptionMatchId]); }); + it("ranks comment matches ahead of description-only matches", async () => { + const companyId = randomUUID(); + const commentMatchId = randomUUID(); + const descriptionMatchId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(issues).values([ + { + id: commentMatchId, + companyId, + title: "Comment match", + status: "todo", + priority: "medium", + }, + { + id: descriptionMatchId, + companyId, + title: "Description match", + description: "Contains pull/3303 in the description", + status: "todo", + priority: "medium", + }, + ]); + + await db.insert(issueComments).values({ + companyId, + issueId: commentMatchId, + body: "Reference: https://github.com/paperclipai/paperclip/pull/3303", + }); + + const result = await svc.list(companyId, { + q: "pull/3303", + limit: 2, + includeRoutineExecutions: true, + }); + + expect(result.map((issue) => issue.id)).toEqual([commentMatchId, descriptionMatchId]); + }); + it("accepts issue identifiers through getById", async () => { const companyId = randomUUID(); const issueId = randomUUID(); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index bb40be79..f7ac19da 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -997,8 +997,8 @@ export function issueService(db: Db) { WHEN ${titleContainsMatch} THEN 1 WHEN ${identifierStartsWithMatch} THEN 2 WHEN ${identifierContainsMatch} THEN 3 - WHEN ${descriptionContainsMatch} THEN 4 - WHEN ${commentContainsMatch} THEN 5 + WHEN ${commentContainsMatch} THEN 4 + WHEN ${descriptionContainsMatch} THEN 5 ELSE 6 END `; diff --git a/ui/src/components/CommandPalette.test.tsx b/ui/src/components/CommandPalette.test.tsx new file mode 100644 index 00000000..b229cc96 --- /dev/null +++ b/ui/src/components/CommandPalette.test.tsx @@ -0,0 +1,190 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import type { ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CommandPalette } from "./CommandPalette"; + +const companyState = vi.hoisted(() => ({ + selectedCompanyId: "company-1", +})); + +const dialogState = vi.hoisted(() => ({ + openNewIssue: vi.fn(), + openNewAgent: vi.fn(), +})); + +const sidebarState = vi.hoisted(() => ({ + isMobile: false, + setSidebarOpen: vi.fn(), +})); + +const mockIssuesApi = vi.hoisted(() => ({ + list: vi.fn(), +})); + +const mockAgentsApi = vi.hoisted(() => ({ + list: vi.fn(), +})); + +const mockProjectsApi = vi.hoisted(() => ({ + list: vi.fn(), +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => companyState, +})); + +vi.mock("../context/DialogContext", () => ({ + useDialog: () => dialogState, +})); + +vi.mock("../context/SidebarContext", () => ({ + useSidebar: () => sidebarState, +})); + +vi.mock("@/lib/router", () => ({ + useNavigate: () => vi.fn(), +})); + +vi.mock("../api/issues", () => ({ + issuesApi: mockIssuesApi, +})); + +vi.mock("../api/agents", () => ({ + agentsApi: mockAgentsApi, +})); + +vi.mock("../api/projects", () => ({ + projectsApi: mockProjectsApi, +})); + +vi.mock("./Identity", () => ({ + Identity: ({ name }: { name: string }) => {name}, +})); + +vi.mock("@/components/ui/command", () => ({ + CommandDialog: ({ open, children }: { open: boolean; children: ReactNode }) => (open ?
{children}
: null), + CommandEmpty: ({ children }: { children: ReactNode }) =>
{children}
, + CommandGroup: ({ children }: { children: ReactNode }) =>
{children}
, + CommandInput: ({ + value, + onValueChange, + }: { + value: string; + onValueChange: (value: string) => void; + }) => ( +
+ onValueChange(event.currentTarget.value)} + /> +
+ ), + CommandItem: ({ + children, + onSelect, + }: { + children: ReactNode; + onSelect?: () => void; + }) => , + CommandList: ({ children }: { children: ReactNode }) =>
{children}
, + CommandSeparator: () =>
, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function flush() { + await act(async () => { + await Promise.resolve(); + }); +} + +async function waitForAssertion(assertion: () => void, attempts = 20) { + let lastError: unknown; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await flush(); + } + } + throw lastError; +} + +function renderWithQueryClient(node: ReactNode, container: HTMLDivElement) { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + act(() => { + root.render( + + {node} + , + ); + }); + + return { root, queryClient }; +} + +describe("CommandPalette", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + dialogState.openNewIssue.mockReset(); + dialogState.openNewAgent.mockReset(); + sidebarState.setSidebarOpen.mockReset(); + mockIssuesApi.list.mockReset(); + mockAgentsApi.list.mockReset(); + mockProjectsApi.list.mockReset(); + mockIssuesApi.list.mockResolvedValue([]); + mockAgentsApi.list.mockResolvedValue([]); + mockProjectsApi.list.mockResolvedValue([]); + }); + + afterEach(() => { + container.remove(); + }); + + it("includes routine execution issues in search queries", async () => { + const { root } = renderWithQueryClient(, container); + + act(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true })); + }); + + const setQueryButton = container.querySelector('button[aria-label="Set query"]'); + expect(setQueryButton).not.toBeNull(); + + act(() => { + setQueryButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitForAssertion(() => { + expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", { + q: "pull/3303", + limit: 10, + includeRoutineExecutions: true, + }); + }); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index 82987bf6..f5a0ef75 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -65,7 +65,7 @@ export function CommandPalette() { const { data: searchedIssues = [] } = useQuery({ queryKey: queryKeys.issues.search(selectedCompanyId!, searchQuery, undefined, 10), - queryFn: () => issuesApi.list(selectedCompanyId!, { q: searchQuery, limit: 10 }), + queryFn: () => issuesApi.list(selectedCompanyId!, { q: searchQuery, limit: 10, includeRoutineExecutions: true }), enabled: !!selectedCompanyId && open && searchQuery.length > 0, }); diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 002946b4..50a1f0b3 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -20,7 +20,7 @@ import { getApprovalsForTab, getInboxWorkItems, getInboxKeyboardSelectionIndex, - getInboxSearchFallbackIssues, + getInboxSearchSupplementIssues, getRecentTouchedIssues, getUnreadTouchedIssues, groupInboxWorkItems, @@ -612,12 +612,12 @@ describe("inbox helpers", () => { ).toEqual(["newer", "older"]); }); - it("uses remote issue results only when local inbox search has no matches", () => { + it("adds remote issue results that are not already present in inbox search results", () => { const remoteMatch = makeIssue("remote-match", false); remoteMatch.status = "in_progress"; expect( - getInboxSearchFallbackIssues({ + getInboxSearchSupplementIssues({ query: "pull/3303", filteredWorkItems: [], archivedSearchIssues: [], @@ -635,9 +635,9 @@ describe("inbox helpers", () => { ).toEqual(["remote-match"]); expect( - getInboxSearchFallbackIssues({ + getInboxSearchSupplementIssues({ query: "pull/3303", - filteredWorkItems: [{ kind: "issue", timestamp: 1, issue: makeIssue("local", false) }], + filteredWorkItems: [{ kind: "issue", timestamp: 1, issue: makeIssue("remote-match", false) }], archivedSearchIssues: [], remoteIssues: [remoteMatch], issueFilters: { @@ -653,10 +653,10 @@ describe("inbox helpers", () => { ).toEqual([]); expect( - getInboxSearchFallbackIssues({ + getInboxSearchSupplementIssues({ query: "pull/3303", filteredWorkItems: [], - archivedSearchIssues: [makeIssue("archived", false)], + archivedSearchIssues: [makeIssue("remote-match", false)], remoteIssues: [remoteMatch], issueFilters: { statuses: [], diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index f31adf6e..21b2973d 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -371,7 +371,7 @@ export function getArchivedInboxSearchIssues({ .sort(sortIssuesByMostRecentActivity); } -export function getInboxSearchFallbackIssues({ +export function getInboxSearchSupplementIssues({ query, filteredWorkItems, archivedSearchIssues, @@ -390,9 +390,14 @@ export function getInboxSearchFallbackIssues({ }): Issue[] { const normalizedQuery = query.trim(); if (!normalizedQuery) return []; - if (filteredWorkItems.length > 0) return []; - if (archivedSearchIssues.length > 0) return []; - return applyIssueFilters(remoteIssues, issueFilters, currentUserId, enableRoutineVisibilityFilter); + const visibleIssueIds = new Set([ + ...filteredWorkItems + .filter((item): item is Extract => item.kind === "issue") + .map((item) => item.issue.id), + ...archivedSearchIssues.map((issue) => issue.id), + ]); + return applyIssueFilters(remoteIssues, issueFilters, currentUserId, enableRoutineVisibilityFilter) + .filter((issue) => !visibleIssueIds.has(issue.id)); } export function resolveIssueWorkspaceName( diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 30891363..1abe5eec 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -98,7 +98,7 @@ import { getArchivedInboxSearchIssues, getInboxWorkItems, getInboxKeyboardSelectionIndex, - getInboxSearchFallbackIssues, + getInboxSearchSupplementIssues, getLatestFailedRunsByAgent, matchesInboxIssueSearch, getRecentTouchedIssues, @@ -1022,15 +1022,13 @@ export function Inbox() { visibleTouchedIssues, ], ); - const shouldUseIssueSearchFallback = + const shouldUseIssueSearchSupplement = !!selectedCompanyId - && normalizedSearchQuery.length > 0 - && filteredWorkItems.length === 0 - && archivedSearchIssues.length === 0; + && normalizedSearchQuery.length > 0; const { data: remoteIssueSearchResults = [] } = useQuery({ queryKey: [ ...queryKeys.issues.search(selectedCompanyId!, normalizedSearchQuery, undefined, 25), - "inbox-fallback", + "inbox-supplement", issueFilters, ], queryFn: () => @@ -1039,12 +1037,12 @@ export function Inbox() { limit: 25, includeRoutineExecutions: true, }), - enabled: shouldUseIssueSearchFallback, + enabled: shouldUseIssueSearchSupplement, placeholderData: (previousData) => previousData, }); - const issueSearchFallbackResults = useMemo( + const issueSearchSupplementResults = useMemo( () => - getInboxSearchFallbackIssues({ + getInboxSearchSupplementIssues({ query: normalizedSearchQuery, filteredWorkItems, archivedSearchIssues, @@ -1064,10 +1062,13 @@ export function Inbox() { ); const effectiveWorkItems = useMemo( () => - issueSearchFallbackResults.length > 0 - ? getInboxWorkItems({ issues: issueSearchFallbackResults, approvals: [] }) + issueSearchSupplementResults.length > 0 + ? [ + ...filteredWorkItems, + ...getInboxWorkItems({ issues: issueSearchSupplementResults, approvals: [] }), + ] : filteredWorkItems, - [filteredWorkItems, issueSearchFallbackResults], + [filteredWorkItems, issueSearchSupplementResults], ); const archivedSearchIssueIds = useMemo( () => new Set(archivedSearchIssues.map((issue) => issue.id)), From a63e847525ddf5064ab7e309c8dd0e2c18028e69 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 11 Apr 2026 08:34:17 -0500 Subject: [PATCH 3/3] fix(inbox): avoid refetching on filter-only changes --- ui/src/pages/Inbox.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 1abe5eec..3cb0efa8 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -1029,7 +1029,6 @@ export function Inbox() { queryKey: [ ...queryKeys.issues.search(selectedCompanyId!, normalizedSearchQuery, undefined, 25), "inbox-supplement", - issueFilters, ], queryFn: () => issuesApi.list(selectedCompanyId!, {