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)),