mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Add workflow sub-issue progress summary
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
40782f703d
commit
04c9d703b7
8 files changed
+694
-11
No files matched your search
@@ -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();
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -3159,6 +3159,8 @@ export function IssueDetail() {
|
||||
searchWithinLoadedIssues
|
||||
baseCreateIssueDefaults={buildSubIssueDefaultsForViewer(issue, currentUserId)}
|
||||
createIssueLabel="Sub-issue"
|
||||
defaultSortField="workflow"
|
||||
showProgressSummary
|
||||
onUpdateIssue={handleChildIssueUpdate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user