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 2bd2d74b..50a1f0b3 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -20,6 +20,7 @@ import { getApprovalsForTab, getInboxWorkItems, getInboxKeyboardSelectionIndex, + getInboxSearchSupplementIssues, getRecentTouchedIssues, getUnreadTouchedIssues, groupInboxWorkItems, @@ -611,6 +612,65 @@ describe("inbox helpers", () => { ).toEqual(["newer", "older"]); }); + 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( + getInboxSearchSupplementIssues({ + 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( + getInboxSearchSupplementIssues({ + query: "pull/3303", + filteredWorkItems: [{ kind: "issue", timestamp: 1, issue: makeIssue("remote-match", false) }], + archivedSearchIssues: [], + remoteIssues: [remoteMatch], + issueFilters: { + statuses: [], + priorities: [], + assignees: [], + labels: [], + projects: [], + workspaces: [], + showRoutineExecutions: false, + }, + }), + ).toEqual([]); + + expect( + getInboxSearchSupplementIssues({ + query: "pull/3303", + filteredWorkItems: [], + archivedSearchIssues: [makeIssue("remote-match", 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..21b2973d 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,35 @@ export function getArchivedInboxSearchIssues({ .sort(sortIssuesByMostRecentActivity); } +export function getInboxSearchSupplementIssues({ + 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 []; + 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( issue: Pick, { diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index b28938ca..3cb0efa8 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -98,6 +98,7 @@ import { getArchivedInboxSearchIssues, getInboxWorkItems, getInboxKeyboardSelectionIndex, + getInboxSearchSupplementIssues, 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 shouldUseIssueSearchSupplement = + !!selectedCompanyId + && normalizedSearchQuery.length > 0; + const { data: remoteIssueSearchResults = [] } = useQuery({ + queryKey: [ + ...queryKeys.issues.search(selectedCompanyId!, normalizedSearchQuery, undefined, 25), + "inbox-supplement", + ], + queryFn: () => + issuesApi.list(selectedCompanyId!, { + q: normalizedSearchQuery, + limit: 25, + includeRoutineExecutions: true, + }), + enabled: shouldUseIssueSearchSupplement, + placeholderData: (previousData) => previousData, + }); + const issueSearchSupplementResults = useMemo( + () => + getInboxSearchSupplementIssues({ + query: normalizedSearchQuery, + filteredWorkItems, + archivedSearchIssues, + remoteIssues: remoteIssueSearchResults, + issueFilters, + currentUserId, + enableRoutineVisibilityFilter: true, + }), + [ + archivedSearchIssues, + currentUserId, + filteredWorkItems, + issueFilters, + normalizedSearchQuery, + remoteIssueSearchResults, + ], + ); + const effectiveWorkItems = useMemo( + () => + issueSearchSupplementResults.length > 0 + ? [ + ...filteredWorkItems, + ...getInboxWorkItems({ issues: issueSearchSupplementResults, approvals: [] }), + ] + : filteredWorkItems, + [filteredWorkItems, issueSearchSupplementResults], + ); 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],