Merge pull request #2733 from davison/feature/issue-management

Issue list and issue properties panel: improved UI
This commit is contained in:
Dotta 2026-04-06 08:10:42 -05:00 committed by GitHub
commit 89ad6767c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 535 additions and 262 deletions

View file

@ -44,6 +44,7 @@ interface IssuePropertiesProps {
issue: Issue;
onUpdate: (data: Record<string, unknown>) => 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}
</PropertyPicker>
{issue.parentId && (
<PropertyRow label="Parent">
<Link
to={`/issues/${issue.ancestors?.[0]?.identifier ?? issue.parentId}`}
className="text-sm hover:underline"
>
{issue.ancestors?.[0]?.title ?? issue.parentId.slice(0, 8)}
</Link>
</PropertyRow>
)}
{issue.requestDepth > 0 && (
<PropertyRow label="Depth">
<span className="text-sm font-mono">{issue.requestDepth}</span>
@ -615,6 +605,52 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
<span className="text-sm">{timeAgo(issue.updatedAt)}</span>
</PropertyRow>
</div>
{(issue.parentId || (childIssues && childIssues.length > 0)) && (
<>
<Separator />
<div className="space-y-3">
{issue.parentId && (
<div>
<p className="text-xs text-muted-foreground mb-1">Parent task</p>
<div className="flex items-start gap-1.5">
{issue.ancestors?.[0] != null && (
<div className="shrink-0 mt-0.5">
<StatusIcon status={issue.ancestors[0].status} />
</div>
)}
<Link
to={`/issues/${issue.ancestors?.[0]?.identifier ?? issue.parentId}`}
className="text-sm hover:underline"
>
{issue.ancestors?.[0]?.title ?? issue.parentId.slice(0, 8)}
</Link>
</div>
</div>
)}
{childIssues && childIssues.length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-1">Sub-tasks</p>
<div className="space-y-0.5">
{childIssues.map((child) => (
<div key={child.id} className="flex items-start gap-1.5">
<div className="shrink-0 mt-0.5">
<StatusIcon status={child.status} />
</div>
<Link
to={`/issues/${child.identifier ?? child.id}`}
className="text-sm hover:underline"
>
{child.title}
</Link>
</div>
))}
</div>
</div>
)}
</div>
</>
)}
</div>
);
}

View file

@ -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(
<IssueRow
issue={issue}
titleSuffix={<span data-testid="suffix">(3 sub-tasks)</span>}
/>,
);
});
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(<IssueRow issue={createIssue()} />);
});
const titleEl = container.querySelector(".line-clamp-2, .truncate");
expect(titleEl?.textContent).toContain("Inbox item");
act(() => {
root.unmount();
});
});
});

View file

