diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 3b5cb64ac33..3c1008b3b7d 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -12,6 +12,7 @@ import type { } from "@paperclipai/shared"; import { DEFAULT_INBOX_ISSUE_COLUMNS, + buildGroupedInboxSections, buildInboxKeyboardNavEntries, buildInboxDismissedAtByKey, computeInboxBadgeData, @@ -760,6 +761,45 @@ describe("inbox helpers", () => { ).toEqual([]); }); + 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"); + + const archivedIssue = makeIssue("archived", false); + archivedIssue.lastActivityAt = new Date("2026-03-11T03:00:00.000Z"); + + const otherIssue = makeIssue("other", false); + otherIssue.lastActivityAt = new Date("2026-03-11T05:00:00.000Z"); + + const sections = [ + ...buildGroupedInboxSections( + getInboxWorkItems({ issues: [inboxIssue], approvals: [] }), + "none", + {}, + ), + ...buildGroupedInboxSections( + getInboxWorkItems({ issues: [archivedIssue], approvals: [] }), + "none", + {}, + { keyPrefix: "archived-search:", searchSection: "archived" }, + ), + ...buildGroupedInboxSections( + getInboxWorkItems({ issues: [otherIssue], approvals: [] }), + "none", + {}, + { keyPrefix: "other-search:", searchSection: "other" }, + ), + ]; + + expect(sections.map((section) => section.searchSection)).toEqual(["none", "archived", "other"]); + expect( + sections.map((section) => { + const [item] = section.displayItems; + return item?.kind === "issue" ? item.issue.id : null; + }), + ).toEqual(["inbox", "archived", "other"]); + }); + 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 369b79383fb..43f116451de 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -87,6 +87,16 @@ export interface InboxWorkItemGroup { items: InboxWorkItem[]; } +export type InboxSearchSection = "none" | "archived" | "other"; + +export interface InboxGroupedSection { + key: string; + label: string | null; + displayItems: InboxWorkItem[]; + childrenByIssueId: Map; + searchSection: InboxSearchSection; +} + export interface InboxKeyboardGroupSection { key: string; displayItems: InboxWorkItem[]; @@ -916,6 +926,31 @@ export function buildInboxNesting(items: InboxWorkItem[]): { return { displayItems, childrenByIssueId }; } +export function buildGroupedInboxSections( + items: InboxWorkItem[], + groupBy: InboxWorkItemGroupBy, + workspaceGrouping: InboxWorkspaceGroupingOptions, + options?: { keyPrefix?: string; searchSection?: InboxSearchSection; nestingEnabled?: boolean }, +): InboxGroupedSection[] { + const keyPrefix = options?.keyPrefix ?? ""; + const searchSection = options?.searchSection ?? "none"; + const nestingEnabled = options?.nestingEnabled ?? false; + + return groupInboxWorkItems(items, groupBy, workspaceGrouping).map((group) => { + const nestedGroup = nestingEnabled && group.items.some((item) => item.kind === "issue") + ? buildInboxNesting(group.items) + : { displayItems: group.items, childrenByIssueId: new Map() }; + + return { + key: `${keyPrefix}${group.key}`, + label: group.label, + displayItems: nestedGroup.displayItems, + childrenByIssueId: nestedGroup.childrenByIssueId, + searchSection, + }; + }); +} + export function getInboxWorkItemKey(item: InboxWorkItem): string { if (item.kind === "issue") return `issue:${item.issue.id}`; if (item.kind === "approval") return `approval:${item.approval.id}`; diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index ff3d22fa441..6cf23a8b7da 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -97,8 +97,8 @@ import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/sh import { ACTIONABLE_APPROVAL_STATUSES, DEFAULT_INBOX_ISSUE_COLUMNS, + buildGroupedInboxSections, buildInboxKeyboardNavEntries, - buildInboxNesting, getAvailableInboxIssueColumns, getInboxWorkItemKey, getApprovalsForTab, @@ -109,7 +109,6 @@ import { getLatestFailedRunsByAgent, matchesInboxIssueSearch, getRecentTouchedIssues, - groupInboxWorkItems, isInboxEntityDismissed, isMineInboxTab, loadCollapsedInboxGroupKeys, @@ -135,6 +134,7 @@ import { type InboxKeyboardNavEntry, saveLastInboxTab, shouldShowInboxSection, + type InboxGroupedSection, type InboxTab, type InboxWorkItem, type InboxWorkItemGroupBy, @@ -150,38 +150,6 @@ type SectionKey = /** A flat navigation entry for keyboard j/k traversal that includes expanded children. */ type NavEntry = InboxKeyboardNavEntry; -type InboxGroupedSection = { - key: string; - label: string | null; - displayItems: InboxWorkItem[]; - childrenByIssueId: Map; - isArchivedSearch: boolean; -}; - -function buildGroupedInboxSections( - items: InboxWorkItem[], - groupBy: InboxWorkItemGroupBy, - nestingEnabled: boolean, - workspaceGrouping: InboxWorkspaceGroupingOptions, - options?: { keyPrefix?: string; isArchivedSearch?: boolean }, -): InboxGroupedSection[] { - const keyPrefix = options?.keyPrefix ?? ""; - const isArchivedSearch = options?.isArchivedSearch ?? false; - return groupInboxWorkItems(items, groupBy, workspaceGrouping).map((group) => { - const nestedGroup = nestingEnabled && group.items.some((item) => item.kind === "issue") - ? buildInboxNesting(group.items) - : { displayItems: group.items, childrenByIssueId: new Map() }; - - return { - key: `${keyPrefix}${group.key}`, - label: group.label, - displayItems: nestedGroup.displayItems, - childrenByIssueId: nestedGroup.childrenByIssueId, - isArchivedSearch, - }; - }); -} - function firstNonEmptyLine(value: string | null | undefined): string | null { if (!value) return null; const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean); @@ -1081,19 +1049,12 @@ export function Inbox() { 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], + const nonInboxSearchIssueIds = useMemo( + () => new Set([ + ...archivedSearchIssues.map((issue) => issue.id), + ...issueSearchSupplementResults.map((issue) => issue.id), + ]), + [archivedSearchIssues, issueSearchSupplementResults], ); // --- Parent-child nesting for inbox issues --- @@ -1123,15 +1084,27 @@ export function Inbox() { }); }, [selectedCompanyId]); const groupedSections = useMemo(() => [ - ...buildGroupedInboxSections(effectiveWorkItems, groupBy, nestingEnabled, inboxWorkspaceGrouping), + ...buildGroupedInboxSections(filteredWorkItems, groupBy, inboxWorkspaceGrouping, { nestingEnabled }), ...buildGroupedInboxSections( getInboxWorkItems({ issues: archivedSearchIssues, approvals: [] }), groupBy, - nestingEnabled, inboxWorkspaceGrouping, - { keyPrefix: "archived-search:", isArchivedSearch: true }, + { keyPrefix: "archived-search:", searchSection: "archived", nestingEnabled }, ), - ], [archivedSearchIssues, effectiveWorkItems, groupBy, inboxWorkspaceGrouping, nestingEnabled]); + ...buildGroupedInboxSections( + getInboxWorkItems({ issues: issueSearchSupplementResults, approvals: [] }), + groupBy, + inboxWorkspaceGrouping, + { keyPrefix: "other-search:", searchSection: "other", nestingEnabled }, + ), + ], [ + archivedSearchIssues, + filteredWorkItems, + groupBy, + inboxWorkspaceGrouping, + issueSearchSupplementResults, + nestingEnabled, + ]); const totalVisibleWorkItems = useMemo( () => groupedSections.reduce((count, group) => count + group.displayItems.length, 0), [groupedSections], @@ -1500,7 +1473,7 @@ export function Inbox() { flatNavItems, selectedIndex, canArchive: canArchiveFromTab, - archivedSearchIssueIds, + nonInboxSearchIssueIds, archivingIssueIds, undoableArchiveIssueIds, unarchivingIssueIds, @@ -1513,7 +1486,7 @@ export function Inbox() { flatNavItems, selectedIndex, canArchive: canArchiveFromTab, - archivedSearchIssueIds, + nonInboxSearchIssueIds, archivingIssueIds, undoableArchiveIssueIds, unarchivingIssueIds, @@ -1616,10 +1589,10 @@ export function Inbox() { e.preventDefault(); const { issue, item } = resolveNavEntry(st.selectedIndex); if (issue) { - if (!st.archivedSearchIssueIds.has(issue.id) && !st.archivingIssueIds.has(issue.id)) act.archiveIssue(issue.id); + if (!st.nonInboxSearchIssueIds.has(issue.id) && !st.archivingIssueIds.has(issue.id)) act.archiveIssue(issue.id); } else if (item) { if (item.kind === "issue") { - if (!st.archivedSearchIssueIds.has(item.issue.id) && !st.archivingIssueIds.has(item.issue.id)) { + if (!st.nonInboxSearchIssueIds.has(item.issue.id) && !st.archivingIssueIds.has(item.issue.id)) { act.archiveIssue(item.issue.id); } } else { @@ -2113,15 +2086,18 @@ export function Inbox() { return groupedSections.flatMap((group, groupIndex) => { const elements: ReactNode[] = []; const isGroupCollapsed = collapsedGroupKeys.has(group.key); - if (group.isArchivedSearch && (groupIndex === 0 || !groupedSections[groupIndex - 1]?.isArchivedSearch)) { + if ( + group.searchSection !== "none" + && group.searchSection !== groupedSections[groupIndex - 1]?.searchSection + ) { elements.push(
- Archived + {group.searchSection === "archived" ? "Archived" : "Other results"}
, @@ -2292,7 +2268,7 @@ export function Inbox() { const childIssues = group.childrenByIssueId.get(issue.id) ?? []; const hasChildren = childIssues.length > 0; const isExpanded = hasChildren && !collapsedInboxParents.has(issue.id); - const canArchiveIssue = canArchiveFromTab && !group.isArchivedSearch; + const canArchiveIssue = canArchiveFromTab && group.searchSection === "none"; const parentRow = renderInboxIssue({ issue, depth: 0,