From 097f30b1389f9d46ef288791b101e9407d89f411 Mon Sep 17 00:00:00 2001 From: dotta Date: Wed, 8 Apr 2026 07:52:57 -0500 Subject: [PATCH] feat(ui): nest parent-child issues in inbox/mine view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groups child issues under their parent in the inbox, matching the nesting pattern used on the issues list page. Parent groups sort by the most recent activity across all family members. Archived parents don't hide their children — orphans show independently. Co-Authored-By: Paperclip --- ui/src/lib/inbox.ts | 62 ++++++++++++ ui/src/pages/Inbox.tsx | 210 ++++++++++++++++++++++++++++------------- 2 files changed, 207 insertions(+), 65 deletions(-) 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; })}