From a8a8ff6eca63769184c5ddc86d11111dc6df2b68 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:57:13 +0800 Subject: [PATCH] feat(issues): add sub-issue support (#483) * feat(issues): add sub-issue support - Backend: Add ListChildIssues SQL query, add parent_issue_id to UpdateIssue, add GET /api/issues/{id}/children endpoint - Frontend: Display parent issue breadcrumb and link in issue detail sidebar, show child issues list with status icons, add "Create sub-issue" action in dropdown menu and sidebar, pass parent_issue_id through create issue modal - Update test mocks for new API method * fix(issues): add parent validation, cycle detection, and improve child refresh - CreateIssue: validate parent issue exists in the same workspace - UpdateIssue: validate parent exists, prevent self-referencing, detect circular parent chains (up to 10 levels deep) - Frontend: derive child issues from store when available instead of refetching on every global issue count change --- .../app/(dashboard)/issues/[id]/page.test.tsx | 1 + .../issues/components/issue-detail.tsx | 115 +++++++++++++++++- apps/web/features/modals/create-issue.tsx | 9 +- apps/web/shared/api/client.ts | 4 + apps/web/shared/types/api.ts | 1 + server/cmd/server/router.go | 1 + server/internal/handler/issue.go | 83 +++++++++++-- server/pkg/db/generated/issue.sql.go | 66 ++++++++-- server/pkg/db/queries/issue.sql | 6 + 9 files changed, 267 insertions(+), 19 deletions(-) diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx index bbfa6727..af5a3d8c 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx @@ -166,6 +166,7 @@ vi.mock("@/shared/api", () => ({ getActiveTasksForIssue: vi.fn().mockResolvedValue({ tasks: [] }), listTasksByIssue: vi.fn().mockResolvedValue([]), listTaskMessages: vi.fn().mockResolvedValue([]), + listChildIssues: vi.fn().mockResolvedValue({ issues: [] }), }, })); diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index 6f130587..97becdef 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -13,6 +13,7 @@ import { Link2, MoreHorizontal, PanelRight, + Plus, Trash2, UserMinus, Users, @@ -57,7 +58,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command"; import { AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar"; import { ActorAvatar } from "@/components/common/actor-avatar"; -import type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types"; +import type { Issue, UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types"; import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config"; import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker, canAssignAgent } from "@/features/issues/components"; import { CommentCard } from "./comment-card"; @@ -75,6 +76,7 @@ import { useIssueReactions } from "@/features/issues/hooks/use-issue-reactions"; import { useIssueSubscribers } from "@/features/issues/hooks/use-issue-subscribers"; import { ReactionBar } from "@/components/common/reaction-bar"; import { useFileUpload } from "@/shared/hooks/use-file-upload"; +import { useModalStore } from "@/features/modals"; import { timeAgo } from "@/shared/utils"; function shortDate(date: string | null): string { @@ -225,6 +227,38 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo subscribers, loading: subscribersLoading, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber, } = useIssueSubscribers(id, user?.id); + // Sub-issue state — derive from store when possible, fetch otherwise + const [parentIssue, setParentIssue] = useState(null); + const [childIssues, setChildIssues] = useState([]); + + // Fetch parent issue when parent_issue_id changes + useEffect(() => { + if (!issue?.parent_issue_id) { + setParentIssue(null); + return; + } + // Try store first, then fetch + const storeParent = allIssues.find((i) => i.id === issue.parent_issue_id); + if (storeParent) { + setParentIssue(storeParent); + } else { + api.getIssue(issue.parent_issue_id).then(setParentIssue).catch(() => setParentIssue(null)); + } + }, [issue?.parent_issue_id, allIssues]); + + // Fetch child issues once, then keep in sync via store + const childIssuesFromStore = allIssues.filter((i) => i.parent_issue_id === id); + useEffect(() => { + if (!issue) return; + // If store has children, use them directly + if (childIssuesFromStore.length > 0) { + setChildIssues(childIssuesFromStore); + return; + } + // Fetch from API (children may not be in the store yet, e.g. deep-linked) + api.listChildIssues(issue.id).then((r) => setChildIssues(r.issues)).catch(() => setChildIssues([])); + }, [issue?.id, childIssuesFromStore.length]); + const loading = issueLoading; // Scroll to highlighted comment once timeline loads (fire only once per highlightCommentId) @@ -377,6 +411,17 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo )} + {parentIssue && ( + <> + + {parentIssue.identifier} + + + + )} {issue.identifier} @@ -547,6 +592,17 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo + {/* Create sub-issue */} + { + useModalStore.getState().open("create-issue", { + parent_issue_id: issue.id, + parent_issue_identifier: issue.identifier, + }); + }}> + + Create sub-issue + + {/* Copy link */} { navigator.clipboard.writeText(window.location.href); @@ -1005,6 +1061,63 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo } + {/* Parent issue */} + {parentIssue && ( +
+
+ + Parent issue +
+
+ + + {parentIssue.identifier} + {parentIssue.title} + +
+
+ )} + + {/* Sub-issues */} +
+
+ + Sub-issues + {childIssues.length > 0 && ( + {childIssues.length} + )} + +
+
+ {childIssues.map((child) => ( + + + {child.identifier} + {child.title} + + ))} + {childIssues.length === 0 && ( + No sub-issues + )} +
+
+ {/* Details section */}