From 04c9d703b7cc0b7ee414178b17287c911ce28b3c Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 24 Apr 2026 11:21:42 -0500 Subject: [PATCH] Add workflow sub-issue progress summary Co-Authored-By: Paperclip --- ui/src/components/IssuesList.test.tsx | 140 +++++++++++++++++- ui/src/components/IssuesList.tsx | 172 ++++++++++++++++++++-- ui/src/lib/issue-detail-subissues.test.ts | 61 +++++++- ui/src/lib/issue-detail-subissues.ts | 59 ++++++++ ui/src/lib/workflow-sort.test.ts | 153 +++++++++++++++++++ ui/src/lib/workflow-sort.ts | 117 +++++++++++++++ ui/src/pages/IssueDetail.test.tsx | 1 + ui/src/pages/IssueDetail.tsx | 2 + 8 files changed, 694 insertions(+), 11 deletions(-) create mode 100644 ui/src/lib/workflow-sort.test.ts create mode 100644 ui/src/lib/workflow-sort.ts diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index ec625f91ef5..f72915fa012 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, })); @@ -350,6 +366,128 @@ 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 { 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).toContain("Next up"); + const link = container.querySelector('a[href="/issues/PAP-2"]'); + expect(link?.textContent).toContain("Implement next slice"); + }); + + 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 01d2479ea49..a6e1afcf28c 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) => { @@ -221,6 +270,8 @@ interface IssuesListProps { searchWithinLoadedIssues?: boolean; baseCreateIssueDefaults?: Record; createIssueLabel?: string; + defaultSortField?: IssueSortField; + showProgressSummary?: boolean; enableRoutineVisibilityFilter?: boolean; mutedIssueIds?: Set; issueBadgeById?: Map; @@ -290,6 +341,95 @@ 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 }) => ( +
+
+ {statusEntries.map(({ status, count }) => ( + + {issueStatusLabels[status]} {count} + + ))} +
+
+ +
+ {target && targetIssue ? ( + <> +
+ {target.kind === "next" ? "Next up" : "Waiting on blockers"} +
+ + + {targetIssue.identifier ?? targetIssue.id.slice(0, 8)} + {" "} + {targetIssue.title} + + + ) : summary.doneCount === summary.totalCount ? ( +
All sub-issues done
+ ) : ( +
No actionable sub-issues
+ )} +
+
+
+ ); +} + export function IssuesList({ issues, isLoading, @@ -307,6 +447,8 @@ export function IssuesList({ searchWithinLoadedIssues = false, baseCreateIssueDefaults, createIssueLabel, + defaultSortField, + showProgressSummary = false, enableRoutineVisibilityFilter = false, mutedIssueIds, issueBadgeById, @@ -338,7 +480,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 +500,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 +814,13 @@ export function IssuesList({ issueFilterWorkspaceContext, ]); + const progressSummary = useMemo( + () => shouldRenderSubIssueProgressSummary(showProgressSummary, issues.length) + ? buildSubIssueProgressSummary(issues) + : null, + [issues, showProgressSummary], + ); + const { data: labels } = useQuery({ queryKey: queryKeys.issues.labels(selectedCompanyId!), queryFn: () => issuesApi.listLabels(selectedCompanyId!), @@ -829,6 +978,10 @@ export function IssuesList({ return (
+ {progressSummary ? ( + + ) : null} + {/* Toolbar */}
@@ -911,6 +1064,7 @@ export function IssuesList({
{([ + ["workflow", "Workflow"], ["status", "Status"], ["priority", "Priority"], ["title", "Title"], diff --git a/ui/src/lib/issue-detail-subissues.test.ts b/ui/src/lib/issue-detail-subissues.test.ts index 0c0b97cad69..6438948299b 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,41 @@ 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"), + ]); + + 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.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 22801b7256f..4c3d610c6f3 100644 --- a/ui/src/lib/issue-detail-subissues.ts +++ b/ui/src/lib/issue-detail-subissues.ts @@ -1,3 +1,62 @@ +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> = {}; + for (const issue of issues) { + countsByStatus[issue.status] = (countsByStatus[issue.status] ?? 0) + 1; + } + + const orderedIssues = workflowSort(issues); + 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: issues.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 00000000000..e80e2c96bbc --- /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 00000000000..a90fae17b85 --- /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 797cfd9624f..cf3d158db0e 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 319cb7b75d6..6b33e056b6b 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -3159,6 +3159,8 @@ export function IssueDetail() { searchWithinLoadedIssues baseCreateIssueDefaults={buildSubIssueDefaultsForViewer(issue, currentUserId)} createIssueLabel="Sub-issue" + defaultSortField="workflow" + showProgressSummary onUpdateIssue={handleChildIssueUpdate} />