Add workflow sub-issue progress summary

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-25 21:48:31 -05:00
1 parent 40782f703d
commit 04c9d703b7
8 files changed
+694 -11

No files matched your search

+139 -1
View File
@@ -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<HTMLAnchorElement> & {
to: string;
state?: unknown;
issuePrefetch?: unknown;
}) => (
<a href={to} {...props}>{children}</a>
),
}));
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(
<IssuesList
issues={[blockedIssue, nextIssue, doneIssue]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
showProgressSummary
onUpdateIssue={() => 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(
<IssuesList
issues={[createIssue()]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
onUpdateIssue={() => 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(
<IssuesList
issues={[
createIssue({
id: "issue-done",
identifier: "PAP-1",
title: "Completed setup",
status: "done",
createdAt: new Date("2026-04-01T00:00:00.000Z"),
}),
createIssue({
id: "issue-blocked",
identifier: "PAP-2",
title: "Blocked follow-up",
status: "blocked",
createdAt: new Date("2026-04-02T00:00:00.000Z"),
}),
]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
showProgressSummary
onUpdateIssue={() => 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();
+163 -9
View File
@@ -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<IssueStatus, string> = {
backlog: "Backlog",
todo: "Todo",
in_progress: "In progress",
in_review: "In review",
done: "Done",
blocked: "Blocked",
cancelled: "Cancelled",
};
const progressSegmentClasses: Record<IssueStatus, string> = {
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<string, unknown>;
createIssueLabel?: string;
defaultSortField?: IssueSortField;
showProgressSummary?: boolean;
enableRoutineVisibilityFilter?: boolean;
mutedIssueIds?: Set<string>;
issueBadgeById?: Map<string, string>;
@@ -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 (
<div className="rounded-md border border-border bg-muted/20 p-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<span className="font-medium text-foreground">
{summary.doneCount}/{summary.totalCount} done
</span>
<span className="text-muted-foreground">
{summary.inProgressCount} in progress
</span>
<span className="text-muted-foreground">
{summary.blockedCount} blocked
</span>
</div>
<div
role="progressbar"
aria-label="Sub-issues completion progress"
aria-valuemin={0}
aria-valuenow={summary.doneCount}
aria-valuemax={summary.totalCount}
className="flex h-2 w-full overflow-hidden rounded-full bg-muted"
>
{statusEntries.map(({ status, count }) => (
<span
key={status}
className={cn("h-full", progressSegmentClasses[status])}
style={{ width: `${(count / summary.totalCount) * 100}%` }}
title={`${issueStatusLabels[status]}: ${count}`}
aria-hidden="true"
/>
))}
</div>
<div className="flex flex-wrap gap-1.5">
{statusEntries.map(({ status, count }) => (
<span
key={status}
className={cn("inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium", statusBadge[status])}
>
{issueStatusLabels[status]} {count}
</span>
))}
</div>
</div>
<div className="min-w-0 rounded-md border border-border bg-background px-3 py-2 text-sm lg:w-72">
{target && targetIssue ? (
<>
<div className="text-xs font-medium text-muted-foreground">
{target.kind === "next" ? "Next up" : "Waiting on blockers"}
</div>
<Link
to={createIssueDetailPath(targetPathId)}
state={targetState}
issuePrefetch={targetIssue}
className="mt-1 block min-w-0 text-foreground underline-offset-2 hover:underline"
>
<span className="font-mono text-xs text-muted-foreground">
{targetIssue.identifier ?? targetIssue.id.slice(0, 8)}
</span>{" "}
<span>{targetIssue.title}</span>
</Link>
</>
) : summary.doneCount === summary.totalCount ? (
<div className="text-sm font-medium text-foreground">All sub-issues done</div>
) : (
<div className="text-sm font-medium text-foreground">No actionable sub-issues</div>
)}
</div>
</div>
</div>
);
}
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<IssueViewState>(() =>
getInitialWorkspaceViewState(scopedKey, initialAssignees, initialWorkspaces),
getInitialWorkspaceViewState(scopedKey, initialAssignees, initialWorkspaces, defaultSortField),
);
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(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 (
<div className="space-y-4">
{progressSummary ? (
<SubIssueProgressSummaryStrip summary={progressSummary} issueLinkState={issueLinkState} />
) : null}
{/* Toolbar */}
<div className="flex items-center justify-between gap-2 sm:gap-3">
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
@@ -911,6 +1064,7 @@ export function IssuesList({
<PopoverContent align="end" className="w-48 p-0">
<div className="p-2 space-y-0.5">
{([
["workflow", "Workflow"],
["status", "Status"],
["priority", "Priority"],
["title", "Title"],
+60 -1
View File
@@ -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");
});
});
+59
View File
@@ -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<Record<IssueStatus, number>>;
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<Record<IssueStatus, number>> = {};
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";
}
+153
View File
@@ -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);
});
});
+117
View File
@@ -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<T extends WorkflowSortIssue>(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<string, T>();
for (const issue of issues) byId.set(issue.id, issue);
const successors = new Map<string, string[]>();
const inDegree = new Map<string, number>();
for (const issue of issues) {
successors.set(issue.id, []);
inDegree.set(issue.id, 0);
}
for (const issue of issues) {
const seenBlockers = new Set<string>();
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<string>();
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;
}
+1
View File
@@ -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"))
+2
View File
@@ -3159,6 +3159,8 @@ export function IssueDetail() {
searchWithinLoadedIssues
baseCreateIssueDefaults={buildSubIssueDefaultsForViewer(issue, currentUserId)}
createIssueLabel="Sub-issue"
defaultSortField="workflow"
showProgressSummary
onUpdateIssue={handleChildIssueUpdate}
/>
</div>