Add workflow checklist affordances to sub-issue rows

Show workflow-only step gutters, a single current-step marker, done-title de-emphasis, and visible-blocker chips that jump to sibling rows so ordered sub-issues read like a checklist while preserving existing row interactions.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-25 21:48:32 -05:00
1 parent 04c9d703b7
commit e959fba03f
3 files changed
+177 -2

No files matched your search

+28 -1
View File
@@ -23,6 +23,11 @@ interface IssueRowProps {
desktopTrailing?: ReactNode;
trailingMeta?: ReactNode;
titleSuffix?: ReactNode;
titleClassName?: string;
checklistStepNumber?: number | null;
checklistCurrentStep?: boolean;
checklistDependencyChips?: ReactNode;
checklistRowId?: string;
unreadState?: UnreadState | null;
onMarkRead?: () => void;
onArchive?: () => void;
@@ -41,6 +46,11 @@ export function IssueRow({
desktopTrailing,
trailingMeta,
titleSuffix,
titleClassName,
checklistStepNumber = null,
checklistCurrentStep = false,
checklistDependencyChips,
checklistRowId,
unreadState = null,
onMarkRead,
onArchive,
@@ -53,6 +63,7 @@ export function IssueRow({
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined;
const detailState = withIssueDetailHeaderSeed(issueLinkState, issue);
const hasChecklistStep = checklistStepNumber !== null;
return (
<Link
@@ -61,20 +72,36 @@ export function IssueRow({
disableIssueQuicklook
issuePrefetch={issue}
data-inbox-issue-link
id={checklistRowId}
aria-current={checklistCurrentStep ? "step" : undefined}
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)}
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",
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
checklistCurrentStep ? "border-l-2 border-l-primary/40 pl-[calc(theme(spacing.2)-2px)] sm:pl-[calc(theme(spacing.1)-2px)]" : null,
className,
)}
>
{hasChecklistStep ? (
<span
className="hidden w-8 shrink-0 self-center text-right font-mono text-xs text-muted-foreground sm:inline-block"
aria-hidden="true"
>
{checklistStepNumber}.
</span>
) : null}
<span className="shrink-0 pt-px sm:hidden">
{mobileLeading ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />}
</span>
<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}
</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">
{desktopLeadingSpacer ? (
<span className="hidden w-3.5 shrink-0 sm:block" />
+69 -1
View File
@@ -91,15 +91,32 @@ vi.mock("./IssueRow", () => ({
issue,
desktopMetaLeading,
desktopTrailing,
titleClassName,
checklistStepNumber,
checklistCurrentStep,
checklistDependencyChips,
checklistRowId,
}: {
issue: Issue;
desktopMetaLeading?: ReactNode;
desktopTrailing?: ReactNode;
titleClassName?: string;
checklistStepNumber?: number | 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>
{desktopMetaLeading}
{desktopTrailing}
{checklistDependencyChips}
</div>
),
}));
@@ -428,6 +445,57 @@ describe("IssuesList", () => {
});
});
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(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");
});
act(() => {
root.unmount();
});
});
it("hides the sub-issue progress summary unless it is enabled and populated", async () => {
const { root } = renderWithQueryClient(
<IssuesList
+80
View File
@@ -236,6 +236,10 @@ function issueMatchesLocalSearch(issue: Issue, normalizedSearch: string): boolea
].some((value) => value?.toLowerCase().includes(normalizedSearch));
}
function isActionableWorkflowStatus(status: IssueStatus): boolean {
return status !== "done" && status !== "cancelled" && status !== "blocked";
}
/* ── Component ── */
interface Agent {
@@ -820,6 +824,42 @@ export function IssuesList({
: null,
[issues, showProgressSummary],
);
const checklistAffordanceEnabled = useMemo(
() =>
defaultSortField === "workflow"
&& viewState.sortField === "workflow"
&& viewState.groupBy === "none",
[defaultSortField, viewState.groupBy, viewState.sortField],
);
const workflowChecklistMeta = useMemo(() => {
if (!checklistAffordanceEnabled) return null;
const visibleIssueIds = new Set(filtered.map((issue) => issue.id));
const stepNumberByIssueId = new Map<string, number>();
const unresolvedVisibleBlockersByIssueId = new Map<string, string[]>();
filtered.forEach((issue, index) => {
stepNumberByIssueId.set(issue.id, index + 1);
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]);
const { data: labels } = useQuery({
queryKey: queryKeys.issues.labels(selectedCompanyId!),
@@ -1237,6 +1277,41 @@ export function IssuesList({
: 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);
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={`Blocked by ${label}`}
aria-label={`Blocked by ${label}`}
>
blocked by {label}
</button>
);
})}
</>
) : null;
return (
<div
@@ -1254,6 +1329,11 @@ export function IssuesList({
<IssueRow
issue={issue}
issueLinkState={issueLinkState}
checklistStepNumber={checklistStepNumber}
checklistCurrentStep={checklistMeta?.currentStepIssueId === issue.id}
checklistDependencyChips={checklistDependencyChips}
checklistRowId={checklistRowId}
titleClassName={doneRowTitleClass}
titleSuffix={(
<>
{hasChildren && !isExpanded ? (