Merge pull request #3385 from paperclipai/pap-1347-inbox-issue-search

feat(inbox): improve issue search matches
This commit is contained in:
Dotta
2026-04-11 08:44:59 -05:00
committed by GitHub
7 changed files with 383 additions and 9 deletions
@@ -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();
+2 -2
View File
@@ -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
`;
+190
View File
@@ -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 }) => <span>{name}</span>,
}));
vi.mock("@/components/ui/command", () => ({
CommandDialog: ({ open, children }: { open: boolean; children: ReactNode }) => (open ? <div>{children}</div> : null),
CommandEmpty: ({ children }: { children: ReactNode }) => <div>{children}</div>,
CommandGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
CommandInput: ({
value,
onValueChange,
}: {
value: string;
onValueChange: (value: string) => void;
}) => (
<div>
<input
aria-label="Command search"
value={value}
onChange={(event) => onValueChange(event.currentTarget.value)}
/>
<button type="button" aria-label="Set query" onClick={() => onValueChange("pull/3303")} />
</div>
),
CommandItem: ({
children,
onSelect,
}: {
children: ReactNode;
onSelect?: () => void;
}) => <button onClick={onSelect}>{children}</button>,
CommandList: ({ children }: { children: ReactNode }) => <div>{children}</div>,
CommandSeparator: () => <hr />,
}));
// 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(
<QueryClientProvider client={queryClient}>
{node}
</QueryClientProvider>,
);
});
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(<CommandPalette />, 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();
});
});
});
+1 -1
View File
@@ -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,
});
+60
View File
@@ -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");
+30
View File
@@ -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<InboxWorkItem, { kind: "issue" }> => 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<Issue, "executionWorkspaceId" | "projectId" | "projectWorkspaceId">,
{
+55 -6
View File
@@ -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<InboxFilterPreferences>(
() => 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<Set<string>>(new Set());
const groupedSections = useMemo<InboxGroupedSection[]>(() => [
...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],