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
22 changed files with 1449 additions and 18 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

+35
View File
@@ -0,0 +1,35 @@
import { chromium } from "@playwright/test";
import { mkdir } from "node:fs/promises";
import { argv } from "node:process";
const outDir = argv[2] ?? "/tmp/paperclip/pap-2189-subissues-screens";
const baseUrl = argv[3] ?? "http://localhost:6006";
await mkdir(outDir, { recursive: true });
const id = "ux-labs-sub-issues-workflow-checklist--default";
const runs = [
{ name: "desktop-1440x900-dark", w: 1440, h: 900, theme: "dark" },
{ name: "desktop-1440x900-light", w: 1440, h: 900, theme: "light" },
{ name: "mobile-390x844-dark", w: 390, h: 844, theme: "dark" },
{ name: "mobile-390x844-light", w: 390, h: 844, theme: "light" },
];
const browser = await chromium.launch();
try {
for (const run of runs) {
const url = `${baseUrl}/iframe.html?id=${id}&viewMode=story&globals=theme:${run.theme}`;
const context = await browser.newContext({
viewport: { width: run.w, height: run.h },
deviceScaleFactor: 2,
});
const page = await context.newPage();
await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 });
await page.waitForTimeout(1200);
const file = `${outDir}/${run.name}.png`;
await page.screenshot({ path: file, fullPage: true });
console.log("wrote", file);
await context.close();
}
} finally {
await browser.close();
}
@@ -895,6 +895,69 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
); );
}); });
it("includes blockedBy summaries on list rows in one batched pass", async () => {
const companyId = randomUUID();
const blockerId = randomUUID();
const blockedId = randomUUID();
const unblockedId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values([
{
id: blockerId,
companyId,
title: "Blocker issue",
status: "todo",
priority: "high",
},
{
id: blockedId,
companyId,
title: "Blocked issue",
status: "blocked",
priority: "medium",
},
{
id: unblockedId,
companyId,
title: "Unblocked issue",
status: "todo",
priority: "medium",
},
]);
await db.insert(issueRelations).values({
companyId,
issueId: blockerId,
relatedIssueId: blockedId,
type: "blocks",
});
const defaultResult = await svc.list(companyId);
expect(defaultResult.find((issue) => issue.id === blockedId)?.blockedBy).toBeUndefined();
const result = await svc.list(companyId, { includeBlockedBy: true });
const byId = new Map(result.map((issue) => [issue.id, issue]));
expect(byId.get(blockedId)?.blockedBy).toEqual([
expect.objectContaining({
id: blockerId,
identifier: null,
title: "Blocker issue",
status: "todo",
priority: "high",
}),
]);
expect(byId.get(blockerId)?.blockedBy).toEqual([]);
expect(byId.get(unblockedId)?.blockedBy).toEqual([]);
});
it("trims list payload fields that can grow large on issue index routes", async () => { it("trims list payload fields that can grow large on issue index routes", async () => {
const companyId = randomUUID(); const companyId = randomUUID();
const issueId = randomUUID(); const issueId = randomUUID();
+1
View File
@@ -949,6 +949,7 @@ export function issueRoutes(
req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1", req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1",
excludeRoutineExecutions: excludeRoutineExecutions:
req.query.excludeRoutineExecutions === "true" || req.query.excludeRoutineExecutions === "1", req.query.excludeRoutineExecutions === "true" || req.query.excludeRoutineExecutions === "1",
includeBlockedBy: req.query.includeBlockedBy === "true" || req.query.includeBlockedBy === "1",
q: req.query.q as string | undefined, q: req.query.q as string | undefined,
limit, limit,
}); });
+65 -1
View File
@@ -101,6 +101,7 @@ export interface IssueFilters {
originId?: string; originId?: string;
includeRoutineExecutions?: boolean; includeRoutineExecutions?: boolean;
excludeRoutineExecutions?: boolean; excludeRoutineExecutions?: boolean;
includeBlockedBy?: boolean;
q?: string; q?: string;
limit?: number; limit?: number;
} }
@@ -1296,6 +1297,63 @@ async function lastActivityStatsForIssues(
return [...byIssueId.values()]; return [...byIssueId.values()];
} }
async function blockedByMapForIssues(
dbOrTx: any,
companyId: string,
issueIds: string[],
): Promise<Map<string, IssueRelationIssueSummary[]>> {
const map = new Map<string, IssueRelationIssueSummary[]>();
const uniqueIssueIds = [...new Set(issueIds)];
if (uniqueIssueIds.length === 0) return map;
for (const issueId of uniqueIssueIds) {
map.set(issueId, []);
}
for (const issueIdChunk of chunkList(uniqueIssueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
const rows = await dbOrTx
.select({
currentIssueId: issueRelations.relatedIssueId,
relatedId: issues.id,
identifier: issues.identifier,
title: issues.title,
status: issues.status,
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
})
.from(issueRelations)
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
.where(
and(
eq(issueRelations.companyId, companyId),
eq(issueRelations.type, "blocks"),
inArray(issueRelations.relatedIssueId, issueIdChunk),
),
);
for (const row of rows) {
const blockedBy = map.get(row.currentIssueId);
if (!blockedBy) continue;
blockedBy.push({
id: row.relatedId,
identifier: row.identifier,
title: row.title,
status: row.status as IssueRelationIssueSummary["status"],
priority: row.priority as IssueRelationIssueSummary["priority"],
assigneeAgentId: row.assigneeAgentId,
assigneeUserId: row.assigneeUserId,
});
}
}
for (const blockedBy of map.values()) {
blockedBy.sort((a, b) => a.title.localeCompare(b.title));
}
return map;
}
export function issueService(db: Db) { export function issueService(db: Db) {
const instanceSettings = instanceSettingsService(db); const instanceSettings = instanceSettingsService(db);
const treeControlSvc = issueTreeControlService(db); const treeControlSvc = issueTreeControlService(db);
@@ -1784,6 +1842,7 @@ export function issueService(db: Db) {
const inboxArchivedByUserId = filters?.inboxArchivedByUserId?.trim() || undefined; const inboxArchivedByUserId = filters?.inboxArchivedByUserId?.trim() || undefined;
const unreadForUserId = filters?.unreadForUserId?.trim() || undefined; const unreadForUserId = filters?.unreadForUserId?.trim() || undefined;
const contextUserId = unreadForUserId ?? touchedByUserId ?? inboxArchivedByUserId; const contextUserId = unreadForUserId ?? touchedByUserId ?? inboxArchivedByUserId;
const includeBlockedBy = filters?.includeBlockedBy === true;
const rawSearch = filters?.q?.trim() ?? ""; const rawSearch = filters?.q?.trim() ?? "";
const hasSearch = rawSearch.length > 0; const hasSearch = rawSearch.length > 0;
const escapedSearch = hasSearch ? escapeLikePattern(rawSearch) : ""; const escapedSearch = hasSearch ? escapeLikePattern(rawSearch) : "";
@@ -1914,7 +1973,7 @@ export function issueService(db: Db) {
} }
const issueIds = withRuns.map((row) => row.id); const issueIds = withRuns.map((row) => row.id);
const [statsRows, readRows, lastActivityRows] = await Promise.all([ const [statsRows, readRows, lastActivityRows, blockedByMap] = await Promise.all([
contextUserId contextUserId
? userCommentStatsForIssues(db, companyId, contextUserId, issueIds) ? userCommentStatsForIssues(db, companyId, contextUserId, issueIds)
: Promise.resolve([]), : Promise.resolve([]),
@@ -1922,6 +1981,9 @@ export function issueService(db: Db) {
? userReadStatsForIssues(db, companyId, contextUserId, issueIds) ? userReadStatsForIssues(db, companyId, contextUserId, issueIds)
: Promise.resolve([]), : Promise.resolve([]),
lastActivityStatsForIssues(db, companyId, issueIds), lastActivityStatsForIssues(db, companyId, issueIds),
includeBlockedBy
? blockedByMapForIssues(db, companyId, issueIds)
: Promise.resolve(new Map<string, IssueRelationIssueSummary[]>()),
]); ]);
const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row])); const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row]));
const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row])); const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row]));
@@ -1937,6 +1999,7 @@ export function issueService(db: Db) {
) ?? row.updatedAt; ) ?? row.updatedAt;
return { return {
...row, ...row,
...(includeBlockedBy ? { blockedBy: blockedByMap.get(row.id) ?? [] } : {}),
lastActivityAt, lastActivityAt,
...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}), ...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}),
}; };
@@ -1954,6 +2017,7 @@ export function issueService(db: Db) {
) ?? row.updatedAt; ) ?? row.updatedAt;
return { return {
...row, ...row,
...(includeBlockedBy ? { blockedBy: blockedByMap.get(row.id) ?? [] } : {}),
lastActivityAt, lastActivityAt,
...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}), ...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}),
...deriveIssueUserContext(row, contextUserId, { ...deriveIssueUserContext(row, contextUserId, {
+2 -2
View File
@@ -25,10 +25,10 @@ describe("issuesApi.list", () => {
}); });
it("passes descendantOf through to the company issues endpoint", async () => { it("passes descendantOf through to the company issues endpoint", async () => {
await issuesApi.list("company-1", { descendantOf: "issue-root-1", limit: 25 }); await issuesApi.list("company-1", { descendantOf: "issue-root-1", includeBlockedBy: true, limit: 25 });
expect(mockApi.get).toHaveBeenCalledWith( expect(mockApi.get).toHaveBeenCalledWith(
"/companies/company-1/issues?descendantOf=issue-root-1&limit=25", "/companies/company-1/issues?descendantOf=issue-root-1&includeBlockedBy=true&limit=25",
); );
}); });
+2
View File
@@ -45,6 +45,7 @@ export const issuesApi = {
originId?: string; originId?: string;
descendantOf?: string; descendantOf?: string;
includeRoutineExecutions?: boolean; includeRoutineExecutions?: boolean;
includeBlockedBy?: boolean;
q?: string; q?: string;
limit?: number; limit?: number;
}, },
@@ -66,6 +67,7 @@ export const issuesApi = {
if (filters?.originId) params.set("originId", filters.originId); if (filters?.originId) params.set("originId", filters.originId);
if (filters?.descendantOf) params.set("descendantOf", filters.descendantOf); if (filters?.descendantOf) params.set("descendantOf", filters.descendantOf);
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true"); if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true");
if (filters?.q) params.set("q", filters.q); if (filters?.q) params.set("q", filters.q);
if (filters?.limit) params.set("limit", String(filters.limit)); if (filters?.limit) params.set("limit", String(filters.limit));
const qs = params.toString(); const qs = params.toString();
+7
View File
@@ -139,12 +139,14 @@ export function InboxIssueMetaLeading({
showStatus = true, showStatus = true,
showIdentifier = true, showIdentifier = true,
statusSlot, statusSlot,
checklistStepNumber = null,
}: { }: {
issue: Issue; issue: Issue;
isLive: boolean; isLive: boolean;
showStatus?: boolean; showStatus?: boolean;
showIdentifier?: boolean; showIdentifier?: boolean;
statusSlot?: ReactNode; statusSlot?: ReactNode;
checklistStepNumber?: number | string | null;
}) { }) {
return ( return (
<> <>
@@ -153,6 +155,11 @@ export function InboxIssueMetaLeading({
{statusSlot ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} />} {statusSlot ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} />}
</span> </span>
) : null} ) : null}
{checklistStepNumber !== null ? (
<span className="shrink-0 font-mono text-xs text-muted-foreground" aria-hidden="true">
{checklistStepNumber}.
</span>
) : null}
{showIdentifier ? ( {showIdentifier ? (
<span className="shrink-0 font-mono text-xs text-muted-foreground"> <span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)} {issue.identifier ?? issue.id.slice(0, 8)}
+25
View File
@@ -202,6 +202,31 @@ describe("IssueRow", () => {
}); });
}); });
it("renders checklist step numbers beside the issue identifier", () => {
const root = createRoot(container);
act(() => {
root.render(
<IssueRow
issue={createIssue({ identifier: "PAP-42" })}
checklistStepNumber="2.1"
mobileMeta="updated now"
/>,
);
});
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
const metaRow = Array.from(link?.querySelectorAll("span.flex.items-center.gap-2") ?? [])
.find((element) => element.textContent?.includes("PAP-42"));
expect(metaRow).not.toBeUndefined();
expect(metaRow?.textContent?.replace(/\s+/g, "")).toContain("2.1.PAP-42");
act(() => {
root.unmount();
});
});
it("renders without error when titleSuffix is omitted", () => { it("renders without error when titleSuffix is omitted", () => {
const root = createRoot(container); const root = createRoot(container);
+26 -1
View File
@@ -23,6 +23,11 @@ interface IssueRowProps {
desktopTrailing?: ReactNode; desktopTrailing?: ReactNode;
trailingMeta?: ReactNode; trailingMeta?: ReactNode;
titleSuffix?: ReactNode; titleSuffix?: ReactNode;
titleClassName?: string;
checklistStepNumber?: number | string | null;
checklistCurrentStep?: boolean;
checklistDependencyChips?: ReactNode;
checklistRowId?: string;
unreadState?: UnreadState | null; unreadState?: UnreadState | null;
onMarkRead?: () => void; onMarkRead?: () => void;
onArchive?: () => void; onArchive?: () => void;
@@ -41,6 +46,11 @@ export function IssueRow({
desktopTrailing, desktopTrailing,
trailingMeta, trailingMeta,
titleSuffix, titleSuffix,
titleClassName,
checklistStepNumber = null,
checklistCurrentStep = false,
checklistDependencyChips,
checklistRowId,
unreadState = null, unreadState = null,
onMarkRead, onMarkRead,
onArchive, onArchive,
@@ -53,6 +63,12 @@ export function IssueRow({
const showUnreadDot = unreadState === "visible" || unreadState === "fading"; const showUnreadDot = unreadState === "visible" || unreadState === "fading";
const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined; const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined;
const detailState = withIssueDetailHeaderSeed(issueLinkState, issue); const detailState = withIssueDetailHeaderSeed(issueLinkState, issue);
const hasChecklistStep = checklistStepNumber !== null;
const checklistStep = hasChecklistStep ? (
<span className="shrink-0 font-mono text-xs text-muted-foreground" aria-hidden="true">
{checklistStepNumber}.
</span>
) : null;
return ( return (
<Link <Link
@@ -61,10 +77,13 @@ export function IssueRow({
disableIssueQuicklook disableIssueQuicklook
issuePrefetch={issue} issuePrefetch={issue}
data-inbox-issue-link data-inbox-issue-link
id={checklistRowId}
aria-current={checklistCurrentStep ? "step" : undefined}
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)} onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)}
className={cn( className={cn(
"group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors last:border-b-0 sm:items-center sm:py-2 sm:pl-1", "group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
selected ? "hover:bg-transparent" : "hover:bg-accent/50", selected ? "hover:bg-transparent" : "hover:bg-accent/50",
checklistCurrentStep ? "border-l-2 border-l-primary bg-primary/5 pl-[calc(theme(spacing.2)-2px)] sm:pl-[calc(theme(spacing.1)-2px)]" : null,
className, className,
)} )}
> >
@@ -72,9 +91,14 @@ export function IssueRow({
{mobileLeading ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />} {mobileLeading ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />}
</span> </span>
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents"> <span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
<span className="line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none"> <span className={cn("line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none", titleClassName)}>
{issue.title}{titleSuffix} {issue.title}{titleSuffix}
</span> </span>
{checklistDependencyChips ? (
<span className="flex flex-wrap gap-1 sm:order-3 sm:ml-[calc(theme(spacing.3)+theme(spacing.2))]">
{checklistDependencyChips}
</span>
) : null}
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0"> <span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
{desktopLeadingSpacer ? ( {desktopLeadingSpacer ? (
<span className="hidden w-3.5 shrink-0 sm:block" /> <span className="hidden w-3.5 shrink-0 sm:block" />
@@ -84,6 +108,7 @@ export function IssueRow({
<span className="hidden shrink-0 sm:inline-flex"> <span className="hidden shrink-0 sm:inline-flex">
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} /> <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />
</span> </span>
{checklistStep}
<span className="shrink-0 font-mono text-xs text-muted-foreground"> <span className="shrink-0 font-mono text-xs text-muted-foreground">
{identifier} {identifier}
</span> </span>
+279 -2
View File
@@ -2,7 +2,7 @@
import { act } from "react"; import { act } from "react";
import { createRoot } from "react-dom/client"; 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared"; import type { Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -50,6 +50,22 @@ vi.mock("../context/DialogContext", () => ({
useDialog: () => dialogState, 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", () => ({ vi.mock("../api/issues", () => ({
issuesApi: mockIssuesApi, issuesApi: mockIssuesApi,
})); }));
@@ -75,15 +91,32 @@ vi.mock("./IssueRow", () => ({
issue, issue,
desktopMetaLeading, desktopMetaLeading,
desktopTrailing, desktopTrailing,
titleClassName,
checklistStepNumber,
checklistCurrentStep,
checklistDependencyChips,
checklistRowId,
}: { }: {
issue: Issue; issue: Issue;
desktopMetaLeading?: ReactNode; desktopMetaLeading?: ReactNode;
desktopTrailing?: ReactNode; desktopTrailing?: ReactNode;
titleClassName?: string;
checklistStepNumber?: number | string | null;
checklistCurrentStep?: boolean;
checklistDependencyChips?: ReactNode;
checklistRowId?: string;
}) => ( }) => (
<div data-testid="issue-row"> <div
data-testid="issue-row"
id={checklistRowId}
data-step={checklistStepNumber ?? undefined}
data-current-step={checklistCurrentStep ? "true" : undefined}
data-title-class={titleClassName ?? undefined}
>
<span>{issue.title}</span> <span>{issue.title}</span>
{desktopMetaLeading} {desktopMetaLeading}
{desktopTrailing} {desktopTrailing}
{checklistDependencyChips}
</div> </div>
), ),
})); }));
@@ -350,6 +383,250 @@ describe("IssuesList", () => {
}); });
}); });
it("renders the opt-in sub-issue progress summary with workflow next-up linking", async () => {
const doneIssue = createIssue({
id: "issue-done",
identifier: "PAP-1",
title: "Completed setup",
status: "done",
createdAt: new Date("2026-04-01T00:00:00.000Z"),
});
const nextIssue = createIssue({
id: "issue-next",
identifier: "PAP-2",
title: "Implement next slice",
status: "todo",
createdAt: new Date("2026-04-02T00:00:00.000Z"),
blockedBy: [{
id: "issue-done",
identifier: "PAP-1",
title: "Completed setup",
status: "done",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
}],
});
const blockedIssue = createIssue({
id: "issue-blocked",
identifier: "PAP-3",
title: "Blocked follow-up",
status: "blocked",
createdAt: new Date("2026-04-03T00:00:00.000Z"),
});
const cancelledIssue = createIssue({
id: "issue-cancelled",
identifier: "PAP-4",
title: "Cancelled follow-up",
status: "cancelled",
createdAt: new Date("2026-04-04T00:00:00.000Z"),
});
const { root } = renderWithQueryClient(
<IssuesList
issues={[cancelledIssue, 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).not.toContain("Done 1");
expect(container.textContent).toContain("Next up");
const link = container.querySelector('a[href="/issues/PAP-2"]');
expect(link?.textContent).toContain("Implement next slice");
expect(container.querySelector('[title="Cancelled: 1"]')).toBeNull();
});
act(() => {
root.unmount();
});
});
it("adds checklist affordances for workflow-sorted sub-issue lists", async () => {
const issueDone = createIssue({
id: "issue-done",
identifier: "PAP-1",
title: "Done first",
status: "done",
createdAt: new Date("2026-04-01T00:00:00.000Z"),
});
const issueBlocked = createIssue({
id: "issue-blocked",
identifier: "PAP-2",
title: "Blocked issue",
status: "blocked",
blockedBy: [{ id: "issue-active", identifier: "PAP-3", title: "Active blocker", status: "todo", priority: "medium", assigneeAgentId: null, assigneeUserId: null }],
createdAt: new Date("2026-04-02T00:00:00.000Z"),
});
const issueActive = createIssue({
id: "issue-active",
identifier: "PAP-3",
title: "Active blocker",
status: "todo",
createdAt: new Date("2026-04-03T00:00:00.000Z"),
});
const { root } = renderWithQueryClient(
<IssuesList
issues={[issueBlocked, issueActive, issueDone]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
defaultSortField="workflow"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
const rows = Array.from(container.querySelectorAll('[data-testid="issue-row"]'));
expect(rows).toHaveLength(3);
expect(rows.map((row) => row.getAttribute("data-step"))).toEqual(["1", "2", "3"]);
expect(container.textContent?.replace(/\s+/g, "")).toContain("1.PAP-1");
expect(container.textContent?.replace(/\s+/g, "")).toContain("2.PAP-3");
expect(rows.filter((row) => row.getAttribute("data-current-step") === "true")).toHaveLength(1);
expect(rows.find((row) => row.textContent?.includes("Active blocker"))?.getAttribute("data-current-step")).toBe("true");
expect(rows.find((row) => row.textContent?.includes("Done first"))?.getAttribute("data-title-class")).toContain("text-muted-foreground");
expect(container.textContent).toContain("blocked by PAP-3 · step 2");
});
act(() => {
root.unmount();
});
});
it("uses hierarchical checklist step numbers when nested rows render inline", async () => {
const firstRoot = createIssue({
id: "issue-first-root",
identifier: "PAP-1",
title: "First root",
status: "done",
createdAt: new Date("2026-04-01T00:00:00.000Z"),
});
const parent = createIssue({
id: "issue-parent",
identifier: "PAP-2",
title: "Parent slice",
status: "todo",
createdAt: new Date("2026-04-02T00:00:00.000Z"),
});
const nextRoot = createIssue({
id: "issue-next-root",
identifier: "PAP-3",
title: "Next root",
status: "todo",
createdAt: new Date("2026-04-03T00:00:00.000Z"),
});
const grandchild = createIssue({
id: "issue-grandchild",
identifier: "PAP-4",
title: "Nested cancelled cleanup",
status: "cancelled",
parentId: "issue-parent",
createdAt: new Date("2026-04-04T00:00:00.000Z"),
});
const { root } = renderWithQueryClient(
<IssuesList
issues={[grandchild, nextRoot, firstRoot, parent]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
defaultSortField="workflow"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
const rows = Array.from(container.querySelectorAll('[data-testid="issue-row"]'));
expect(rows).toHaveLength(4);
expect(rows.map((row) => row.textContent)).toEqual([
expect.stringContaining("First root"),
expect.stringContaining("Parent slice"),
expect.stringContaining("Nested cancelled cleanup"),
expect.stringContaining("Next root"),
]);
expect(rows.map((row) => row.getAttribute("data-step"))).toEqual(["1", "2", "2.1", "3"]);
});
act(() => {
root.unmount();
});
});
it("hides the sub-issue progress summary unless it is enabled and populated", async () => {
const { root } = renderWithQueryClient(
<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 () => { it("debounces search updates so typing does not notify the page on every keystroke", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
+266 -9
View File
@@ -3,6 +3,7 @@ import { useQueries, useQuery } from "@tanstack/react-query";
import { accessApi } from "../api/access"; import { accessApi } from "../api/access";
import { useDialog } from "../context/DialogContext"; import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { Link } from "@/lib/router";
import { executionWorkspacesApi } from "../api/execution-workspaces"; import { executionWorkspacesApi } from "../api/execution-workspaces";
import { issuesApi } from "../api/issues"; import { issuesApi } from "../api/issues";
import { authApi } from "../api/auth"; import { authApi } from "../api/auth";
@@ -14,6 +15,12 @@ import {
} from "../lib/keyboardShortcuts"; } from "../lib/keyboardShortcuts";
import { formatAssigneeUserLabel } from "../lib/assignees"; import { formatAssigneeUserLabel } from "../lib/assignees";
import { buildCompanyUserLabelMap, buildCompanyUserProfileMap } from "../lib/company-members"; 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 { groupBy } from "../lib/groupBy";
import { import {
applyIssueFilters, applyIssueFilters,
@@ -58,7 +65,8 @@ import { KanbanBoard } from "./KanbanBoard";
import { buildIssueTree, countDescendants } from "../lib/issue-tree"; import { buildIssueTree, countDescendants } from "../lib/issue-tree";
import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults"; import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
import { statusBadge } from "../lib/status-colors"; 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_DEBOUNCE_MS = 250;
const ISSUE_SEARCH_RESULT_LIMIT = 200; const ISSUE_SEARCH_RESULT_LIMIT = 200;
const ISSUE_BOARD_COLUMN_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_SIZE = 150;
const ISSUE_ROW_RENDER_BATCH_DELAY_MS = 0; const ISSUE_ROW_RENDER_BATCH_DELAY_MS = 0;
const boardIssueStatuses = ISSUE_STATUSES; 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 ── */ /* ── View state ── */
export type IssueSortField = "status" | "priority" | "title" | "created" | "updated" | "workflow";
export type IssueViewState = IssueFilterState & { export type IssueViewState = IssueFilterState & {
sortField: "status" | "priority" | "title" | "created" | "updated"; sortField: IssueSortField;
sortDir: "asc" | "desc"; sortDir: "asc" | "desc";
groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none"; groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none";
viewMode: "list" | "board"; viewMode: "list" | "board";
@@ -105,11 +133,19 @@ function saveViewState(key: string, state: IssueViewState) {
localStorage.setItem(key, JSON.stringify(state)); 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); 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 { return {
...stored, ...base,
assignees: initialAssignees, assignees: initialAssignees,
statuses: [], statuses: [],
}; };
@@ -119,8 +155,9 @@ function getInitialWorkspaceViewState(
key: string, key: string,
initialAssignees?: string[], initialAssignees?: string[],
initialWorkspaces?: string[], initialWorkspaces?: string[],
defaultSortField?: IssueSortField,
): IssueViewState { ): IssueViewState {
const stored = getInitialViewState(key, initialAssignees); const stored = getInitialViewState(key, initialAssignees, defaultSortField);
if (!initialWorkspaces) return stored; if (!initialWorkspaces) return stored;
return { return {
...stored, ...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 { function getIssueColumnsStorageKey(key: string): string {
return `${key}:issue-columns`; return `${key}:issue-columns`;
} }
@@ -157,6 +202,10 @@ function saveIssueColumns(key: string, columns: InboxIssueColumn[]) {
} }
function sortIssues(issues: Issue[], state: IssueViewState): Issue[] { 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 sorted = [...issues];
const dir = state.sortDir === "asc" ? 1 : -1; const dir = state.sortDir === "asc" ? 1 : -1;
sorted.sort((a, b) => { sorted.sort((a, b) => {
@@ -187,6 +236,39 @@ function issueMatchesLocalSearch(issue: Issue, normalizedSearch: string): boolea
].some((value) => value?.toLowerCase().includes(normalizedSearch)); ].some((value) => value?.toLowerCase().includes(normalizedSearch));
} }
function isActionableWorkflowStatus(status: IssueStatus): boolean {
return status !== "done" && status !== "cancelled" && status !== "blocked";
}
function buildChecklistStepNumberMap(issues: Issue[], nestingEnabled: boolean): Map<string, string> {
const stepNumberByIssueId = new Map<string, string>();
if (!nestingEnabled) {
issues.forEach((issue, index) => {
stepNumberByIssueId.set(issue.id, String(index + 1));
});
return stepNumberByIssueId;
}
const { roots, childMap } = buildIssueTree(issues);
const visit = (siblings: Issue[], prefix: string | null) => {
siblings.forEach((issue, index) => {
const stepNumber = prefix ? `${prefix}.${index + 1}` : String(index + 1);
stepNumberByIssueId.set(issue.id, stepNumber);
visit(childMap.get(issue.id) ?? [], stepNumber);
});
};
visit(roots, null);
issues.forEach((issue, index) => {
if (!stepNumberByIssueId.has(issue.id)) {
stepNumberByIssueId.set(issue.id, String(index + 1));
}
});
return stepNumberByIssueId;
}
/* ── Component ── */ /* ── Component ── */
interface Agent { interface Agent {
@@ -221,6 +303,8 @@ interface IssuesListProps {
searchWithinLoadedIssues?: boolean; searchWithinLoadedIssues?: boolean;
baseCreateIssueDefaults?: Record<string, unknown>; baseCreateIssueDefaults?: Record<string, unknown>;
createIssueLabel?: string; createIssueLabel?: string;
defaultSortField?: IssueSortField;
showProgressSummary?: boolean;
enableRoutineVisibilityFilter?: boolean; enableRoutineVisibilityFilter?: boolean;
mutedIssueIds?: Set<string>; mutedIssueIds?: Set<string>;
issueBadgeById?: Map<string, string>; issueBadgeById?: Map<string, string>;
@@ -290,6 +374,87 @@ function IssueSearchInput({
); );
} }
function SubIssueProgressSummaryStrip({
summary,
issueLinkState,
}: {
summary: SubIssueProgressSummary;
issueLinkState?: unknown;
}) {
const target = summary.target;
const targetIssue = target?.issue ?? null;
const targetPathId = targetIssue?.identifier ?? targetIssue?.id ?? "";
const targetState = targetIssue ? withIssueDetailHeaderSeed(issueLinkState, targetIssue) : undefined;
const statusEntries = ISSUE_STATUSES
.map((status) => ({ status, count: summary.countsByStatus[status] ?? 0 }))
.filter((entry) => entry.count > 0);
return (
<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>
<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.totalCount === 0 ? (
<div className="text-sm font-medium text-foreground">No active sub-issues</div>
) : 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({ export function IssuesList({
issues, issues,
isLoading, isLoading,
@@ -307,6 +472,8 @@ export function IssuesList({
searchWithinLoadedIssues = false, searchWithinLoadedIssues = false,
baseCreateIssueDefaults, baseCreateIssueDefaults,
createIssueLabel, createIssueLabel,
defaultSortField,
showProgressSummary = false,
enableRoutineVisibilityFilter = false, enableRoutineVisibilityFilter = false,
mutedIssueIds, mutedIssueIds,
issueBadgeById, issueBadgeById,
@@ -338,7 +505,7 @@ export function IssuesList({
const initialWorkspacesKey = initialWorkspaces?.join("|") ?? ""; const initialWorkspacesKey = initialWorkspaces?.join("|") ?? "";
const [viewState, setViewState] = useState<IssueViewState>(() => const [viewState, setViewState] = useState<IssueViewState>(() =>
getInitialWorkspaceViewState(scopedKey, initialAssignees, initialWorkspaces), getInitialWorkspaceViewState(scopedKey, initialAssignees, initialWorkspaces, defaultSortField),
); );
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null); const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
const [assigneeSearch, setAssigneeSearch] = useState(""); const [assigneeSearch, setAssigneeSearch] = useState("");
@@ -358,9 +525,9 @@ export function IssuesList({
const nextContextKey = `${scopedKey}::${initialAssigneesKey}::${initialWorkspacesKey}`; const nextContextKey = `${scopedKey}::${initialAssigneesKey}::${initialWorkspacesKey}`;
if (prevViewStateContextKey.current !== nextContextKey) { if (prevViewStateContextKey.current !== nextContextKey) {
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); const prevColumnsScopedKey = useRef(scopedKey);
useEffect(() => { useEffect(() => {
@@ -672,6 +839,47 @@ export function IssuesList({
issueFilterWorkspaceContext, issueFilterWorkspaceContext,
]); ]);
const progressSummary = useMemo(
() => shouldRenderSubIssueProgressSummary(showProgressSummary, issues.length)
? buildSubIssueProgressSummary(issues)
: null,
[issues, showProgressSummary],
);
const checklistAffordanceEnabled = useMemo(
() =>
defaultSortField === "workflow"
&& viewState.groupBy === "none",
[defaultSortField, viewState.groupBy],
);
const workflowChecklistMeta = useMemo(() => {
if (!checklistAffordanceEnabled) return null;
const visibleIssueIds = new Set(filtered.map((issue) => issue.id));
const stepNumberByIssueId = buildChecklistStepNumberMap(filtered, viewState.nestingEnabled);
const unresolvedVisibleBlockersByIssueId = new Map<string, string[]>();
filtered.forEach((issue) => {
const unresolvedVisible = (issue.blockedBy ?? [])
.map((blocker) => blocker.id)
.filter((blockerId) => {
if (!visibleIssueIds.has(blockerId)) return false;
const blockerIssue = issueById.get(blockerId);
if (!blockerIssue) return false;
return blockerIssue.status !== "done" && blockerIssue.status !== "cancelled";
});
unresolvedVisibleBlockersByIssueId.set(issue.id, unresolvedVisible);
});
const firstActionable = filtered.find((issue) => isActionableWorkflowStatus(issue.status)) ?? null;
const currentStepIssue = firstActionable ?? filtered.find((issue) => issue.status === "blocked") ?? null;
return {
stepNumberByIssueId,
unresolvedVisibleBlockersByIssueId,
currentStepIssueId: currentStepIssue?.id ?? null,
};
}, [checklistAffordanceEnabled, filtered, issueById, viewState.nestingEnabled]);
const { data: labels } = useQuery({ const { data: labels } = useQuery({
queryKey: queryKeys.issues.labels(selectedCompanyId!), queryKey: queryKeys.issues.labels(selectedCompanyId!),
queryFn: () => issuesApi.listLabels(selectedCompanyId!), queryFn: () => issuesApi.listLabels(selectedCompanyId!),
@@ -829,6 +1037,10 @@ export function IssuesList({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{progressSummary ? (
<SubIssueProgressSummaryStrip summary={progressSummary} issueLinkState={issueLinkState} />
) : null}
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center justify-between gap-2 sm:gap-3"> <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"> <div className="flex min-w-0 items-center gap-2 sm:gap-3">
@@ -911,6 +1123,7 @@ export function IssuesList({
<PopoverContent align="end" className="w-48 p-0"> <PopoverContent align="end" className="w-48 p-0">
<div className="p-2 space-y-0.5"> <div className="p-2 space-y-0.5">
{([ {([
["workflow", "Workflow"],
["status", "Status"], ["status", "Status"],
["priority", "Priority"], ["priority", "Priority"],
["title", "Title"], ["title", "Title"],
@@ -1083,6 +1296,44 @@ export function IssuesList({
: viewState.collapsedParents.filter((id) => id !== issue.id), : viewState.collapsedParents.filter((id) => id !== issue.id),
}); });
}; };
const checklistMeta = workflowChecklistMeta;
const checklistStepNumber = checklistMeta?.stepNumberByIssueId.get(issue.id) ?? null;
const unresolvedVisibleBlockers = checklistMeta?.unresolvedVisibleBlockersByIssueId.get(issue.id) ?? [];
const checklistRowId = checklistMeta ? `issue-workflow-row-${issue.id}` : undefined;
const doneRowTitleClass = checklistMeta && issue.status === "done"
? "text-muted-foreground"
: undefined;
const checklistDependencyChips = checklistMeta && unresolvedVisibleBlockers.length > 0 ? (
<>
{unresolvedVisibleBlockers.map((blockerId) => {
const blockerIssue = issueById.get(blockerId);
if (!blockerIssue) return null;
const label = blockerIssue.identifier ?? blockerIssue.id.slice(0, 8);
const blockerStep = checklistMeta.stepNumberByIssueId.get(blockerId);
const blockerStepSuffix = blockerStep ? ` \u00b7 step ${blockerStep}` : "";
const chipLabel = `blocked by ${label}${blockerStepSuffix}`;
return (
<button
key={blockerId}
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
const target = document.getElementById(`issue-workflow-row-${blockerId}`);
if (!target) return;
target.scrollIntoView({ behavior: "smooth", block: "nearest" });
target.focus?.();
}}
className="inline-flex items-center rounded-full border border-amber-400/45 bg-amber-50/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 hover:bg-amber-100/80 dark:border-amber-300/35 dark:bg-amber-400/10 dark:text-amber-300"
title={chipLabel}
aria-label={chipLabel}
>
{chipLabel}
</button>
);
})}
</>
) : null;
return ( return (
<div <div
@@ -1100,6 +1351,11 @@ export function IssuesList({
<IssueRow <IssueRow
issue={issue} issue={issue}
issueLinkState={issueLinkState} issueLinkState={issueLinkState}
checklistStepNumber={checklistStepNumber}
checklistCurrentStep={checklistMeta?.currentStepIssueId === issue.id}
checklistDependencyChips={checklistDependencyChips}
checklistRowId={checklistRowId}
titleClassName={doneRowTitleClass}
titleSuffix={( titleSuffix={(
<> <>
{hasChildren && !isExpanded ? ( {hasChildren && !isExpanded ? (
@@ -1155,6 +1411,7 @@ export function IssuesList({
isLive={liveIssueIds?.has(issue.id) === true} isLive={liveIssueIds?.has(issue.id) === true}
showStatus={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")} showStatus={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")}
showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")} showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
checklistStepNumber={checklistStepNumber}
statusSlot={( statusSlot={(
<span onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}> <span onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} /> <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
+62 -1
View File
@@ -1,7 +1,28 @@
// @vitest-environment node // @vitest-environment node
import { describe, expect, it } from "vitest"; 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", () => { describe("shouldRenderRichSubIssuesSection", () => {
it("shows the rich sub-issues section while child issues are loading", () => { it("shows the rich sub-issues section while child issues are loading", () => {
@@ -16,3 +37,43 @@ describe("shouldRenderRichSubIssuesSection", () => {
expect(shouldRenderRichSubIssuesSection(false, 0)).toBe(false); 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");
});
});
+60
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 { export function shouldRenderRichSubIssuesSection(childIssuesLoading: boolean, childIssueCount: number): boolean {
return childIssuesLoading || childIssueCount > 0; 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";
}
+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(() => { await waitForAssertion(() => {
expect(container.textContent).toContain("Subtree pause is active."); 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].issueBadgeById.get("child-1")).toBe("Paused");
expect(mockIssuesListRender.mock.calls.at(-1)?.[0].showProgressSummary).toBe(true);
}); });
const resumeButton = Array.from(container.querySelectorAll("button")) const resumeButton = Array.from(container.querySelectorAll("button"))
+4 -2
View File
@@ -1151,7 +1151,7 @@ export function IssueDetail() {
issue?.id && resolvedCompanyId issue?.id && resolvedCompanyId
? queryKeys.issues.listByDescendantRoot(resolvedCompanyId, issue.id) ? queryKeys.issues.listByDescendantRoot(resolvedCompanyId, issue.id)
: ["issues", "parent", "pending"], : ["issues", "parent", "pending"],
queryFn: () => issuesApi.list(resolvedCompanyId!, { descendantOf: issue!.id }), queryFn: () => issuesApi.list(resolvedCompanyId!, { descendantOf: issue!.id, includeBlockedBy: true }),
enabled: !!resolvedCompanyId && !!issue?.id, enabled: !!resolvedCompanyId && !!issue?.id,
placeholderData: keepPreviousDataForSameQueryTail<Issue[]>(issue?.id ?? "pending"), placeholderData: keepPreviousDataForSameQueryTail<Issue[]>(issue?.id ?? "pending"),
}); });
@@ -3155,10 +3155,12 @@ export function IssueDetail() {
projectId={issue.projectId ?? undefined} projectId={issue.projectId ?? undefined}
viewStateKey={`paperclip:issue-detail:${issue.id}:subissues-view`} viewStateKey={`paperclip:issue-detail:${issue.id}:subissues-view`}
issueLinkState={resolvedIssueDetailState ?? location.state} issueLinkState={resolvedIssueDetailState ?? location.state}
searchFilters={{ descendantOf: issue.id }} searchFilters={{ descendantOf: issue.id, includeBlockedBy: true }}
searchWithinLoadedIssues searchWithinLoadedIssues
baseCreateIssueDefaults={buildSubIssueDefaultsForViewer(issue, currentUserId)} baseCreateIssueDefaults={buildSubIssueDefaultsForViewer(issue, currentUserId)}
createIssueLabel="Sub-issue" createIssueLabel="Sub-issue"
defaultSortField="workflow"
showProgressSummary
onUpdateIssue={handleChildIssueUpdate} onUpdateIssue={handleChildIssueUpdate}
/> />
</div> </div>
@@ -0,0 +1,281 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useState } from "react";
import type { Issue } from "@paperclipai/shared";
import { useQueryClient } from "@tanstack/react-query";
import { IssuesList } from "@/components/IssuesList";
import { queryKeys } from "@/lib/queryKeys";
import {
createIssue,
storybookAgents,
storybookAuthSession,
storybookCompanies,
storybookIssueLabels,
storybookProjects,
} from "../fixtures/paperclipData";
const companyId = "company-storybook";
const parentId = "issue-pap-1953";
type BlockerRef = NonNullable<Issue["blockedBy"]>[number];
function child(overrides: Partial<Issue>): Issue {
return createIssue({
parentId,
projectId: storybookProjects[0]!.id,
projectWorkspaceId: storybookProjects[0]!.workspaces[0]?.id ?? null,
goalId: null,
blockedBy: [],
blocks: [],
labelIds: [],
labels: [],
...overrides,
});
}
const blockerRef = (issue: Issue): BlockerRef => ({
id: issue.id,
identifier: issue.identifier,
title: issue.title,
status: issue.status,
priority: issue.priority,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
});
const baseCreatedAt = new Date("2026-04-10T12:00:00.000Z").getTime();
const createdAt = (offsetMinutes: number) =>
new Date(baseCreatedAt + offsetMinutes * 60_000);
// Mirrors the PAP-1953 topology called out in the PAP-2189 plan:
// 1954 Scoping (done) — root
// 1955 Security scoping (done) — root
// 1960 Phase 1 (done) → 1961 Phase 2 (done)
// 1962 Phase 3 (done) → 1963 Phase 4 (done)
// → 1964 Phase 5 (in_progress)
// → 1965 Phase 6 (blocked)
// → 1966 Phase 7 (blocked)
const scoping = child({
id: "issue-pap-1954",
identifier: "PAP-1954",
issueNumber: 1954,
title: "Scoping review",
status: "done",
priority: "medium",
completedAt: createdAt(120),
createdAt: createdAt(0),
});
const security = child({
id: "issue-pap-1955",
identifier: "PAP-1955",
issueNumber: 1955,
title: "Security scoping",
status: "done",
priority: "medium",
completedAt: createdAt(180),
createdAt: createdAt(10),
});
const phase1 = child({
id: "issue-pap-1960",
identifier: "PAP-1960",
issueNumber: 1960,
title: "Phase 1 — groundwork",
status: "done",
priority: "medium",
completedAt: createdAt(600),
createdAt: createdAt(20),
});
const phase2 = child({
id: "issue-pap-1961",
identifier: "PAP-1961",
issueNumber: 1961,
title: "Phase 2 — integration",
status: "done",
priority: "medium",
completedAt: createdAt(720),
createdAt: createdAt(30),
blockedBy: [blockerRef(phase1)],
});
const phase3 = child({
id: "issue-pap-1962",
identifier: "PAP-1962",
issueNumber: 1962,
title: "Phase 3 — data model",
status: "done",
priority: "medium",
completedAt: createdAt(800),
createdAt: createdAt(40),
});
const phase4 = child({
id: "issue-pap-1963",
identifier: "PAP-1963",
issueNumber: 1963,
title: "Phase 4 — API surface",
status: "done",
priority: "medium",
completedAt: createdAt(900),
createdAt: createdAt(50),
blockedBy: [blockerRef(phase3)],
});
const phase5 = child({
id: "issue-pap-1964",
identifier: "PAP-1964",
issueNumber: 1964,
title: "Phase 5 — UI polish",
status: "in_progress",
priority: "high",
createdAt: createdAt(60),
blockedBy: [blockerRef(phase4)],
});
const phase6 = child({
id: "issue-pap-1965",
identifier: "PAP-1965",
issueNumber: 1965,
title: "Phase 6 — telemetry wiring",
status: "blocked",
priority: "medium",
createdAt: createdAt(70),
blockedBy: [blockerRef(phase5)],
});
const phase7 = child({
id: "issue-pap-1966",
identifier: "PAP-1966",
issueNumber: 1966,
title: "Phase 7 — rollout",
status: "blocked",
priority: "medium",
createdAt: createdAt(80),
blockedBy: [blockerRef(phase6)],
});
const subIssues: Issue[] = [
scoping,
security,
phase1,
phase2,
phase3,
phase4,
phase5,
phase6,
phase7,
];
const viewStateKey = "storybook:sub-issues-workflow:list";
const scopedKey = `${viewStateKey}:${companyId}`;
function hydrateQueries(client: ReturnType<typeof useQueryClient>) {
client.setQueryData(queryKeys.companies.all, storybookCompanies);
client.setQueryData(queryKeys.auth.session, storybookAuthSession);
client.setQueryData(queryKeys.agents.list(companyId), storybookAgents);
client.setQueryData(queryKeys.projects.list(companyId), storybookProjects);
client.setQueryData(queryKeys.issues.labels(companyId), storybookIssueLabels);
client.setQueryData(queryKeys.issues.list(companyId), subIssues);
client.setQueryData(queryKeys.access.companyUserDirectory(companyId), {
users: [
{
principalId: "user-board",
status: "active",
user: {
id: "user-board",
email: "riley@paperclip.local",
name: "Riley Board",
image: null,
},
},
],
});
client.setQueryData(queryKeys.instance.experimentalSettings, {
enableIsolatedWorkspaces: true,
enableRoutineTriggers: true,
});
}
function Hydrated({ children }: { children: React.ReactNode }) {
const queryClient = useQueryClient();
const [ready] = useState(() => {
hydrateQueries(queryClient);
if (typeof window !== "undefined") {
window.localStorage.removeItem(scopedKey);
window.localStorage.removeItem(`${scopedKey}:issue-columns`);
}
return true;
});
return ready ? children : null;
}
function SubIssuesWorkflowPanel() {
return (
<div className="paperclip-story">
<main className="paperclip-story__inner">
<div className="mx-auto max-w-5xl space-y-5">
<header className="space-y-1">
<div className="paperclip-story__label">Issue Detail · Sub-issues</div>
<h1 className="text-2xl font-semibold tracking-tight">
Workflow-sorted sub-issues with checklist affordances
</h1>
<p className="max-w-3xl text-sm text-muted-foreground">
Fixture mirrors the PAP-1953 topology called out in the PAP-2189
plan: two standalone scoping items, a Phase 12 pair, and a long
Phase 34567 chain. The panel renders with
<code className="mx-1 rounded bg-muted px-1 py-0.5 font-mono text-xs">
defaultSortField="workflow"
</code>
and
<code className="mx-1 rounded bg-muted px-1 py-0.5 font-mono text-xs">
showProgressSummary
</code>
so reviewers see the full checklist surface in isolation.
</p>
</header>
<div className="rounded-lg border border-border bg-background p-5">
<IssuesList
issues={subIssues}
agents={storybookAgents}
projects={storybookProjects}
viewStateKey={viewStateKey}
defaultSortField="workflow"
showProgressSummary
onUpdateIssue={() => undefined}
createIssueLabel="Sub-issue"
/>
</div>
</div>
</main>
</div>
);
}
const meta = {
title: "UX Labs/Sub-issues Workflow Checklist",
component: SubIssuesWorkflowPanel,
parameters: {
layout: "fullscreen",
docs: {
description: {
component:
"Review surface for the PAP-2189 checklist-style sub-issues work. Renders the IssuesList component with the Sub-issues panel props so the progress strip, workflow sort, step gutter, current marker, done de-emphasis, and blocker chips are all visible against a PAP-1953-like topology.",
},
},
},
decorators: [
(StoryRender) => (
<Hydrated>
<StoryRender />
</Hydrated>
),
],
} satisfies Meta<typeof SubIssuesWorkflowPanel>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};