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