diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index a69bd6c7..d2f9c1be 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -314,6 +314,68 @@ export function getInboxWorkItems({ }); } +/** + * Groups parent-child issues in a flat InboxWorkItem list. + * + * - Children whose parent is also in the list are removed from the top level + * and stored in `childrenByIssueId`. + * - The parent's sort timestamp becomes max(parent, children) so that a group + * with a recently-updated child floats to the top. + * - If a parent is absent (e.g. archived), children remain as independent roots. + */ +export function buildInboxNesting(items: InboxWorkItem[]): { + displayItems: InboxWorkItem[]; + childrenByIssueId: Map; +} { + const issueItems: (InboxWorkItem & { kind: "issue" })[] = []; + const nonIssueItems: InboxWorkItem[] = []; + for (const item of items) { + if (item.kind === "issue") issueItems.push(item as InboxWorkItem & { kind: "issue" }); + else nonIssueItems.push(item); + } + + const issueIdSet = new Set(issueItems.map((i) => i.issue.id)); + const childrenByIssueId = new Map(); + const childIds = new Set(); + + for (const item of issueItems) { + const { issue } = item; + if (issue.parentId && issueIdSet.has(issue.parentId)) { + childIds.add(issue.id); + const arr = childrenByIssueId.get(issue.parentId) ?? []; + arr.push(issue); + childrenByIssueId.set(issue.parentId, arr); + } + } + + // Sort each child list by most recent activity + for (const children of childrenByIssueId.values()) { + children.sort(sortIssuesByMostRecentActivity); + } + + // Build root issue items with group-adjusted timestamps + const rootIssueItems: InboxWorkItem[] = issueItems + .filter((item) => !childIds.has(item.issue.id)) + .map((item) => { + const children = childrenByIssueId.get(item.issue.id); + if (!children?.length) return item; + const maxChildTs = Math.max(...children.map(issueLastActivityTimestamp)); + return { ...item, timestamp: Math.max(item.timestamp, maxChildTs) }; + }); + + // Merge and re-sort + const displayItems = [...rootIssueItems, ...nonIssueItems].sort((a, b) => { + const diff = b.timestamp - a.timestamp; + if (diff !== 0) return diff; + if (a.kind === "issue" && b.kind === "issue") { + return sortIssuesByMostRecentActivity(a.issue, b.issue); + } + return 0; + }); + + return { displayItems, childrenByIssueId }; +} + export function shouldShowInboxSection({ tab, hasItems, diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 075c50d7..d6872ff9 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -62,6 +62,7 @@ import { import { Inbox as InboxIcon, AlertTriangle, + ChevronRight, XCircle, X, RotateCcw, @@ -74,6 +75,7 @@ import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/sh import { ACTIONABLE_APPROVAL_STATUSES, DEFAULT_INBOX_ISSUE_COLUMNS, + buildInboxNesting, getAvailableInboxIssueColumns, getApprovalsForTab, getInboxWorkItems, @@ -900,6 +902,21 @@ export function Inbox() { projectWorkspaceById, ]); + // --- Parent-child nesting for inbox issues --- + const [collapsedInboxParents, setCollapsedInboxParents] = useState>(new Set()); + const { displayItems: nestedWorkItems, childrenByIssueId } = useMemo( + () => buildInboxNesting(filteredWorkItems), + [filteredWorkItems], + ); + const toggleInboxParentCollapse = useCallback((parentId: string) => { + setCollapsedInboxParents((prev) => { + const next = new Set(prev); + if (next.has(parentId)) next.delete(parentId); + else next.add(parentId); + return next; + }); + }, []); + const agentName = (id: string | null) => { if (!id) return null; return agentById.get(id) ?? null; @@ -1169,12 +1186,12 @@ export function Inbox() { // Keep selection valid when the list shape changes, but do not auto-select on initial load. useEffect(() => { - setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, filteredWorkItems.length)); - }, [filteredWorkItems.length]); + setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, nestedWorkItems.length)); + }, [nestedWorkItems.length]); // Use refs for keyboard handler to avoid stale closures const kbStateRef = useRef({ - workItems: filteredWorkItems, + workItems: nestedWorkItems, selectedIndex, canArchive: canArchiveFromTab, archivingIssueIds, @@ -1183,7 +1200,7 @@ export function Inbox() { readItems, }); kbStateRef.current = { - workItems: filteredWorkItems, + workItems: nestedWorkItems, selectedIndex, canArchive: canArchiveFromTab, archivingIssueIds, @@ -1339,7 +1356,7 @@ export function Inbox() { dashboard.costs.monthUtilizationPercent >= 80 && !dismissed.has("alert:budget"); const hasAlerts = showAggregateAgentError || showBudgetAlert; - const showWorkItemsSection = filteredWorkItems.length > 0; + const showWorkItemsSection = nestedWorkItems.length > 0; const showAlertsSection = shouldShowInboxSection({ tab, hasItems: hasAlerts, @@ -1526,7 +1543,7 @@ export function Inbox() { {showSeparatorBefore("work_items") && }
- {filteredWorkItems.flatMap((item, index) => { + {nestedWorkItems.flatMap((item, index) => { const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => (
0 && item.timestamp > 0 && item.timestamp < todayCutoff && - filteredWorkItems[index - 1].timestamp >= todayCutoff; + nestedWorkItems[index - 1].timestamp >= todayCutoff; const elements: ReactNode[] = []; if (showTodayDivider) { elements.push( @@ -1666,72 +1683,135 @@ export function Inbox() { } const issue = item.issue; - const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); - const isFading = fadingOutIssues.has(issue.id); - const isArchiving = archivingIssueIds.has(issue.id); - const issueProject = issue.projectId ? projectById.get(issue.projectId) ?? null : null; - const row = ( - - } - mobileMeta={issueActivityText(issue).toLowerCase()} - unreadState={ - isUnread ? "visible" : isFading ? "fading" : "hidden" - } - onMarkRead={() => markReadMutation.mutate(issue.id)} - onArchive={ - canArchiveFromTab - ? () => archiveIssueMutation.mutate(issue.id) - : undefined - } - archiveDisabled={isArchiving || archiveIssueMutation.isPending} - desktopTrailing={ - visibleTrailingIssueColumns.length > 0 ? ( - - ) : undefined - } - /> - ); + const childIssues = childrenByIssueId.get(issue.id) ?? []; + const hasChildren = childIssues.length > 0; + const isExpanded = hasChildren && !collapsedInboxParents.has(issue.id); + const renderInboxIssue = (iss: Issue, depth: number, sel: boolean) => { + const isUnread = iss.isUnreadForMe && !fadingOutIssues.has(iss.id); + const isFading = fadingOutIssues.has(iss.id); + const isArch = archivingIssueIds.has(iss.id); + const proj = iss.projectId ? projectById.get(iss.projectId) ?? null : null; + return ( + + {depth === 0 && hasChildren ? ( + + ) : depth === 0 ? null : ( + + )} + + + } + titleSuffix={hasChildren && !isExpanded && depth === 0 ? ( + + ({childIssues.length} sub-task{childIssues.length !== 1 ? "s" : ""}) + + ) : undefined} + mobileMeta={issueActivityText(iss).toLowerCase()} + mobileLeading={ + depth === 0 && hasChildren ? ( + + ) : undefined + } + unreadState={ + isUnread ? "visible" : isFading ? "fading" : "hidden" + } + onMarkRead={() => markReadMutation.mutate(iss.id)} + onArchive={ + canArchiveFromTab + ? () => archiveIssueMutation.mutate(iss.id) + : undefined + } + archiveDisabled={isArch || archiveIssueMutation.isPending} + desktopTrailing={ + visibleTrailingIssueColumns.length > 0 ? ( + + ) : undefined + } + /> + ); + }; + + // Render parent issue + const parentRow = renderInboxIssue(issue, 0, isSelected); elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? ( archiveIssueMutation.mutate(issue.id)} > - {row} + {parentRow} - ) : row)); + ) : parentRow)); + + // Render children if expanded + if (isExpanded) { + for (const child of childIssues) { + const childRow = renderInboxIssue(child, 1, false); + const isChildArchiving = archivingIssueIds.has(child.id); + elements.push( +
+ {canArchiveFromTab ? ( + archiveIssueMutation.mutate(child.id)} + > + {childRow} + + ) : childRow} +
, + ); + } + } return elements; })}