@ -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({
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
<span className="line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none">
{issue.title}
{issue.title}{titleSuffix}
</span>
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
{desktopLeadingSpacer ? (

View file

@ -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 (
<KanbanBoard
issues={filtered}
agents={agents}
liveIssueIds={liveIssueIds}
onUpdateIssue={onUpdateIssue}
/>
);
}
return groupedContent.map((group) => (
<Collapsible
key={group.key}
open={!viewState.collapsedGroups.includes(group.key)}
onOpenChange={(open) => {
updateView({
collapsedGroups: open
? viewState.collapsedGroups.filter((k) => k !== group.key)
: [...viewState.collapsedGroups, group.key],
});
}}
>
{group.label && (
<div className="flex items-center py-1.5 pl-1 pr-3">
<CollapsibleTrigger className="flex items-center gap-1.5">
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
<span className="text-sm font-semibold uppercase tracking-wide">
{group.label}
</span>
</CollapsibleTrigger>
<Button
variant="ghost"
size="icon-xs"
className="ml-auto text-muted-foreground"
onClick={() => openNewIssue(newIssueDefaults(group.key))}
>
<Plus className="h-3 w-3" />
</Button>
</div>
)}
<CollapsibleContent>
{group.items.map((issue) => (
<IssueRow
key={issue.id}
issue={issue}
issueLinkState={issueLinkState}
desktopLeadingSpacer
mobileLeading={(
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<StatusIcon
status={issue.status}
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
/>
</span>
)}
desktopMetaLeading={(
<>
<span
className="hidden shrink-0 sm:inline-flex"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<StatusIcon
status={issue.status}
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
/>
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{liveIssueIds?.has(issue.id) && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
</span>
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
Live
</span>
</span>
)}
</>
)}
mobileMeta={timeAgo(issue.updatedAt)}
desktopTrailing={(
<>
{(issue.labels ?? []).length > 0 && (
<span className="hidden items-center gap-1 overflow-hidden md:flex md:max-w-[240px]">
{(issue.labels ?? []).slice(0, 3).map((label) => (
<span
key={label.id}
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
style={{
borderColor: label.color,
color: pickTextColorForPillBg(label.color, 0.12),
backgroundColor: `${label.color}1f`,
}}
>
{label.name}
</span>
))}
{(issue.labels ?? []).length > 3 && (
<span className="text-[10px] text-muted-foreground">
+{(issue.labels ?? []).length - 3}
</span>
)}
</span>
)}
<Popover
open={assigneePickerIssueId === issue.id}
onOpenChange={(open) => {
setAssigneePickerIssueId(open ? issue.id : null);
if (!open) setAssigneeSearch("");
}}
>
<PopoverTrigger asChild>
<button
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 transition-colors hover:bg-accent/50"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
) : issue.assigneeUserId ? (
<span className="inline-flex items-center gap-1.5 text-xs">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
Assignee
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent
className="w-56 p-1"
align="end"
onClick={(e) => e.stopPropagation()}
onPointerDownOutside={() => setAssigneeSearch("")}
>
<input
className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50"
placeholder="Search assignees..."
value={assigneeSearch}
onChange={(e) => setAssigneeSearch(e.target.value)}
autoFocus
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
<button
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null, null);
}}
>
No assignee
</button>
{currentUserId && (
<button
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
issue.assigneeUserId === currentUserId && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null, currentUserId);
}}
>
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>Me</span>
</button>
)}
{(agents ?? [])
.filter((agent) => {
if (!assigneeSearch.trim()) return true;
return agent.name
.toLowerCase()
.includes(assigneeSearch.toLowerCase());
})
.map((agent) => (
<button
key={agent.id}
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
issue.assigneeAgentId === agent.id && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, agent.id, null);
}}
>
<Identity name={agent.name} size="sm" className="min-w-0" />
</button>
))}
</div>
</PopoverContent>
</Popover>
</>
)}
trailingMeta={formatDate(issue.createdAt)}
/>
))}
</CollapsibleContent>
</Collapsible>
));
}, [
agents,
agentName,
assigneePickerIssueId,
assigneeSearch,
assignIssue,
currentUserId,
filtered,
groupedContent,
issueLinkState,
liveIssueIds,
newIssueDefaults,
onUpdateIssue,
openNewIssue,
updateView,
viewState.collapsedGroups,
]);
return (
<div className="space-y-4">
@ -870,7 +651,257 @@ export function IssuesList({
/>
)}
{listContent}
{viewState.viewMode === "board" ? (
<KanbanBoard
issues={filtered}
agents={agents}
liveIssueIds={liveIssueIds}
onUpdateIssue={onUpdateIssue}
/>
) : (
groupedContent.map((group) => (
<Collapsible
key={group.key}
open={!viewState.collapsedGroups.includes(group.key)}
onOpenChange={(open) => {
updateView({
collapsedGroups: open
? viewState.collapsedGroups.filter((k) => k !== group.key)
: [...viewState.collapsedGroups, group.key],
});
}}
>
{group.label && (
<div className="flex items-center py-1.5 pl-1 pr-3">
<CollapsibleTrigger className="flex items-center gap-1.5">
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
<span className="text-sm font-semibold uppercase tracking-wide">
{group.label}
</span>
</CollapsibleTrigger>
<Button
variant="ghost"
size="icon-xs"
className="ml-auto text-muted-foreground"
onClick={() => openNewIssue(newIssueDefaults(group.key))}
>
<Plus className="h-3 w-3" />
</Button>
</div>
)}
<CollapsibleContent>
{(() => {
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 (
<div key={issue.id} style={depth > 0 ? { paddingLeft: `${depth * 16}px` } : undefined}>
<IssueRow
issue={issue}
issueLinkState={issueLinkState}
titleSuffix={hasChildren && !isExpanded ? (
<span className="ml-1.5 text-xs text-muted-foreground">
({totalDescendants} sub-task{totalDescendants !== 1 ? "s" : ""})
</span>
) : undefined}
mobileLeading={
hasChildren ? (
<button type="button" onClick={toggleCollapse}>
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
</button>
) : (
<span onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon status={issue.status} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
</span>
)
}
desktopMetaLeading={(
<>
{hasChildren ? (
<button
type="button"
className="hidden shrink-0 items-center sm:inline-flex"
onClick={toggleCollapse}
>
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
</button>
) : (
<span className="hidden w-3.5 shrink-0 sm:block" />
)}
<span
className="hidden shrink-0 sm:inline-flex"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
>
<StatusIcon status={issue.status} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{liveIssueIds?.has(issue.id) && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
</span>
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
Live
</span>
</span>
)}
</>
)}
mobileMeta={timeAgo(issue.updatedAt)}
desktopTrailing={(
<>
{(issue.labels ?? []).length > 0 && (
<span className="hidden items-center gap-1 overflow-hidden md:flex md:max-w-[240px]">
{(issue.labels ?? []).slice(0, 3).map((label) => (
<span
key={label.id}
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
style={{
borderColor: label.color,
color: pickTextColorForPillBg(label.color, 0.12),
backgroundColor: `${label.color}1f`,
}}
>
{label.name}
</span>
))}
{(issue.labels ?? []).length > 3 && (
<span className="text-[10px] text-muted-foreground">
+{(issue.labels ?? []).length - 3}
</span>
)}
</span>
)}
<Popover
open={assigneePickerIssueId === issue.id}
onOpenChange={(open) => {
setAssigneePickerIssueId(open ? issue.id : null);
if (!open) setAssigneeSearch("");
}}
>
<PopoverTrigger asChild>
<button
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 transition-colors hover:bg-accent/50"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
>
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
) : issue.assigneeUserId ? (
<span className="inline-flex items-center gap-1.5 text-xs">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
Assignee
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent
className="w-56 p-1"
align="end"
onClick={(e) => e.stopPropagation()}
onPointerDownOutside={() => setAssigneeSearch("")}
>
<input
className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50"
placeholder="Search assignees..."
value={assigneeSearch}
onChange={(e) => setAssigneeSearch(e.target.value)}
autoFocus
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
<button
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null, null);
}}
>
No assignee
</button>
{currentUserId && (
<button
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
issue.assigneeUserId === currentUserId && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null, currentUserId);
}}
>
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>Me</span>
</button>
)}
{(agents ?? [])
.filter((agent) => {
if (!assigneeSearch.trim()) return true;
return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase());
})
.map((agent) => (
<button
key={agent.id}
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
issue.assigneeAgentId === agent.id && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, agent.id, null);
}}
>
<Identity name={agent.name} size="sm" className="min-w-0" />
</button>
))}
</div>
</PopoverContent>
</Popover>
</>
)}
trailingMeta={formatDate(issue.createdAt)}
/>
{hasChildren && isExpanded && children.map((child) => renderIssueRow(child, depth + 1))}
</div>
);
};
return roots.map((issue) => renderIssueRow(issue, 0));
})()}
</CollapsibleContent>
</Collapsible>
))
)}
</div>
);
}

View file

@ -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);
});
});

36
ui/src/lib/issue-tree.ts Normal file
View file

@ -0,0 +1,36 @@
import type { Issue } from "@paperclipai/shared";
export interface IssueTree {
roots: Issue[];
childMap: Map<string, Issue[]>;
}
/**
* Builds a parentchildren 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<string, Issue[]>();
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<string, Issue[]>): number {
const children = childMap.get(id) ?? [];
return children.reduce((sum, c) => sum + 1 + countDescendants(c.id, childMap), 0);
}

View file

@ -985,11 +985,11 @@ export function IssueDetail() {
useEffect(() => {
if (issue) {
openPanel(
<IssueProperties issue={issue} onUpdate={(data) => updateIssue.mutate(data)} />
<IssueProperties issue={issue} onUpdate={(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() {
</SheetHeader>
<ScrollArea className="flex-1 overflow-y-auto">
<div className="px-4 pb-4">
<IssueProperties issue={issue} onUpdate={(data) => updateIssue.mutate(data)} inline />
<IssueProperties issue={issue} onUpdate={(data) => updateIssue.mutate(data)} inline childIssues={childIssues} />
</div>
</ScrollArea>
</SheetContent>