diff --git a/doc/assets/pap-2189/desktop-1440x900-dark.png b/doc/assets/pap-2189/desktop-1440x900-dark.png new file mode 100644 index 00000000..a710a802 Binary files /dev/null and b/doc/assets/pap-2189/desktop-1440x900-dark.png differ diff --git a/doc/assets/pap-2189/desktop-1440x900-light.png b/doc/assets/pap-2189/desktop-1440x900-light.png new file mode 100644 index 00000000..a710a802 Binary files /dev/null and b/doc/assets/pap-2189/desktop-1440x900-light.png differ diff --git a/doc/assets/pap-2189/mobile-390x844-dark.png b/doc/assets/pap-2189/mobile-390x844-dark.png new file mode 100644 index 00000000..4ea79528 Binary files /dev/null and b/doc/assets/pap-2189/mobile-390x844-dark.png differ diff --git a/doc/assets/pap-2189/mobile-390x844-light.png b/doc/assets/pap-2189/mobile-390x844-light.png new file mode 100644 index 00000000..4ea79528 Binary files /dev/null and b/doc/assets/pap-2189/mobile-390x844-light.png differ diff --git a/scripts/screenshot-subissues.mjs b/scripts/screenshot-subissues.mjs new file mode 100644 index 00000000..ed9d7f7e --- /dev/null +++ b/scripts/screenshot-subissues.mjs @@ -0,0 +1,35 @@ +import { chromium } from "@playwright/test"; +import { mkdir } from "node:fs/promises"; +import { argv } from "node:process"; + +const outDir = argv[2] ?? "/tmp/paperclip/pap-2189-subissues-screens"; +const baseUrl = argv[3] ?? "http://localhost:6006"; +await mkdir(outDir, { recursive: true }); + +const id = "ux-labs-sub-issues-workflow-checklist--default"; +const runs = [ + { name: "desktop-1440x900-dark", w: 1440, h: 900, theme: "dark" }, + { name: "desktop-1440x900-light", w: 1440, h: 900, theme: "light" }, + { name: "mobile-390x844-dark", w: 390, h: 844, theme: "dark" }, + { name: "mobile-390x844-light", w: 390, h: 844, theme: "light" }, +]; + +const browser = await chromium.launch(); +try { + for (const run of runs) { + const url = `${baseUrl}/iframe.html?id=${id}&viewMode=story&globals=theme:${run.theme}`; + const context = await browser.newContext({ + viewport: { width: run.w, height: run.h }, + deviceScaleFactor: 2, + }); + const page = await context.newPage(); + await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 }); + await page.waitForTimeout(1200); + const file = `${outDir}/${run.name}.png`; + await page.screenshot({ path: file, fullPage: true }); + console.log("wrote", file); + await context.close(); + } +} finally { + await browser.close(); +} diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index c8be8ff1..16afd95a 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -895,6 +895,69 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { ); }); + it("includes blockedBy summaries on list rows in one batched pass", async () => { + const companyId = randomUUID(); + const blockerId = randomUUID(); + const blockedId = randomUUID(); + const unblockedId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(issues).values([ + { + id: blockerId, + companyId, + title: "Blocker issue", + status: "todo", + priority: "high", + }, + { + id: blockedId, + companyId, + title: "Blocked issue", + status: "blocked", + priority: "medium", + }, + { + id: unblockedId, + companyId, + title: "Unblocked issue", + status: "todo", + priority: "medium", + }, + ]); + + await db.insert(issueRelations).values({ + companyId, + issueId: blockerId, + relatedIssueId: blockedId, + type: "blocks", + }); + + const defaultResult = await svc.list(companyId); + expect(defaultResult.find((issue) => issue.id === blockedId)?.blockedBy).toBeUndefined(); + + const result = await svc.list(companyId, { includeBlockedBy: true }); + const byId = new Map(result.map((issue) => [issue.id, issue])); + + expect(byId.get(blockedId)?.blockedBy).toEqual([ + expect.objectContaining({ + id: blockerId, + identifier: null, + title: "Blocker issue", + status: "todo", + priority: "high", + }), + ]); + expect(byId.get(blockerId)?.blockedBy).toEqual([]); + expect(byId.get(unblockedId)?.blockedBy).toEqual([]); + }); + it("trims list payload fields that can grow large on issue index routes", async () => { const companyId = randomUUID(); const issueId = randomUUID(); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 4458f382..8959d889 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -949,6 +949,7 @@ export function issueRoutes( req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1", excludeRoutineExecutions: req.query.excludeRoutineExecutions === "true" || req.query.excludeRoutineExecutions === "1", + includeBlockedBy: req.query.includeBlockedBy === "true" || req.query.includeBlockedBy === "1", q: req.query.q as string | undefined, limit, }); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 01bd1d7c..48893d03 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -101,6 +101,7 @@ export interface IssueFilters { originId?: string; includeRoutineExecutions?: boolean; excludeRoutineExecutions?: boolean; + includeBlockedBy?: boolean; q?: string; limit?: number; } @@ -1296,6 +1297,63 @@ async function lastActivityStatsForIssues( return [...byIssueId.values()]; } +async function blockedByMapForIssues( + dbOrTx: any, + companyId: string, + issueIds: string[], +): Promise> { + const map = new Map(); + const uniqueIssueIds = [...new Set(issueIds)]; + if (uniqueIssueIds.length === 0) return map; + + for (const issueId of uniqueIssueIds) { + map.set(issueId, []); + } + + for (const issueIdChunk of chunkList(uniqueIssueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { + const rows = await dbOrTx + .select({ + currentIssueId: issueRelations.relatedIssueId, + relatedId: issues.id, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + priority: issues.priority, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + }) + .from(issueRelations) + .innerJoin(issues, eq(issueRelations.issueId, issues.id)) + .where( + and( + eq(issueRelations.companyId, companyId), + eq(issueRelations.type, "blocks"), + inArray(issueRelations.relatedIssueId, issueIdChunk), + ), + ); + + for (const row of rows) { + const blockedBy = map.get(row.currentIssueId); + if (!blockedBy) continue; + blockedBy.push({ + id: row.relatedId, + identifier: row.identifier, + title: row.title, + status: row.status as IssueRelationIssueSummary["status"], + priority: row.priority as IssueRelationIssueSummary["priority"], + assigneeAgentId: row.assigneeAgentId, + assigneeUserId: row.assigneeUserId, + }); + } + } + + for (const blockedBy of map.values()) { + blockedBy.sort((a, b) => a.title.localeCompare(b.title)); + } + + return map; +} + export function issueService(db: Db) { const instanceSettings = instanceSettingsService(db); const treeControlSvc = issueTreeControlService(db); @@ -1784,6 +1842,7 @@ export function issueService(db: Db) { const inboxArchivedByUserId = filters?.inboxArchivedByUserId?.trim() || undefined; const unreadForUserId = filters?.unreadForUserId?.trim() || undefined; const contextUserId = unreadForUserId ?? touchedByUserId ?? inboxArchivedByUserId; + const includeBlockedBy = filters?.includeBlockedBy === true; const rawSearch = filters?.q?.trim() ?? ""; const hasSearch = rawSearch.length > 0; const escapedSearch = hasSearch ? escapeLikePattern(rawSearch) : ""; @@ -1914,7 +1973,7 @@ export function issueService(db: Db) { } const issueIds = withRuns.map((row) => row.id); - const [statsRows, readRows, lastActivityRows] = await Promise.all([ + const [statsRows, readRows, lastActivityRows, blockedByMap] = await Promise.all([ contextUserId ? userCommentStatsForIssues(db, companyId, contextUserId, issueIds) : Promise.resolve([]), @@ -1922,6 +1981,9 @@ export function issueService(db: Db) { ? userReadStatsForIssues(db, companyId, contextUserId, issueIds) : Promise.resolve([]), lastActivityStatsForIssues(db, companyId, issueIds), + includeBlockedBy + ? blockedByMapForIssues(db, companyId, issueIds) + : Promise.resolve(new Map()), ]); const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row])); const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row])); @@ -1937,6 +1999,7 @@ export function issueService(db: Db) { ) ?? row.updatedAt; return { ...row, + ...(includeBlockedBy ? { blockedBy: blockedByMap.get(row.id) ?? [] } : {}), lastActivityAt, ...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}), }; @@ -1954,6 +2017,7 @@ export function issueService(db: Db) { ) ?? row.updatedAt; return { ...row, + ...(includeBlockedBy ? { blockedBy: blockedByMap.get(row.id) ?? [] } : {}), lastActivityAt, ...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}), ...deriveIssueUserContext(row, contextUserId, { diff --git a/ui/src/api/issues.test.ts b/ui/src/api/issues.test.ts index dedbfa9c..6dba7027 100644 --- a/ui/src/api/issues.test.ts +++ b/ui/src/api/issues.test.ts @@ -25,10 +25,10 @@ describe("issuesApi.list", () => { }); it("passes descendantOf through to the company issues endpoint", async () => { - await issuesApi.list("company-1", { descendantOf: "issue-root-1", limit: 25 }); + await issuesApi.list("company-1", { descendantOf: "issue-root-1", includeBlockedBy: true, limit: 25 }); expect(mockApi.get).toHaveBeenCalledWith( - "/companies/company-1/issues?descendantOf=issue-root-1&limit=25", + "/companies/company-1/issues?descendantOf=issue-root-1&includeBlockedBy=true&limit=25", ); }); diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index d7e244b2..53fb9b92 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -45,6 +45,7 @@ export const issuesApi = { originId?: string; descendantOf?: string; includeRoutineExecutions?: boolean; + includeBlockedBy?: boolean; q?: string; limit?: number; }, @@ -66,6 +67,7 @@ export const issuesApi = { if (filters?.originId) params.set("originId", filters.originId); if (filters?.descendantOf) params.set("descendantOf", filters.descendantOf); if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true"); + if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true"); if (filters?.q) params.set("q", filters.q); if (filters?.limit) params.set("limit", String(filters.limit)); const qs = params.toString(); diff --git a/ui/src/components/IssueColumns.tsx b/ui/src/components/IssueColumns.tsx index 54813689..d0ef0d6a 100644 --- a/ui/src/components/IssueColumns.tsx +++ b/ui/src/components/IssueColumns.tsx @@ -139,12 +139,14 @@ export function InboxIssueMetaLeading({ showStatus = true, showIdentifier = true, statusSlot, + checklistStepNumber = null, }: { issue: Issue; isLive: boolean; showStatus?: boolean; showIdentifier?: boolean; statusSlot?: ReactNode; + checklistStepNumber?: number | string | null; }) { return ( <> @@ -153,6 +155,11 @@ export function InboxIssueMetaLeading({ {statusSlot ?? } ) : null} + {checklistStepNumber !== null ? ( + + ) : null} {showIdentifier ? ( {issue.identifier ?? issue.id.slice(0, 8)} diff --git a/ui/src/components/IssueRow.test.tsx b/ui/src/components/IssueRow.test.tsx index 60a201ac..944712e6 100644 --- a/ui/src/components/IssueRow.test.tsx +++ b/ui/src/components/IssueRow.test.tsx @@ -202,6 +202,31 @@ describe("IssueRow", () => { }); }); + it("renders checklist step numbers beside the issue identifier", () => { + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null; + const metaRow = Array.from(link?.querySelectorAll("span.flex.items-center.gap-2") ?? []) + .find((element) => element.textContent?.includes("PAP-42")); + + expect(metaRow).not.toBeUndefined(); + expect(metaRow?.textContent?.replace(/\s+/g, "")).toContain("2.1.PAP-42"); + + act(() => { + root.unmount(); + }); + }); + it("renders without error when titleSuffix is omitted", () => { const root = createRoot(container); diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index 7e5514df..d6969d23 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 | string | 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,12 @@ 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; + const checklistStep = hasChecklistStep ? ( + + ) : 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 bg-primary/5 pl-[calc(theme(spacing.2)-2px)] sm:pl-[calc(theme(spacing.1)-2px)]" : null, className, )} > @@ -72,9 +91,14 @@ export function IssueRow({ {mobileLeading ?? } - + {issue.title}{titleSuffix} + {checklistDependencyChips ? ( + + {checklistDependencyChips} + + ) : null} {desktopLeadingSpacer ? ( @@ -84,6 +108,7 @@ export function IssueRow({ + {checklistStep} {identifier} diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index ec625f91..0d6d3358 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -2,7 +2,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; -import type { ReactNode } from "react"; +import type { AnchorHTMLAttributes, ReactNode } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Issue } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -50,6 +50,22 @@ vi.mock("../context/DialogContext", () => ({ useDialog: () => dialogState, })); +vi.mock("@/lib/router", () => ({ + Link: ({ + children, + to, + state: _state, + issuePrefetch: _issuePrefetch, + ...props + }: AnchorHTMLAttributes & { + to: string; + state?: unknown; + issuePrefetch?: unknown; + }) => ( + {children} + ), +})); + vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi, })); @@ -75,15 +91,32 @@ vi.mock("./IssueRow", () => ({ issue, desktopMetaLeading, desktopTrailing, + titleClassName, + checklistStepNumber, + checklistCurrentStep, + checklistDependencyChips, + checklistRowId, }: { issue: Issue; desktopMetaLeading?: ReactNode; desktopTrailing?: ReactNode; + titleClassName?: string; + checklistStepNumber?: number | string | null; + checklistCurrentStep?: boolean; + checklistDependencyChips?: ReactNode; + checklistRowId?: string; }) => ( -
+
{issue.title} {desktopMetaLeading} {desktopTrailing} + {checklistDependencyChips}
), })); @@ -350,6 +383,250 @@ describe("IssuesList", () => { }); }); + it("renders the opt-in sub-issue progress summary with workflow next-up linking", async () => { + const doneIssue = createIssue({ + id: "issue-done", + identifier: "PAP-1", + title: "Completed setup", + status: "done", + createdAt: new Date("2026-04-01T00:00:00.000Z"), + }); + const nextIssue = createIssue({ + id: "issue-next", + identifier: "PAP-2", + title: "Implement next slice", + status: "todo", + createdAt: new Date("2026-04-02T00:00:00.000Z"), + blockedBy: [{ + id: "issue-done", + identifier: "PAP-1", + title: "Completed setup", + status: "done", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + }], + }); + const blockedIssue = createIssue({ + id: "issue-blocked", + identifier: "PAP-3", + title: "Blocked follow-up", + status: "blocked", + createdAt: new Date("2026-04-03T00:00:00.000Z"), + }); + const cancelledIssue = createIssue({ + id: "issue-cancelled", + identifier: "PAP-4", + title: "Cancelled follow-up", + status: "cancelled", + createdAt: new Date("2026-04-04T00:00:00.000Z"), + }); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + const progress = container.querySelector('[role="progressbar"]'); + expect(progress).not.toBeNull(); + expect(progress?.getAttribute("aria-valuenow")).toBe("1"); + expect(progress?.getAttribute("aria-valuemax")).toBe("3"); + expect(container.textContent).toContain("1/3 done"); + expect(container.textContent).toContain("0 in progress"); + expect(container.textContent).toContain("1 blocked"); + expect(container.textContent).not.toContain("Done 1"); + expect(container.textContent).toContain("Next up"); + const link = container.querySelector('a[href="/issues/PAP-2"]'); + expect(link?.textContent).toContain("Implement next slice"); + expect(container.querySelector('[title="Cancelled: 1"]')).toBeNull(); + }); + + act(() => { + root.unmount(); + }); + }); + + 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(container.textContent?.replace(/\s+/g, "")).toContain("1.PAP-1"); + expect(container.textContent?.replace(/\s+/g, "")).toContain("2.PAP-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 · step 2"); + }); + + act(() => { + root.unmount(); + }); + }); + + it("uses hierarchical checklist step numbers when nested rows render inline", async () => { + const firstRoot = createIssue({ + id: "issue-first-root", + identifier: "PAP-1", + title: "First root", + status: "done", + createdAt: new Date("2026-04-01T00:00:00.000Z"), + }); + const parent = createIssue({ + id: "issue-parent", + identifier: "PAP-2", + title: "Parent slice", + status: "todo", + createdAt: new Date("2026-04-02T00:00:00.000Z"), + }); + const nextRoot = createIssue({ + id: "issue-next-root", + identifier: "PAP-3", + title: "Next root", + status: "todo", + createdAt: new Date("2026-04-03T00:00:00.000Z"), + }); + const grandchild = createIssue({ + id: "issue-grandchild", + identifier: "PAP-4", + title: "Nested cancelled cleanup", + status: "cancelled", + parentId: "issue-parent", + createdAt: new Date("2026-04-04T00:00:00.000Z"), + }); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + const rows = Array.from(container.querySelectorAll('[data-testid="issue-row"]')); + expect(rows).toHaveLength(4); + expect(rows.map((row) => row.textContent)).toEqual([ + expect.stringContaining("First root"), + expect.stringContaining("Parent slice"), + expect.stringContaining("Nested cancelled cleanup"), + expect.stringContaining("Next root"), + ]); + expect(rows.map((row) => row.getAttribute("data-step"))).toEqual(["1", "2", "2.1", "3"]); + }); + + act(() => { + root.unmount(); + }); + }); + + it("hides the sub-issue progress summary unless it is enabled and populated", async () => { + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(container.querySelector('[role="progressbar"]')).toBeNull(); + }); + + act(() => { + root.unmount(); + }); + }); + + it("shows waiting on blockers when every remaining sub-issue is blocked", async () => { + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Waiting on blockers"); + const link = container.querySelector('a[href="/issues/PAP-2"]'); + expect(link?.textContent).toContain("Blocked follow-up"); + }); + + act(() => { + root.unmount(); + }); + }); + it("debounces search updates so typing does not notify the page on every keystroke", async () => { vi.useFakeTimers(); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 01d2479e..48a413a7 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -3,6 +3,7 @@ import { useQueries, useQuery } from "@tanstack/react-query"; import { accessApi } from "../api/access"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; +import { Link } from "@/lib/router"; import { executionWorkspacesApi } from "../api/execution-workspaces"; import { issuesApi } from "../api/issues"; import { authApi } from "../api/auth"; @@ -14,6 +15,12 @@ import { } from "../lib/keyboardShortcuts"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { buildCompanyUserLabelMap, buildCompanyUserProfileMap } from "../lib/company-members"; +import { createIssueDetailPath, withIssueDetailHeaderSeed } from "../lib/issueDetailBreadcrumb"; +import { + buildSubIssueProgressSummary, + shouldRenderSubIssueProgressSummary, + type SubIssueProgressSummary, +} from "../lib/issue-detail-subissues"; import { groupBy } from "../lib/groupBy"; import { applyIssueFilters, @@ -58,7 +65,8 @@ import { KanbanBoard } from "./KanbanBoard"; import { buildIssueTree, countDescendants } from "../lib/issue-tree"; import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults"; import { statusBadge } from "../lib/status-colors"; -import { ISSUE_STATUSES, type Issue, type Project } from "@paperclipai/shared"; +import { workflowSort } from "../lib/workflow-sort"; +import { ISSUE_STATUSES, type Issue, type IssueStatus, type Project } from "@paperclipai/shared"; const ISSUE_SEARCH_DEBOUNCE_MS = 250; const ISSUE_SEARCH_RESULT_LIMIT = 200; const ISSUE_BOARD_COLUMN_RESULT_LIMIT = 200; @@ -66,11 +74,31 @@ const INITIAL_ISSUE_ROW_RENDER_LIMIT = 100; const ISSUE_ROW_RENDER_BATCH_SIZE = 150; const ISSUE_ROW_RENDER_BATCH_DELAY_MS = 0; const boardIssueStatuses = ISSUE_STATUSES; +const issueStatusLabels: Record = { + backlog: "Backlog", + todo: "Todo", + in_progress: "In progress", + in_review: "In review", + done: "Done", + blocked: "Blocked", + cancelled: "Cancelled", +}; +const progressSegmentClasses: Record = { + backlog: "bg-muted-foreground/40", + todo: "bg-blue-500", + in_progress: "bg-yellow-500", + in_review: "bg-violet-500", + done: "bg-green-500", + blocked: "bg-red-500", + cancelled: "bg-neutral-400", +}; /* ── View state ── */ +export type IssueSortField = "status" | "priority" | "title" | "created" | "updated" | "workflow"; + export type IssueViewState = IssueFilterState & { - sortField: "status" | "priority" | "title" | "created" | "updated"; + sortField: IssueSortField; sortDir: "asc" | "desc"; groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none"; viewMode: "list" | "board"; @@ -105,11 +133,19 @@ function saveViewState(key: string, state: IssueViewState) { localStorage.setItem(key, JSON.stringify(state)); } -function getInitialViewState(key: string, initialAssignees?: string[]): IssueViewState { +function getInitialViewState( + key: string, + initialAssignees?: string[], + defaultSortField?: IssueSortField, +): IssueViewState { + const hasStored = hasStoredViewState(key); const stored = getViewState(key); - if (!initialAssignees) return stored; + const base = !hasStored && defaultSortField + ? { ...stored, sortField: defaultSortField, sortDir: "asc" as const } + : stored; + if (!initialAssignees) return base; return { - ...stored, + ...base, assignees: initialAssignees, statuses: [], }; @@ -119,8 +155,9 @@ function getInitialWorkspaceViewState( key: string, initialAssignees?: string[], initialWorkspaces?: string[], + defaultSortField?: IssueSortField, ): IssueViewState { - const stored = getInitialViewState(key, initialAssignees); + const stored = getInitialViewState(key, initialAssignees, defaultSortField); if (!initialWorkspaces) return stored; return { ...stored, @@ -129,6 +166,14 @@ function getInitialWorkspaceViewState( }; } +function hasStoredViewState(key: string): boolean { + try { + return localStorage.getItem(key) !== null; + } catch { + return false; + } +} + function getIssueColumnsStorageKey(key: string): string { return `${key}:issue-columns`; } @@ -157,6 +202,10 @@ function saveIssueColumns(key: string, columns: InboxIssueColumn[]) { } function sortIssues(issues: Issue[], state: IssueViewState): Issue[] { + if (state.sortField === "workflow") { + const ordered = workflowSort(issues); + return state.sortDir === "desc" ? [...ordered].reverse() : ordered; + } const sorted = [...issues]; const dir = state.sortDir === "asc" ? 1 : -1; sorted.sort((a, b) => { @@ -187,6 +236,39 @@ function issueMatchesLocalSearch(issue: Issue, normalizedSearch: string): boolea ].some((value) => value?.toLowerCase().includes(normalizedSearch)); } +function isActionableWorkflowStatus(status: IssueStatus): boolean { + return status !== "done" && status !== "cancelled" && status !== "blocked"; +} + +function buildChecklistStepNumberMap(issues: Issue[], nestingEnabled: boolean): Map { + const stepNumberByIssueId = new Map(); + + if (!nestingEnabled) { + issues.forEach((issue, index) => { + stepNumberByIssueId.set(issue.id, String(index + 1)); + }); + return stepNumberByIssueId; + } + + const { roots, childMap } = buildIssueTree(issues); + const visit = (siblings: Issue[], prefix: string | null) => { + siblings.forEach((issue, index) => { + const stepNumber = prefix ? `${prefix}.${index + 1}` : String(index + 1); + stepNumberByIssueId.set(issue.id, stepNumber); + visit(childMap.get(issue.id) ?? [], stepNumber); + }); + }; + visit(roots, null); + + issues.forEach((issue, index) => { + if (!stepNumberByIssueId.has(issue.id)) { + stepNumberByIssueId.set(issue.id, String(index + 1)); + } + }); + + return stepNumberByIssueId; +} + /* ── Component ── */ interface Agent { @@ -221,6 +303,8 @@ interface IssuesListProps { searchWithinLoadedIssues?: boolean; baseCreateIssueDefaults?: Record; createIssueLabel?: string; + defaultSortField?: IssueSortField; + showProgressSummary?: boolean; enableRoutineVisibilityFilter?: boolean; mutedIssueIds?: Set; issueBadgeById?: Map; @@ -290,6 +374,87 @@ function IssueSearchInput({ ); } +function SubIssueProgressSummaryStrip({ + summary, + issueLinkState, +}: { + summary: SubIssueProgressSummary; + issueLinkState?: unknown; +}) { + const target = summary.target; + const targetIssue = target?.issue ?? null; + const targetPathId = targetIssue?.identifier ?? targetIssue?.id ?? ""; + const targetState = targetIssue ? withIssueDetailHeaderSeed(issueLinkState, targetIssue) : undefined; + const statusEntries = ISSUE_STATUSES + .map((status) => ({ status, count: summary.countsByStatus[status] ?? 0 })) + .filter((entry) => entry.count > 0); + + return ( +
+
+
+
+ + {summary.doneCount}/{summary.totalCount} done + + + {summary.inProgressCount} in progress + + + {summary.blockedCount} blocked + +
+
+ {statusEntries.map(({ status, count }) => ( +
+
+ +
+ {target && targetIssue ? ( + <> +
+ {target.kind === "next" ? "Next up" : "Waiting on blockers"} +
+ + + {targetIssue.identifier ?? targetIssue.id.slice(0, 8)} + {" "} + {targetIssue.title} + + + ) : summary.totalCount === 0 ? ( +
No active sub-issues
+ ) : summary.doneCount === summary.totalCount ? ( +
All sub-issues done
+ ) : ( +
No actionable sub-issues
+ )} +
+
+
+ ); +} + export function IssuesList({ issues, isLoading, @@ -307,6 +472,8 @@ export function IssuesList({ searchWithinLoadedIssues = false, baseCreateIssueDefaults, createIssueLabel, + defaultSortField, + showProgressSummary = false, enableRoutineVisibilityFilter = false, mutedIssueIds, issueBadgeById, @@ -338,7 +505,7 @@ export function IssuesList({ const initialWorkspacesKey = initialWorkspaces?.join("|") ?? ""; const [viewState, setViewState] = useState(() => - getInitialWorkspaceViewState(scopedKey, initialAssignees, initialWorkspaces), + getInitialWorkspaceViewState(scopedKey, initialAssignees, initialWorkspaces, defaultSortField), ); const [assigneePickerIssueId, setAssigneePickerIssueId] = useState(null); const [assigneeSearch, setAssigneeSearch] = useState(""); @@ -358,9 +525,9 @@ export function IssuesList({ const nextContextKey = `${scopedKey}::${initialAssigneesKey}::${initialWorkspacesKey}`; if (prevViewStateContextKey.current !== nextContextKey) { prevViewStateContextKey.current = nextContextKey; - setViewState(getInitialWorkspaceViewState(scopedKey, initialAssignees, initialWorkspaces)); + setViewState(getInitialWorkspaceViewState(scopedKey, initialAssignees, initialWorkspaces, defaultSortField)); } - }, [scopedKey, initialAssignees, initialAssigneesKey, initialWorkspaces, initialWorkspacesKey]); + }, [scopedKey, initialAssignees, initialAssigneesKey, initialWorkspaces, initialWorkspacesKey, defaultSortField]); const prevColumnsScopedKey = useRef(scopedKey); useEffect(() => { @@ -672,6 +839,47 @@ export function IssuesList({ issueFilterWorkspaceContext, ]); + const progressSummary = useMemo( + () => shouldRenderSubIssueProgressSummary(showProgressSummary, issues.length) + ? buildSubIssueProgressSummary(issues) + : null, + [issues, showProgressSummary], + ); + const checklistAffordanceEnabled = useMemo( + () => + defaultSortField === "workflow" + && viewState.groupBy === "none", + [defaultSortField, viewState.groupBy], + ); + const workflowChecklistMeta = useMemo(() => { + if (!checklistAffordanceEnabled) return null; + + const visibleIssueIds = new Set(filtered.map((issue) => issue.id)); + const stepNumberByIssueId = buildChecklistStepNumberMap(filtered, viewState.nestingEnabled); + const unresolvedVisibleBlockersByIssueId = new Map(); + + filtered.forEach((issue) => { + 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, viewState.nestingEnabled]); + const { data: labels } = useQuery({ queryKey: queryKeys.issues.labels(selectedCompanyId!), queryFn: () => issuesApi.listLabels(selectedCompanyId!), @@ -829,6 +1037,10 @@ export function IssuesList({ return (
+ {progressSummary ? ( + + ) : null} + {/* Toolbar */}
@@ -911,6 +1123,7 @@ export function IssuesList({
{([ + ["workflow", "Workflow"], ["status", "Status"], ["priority", "Priority"], ["title", "Title"], @@ -1083,6 +1296,44 @@ 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); + const blockerStep = checklistMeta.stepNumberByIssueId.get(blockerId); + const blockerStepSuffix = blockerStep ? ` \u00b7 step ${blockerStep}` : ""; + const chipLabel = `blocked by ${label}${blockerStepSuffix}`; + return ( + + ); + })} + + ) : null; return (
{hasChildren && !isExpanded ? ( @@ -1155,6 +1411,7 @@ export function IssuesList({ isLive={liveIssueIds?.has(issue.id) === true} showStatus={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")} showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")} + checklistStepNumber={checklistStepNumber} statusSlot={( { e.preventDefault(); e.stopPropagation(); }}> onUpdateIssue(issue.id, { status: s })} /> diff --git a/ui/src/lib/issue-detail-subissues.test.ts b/ui/src/lib/issue-detail-subissues.test.ts index 0c0b97ca..586226ba 100644 --- a/ui/src/lib/issue-detail-subissues.test.ts +++ b/ui/src/lib/issue-detail-subissues.test.ts @@ -1,7 +1,28 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; -import { shouldRenderRichSubIssuesSection } from "./issue-detail-subissues"; +import type { Issue } from "@paperclipai/shared"; +import { + buildSubIssueProgressSummary, + shouldRenderRichSubIssuesSection, + shouldRenderSubIssueProgressSummary, +} from "./issue-detail-subissues"; + +function issue( + id: string, + status: Issue["status"], + createdAt: string, + blockedByIds: string[] = [], +): Issue { + return { + id, + identifier: `PAP-${id}`, + title: `Issue ${id}`, + status, + createdAt: new Date(createdAt), + blockedBy: blockedByIds.map((blockerId) => ({ id: blockerId })), + } as Issue; +} describe("shouldRenderRichSubIssuesSection", () => { it("shows the rich sub-issues section while child issues are loading", () => { @@ -16,3 +37,43 @@ describe("shouldRenderRichSubIssuesSection", () => { expect(shouldRenderRichSubIssuesSection(false, 0)).toBe(false); }); }); + +describe("shouldRenderSubIssueProgressSummary", () => { + it("requires both the opt-in flag and child issues", () => { + expect(shouldRenderSubIssueProgressSummary(true, 1)).toBe(true); + expect(shouldRenderSubIssueProgressSummary(false, 1)).toBe(false); + expect(shouldRenderSubIssueProgressSummary(true, 0)).toBe(false); + }); +}); + +describe("buildSubIssueProgressSummary", () => { + it("counts statuses and picks the first actionable issue in workflow order", () => { + const summary = buildSubIssueProgressSummary([ + issue("3", "todo", "2026-04-03T00:00:00.000Z", ["2"]), + issue("1", "done", "2026-04-01T00:00:00.000Z"), + issue("2", "in_progress", "2026-04-02T00:00:00.000Z", ["1"]), + issue("4", "blocked", "2026-04-04T00:00:00.000Z"), + issue("5", "cancelled", "2026-04-05T00:00:00.000Z"), + ]); + + expect(summary.totalCount).toBe(4); + expect(summary.doneCount).toBe(1); + expect(summary.inProgressCount).toBe(1); + expect(summary.blockedCount).toBe(1); + expect(summary.countsByStatus.todo).toBe(1); + expect(summary.countsByStatus.cancelled).toBeUndefined(); + expect(summary.target?.kind).toBe("next"); + expect(summary.target?.issue.id).toBe("2"); + }); + + it("waits on the first blocked issue when no remaining work is actionable", () => { + const summary = buildSubIssueProgressSummary([ + issue("1", "done", "2026-04-01T00:00:00.000Z"), + issue("2", "blocked", "2026-04-02T00:00:00.000Z"), + issue("3", "cancelled", "2026-04-03T00:00:00.000Z"), + ]); + + expect(summary.target?.kind).toBe("blocked"); + expect(summary.target?.issue.id).toBe("2"); + }); +}); diff --git a/ui/src/lib/issue-detail-subissues.ts b/ui/src/lib/issue-detail-subissues.ts index 22801b72..3bd3174a 100644 --- a/ui/src/lib/issue-detail-subissues.ts +++ b/ui/src/lib/issue-detail-subissues.ts @@ -1,3 +1,63 @@ +import type { Issue, IssueStatus } from "@paperclipai/shared"; +import { workflowSort } from "./workflow-sort"; + +export type SubIssueProgressTargetKind = "next" | "blocked"; + +export type SubIssueProgressTarget = { + issue: Issue; + kind: SubIssueProgressTargetKind; +}; + +export type SubIssueProgressSummary = { + totalCount: number; + doneCount: number; + inProgressCount: number; + blockedCount: number; + countsByStatus: Partial>; + target: SubIssueProgressTarget | null; +}; + export function shouldRenderRichSubIssuesSection(childIssuesLoading: boolean, childIssueCount: number): boolean { return childIssuesLoading || childIssueCount > 0; } + +export function shouldRenderSubIssueProgressSummary(enabled: boolean | undefined, childIssueCount: number): boolean { + return enabled === true && childIssueCount > 0; +} + +export function buildSubIssueProgressSummary(issues: Issue[]): SubIssueProgressSummary { + const countsByStatus: Partial> = {}; + const progressIssues = issues.filter((issue) => issue.status !== "cancelled"); + for (const issue of progressIssues) { + countsByStatus[issue.status] = (countsByStatus[issue.status] ?? 0) + 1; + } + + const orderedIssues = workflowSort(progressIssues); + const nextIssue = orderedIssues.find((issue) => isActionableStatus(issue.status)) ?? null; + const remainingIssues = orderedIssues.filter((issue) => !isTerminalStatus(issue.status)); + const blockedIssue = + nextIssue === null && remainingIssues.length > 0 && remainingIssues.every((issue) => issue.status === "blocked") + ? remainingIssues[0] + : null; + + return { + totalCount: progressIssues.length, + doneCount: countsByStatus.done ?? 0, + inProgressCount: countsByStatus.in_progress ?? 0, + blockedCount: countsByStatus.blocked ?? 0, + countsByStatus, + target: nextIssue + ? { issue: nextIssue, kind: "next" } + : blockedIssue + ? { issue: blockedIssue, kind: "blocked" } + : null, + }; +} + +function isActionableStatus(status: IssueStatus): boolean { + return status !== "done" && status !== "cancelled" && status !== "blocked"; +} + +function isTerminalStatus(status: IssueStatus): boolean { + return status === "done" || status === "cancelled"; +} diff --git a/ui/src/lib/workflow-sort.test.ts b/ui/src/lib/workflow-sort.test.ts new file mode 100644 index 00000000..e80e2c96 --- /dev/null +++ b/ui/src/lib/workflow-sort.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; +import { workflowSort, type WorkflowSortIssue } from "./workflow-sort"; + +type TestIssue = WorkflowSortIssue & { label?: string }; + +function issue( + id: string, + createdAt: string, + blockedByIds: string[] = [], + label?: string, +): TestIssue { + return { + id, + createdAt, + blockedBy: blockedByIds.map((blockerId) => ({ id: blockerId })), + label, + }; +} + +function orderedIds(issues: TestIssue[]): string[] { + return issues.map((entry) => entry.id); +} + +describe("workflowSort", () => { + it("returns a stable creation-order list when there are no blockers (roots only)", () => { + const out = workflowSort([ + issue("b", "2026-04-02T00:00:00.000Z"), + issue("a", "2026-04-01T00:00:00.000Z"), + issue("c", "2026-04-03T00:00:00.000Z"), + ]); + expect(orderedIds(out)).toEqual(["a", "b", "c"]); + }); + + it("keeps a short two-node chain contiguous right after its predecessor", () => { + const out = workflowSort([ + issue("z", "2026-04-05T00:00:00.000Z"), + issue("chain-end", "2026-04-03T00:00:00.000Z", ["chain-start"]), + issue("chain-start", "2026-04-02T00:00:00.000Z"), + ]); + expect(orderedIds(out)).toEqual(["chain-start", "chain-end", "z"]); + }); + + it("walks long linear chains all the way to the end (PAP-1953 shape)", () => { + // Chain shape taken from the plan on PAP-2189: + // roots standalone: 1954, 1955 + // short chain: 1960 -> 1961 + // long chain: 1962 -> 1963 -> 1964 -> 1965 -> 1966 + const created = (days: number) => + new Date(Date.UTC(2026, 3, days)).toISOString(); + const input: TestIssue[] = [ + issue("1964", created(7), ["1963"]), + issue("1966", created(9), ["1965"]), + issue("1955", created(2)), + issue("1960", created(3)), + issue("1961", created(4), ["1960"]), + issue("1963", created(6), ["1962"]), + issue("1954", created(1)), + issue("1965", created(8), ["1964"]), + issue("1962", created(5)), + ]; + const out = workflowSort(input); + expect(orderedIds(out)).toEqual([ + "1954", + "1955", + "1960", + "1961", + "1962", + "1963", + "1964", + "1965", + "1966", + ]); + }); + + it("stops chain walking at a branch and returns to the ready queue in tie-break order", () => { + // root -> child-a, root -> child-b. Root has two successors, so walk stops + // after root and we fall back to ready-queue ordering (createdAt asc). + const out = workflowSort([ + issue("later-standalone", "2026-04-10T00:00:00.000Z"), + issue("child-b", "2026-04-03T00:00:00.000Z", ["root"]), + issue("child-a", "2026-04-02T00:00:00.000Z", ["root"]), + issue("root", "2026-04-01T00:00:00.000Z"), + ]); + expect(orderedIds(out)).toEqual(["root", "child-a", "child-b", "later-standalone"]); + }); + + it("stops chain walking at a merge (successor has multiple predecessors)", () => { + // a and b both block c. After emitting a, c still has pending predecessor + // b, so the chain walk breaks. c emits once both predecessors are done. + const out = workflowSort([ + issue("c", "2026-04-03T00:00:00.000Z", ["a", "b"]), + issue("a", "2026-04-01T00:00:00.000Z"), + issue("b", "2026-04-02T00:00:00.000Z"), + ]); + expect(orderedIds(out)).toEqual(["a", "b", "c"]); + }); + + it("treats blockers outside the visible set as absent for ordering", () => { + // beta's blocker 'alpha' is not in the visible list, so beta is treated as + // a root and sorts purely by createdAt against the other root. + const out = workflowSort([ + issue("beta", "2026-04-01T00:00:00.000Z", ["alpha"]), + issue("gamma", "2026-04-02T00:00:00.000Z"), + ]); + expect(orderedIds(out)).toEqual(["beta", "gamma"]); + }); + + it("breaks ties by id when createdAt collides", () => { + const same = "2026-04-01T00:00:00.000Z"; + const out = workflowSort([ + issue("z", same), + issue("a", same), + issue("m", same), + ]); + expect(orderedIds(out)).toEqual(["a", "m", "z"]); + }); + + it("falls back to tie-break order when the input contains a cycle", () => { + // a blocks b, b blocks a. Neither has in-degree 0, so nothing would emit + // via the greedy walk — the guard must fall back to a deterministic order. + const out = workflowSort([ + issue("b", "2026-04-02T00:00:00.000Z", ["a"]), + issue("a", "2026-04-01T00:00:00.000Z", ["b"]), + ]); + expect(orderedIds(out)).toEqual(["a", "b"]); + }); + + it("guards against malformed self-loops without hanging", () => { + const out = workflowSort([ + issue("self", "2026-04-01T00:00:00.000Z", ["self"]), + issue("next", "2026-04-02T00:00:00.000Z"), + ]); + expect(orderedIds(out)).toEqual(["self", "next"]); + }); + + it("returns a new array without mutating the input", () => { + const input = [ + issue("b", "2026-04-02T00:00:00.000Z"), + issue("a", "2026-04-01T00:00:00.000Z"), + ]; + const snapshot = orderedIds(input); + const out = workflowSort(input); + expect(out).not.toBe(input); + expect(orderedIds(input)).toEqual(snapshot); + expect(orderedIds(out)).toEqual(["a", "b"]); + }); + + it("handles empty or single-item inputs", () => { + expect(workflowSort([])).toEqual([]); + const single = [issue("only", "2026-04-01T00:00:00.000Z")]; + expect(workflowSort(single)).toEqual(single); + }); +}); diff --git a/ui/src/lib/workflow-sort.ts b/ui/src/lib/workflow-sort.ts new file mode 100644 index 00000000..a90fae17 --- /dev/null +++ b/ui/src/lib/workflow-sort.ts @@ -0,0 +1,117 @@ +export type WorkflowSortBlocker = { id: string }; + +export type WorkflowSortIssue = { + id: string; + createdAt: Date | string; + blockedBy?: WorkflowSortBlocker[] | null; +}; + +// Orders siblings so that blocker chains stay contiguous (predecessor emitted +// immediately before its successor) when the graph is linear enough to allow +// it. Branches, merges, and cross-parent blockers stop the chain walk and send +// control back to the ready queue, where creation order (then id) breaks ties. +// +// Blockers whose id is absent from the input are treated as absent for +// ordering — the row chip can still surface them visually later. +// +// If the input contains a cycle (API rejects this, so it shouldn't happen in +// practice), the util degrades to a pure tie-break sort instead of hanging. +export function workflowSort(issues: T[]): T[] { + if (issues.length <= 1) return [...issues]; + + const tieBreakAsc = (a: T, b: T): number => { + const ta = toTimestamp(a.createdAt); + const tb = toTimestamp(b.createdAt); + if (ta !== tb) return ta - tb; + if (a.id < b.id) return -1; + if (a.id > b.id) return 1; + return 0; + }; + + const byId = new Map(); + for (const issue of issues) byId.set(issue.id, issue); + + const successors = new Map(); + const inDegree = new Map(); + for (const issue of issues) { + successors.set(issue.id, []); + inDegree.set(issue.id, 0); + } + for (const issue of issues) { + const seenBlockers = new Set(); + for (const blocker of issue.blockedBy ?? []) { + if (!blocker || !byId.has(blocker.id)) continue; + if (blocker.id === issue.id) continue; + if (seenBlockers.has(blocker.id)) continue; + seenBlockers.add(blocker.id); + successors.get(blocker.id)!.push(issue.id); + inDegree.set(issue.id, (inDegree.get(issue.id) ?? 0) + 1); + } + } + + for (const ids of successors.values()) { + ids.sort((a, b) => tieBreakAsc(byId.get(a)!, byId.get(b)!)); + } + + const ready: T[] = []; + for (const issue of issues) { + if (inDegree.get(issue.id) === 0) ready.push(issue); + } + ready.sort(tieBreakAsc); + + const emitted = new Set(); + const output: T[] = []; + + const insertReady = (issue: T): void => { + let lo = 0; + let hi = ready.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (tieBreakAsc(ready[mid], issue) <= 0) lo = mid + 1; + else hi = mid; + } + ready.splice(lo, 0, issue); + }; + + const releaseSuccessors = (id: string): void => { + for (const succId of successors.get(id) ?? []) { + if (emitted.has(succId)) continue; + const remaining = (inDegree.get(succId) ?? 0) - 1; + inDegree.set(succId, remaining); + if (remaining === 0) { + const succ = byId.get(succId); + if (succ) insertReady(succ); + } + } + }; + + while (ready.length > 0) { + let current = ready.shift()!; + while (current && !emitted.has(current.id)) { + output.push(current); + emitted.add(current.id); + releaseSuccessors(current.id); + + const succIds = successors.get(current.id) ?? []; + if (succIds.length !== 1) break; + const nextId = succIds[0]; + if (emitted.has(nextId)) break; + if ((inDegree.get(nextId) ?? 0) !== 0) break; + const nextIndex = ready.findIndex((issue) => issue.id === nextId); + if (nextIndex < 0) break; + [current] = ready.splice(nextIndex, 1); + } + } + + if (emitted.size < issues.length) { + return [...issues].sort(tieBreakAsc); + } + + return output; +} + +function toTimestamp(value: Date | string | null | undefined): number { + if (!value) return 0; + const ts = value instanceof Date ? value.getTime() : new Date(value).getTime(); + return Number.isFinite(ts) ? ts : 0; +} diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 797cfd96..cf3d158d 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -908,6 +908,7 @@ describe("IssueDetail", () => { await waitForAssertion(() => { expect(container.textContent).toContain("Subtree pause is active."); expect(mockIssuesListRender.mock.calls.at(-1)?.[0].issueBadgeById.get("child-1")).toBe("Paused"); + expect(mockIssuesListRender.mock.calls.at(-1)?.[0].showProgressSummary).toBe(true); }); const resumeButton = Array.from(container.querySelectorAll("button")) diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 319cb7b7..f5ae8616 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1151,7 +1151,7 @@ export function IssueDetail() { issue?.id && resolvedCompanyId ? queryKeys.issues.listByDescendantRoot(resolvedCompanyId, issue.id) : ["issues", "parent", "pending"], - queryFn: () => issuesApi.list(resolvedCompanyId!, { descendantOf: issue!.id }), + queryFn: () => issuesApi.list(resolvedCompanyId!, { descendantOf: issue!.id, includeBlockedBy: true }), enabled: !!resolvedCompanyId && !!issue?.id, placeholderData: keepPreviousDataForSameQueryTail(issue?.id ?? "pending"), }); @@ -3155,10 +3155,12 @@ export function IssueDetail() { projectId={issue.projectId ?? undefined} viewStateKey={`paperclip:issue-detail:${issue.id}:subissues-view`} issueLinkState={resolvedIssueDetailState ?? location.state} - searchFilters={{ descendantOf: issue.id }} + searchFilters={{ descendantOf: issue.id, includeBlockedBy: true }} searchWithinLoadedIssues baseCreateIssueDefaults={buildSubIssueDefaultsForViewer(issue, currentUserId)} createIssueLabel="Sub-issue" + defaultSortField="workflow" + showProgressSummary onUpdateIssue={handleChildIssueUpdate} />
diff --git a/ui/storybook/stories/sub-issues-workflow.stories.tsx b/ui/storybook/stories/sub-issues-workflow.stories.tsx new file mode 100644 index 00000000..87ac834d --- /dev/null +++ b/ui/storybook/stories/sub-issues-workflow.stories.tsx @@ -0,0 +1,281 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import type { Issue } from "@paperclipai/shared"; +import { useQueryClient } from "@tanstack/react-query"; +import { IssuesList } from "@/components/IssuesList"; +import { queryKeys } from "@/lib/queryKeys"; +import { + createIssue, + storybookAgents, + storybookAuthSession, + storybookCompanies, + storybookIssueLabels, + storybookProjects, +} from "../fixtures/paperclipData"; + +const companyId = "company-storybook"; +const parentId = "issue-pap-1953"; + +type BlockerRef = NonNullable[number]; + +function child(overrides: Partial): Issue { + return createIssue({ + parentId, + projectId: storybookProjects[0]!.id, + projectWorkspaceId: storybookProjects[0]!.workspaces[0]?.id ?? null, + goalId: null, + blockedBy: [], + blocks: [], + labelIds: [], + labels: [], + ...overrides, + }); +} + +const blockerRef = (issue: Issue): BlockerRef => ({ + id: issue.id, + identifier: issue.identifier, + title: issue.title, + status: issue.status, + priority: issue.priority, + assigneeAgentId: issue.assigneeAgentId, + assigneeUserId: issue.assigneeUserId, +}); + +const baseCreatedAt = new Date("2026-04-10T12:00:00.000Z").getTime(); +const createdAt = (offsetMinutes: number) => + new Date(baseCreatedAt + offsetMinutes * 60_000); + +// Mirrors the PAP-1953 topology called out in the PAP-2189 plan: +// 1954 Scoping (done) — root +// 1955 Security scoping (done) — root +// 1960 Phase 1 (done) → 1961 Phase 2 (done) +// 1962 Phase 3 (done) → 1963 Phase 4 (done) +// → 1964 Phase 5 (in_progress) +// → 1965 Phase 6 (blocked) +// → 1966 Phase 7 (blocked) + +const scoping = child({ + id: "issue-pap-1954", + identifier: "PAP-1954", + issueNumber: 1954, + title: "Scoping review", + status: "done", + priority: "medium", + completedAt: createdAt(120), + createdAt: createdAt(0), +}); + +const security = child({ + id: "issue-pap-1955", + identifier: "PAP-1955", + issueNumber: 1955, + title: "Security scoping", + status: "done", + priority: "medium", + completedAt: createdAt(180), + createdAt: createdAt(10), +}); + +const phase1 = child({ + id: "issue-pap-1960", + identifier: "PAP-1960", + issueNumber: 1960, + title: "Phase 1 — groundwork", + status: "done", + priority: "medium", + completedAt: createdAt(600), + createdAt: createdAt(20), +}); + +const phase2 = child({ + id: "issue-pap-1961", + identifier: "PAP-1961", + issueNumber: 1961, + title: "Phase 2 — integration", + status: "done", + priority: "medium", + completedAt: createdAt(720), + createdAt: createdAt(30), + blockedBy: [blockerRef(phase1)], +}); + +const phase3 = child({ + id: "issue-pap-1962", + identifier: "PAP-1962", + issueNumber: 1962, + title: "Phase 3 — data model", + status: "done", + priority: "medium", + completedAt: createdAt(800), + createdAt: createdAt(40), +}); + +const phase4 = child({ + id: "issue-pap-1963", + identifier: "PAP-1963", + issueNumber: 1963, + title: "Phase 4 — API surface", + status: "done", + priority: "medium", + completedAt: createdAt(900), + createdAt: createdAt(50), + blockedBy: [blockerRef(phase3)], +}); + +const phase5 = child({ + id: "issue-pap-1964", + identifier: "PAP-1964", + issueNumber: 1964, + title: "Phase 5 — UI polish", + status: "in_progress", + priority: "high", + createdAt: createdAt(60), + blockedBy: [blockerRef(phase4)], +}); + +const phase6 = child({ + id: "issue-pap-1965", + identifier: "PAP-1965", + issueNumber: 1965, + title: "Phase 6 — telemetry wiring", + status: "blocked", + priority: "medium", + createdAt: createdAt(70), + blockedBy: [blockerRef(phase5)], +}); + +const phase7 = child({ + id: "issue-pap-1966", + identifier: "PAP-1966", + issueNumber: 1966, + title: "Phase 7 — rollout", + status: "blocked", + priority: "medium", + createdAt: createdAt(80), + blockedBy: [blockerRef(phase6)], +}); + +const subIssues: Issue[] = [ + scoping, + security, + phase1, + phase2, + phase3, + phase4, + phase5, + phase6, + phase7, +]; + +const viewStateKey = "storybook:sub-issues-workflow:list"; +const scopedKey = `${viewStateKey}:${companyId}`; + +function hydrateQueries(client: ReturnType) { + client.setQueryData(queryKeys.companies.all, storybookCompanies); + client.setQueryData(queryKeys.auth.session, storybookAuthSession); + client.setQueryData(queryKeys.agents.list(companyId), storybookAgents); + client.setQueryData(queryKeys.projects.list(companyId), storybookProjects); + client.setQueryData(queryKeys.issues.labels(companyId), storybookIssueLabels); + client.setQueryData(queryKeys.issues.list(companyId), subIssues); + client.setQueryData(queryKeys.access.companyUserDirectory(companyId), { + users: [ + { + principalId: "user-board", + status: "active", + user: { + id: "user-board", + email: "riley@paperclip.local", + name: "Riley Board", + image: null, + }, + }, + ], + }); + client.setQueryData(queryKeys.instance.experimentalSettings, { + enableIsolatedWorkspaces: true, + enableRoutineTriggers: true, + }); +} + +function Hydrated({ children }: { children: React.ReactNode }) { + const queryClient = useQueryClient(); + const [ready] = useState(() => { + hydrateQueries(queryClient); + if (typeof window !== "undefined") { + window.localStorage.removeItem(scopedKey); + window.localStorage.removeItem(`${scopedKey}:issue-columns`); + } + return true; + }); + return ready ? children : null; +} + +function SubIssuesWorkflowPanel() { + return ( +
+
+
+
+
Issue Detail · Sub-issues
+

+ Workflow-sorted sub-issues with checklist affordances +

+

+ Fixture mirrors the PAP-1953 topology called out in the PAP-2189 + plan: two standalone scoping items, a Phase 1→2 pair, and a long + Phase 3→4→5→6→7 chain. The panel renders with + + defaultSortField="workflow" + + and + + showProgressSummary + + so reviewers see the full checklist surface in isolation. +

+
+
+ undefined} + createIssueLabel="Sub-issue" + /> +
+
+
+
+ ); +} + +const meta = { + title: "UX Labs/Sub-issues Workflow Checklist", + component: SubIssuesWorkflowPanel, + parameters: { + layout: "fullscreen", + docs: { + description: { + component: + "Review surface for the PAP-2189 checklist-style sub-issues work. Renders the IssuesList component with the Sub-issues panel props so the progress strip, workflow sort, step gutter, current marker, done de-emphasis, and blocker chips are all visible against a PAP-1953-like topology.", + }, + }, + }, + decorators: [ + (StoryRender) => ( + + + + ), + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {};