Present ordered sub-issues as a workflow checklist (#4523)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Operators use issue detail pages and child issue lists to understand
multi-step execution plans.
> - Ordered sub-issues currently read like a flat table, so dependency
chains and current next steps are harder to scan.
> - The branch work adds a workflow-oriented presentation for child
issues without changing the single-assignee task model.
> - This pull request makes ordered sub-issues read more like a progress
checklist while preserving normal issue list controls.
> - The benefit is that operators can see completed steps, active work,
blocked follow-ups, and dependency order at a glance.

## What Changed

- Added workflow sorting utilities and tests for dependency-aware child
issue ordering.
- Added sub-issue progress summary, checklist numbering, current-step
affordances, blocker context, and done-state de-emphasis in the issue
list UI.
- Wired issue detail sub-issue panels to use the workflow sort/progress
checklist presentation.
- Updated issue service behavior/tests for child issue ordering inputs
used by the UI.
- Added a Storybook visual review fixture and screenshot helper for the
sub-issue workflow checklist surface.

## Verification

- `pnpm run preflight:workspace-links && pnpm exec vitest run
server/src/__tests__/issues-service.test.ts
ui/src/components/IssueRow.test.tsx
ui/src/components/IssuesList.test.tsx ui/src/pages/IssueDetail.test.tsx
ui/src/lib/issue-detail-subissues.test.ts
ui/src/lib/workflow-sort.test.ts`
- Result: 6 test files passed, 55 tests passed, 34 embedded Postgres
issue-service tests skipped because `@embedded-postgres/darwin-x64` is
unavailable on this host.
- Visual review: generated Storybook screenshots from the existing local
Storybook server on port 6006 with `node
scripts/screenshot-subissues.mjs /tmp/pap-2189-subissues-screens
http://localhost:6006`.
- Screenshot artifacts:
- Desktop dark: ![Desktop
dark](doc/assets/pap-2189/desktop-1440x900-dark.png)
- Desktop light: ![Desktop
light](doc/assets/pap-2189/desktop-1440x900-light.png)
- Mobile dark: ![Mobile
dark](doc/assets/pap-2189/mobile-390x844-dark.png)
- Mobile light: ![Mobile
light](doc/assets/pap-2189/mobile-390x844-light.png)
- Local Storybook note: starting a second Storybook process selected
port 6008 because 6006 was occupied, then Vite failed with an esbuild
host/binary version mismatch (`0.25.12` host vs `0.27.3` binary). The
already-running Storybook server on 6006 served the fixture successfully
for screenshots.

## Risks

- Medium UI risk: the issue list now has additional sub-issue-specific
visual states, so dense lists should be checked for spacing and
scanability.
- Low ordering risk: workflow sorting is covered by focused unit tests,
but unusual dependency topologies may still need reviewer attention.
- No migration risk: this PR does not add database migrations or touch
`pnpm-lock.yaml`.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool-enabled shell/git/GitHub
workflow. Context window is runtime-provided and not exposed in this
environment.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-04-26 07:36:49 -05:00 committed by GitHub
parent 40782f703d
commit df425fde96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1449 additions and 18 deletions

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,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");
});
});

View file

@ -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<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>> = {};
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";
}

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
ui/src/lib/workflow-sort.ts Normal file
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;
}