diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index 7e5514dffa5..17734343bc7 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -23,6 +23,11 @@ interface IssueRowProps { desktopTrailing?: ReactNode; trailingMeta?: ReactNode; titleSuffix?: ReactNode; + titleClassName?: string; + checklistStepNumber?: number | null; + checklistCurrentStep?: boolean; + checklistDependencyChips?: ReactNode; + checklistRowId?: string; unreadState?: UnreadState | null; onMarkRead?: () => void; onArchive?: () => void; @@ -41,6 +46,11 @@ export function IssueRow({ desktopTrailing, trailingMeta, titleSuffix, + titleClassName, + checklistStepNumber = null, + checklistCurrentStep = false, + checklistDependencyChips, + checklistRowId, unreadState = null, onMarkRead, onArchive, @@ -53,6 +63,7 @@ export function IssueRow({ const showUnreadDot = unreadState === "visible" || unreadState === "fading"; const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined; const detailState = withIssueDetailHeaderSeed(issueLinkState, issue); + const hasChecklistStep = checklistStepNumber !== null; return ( rememberIssueDetailLocationState(issuePathId, detailState)} className={cn( "group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors last:border-b-0 sm:items-center sm:py-2 sm:pl-1", selected ? "hover:bg-transparent" : "hover:bg-accent/50", + checklistCurrentStep ? "border-l-2 border-l-primary/40 pl-[calc(theme(spacing.2)-2px)] sm:pl-[calc(theme(spacing.1)-2px)]" : null, className, )} > + {hasChecklistStep ? ( + + ) : null} {mobileLeading ?? } - + {issue.title}{titleSuffix} + {checklistDependencyChips ? ( + + {checklistDependencyChips} + + ) : null} {desktopLeadingSpacer ? ( diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index f72915fa012..b6f6a174a1a 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -91,15 +91,32 @@ vi.mock("./IssueRow", () => ({ issue, desktopMetaLeading, desktopTrailing, + titleClassName, + checklistStepNumber, + checklistCurrentStep, + checklistDependencyChips, + checklistRowId, }: { issue: Issue; desktopMetaLeading?: ReactNode; desktopTrailing?: ReactNode; + titleClassName?: string; + checklistStepNumber?: number | null; + checklistCurrentStep?: boolean; + checklistDependencyChips?: ReactNode; + checklistRowId?: string; }) => ( -
+
{issue.title} {desktopMetaLeading} {desktopTrailing} + {checklistDependencyChips}
), })); @@ -428,6 +445,57 @@ describe("IssuesList", () => { }); }); + it("adds checklist affordances for workflow-sorted sub-issue lists", async () => { + const issueDone = createIssue({ + id: "issue-done", + identifier: "PAP-1", + title: "Done first", + status: "done", + createdAt: new Date("2026-04-01T00:00:00.000Z"), + }); + const issueBlocked = createIssue({ + id: "issue-blocked", + identifier: "PAP-2", + title: "Blocked issue", + status: "blocked", + blockedBy: [{ id: "issue-active", identifier: "PAP-3", title: "Active blocker", status: "todo", priority: "medium", assigneeAgentId: null, assigneeUserId: null }], + createdAt: new Date("2026-04-02T00:00:00.000Z"), + }); + const issueActive = createIssue({ + id: "issue-active", + identifier: "PAP-3", + title: "Active blocker", + status: "todo", + createdAt: new Date("2026-04-03T00:00:00.000Z"), + }); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + const rows = Array.from(container.querySelectorAll('[data-testid="issue-row"]')); + expect(rows).toHaveLength(3); + expect(rows.map((row) => row.getAttribute("data-step"))).toEqual(["1", "2", "3"]); + expect(rows.filter((row) => row.getAttribute("data-current-step") === "true")).toHaveLength(1); + expect(rows.find((row) => row.textContent?.includes("Active blocker"))?.getAttribute("data-current-step")).toBe("true"); + expect(rows.find((row) => row.textContent?.includes("Done first"))?.getAttribute("data-title-class")).toContain("text-muted-foreground"); + expect(container.textContent).toContain("blocked by PAP-3"); + }); + + act(() => { + root.unmount(); + }); + }); + it("hides the sub-issue progress summary unless it is enabled and populated", async () => { const { root } = renderWithQueryClient( value?.toLowerCase().includes(normalizedSearch)); } +function isActionableWorkflowStatus(status: IssueStatus): boolean { + return status !== "done" && status !== "cancelled" && status !== "blocked"; +} + /* ── Component ── */ interface Agent { @@ -820,6 +824,42 @@ export function IssuesList({ : null, [issues, showProgressSummary], ); + const checklistAffordanceEnabled = useMemo( + () => + defaultSortField === "workflow" + && viewState.sortField === "workflow" + && viewState.groupBy === "none", + [defaultSortField, viewState.groupBy, viewState.sortField], + ); + const workflowChecklistMeta = useMemo(() => { + if (!checklistAffordanceEnabled) return null; + + const visibleIssueIds = new Set(filtered.map((issue) => issue.id)); + const stepNumberByIssueId = new Map(); + const unresolvedVisibleBlockersByIssueId = new Map(); + + filtered.forEach((issue, index) => { + stepNumberByIssueId.set(issue.id, index + 1); + const unresolvedVisible = (issue.blockedBy ?? []) + .map((blocker) => blocker.id) + .filter((blockerId) => { + if (!visibleIssueIds.has(blockerId)) return false; + const blockerIssue = issueById.get(blockerId); + if (!blockerIssue) return false; + return blockerIssue.status !== "done" && blockerIssue.status !== "cancelled"; + }); + unresolvedVisibleBlockersByIssueId.set(issue.id, unresolvedVisible); + }); + + const firstActionable = filtered.find((issue) => isActionableWorkflowStatus(issue.status)) ?? null; + const currentStepIssue = firstActionable ?? filtered.find((issue) => issue.status === "blocked") ?? null; + + return { + stepNumberByIssueId, + unresolvedVisibleBlockersByIssueId, + currentStepIssueId: currentStepIssue?.id ?? null, + }; + }, [checklistAffordanceEnabled, filtered, issueById]); const { data: labels } = useQuery({ queryKey: queryKeys.issues.labels(selectedCompanyId!), @@ -1237,6 +1277,41 @@ export function IssuesList({ : viewState.collapsedParents.filter((id) => id !== issue.id), }); }; + const checklistMeta = workflowChecklistMeta; + const checklistStepNumber = checklistMeta?.stepNumberByIssueId.get(issue.id) ?? null; + const unresolvedVisibleBlockers = checklistMeta?.unresolvedVisibleBlockersByIssueId.get(issue.id) ?? []; + const checklistRowId = checklistMeta ? `issue-workflow-row-${issue.id}` : undefined; + const doneRowTitleClass = checklistMeta && issue.status === "done" + ? "text-muted-foreground" + : undefined; + const checklistDependencyChips = checklistMeta && unresolvedVisibleBlockers.length > 0 ? ( + <> + {unresolvedVisibleBlockers.map((blockerId) => { + const blockerIssue = issueById.get(blockerId); + if (!blockerIssue) return null; + const label = blockerIssue.identifier ?? blockerIssue.id.slice(0, 8); + return ( + + ); + })} + + ) : null; return (
{hasChildren && !isExpanded ? (