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