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 light:  - Mobile dark:  - Mobile light:  - 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:
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 |
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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 })} />
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(() => {
|
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"))
|
||||||
|
|||||||
@@ -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 1→2 pair, and a long
|
||||||
|
Phase 3→4→5→6→7 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 = {};
|
||||||
Reference in New Issue
Block a user