diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index ced81b23..8b5fdfcb 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -44,6 +44,7 @@ interface IssuePropertiesProps { issue: Issue; onUpdate: (data: Record) => void; inline?: boolean; + childIssues?: Issue[]; } function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { @@ -117,7 +118,7 @@ function PropertyPicker({ ); } -export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProps) { +export function IssueProperties({ issue, onUpdate, inline, childIssues }: IssuePropertiesProps) { const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); const companyId = issue.companyId ?? selectedCompanyId; @@ -560,17 +561,6 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp {projectContent} - {issue.parentId && ( - - - {issue.ancestors?.[0]?.title ?? issue.parentId.slice(0, 8)} - - - )} - {issue.requestDepth > 0 && ( {issue.requestDepth} @@ -615,6 +605,52 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp {timeAgo(issue.updatedAt)} + + {(issue.parentId || (childIssues && childIssues.length > 0)) && ( + <> + +
+ {issue.parentId && ( +
+

Parent task

+
+ {issue.ancestors?.[0] != null && ( +
+ +
+ )} + + {issue.ancestors?.[0]?.title ?? issue.parentId.slice(0, 8)} + +
+
+ )} + {childIssues && childIssues.length > 0 && ( +
+

Sub-tasks

+
+ {childIssues.map((child) => ( +
+
+ +
+ + {child.title} + +
+ ))} +
+
+ )} +
+ + )} ); } diff --git a/ui/src/components/IssueRow.test.tsx b/ui/src/components/IssueRow.test.tsx index 04deb423..d5f67e9d 100644 --- a/ui/src/components/IssueRow.test.tsx +++ b/ui/src/components/IssueRow.test.tsx @@ -136,4 +136,42 @@ describe("IssueRow", () => { root.unmount(); }); }); + + it("renders titleSuffix inline after the issue title", () => { + const root = createRoot(container); + const issue = createIssue({ title: "Parent task" }); + + act(() => { + root.render( + (3 sub-tasks)} + />, + ); + }); + + const titleEl = container.querySelector(".line-clamp-2, .truncate"); + expect(titleEl?.textContent).toContain("Parent task"); + expect(titleEl?.textContent).toContain("(3 sub-tasks)"); + expect(container.querySelector('[data-testid="suffix"]')).not.toBeNull(); + + act(() => { + root.unmount(); + }); + }); + + it("renders without error when titleSuffix is omitted", () => { + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const titleEl = container.querySelector(".line-clamp-2, .truncate"); + expect(titleEl?.textContent).toContain("Inbox item"); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index 8a01e585..5711a69c 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -18,6 +18,7 @@ interface IssueRowProps { mobileMeta?: ReactNode; desktopTrailing?: ReactNode; trailingMeta?: ReactNode; + titleSuffix?: ReactNode; unreadState?: UnreadState | null; onMarkRead?: () => void; onArchive?: () => void; @@ -35,6 +36,7 @@ export function IssueRow({ mobileMeta, desktopTrailing, trailingMeta, + titleSuffix, unreadState = null, onMarkRead, onArchive, @@ -63,7 +65,7 @@ export function IssueRow({ - {issue.title} + {issue.title}{titleSuffix} {desktopLeadingSpacer ? ( diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 952c649a..eca0c692 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -23,6 +23,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search } from "lucide-react"; import { KanbanBoard } from "./KanbanBoard"; +import { buildIssueTree, countDescendants } from "../lib/issue-tree"; import type { Issue } from "@paperclipai/shared"; /* ── Helpers ── */ @@ -47,6 +48,7 @@ export type IssueViewState = { groupBy: "status" | "priority" | "assignee" | "none"; viewMode: "list" | "board"; collapsedGroups: string[]; + collapsedParents: string[]; }; const defaultViewState: IssueViewState = { @@ -60,6 +62,7 @@ const defaultViewState: IssueViewState = { groupBy: "none", viewMode: "list", collapsedGroups: [], + collapsedParents: [], }; const quickFilterPresets = [ @@ -246,6 +249,29 @@ export function IssuesList({ return next; }); }, [scopedKey]); + + // Prune stale IDs from collapsedParents whenever the issue list changes. + // Deleted or reassigned issues leave orphan IDs in localStorage; this keeps + // the stored array bounded to only current parent IDs. + useEffect(() => { + const parentIds = new Set(issues.map((i) => i.parentId).filter(Boolean) as string[]); + const pruned = viewState.collapsedParents.filter((id) => parentIds.has(id)); + if (pruned.length !== viewState.collapsedParents.length) { + updateView({ collapsedParents: pruned }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [issues]); + + const { data: searchedIssues = [] } = useQuery({ + queryKey: [ + ...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId), + searchFilters ?? {}, + ], + queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }), + enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0, + placeholderData: (previousData) => previousData, + }); + const agentName = useCallback((id: string | null) => { if (!id || !agents) return null; return agents.find((a) => a.id === id)?.name ?? null; @@ -320,251 +346,6 @@ export function IssuesList({ setAssigneeSearch(""); }, [onUpdateIssue]); - const listContent = useMemo(() => { - if (viewState.viewMode === "board") { - return ( - - ); - } - - return groupedContent.map((group) => ( - { - updateView({ - collapsedGroups: open - ? viewState.collapsedGroups.filter((k) => k !== group.key) - : [...viewState.collapsedGroups, group.key], - }); - }} - > - {group.label && ( -
- - - - {group.label} - - - -
- )} - - {group.items.map((issue) => ( - { - e.preventDefault(); - e.stopPropagation(); - }} - > - onUpdateIssue(issue.id, { status: s })} - /> -
- )} - desktopMetaLeading={( - <> - { - e.preventDefault(); - e.stopPropagation(); - }} - > - onUpdateIssue(issue.id, { status: s })} - /> - - - {issue.identifier ?? issue.id.slice(0, 8)} - - {liveIssueIds?.has(issue.id) && ( - - - - - - - Live - - - )} - - )} - mobileMeta={timeAgo(issue.updatedAt)} - desktopTrailing={( - <> - {(issue.labels ?? []).length > 0 && ( - - {(issue.labels ?? []).slice(0, 3).map((label) => ( - - {label.name} - - ))} - {(issue.labels ?? []).length > 3 && ( - - +{(issue.labels ?? []).length - 3} - - )} - - )} - { - setAssigneePickerIssueId(open ? issue.id : null); - if (!open) setAssigneeSearch(""); - }} - > - - - - e.stopPropagation()} - onPointerDownOutside={() => setAssigneeSearch("")} - > - setAssigneeSearch(e.target.value)} - autoFocus - /> -
- - {currentUserId && ( - - )} - {(agents ?? []) - .filter((agent) => { - if (!assigneeSearch.trim()) return true; - return agent.name - .toLowerCase() - .includes(assigneeSearch.toLowerCase()); - }) - .map((agent) => ( - - ))} -
-
-
- - )} - trailingMeta={formatDate(issue.createdAt)} - /> - ))} - - - )); - }, [ - agents, - agentName, - assigneePickerIssueId, - assigneeSearch, - assignIssue, - currentUserId, - filtered, - groupedContent, - issueLinkState, - liveIssueIds, - newIssueDefaults, - onUpdateIssue, - openNewIssue, - updateView, - viewState.collapsedGroups, - ]); return (
@@ -870,7 +651,257 @@ export function IssuesList({ /> )} - {listContent} + {viewState.viewMode === "board" ? ( + + ) : ( + groupedContent.map((group) => ( + { + updateView({ + collapsedGroups: open + ? viewState.collapsedGroups.filter((k) => k !== group.key) + : [...viewState.collapsedGroups, group.key], + }); + }} + > + {group.label && ( +
+ + + + {group.label} + + + +
+ )} + + {(() => { + const { roots, childMap } = buildIssueTree(group.items); + + const renderIssueRow = (issue: Issue, depth: number) => { + const children = childMap.get(issue.id) ?? []; + const hasChildren = children.length > 0; + const totalDescendants = hasChildren ? countDescendants(issue.id, childMap) : 0; + const isExpanded = !viewState.collapsedParents.includes(issue.id); + const toggleCollapse = (e: { preventDefault: () => void; stopPropagation: () => void }) => { + e.preventDefault(); + e.stopPropagation(); + updateView({ + collapsedParents: isExpanded + ? [...viewState.collapsedParents, issue.id] + : viewState.collapsedParents.filter((id) => id !== issue.id), + }); + }; + + return ( +
0 ? { paddingLeft: `${depth * 16}px` } : undefined}> + + ({totalDescendants} sub-task{totalDescendants !== 1 ? "s" : ""}) + + ) : undefined} + mobileLeading={ + hasChildren ? ( + + ) : ( + { e.preventDefault(); e.stopPropagation(); }}> + onUpdateIssue(issue.id, { status: s })} /> + + ) + } + desktopMetaLeading={( + <> + {hasChildren ? ( + + ) : ( + + )} + { e.preventDefault(); e.stopPropagation(); }} + > + onUpdateIssue(issue.id, { status: s })} /> + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {liveIssueIds?.has(issue.id) && ( + + + + + + + Live + + + )} + + )} + mobileMeta={timeAgo(issue.updatedAt)} + desktopTrailing={( + <> + {(issue.labels ?? []).length > 0 && ( + + {(issue.labels ?? []).slice(0, 3).map((label) => ( + + {label.name} + + ))} + {(issue.labels ?? []).length > 3 && ( + + +{(issue.labels ?? []).length - 3} + + )} + + )} + { + setAssigneePickerIssueId(open ? issue.id : null); + if (!open) setAssigneeSearch(""); + }} + > + + + + e.stopPropagation()} + onPointerDownOutside={() => setAssigneeSearch("")} + > + setAssigneeSearch(e.target.value)} + autoFocus + /> +
+ + {currentUserId && ( + + )} + {(agents ?? []) + .filter((agent) => { + if (!assigneeSearch.trim()) return true; + return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase()); + }) + .map((agent) => ( + + ))} +
+
+
+ + )} + trailingMeta={formatDate(issue.createdAt)} + /> + {hasChildren && isExpanded && children.map((child) => renderIssueRow(child, depth + 1))} +
+ ); + }; + + return roots.map((issue) => renderIssueRow(issue, 0)); + })()} +
+
+ )) + )}
); } diff --git a/ui/src/lib/issue-tree.test.ts b/ui/src/lib/issue-tree.test.ts new file mode 100644 index 00000000..75d38f87 --- /dev/null +++ b/ui/src/lib/issue-tree.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import type { Issue } from "@paperclipai/shared"; +import { buildIssueTree, countDescendants } from "./issue-tree"; + +function makeIssue(id: string, parentId: string | null = null): Issue { + return { + id, + identifier: id.toUpperCase(), + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId, + title: `Issue ${id}`, + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: 1, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + labels: [], + labelIds: [], + myLastTouchAt: null, + lastExternalCommentAt: null, + isUnreadForMe: false, + }; +} + +describe("buildIssueTree", () => { + it("returns all items as roots when no parent-child relationships exist", () => { + const items = [makeIssue("a"), makeIssue("b"), makeIssue("c")]; + const { roots, childMap } = buildIssueTree(items); + expect(roots.map((r) => r.id)).toEqual(["a", "b", "c"]); + expect(childMap.size).toBe(0); + }); + + it("places children under their parent and excludes them from roots", () => { + const parent = makeIssue("parent"); + const child1 = makeIssue("child1", "parent"); + const child2 = makeIssue("child2", "parent"); + const { roots, childMap } = buildIssueTree([parent, child1, child2]); + expect(roots.map((r) => r.id)).toEqual(["parent"]); + expect(childMap.get("parent")?.map((c) => c.id)).toEqual(["child1", "child2"]); + }); + + it("handles multiple levels of nesting", () => { + const grandparent = makeIssue("gp"); + const parent = makeIssue("p", "gp"); + const child = makeIssue("c", "p"); + const { roots, childMap } = buildIssueTree([grandparent, parent, child]); + expect(roots.map((r) => r.id)).toEqual(["gp"]); + expect(childMap.get("gp")?.map((i) => i.id)).toEqual(["p"]); + expect(childMap.get("p")?.map((i) => i.id)).toEqual(["c"]); + }); + + it("promotes orphaned sub-tasks (parent not in list) to root level", () => { + // child references a parent that is not in the items array (e.g. filtered out) + const child = makeIssue("child", "missing-parent"); + const unrelated = makeIssue("unrelated"); + const { roots, childMap } = buildIssueTree([child, unrelated]); + expect(roots.map((r) => r.id)).toEqual(["child", "unrelated"]); + expect(childMap.size).toBe(0); + }); + + it("returns empty roots and empty childMap for an empty list", () => { + const { roots, childMap } = buildIssueTree([]); + expect(roots).toEqual([]); + expect(childMap.size).toBe(0); + }); + + it("preserves list order within roots and within children", () => { + const p1 = makeIssue("p1"); + const p2 = makeIssue("p2"); + const c1 = makeIssue("c1", "p1"); + const c2 = makeIssue("c2", "p1"); + const { roots, childMap } = buildIssueTree([p1, c1, p2, c2]); + expect(roots.map((r) => r.id)).toEqual(["p1", "p2"]); + expect(childMap.get("p1")?.map((c) => c.id)).toEqual(["c1", "c2"]); + }); +}); + +describe("countDescendants", () => { + it("returns 0 for a leaf node", () => { + const { childMap } = buildIssueTree([makeIssue("a")]); + expect(countDescendants("a", childMap)).toBe(0); + }); + + it("returns direct child count for a single-level parent", () => { + const { childMap } = buildIssueTree([ + makeIssue("p"), + makeIssue("c1", "p"), + makeIssue("c2", "p"), + ]); + expect(countDescendants("p", childMap)).toBe(2); + }); + + it("counts all descendants across multiple levels", () => { + // P → C → G1, G2 (P has 3 total descendants: C, G1, G2) + const { childMap } = buildIssueTree([ + makeIssue("p"), + makeIssue("c", "p"), + makeIssue("g1", "c"), + makeIssue("g2", "c"), + ]); + expect(countDescendants("p", childMap)).toBe(3); + }); + + it("returns 0 for an id not in the childMap", () => { + const { childMap } = buildIssueTree([makeIssue("a"), makeIssue("b")]); + expect(countDescendants("nonexistent", childMap)).toBe(0); + }); +}); diff --git a/ui/src/lib/issue-tree.ts b/ui/src/lib/issue-tree.ts new file mode 100644 index 00000000..e88709ed --- /dev/null +++ b/ui/src/lib/issue-tree.ts @@ -0,0 +1,36 @@ +import type { Issue } from "@paperclipai/shared"; + +export interface IssueTree { + roots: Issue[]; + childMap: Map; +} + +/** + * Builds a parent→children tree from a flat list of issues. + * + * - `roots` contains issues whose parent is absent from the list (or have no + * parent at all), so orphaned sub-tasks are always visible at root level. + * - `childMap` maps each parent id to its direct children in list order. + */ +export function buildIssueTree(items: Issue[]): IssueTree { + const itemIds = new Set(items.map((i) => i.id)); + const roots = items.filter((i) => !i.parentId || !itemIds.has(i.parentId)); + const childMap = new Map(); + for (const item of items) { + if (item.parentId && itemIds.has(item.parentId)) { + const arr = childMap.get(item.parentId) ?? []; + arr.push(item); + childMap.set(item.parentId, arr); + } + } + return { roots, childMap }; +} + +/** + * Returns the total number of descendants (all depths) of `id` in `childMap`. + * Used to accurately label collapsed parent badges like "(3 sub-tasks)". + */ +export function countDescendants(id: string, childMap: Map): number { + const children = childMap.get(id) ?? []; + return children.reduce((sum, c) => sum + 1 + countDescendants(c.id, childMap), 0); +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index d16a3103..e9c98065 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -985,11 +985,11 @@ export function IssueDetail() { useEffect(() => { if (issue) { openPanel( - updateIssue.mutate(data)} /> + updateIssue.mutate(data)} childIssues={childIssues} /> ); } return () => closePanel(); - }, [issue]); // eslint-disable-line react-hooks/exhaustive-deps + }, [issue, childIssues]); // eslint-disable-line react-hooks/exhaustive-deps const inboxQuickArchiveArmedRef = useRef(false); const canQuickArchiveFromInbox = @@ -1699,7 +1699,7 @@ export function IssueDetail() {
- updateIssue.mutate(data)} inline /> + updateIssue.mutate(data)} inline childIssues={childIssues} />