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/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 3113d41c..5ac50ee9 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 } from "../lib/issue-tree"; import type { Issue } from "@paperclipai/shared"; /* ── Helpers ── */ @@ -667,16 +668,7 @@ export function IssuesList({ )} {(() => { - const itemIds = new Set(group.items.map((i) => i.id)); - const roots = group.items.filter((i) => !i.parentId || !itemIds.has(i.parentId)); - const childMap = new Map(); - for (const item of group.items) { - if (item.parentId && itemIds.has(item.parentId)) { - const arr = childMap.get(item.parentId) ?? []; - arr.push(item); - childMap.set(item.parentId, arr); - } - } + const { roots, childMap } = buildIssueTree(group.items); const renderIssueRow = (issue: Issue, depth: number) => { const children = childMap.get(issue.id) ?? []; diff --git a/ui/src/lib/issue-tree.test.ts b/ui/src/lib/issue-tree.test.ts new file mode 100644 index 00000000..ddb0e6ae --- /dev/null +++ b/ui/src/lib/issue-tree.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import type { Issue } from "@paperclipai/shared"; +import { buildIssueTree } 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"]); + }); +}); diff --git a/ui/src/lib/issue-tree.ts b/ui/src/lib/issue-tree.ts new file mode 100644 index 00000000..c5fa8fc4 --- /dev/null +++ b/ui/src/lib/issue-tree.ts @@ -0,0 +1,27 @@ +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 }; +}