diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index df614026410..a679d2267a8 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -355,6 +355,62 @@ describe("IssuesList", () => { }); }); + it("filters issue rows by external-object status summaries", async () => { + const failedIssue = createIssue({ id: "issue-failed", identifier: "PAP-10", title: "Failed external object" }); + const freshIssue = createIssue({ id: "issue-fresh", identifier: "PAP-11", title: "Fresh external object" }); + const noObjectIssue = createIssue({ id: "issue-none", identifier: "PAP-12", title: "No external object" }); + localStorage.setItem("paperclip:test-issues:company-1", JSON.stringify({ externalObjectStatuses: ["failed"] })); + mockExternalObjectsApi.getIssueSummaries.mockResolvedValue({ + summaries: { + "issue-failed": { + total: 1, + byStatusCategory: { failed: 1 }, + byLiveness: { fresh: 1 }, + highestSeverity: "danger", + staleCount: 0, + authRequiredCount: 0, + unreachableCount: 0, + objects: [], + }, + "issue-fresh": { + total: 1, + byStatusCategory: { succeeded: 1 }, + byLiveness: { fresh: 1 }, + highestSeverity: "success", + staleCount: 0, + authRequiredCount: 0, + unreachableCount: 0, + objects: [], + }, + }, + }); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(mockExternalObjectsApi.getIssueSummaries).toHaveBeenCalledWith( + "company-1", + ["issue-failed", "issue-fresh", "issue-none"], + ); + expect(container.textContent).toContain("Failed external object"); + expect(container.textContent).not.toContain("Fresh external object"); + expect(container.textContent).not.toContain("No external object"); + }); + + act(() => { + root.unmount(); + }); + }); + it("renders server search results instead of filtering the full issue list locally", async () => { const localIssue = createIssue({ id: "issue-local", identifier: "PAP-1", title: "Local issue" }); const serverIssue = createIssue({ id: "issue-server", identifier: "PAP-2", title: "Server result" }); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index d61b43cc2a5..c66868f4bcb 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -968,43 +968,57 @@ export function IssuesList({ [boardIssueQueries, searchWithinLoadedIssues, viewState.viewMode], ); - const filtered = useMemo(() => { + const sourceIssues = useMemo(() => { const useRemoteSearch = normalizedIssueSearch.length > 0 && !searchWithinLoadedIssues; - const sourceIssues = boardIssues ?? (useRemoteSearch ? searchedIssues : issues); - const searchScopedIssues = normalizedIssueSearch.length > 0 && searchWithinLoadedIssues + return boardIssues ?? (useRemoteSearch ? searchedIssues : issues); + }, [boardIssues, issues, normalizedIssueSearch, searchedIssues, searchWithinLoadedIssues]); + + const searchScopedIssues = useMemo( + () => normalizedIssueSearch.length > 0 && searchWithinLoadedIssues ? sourceIssues.filter((issue) => issueMatchesLocalSearch(issue, normalizedIssueSearch)) - : sourceIssues; + : sourceIssues, + [normalizedIssueSearch, searchWithinLoadedIssues, sourceIssues], + ); + const hasExternalObjectStatusFilters = viewState.externalObjectStatuses.length > 0; + const issueIdsForExternalObjectSummaries = useMemo( + () => (viewState.viewMode === "list" || hasExternalObjectStatusFilters + ? searchScopedIssues.map((issue) => issue.id) + : []), + [hasExternalObjectStatusFilters, searchScopedIssues, viewState.viewMode], + ); + const { + summaries: externalObjectSummaryByIssueId, + isLoading: externalObjectSummariesLoading, + isReady: externalObjectSummariesReady, + } = useIssueExternalObjectSummaries( + selectedCompanyId, + issueIdsForExternalObjectSummaries, + ); + const issueFilterContext = useMemo(() => ({ + ...issueFilterWorkspaceContext, + externalObjectSummaryByIssueId, + externalObjectSummariesReady: externalObjectSummariesReady && !externalObjectSummariesLoading, + }), [externalObjectSummariesLoading, externalObjectSummariesReady, externalObjectSummaryByIssueId, issueFilterWorkspaceContext]); + + const filtered = useMemo(() => { const filteredByControls = applyIssueFilters( searchScopedIssues, viewState, currentUserId, enableRoutineVisibilityFilter, liveIssueIds, - issueFilterWorkspaceContext, + issueFilterContext, ); return sortIssues(filteredByControls, viewState); }, [ - boardIssues, - issues, - searchedIssues, - searchWithinLoadedIssues, + searchScopedIssues, viewState, - normalizedIssueSearch, currentUserId, enableRoutineVisibilityFilter, liveIssueIds, - issueFilterWorkspaceContext, + issueFilterContext, ]); - const issueIdsForExternalObjectSummaries = useMemo( - () => (viewState.viewMode === "list" ? filtered.map((issue) => issue.id) : []), - [filtered, viewState.viewMode], - ); - const { summaries: externalObjectSummaryByIssueId } = useIssueExternalObjectSummaries( - selectedCompanyId, - issueIdsForExternalObjectSummaries, - ); - const progressSummary = useMemo( () => shouldRenderSubIssueProgressSummary(showProgressSummary, issues.length) ? buildSubIssueProgressSummary(issues) diff --git a/ui/src/hooks/useIssueExternalObjects.ts b/ui/src/hooks/useIssueExternalObjects.ts index f8e8671bcc1..09201579e40 100644 --- a/ui/src/hooks/useIssueExternalObjects.ts +++ b/ui/src/hooks/useIssueExternalObjects.ts @@ -165,6 +165,7 @@ export function useIssueExternalObjectSummaries( ): { summaries: Map; isLoading: boolean; + isReady: boolean; } { const normalizedIssueIds = useMemo( () => [...new Set(issueIds.filter((issueId) => issueId.length > 0))].sort(), @@ -184,6 +185,7 @@ export function useIssueExternalObjectSummaries( return { summaries, isLoading: enabled && query.isLoading, + isReady: !enabled || query.isSuccess, }; } diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 0c83f8d9f54..e17c8418f61 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -5,6 +5,7 @@ import type { Approval, DashboardSummary, ExecutionWorkspace, + ExternalObjectSummary, HeartbeatRun, Issue, JoinRequest, @@ -958,6 +959,58 @@ describe("inbox helpers", () => { ).toEqual([]); }); + it("applies external-object filters to remote inbox search supplements", () => { + const failedMatch = makeIssue("failed-match", false); + const freshMatch = makeIssue("fresh-match", false); + const summaries = new Map([ + ["failed-match", { + total: 1, + byStatusCategory: { failed: 1 }, + byLiveness: { fresh: 1 }, + highestSeverity: "danger", + staleCount: 0, + authRequiredCount: 0, + unreachableCount: 0, + objects: [], + }], + ["fresh-match", { + total: 1, + byStatusCategory: { succeeded: 1 }, + byLiveness: { fresh: 1 }, + highestSeverity: "success", + staleCount: 0, + authRequiredCount: 0, + unreachableCount: 0, + objects: [], + }], + ]); + + expect( + getInboxSearchSupplementIssues({ + query: "github", + filteredWorkItems: [], + archivedSearchIssues: [], + remoteIssues: [failedMatch, freshMatch], + issueFilters: { + statuses: [], + priorities: [], + assignees: [], + creators: [], + labels: [], + projects: [], + workspaces: [], + liveOnly: false, + externalObjectStatuses: ["failed"], + hideRoutineExecutions: true, + }, + issueFilterContext: { + externalObjectSummaryByIssueId: summaries, + externalObjectSummariesReady: true, + }, + }).map((issue) => issue.id), + ).toEqual(["failed-match"]); + }); + it("keeps inbox search matches ahead of archived and other result sections", () => { const inboxIssue = makeIssue("inbox", false); inboxIssue.lastActivityAt = new Date("2026-03-11T04:00:00.000Z"); diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index 780aeb3bcf6..48885d80858 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -11,6 +11,7 @@ import { defaultIssueFilterState, normalizeIssueFilterState, type IssueFilterState, + type IssueFilterWorkspaceContext, } from "./issue-filters"; import { formatAssigneeUserLabel } from "./assignees"; @@ -462,6 +463,7 @@ export function getInboxSearchSupplementIssues({ currentUserId, enableRoutineVisibilityFilter = false, liveIssueIds, + issueFilterContext = {}, }: { query: string; filteredWorkItems: InboxWorkItem[]; @@ -471,6 +473,7 @@ export function getInboxSearchSupplementIssues({ currentUserId?: string | null; enableRoutineVisibilityFilter?: boolean; liveIssueIds?: ReadonlySet; + issueFilterContext?: IssueFilterWorkspaceContext; }): Issue[] { const normalizedQuery = query.trim(); if (!normalizedQuery) return []; @@ -480,7 +483,14 @@ export function getInboxSearchSupplementIssues({ .map((item) => item.issue.id), ...archivedSearchIssues.map((issue) => issue.id), ]); - return applyIssueFilters(remoteIssues, issueFilters, currentUserId, enableRoutineVisibilityFilter, liveIssueIds) + return applyIssueFilters( + remoteIssues, + issueFilters, + currentUserId, + enableRoutineVisibilityFilter, + liveIssueIds, + issueFilterContext, + ) .filter((issue) => !visibleIssueIds.has(issue.id)); } diff --git a/ui/src/lib/issue-filters.test.ts b/ui/src/lib/issue-filters.test.ts index bb0afde7ac9..4e210b19bfb 100644 --- a/ui/src/lib/issue-filters.test.ts +++ b/ui/src/lib/issue-filters.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; -import type { Issue } from "@paperclipai/shared"; +import type { ExternalObjectSummary, Issue } from "@paperclipai/shared"; import { applyIssueFilters, countActiveIssueFilters, @@ -51,6 +51,20 @@ function makeIssue(overrides: Partial = {}): Issue { }; } +function makeExternalObjectSummary(overrides: Partial = {}): ExternalObjectSummary { + return { + total: 1, + byStatusCategory: {}, + byLiveness: { fresh: 1 }, + highestSeverity: "neutral", + staleCount: 0, + authRequiredCount: 0, + unreachableCount: 0, + objects: [], + ...overrides, + }; +} + describe("issue filters", () => { it("filters issues by creator across agents and users", () => { const issues = [ @@ -169,4 +183,62 @@ describe("issue filters", () => { new Set(["workspace-default"]), )).toBe(true); }); + + it.each([ + ["failed", ["failed-issue", "blocked-issue"]], + ["auth_required", ["auth-issue"]], + ["stale", ["stale-issue"]], + ["none", ["none-issue"]], + ])("filters issues by external-object status token %s", (externalObjectStatus, expectedIssueIds) => { + const issues = [ + makeIssue({ id: "failed-issue" }), + makeIssue({ id: "blocked-issue" }), + makeIssue({ id: "auth-issue" }), + makeIssue({ id: "stale-issue" }), + makeIssue({ id: "none-issue" }), + makeIssue({ id: "fresh-issue" }), + ]; + const summaries = new Map([ + ["failed-issue", makeExternalObjectSummary({ byStatusCategory: { failed: 1 }, highestSeverity: "danger" })], + ["blocked-issue", makeExternalObjectSummary({ byStatusCategory: { blocked: 1 }, highestSeverity: "danger" })], + ["auth-issue", makeExternalObjectSummary({ + byLiveness: { auth_required: 1 }, + highestSeverity: "danger", + authRequiredCount: 1, + })], + ["stale-issue", makeExternalObjectSummary({ + byLiveness: { stale: 1 }, + highestSeverity: "warning", + staleCount: 1, + })], + ["fresh-issue", makeExternalObjectSummary({ byStatusCategory: { succeeded: 1 }, highestSeverity: "success" })], + ]); + + const filtered = applyIssueFilters( + issues, + { ...defaultIssueFilterState, externalObjectStatuses: [externalObjectStatus] }, + null, + false, + undefined, + { + externalObjectSummaryByIssueId: summaries, + externalObjectSummariesReady: true, + }, + ); + + expect(filtered.map((issue) => issue.id)).toEqual(expectedIssueIds); + }); + + it("does not apply external-object filters before summaries are ready", () => { + const filtered = applyIssueFilters( + [makeIssue({ id: "issue-1" })], + { ...defaultIssueFilterState, externalObjectStatuses: ["none"] }, + null, + false, + undefined, + { externalObjectSummaryByIssueId: new Map(), externalObjectSummariesReady: false }, + ); + + expect(filtered).toEqual([]); + }); }); diff --git a/ui/src/lib/issue-filters.ts b/ui/src/lib/issue-filters.ts index d966ceb66b3..a0576a5305b 100644 --- a/ui/src/lib/issue-filters.ts +++ b/ui/src/lib/issue-filters.ts @@ -1,4 +1,4 @@ -import type { Issue } from "@paperclipai/shared"; +import type { ExternalObjectSummary, Issue } from "@paperclipai/shared"; export type IssueFilterWorkspaceLookup = { mode?: string | null; @@ -8,6 +8,8 @@ export type IssueFilterWorkspaceLookup = { export type IssueFilterWorkspaceContext = { executionWorkspaceById?: ReadonlyMap; defaultProjectWorkspaceIdByProjectId?: ReadonlyMap; + externalObjectSummaryByIssueId?: ReadonlyMap; + externalObjectSummariesReady?: boolean; }; export type IssueFilterState = { @@ -159,6 +161,39 @@ export function shouldIncludeIssueFilterWorkspaceOption( && defaultProjectWorkspaceIds.has(workspace.projectWorkspaceId)); } +function summaryRecordCount(record: Record | undefined, key: string): number { + return record?.[key] ?? 0; +} + +function issueMatchesExternalObjectStatusFilter( + summary: ExternalObjectSummary | null | undefined, + value: string, +): boolean { + const total = summary?.total ?? 0; + switch (value) { + case "failed": + return summaryRecordCount(summary?.byStatusCategory, "failed") > 0 + || summaryRecordCount(summary?.byStatusCategory, "blocked") > 0; + case "waiting": + return summaryRecordCount(summary?.byStatusCategory, "waiting") > 0; + case "running": + return summaryRecordCount(summary?.byStatusCategory, "running") > 0; + case "auth_required": + return (summary?.authRequiredCount ?? 0) > 0 + || summaryRecordCount(summary?.byLiveness, "auth_required") > 0; + case "unreachable": + return (summary?.unreachableCount ?? 0) > 0 + || summaryRecordCount(summary?.byLiveness, "unreachable") > 0; + case "stale": + return (summary?.staleCount ?? 0) > 0 + || summaryRecordCount(summary?.byLiveness, "stale") > 0; + case "none": + return total === 0; + default: + return false; + } +} + export function applyIssueFilters( issues: Issue[], state: IssueFilterState, @@ -207,6 +242,16 @@ export function applyIssueFilters( return workspaceId != null && state.workspaces.includes(workspaceId); }); } + if (state.externalObjectStatuses.length > 0) { + const summaries = workspaceContext.externalObjectSummaryByIssueId; + if (!summaries || workspaceContext.externalObjectSummariesReady !== true) return []; + result = result.filter((issue) => { + const summary = summaries.get(issue.id) ?? null; + return state.externalObjectStatuses.some((status) => + issueMatchesExternalObjectStatusFilter(summary, status), + ); + }); + } return result; } diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 865c2238756..3931740b5d7 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -864,13 +864,49 @@ export function Inbox() { const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]); const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]); + 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 inboxIssueIdsForExternalObjectSummaries = useMemo(() => { + const issueIds = new Set(); + for (const issue of mineIssues) issueIds.add(issue.id); + for (const issue of touchedIssues) issueIds.add(issue.id); + for (const issue of remoteIssueSearchResults) issueIds.add(issue.id); + return [...issueIds]; + }, [mineIssues, remoteIssueSearchResults, touchedIssues]); + const { + summaries: externalObjectSummaryByIssueId, + isLoading: externalObjectSummariesLoading, + isReady: externalObjectSummariesReady, + } = useIssueExternalObjectSummaries( + selectedCompanyId, + inboxIssueIdsForExternalObjectSummaries, + ); + const issueFilterContext = useMemo(() => ({ + externalObjectSummaryByIssueId, + externalObjectSummariesReady: externalObjectSummariesReady && !externalObjectSummariesLoading, + }), [externalObjectSummariesLoading, externalObjectSummariesReady, externalObjectSummaryByIssueId]); const visibleMineIssues = useMemo( - () => applyIssueFilters(mineIssues, issueFilters, currentUserId, true, liveIssueIds), - [mineIssues, issueFilters, currentUserId, liveIssueIds], + () => applyIssueFilters(mineIssues, issueFilters, currentUserId, true, liveIssueIds, issueFilterContext), + [mineIssues, issueFilters, currentUserId, liveIssueIds, issueFilterContext], ); const visibleTouchedIssues = useMemo( - () => applyIssueFilters(touchedIssues, issueFilters, currentUserId, true, liveIssueIds), - [touchedIssues, issueFilters, currentUserId, liveIssueIds], + () => applyIssueFilters(touchedIssues, issueFilters, currentUserId, true, liveIssueIds, issueFilterContext), + [touchedIssues, issueFilters, currentUserId, liveIssueIds, issueFilterContext], ); const unreadTouchedIssues = useMemo( () => visibleTouchedIssues.filter((issue) => issue.isUnreadForMe), @@ -1161,23 +1197,6 @@ export function Inbox() { 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({ @@ -1189,11 +1208,13 @@ export function Inbox() { currentUserId, enableRoutineVisibilityFilter: true, liveIssueIds, + issueFilterContext, }), [ archivedSearchIssues, currentUserId, filteredWorkItems, + issueFilterContext, issueFilters, liveIssueIds, normalizedSearchQuery, @@ -1277,20 +1298,6 @@ export function Inbox() { if (!defaults) return; openNewIssue(defaults); }, [groupBy, inboxWorkspaceGrouping, openNewIssue]); - - const inboxIssueIdsForExternalObjectSummaries = useMemo(() => { - const issueIds = new Set(); - for (const section of groupedSections) { - for (const item of section.displayItems) { - if (item.kind === "issue") issueIds.add(item.issue.id); - } - } - return [...issueIds]; - }, [groupedSections]); - const { summaries: externalObjectSummaryByIssueId } = useIssueExternalObjectSummaries( - selectedCompanyId, - inboxIssueIdsForExternalObjectSummaries, - ); const totalVisibleWorkItems = useMemo( () => groupedSections.reduce((count, group) => count + group.displayItems.length, 0), [groupedSections],