mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 19:00:38 +09:00
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:
parent
40782f703d
commit
df425fde96
22 changed files with 1449 additions and 18 deletions
|
|
@ -139,12 +139,14 @@ export function InboxIssueMetaLeading({
|
|||
showStatus = true,
|
||||
showIdentifier = true,
|
||||
statusSlot,
|
||||
checklistStepNumber = null,
|
||||
}: {
|
||||
issue: Issue;
|
||||
isLive: boolean;
|
||||
showStatus?: boolean;
|
||||
showIdentifier?: boolean;
|
||||
statusSlot?: ReactNode;
|
||||
checklistStepNumber?: number | string | null;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
|
|
@ -153,6 +155,11 @@ export function InboxIssueMetaLeading({
|
|||
{statusSlot ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} />}
|
||||
</span>
|
||||
) : null}
|
||||
{checklistStepNumber !== null ? (
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground" aria-hidden="true">
|
||||
{checklistStepNumber}.
|
||||
</span>
|
||||
) : null}
|
||||
{showIdentifier ? (
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{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", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ interface IssueRowProps {
|
|||
desktopTrailing?: ReactNode;
|
||||
trailingMeta?: ReactNode;
|
||||
titleSuffix?: ReactNode;
|
||||
titleClassName?: string;
|
||||
checklistStepNumber?: number | string | 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,12 @@ 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;
|
||||
const checklistStep = hasChecklistStep ? (
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground" aria-hidden="true">
|
||||
{checklistStepNumber}.
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
|
@ -61,10 +77,13 @@ 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 bg-primary/5 pl-[calc(theme(spacing.2)-2px)] sm:pl-[calc(theme(spacing.1)-2px)]" : null,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
|
@ -72,9 +91,14 @@ export function IssueRow({
|
|||
{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" />
|
||||
|
|
@ -84,6 +108,7 @@ export function IssueRow({
|
|||
<span className="hidden shrink-0 sm:inline-flex">
|
||||
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />
|
||||
</span>
|
||||
{checklistStep}
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{identifier}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { ReactNode } from "react";
|
||||
import type { AnchorHTMLAttributes, ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
|
@ -50,6 +50,22 @@ vi.mock("../context/DialogContext", () => ({
|
|||
useDialog: () => dialogState,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
state: _state,
|
||||
issuePrefetch: _issuePrefetch,
|
||||
...props
|
||||
}: AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
to: string;
|
||||
state?: unknown;
|
||||
issuePrefetch?: unknown;
|
||||
}) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: mockIssuesApi,
|
||||
}));
|
||||
|
|
@ -75,15 +91,32 @@ vi.mock("./IssueRow", () => ({
|
|||
issue,
|
||||
desktopMetaLeading,
|
||||
desktopTrailing,
|
||||
titleClassName,
|
||||
checklistStepNumber,
|
||||
checklistCurrentStep,
|
||||
checklistDependencyChips,
|
||||
checklistRowId,
|
||||
}: {
|
||||
issue: Issue;
|
||||
desktopMetaLeading?: 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>
|
||||
{desktopMetaLeading}
|
||||
{desktopTrailing}
|
||||
{checklistDependencyChips}
|
||||
</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 () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useQueries, useQuery } from "@tanstack/react-query";
|
|||
import { accessApi } from "../api/access";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { Link } from "@/lib/router";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { authApi } from "../api/auth";
|
||||
|
|
@ -14,6 +15,12 @@ import {
|
|||
} from "../lib/keyboardShortcuts";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { buildCompanyUserLabelMap, buildCompanyUserProfileMap } from "../lib/company-members";
|
||||
import { createIssueDetailPath, withIssueDetailHeaderSeed } from "../lib/issueDetailBreadcrumb";
|
||||
import {
|
||||
buildSubIssueProgressSummary,
|
||||
shouldRenderSubIssueProgressSummary,
|
||||
type SubIssueProgressSummary,
|
||||
} from "../lib/issue-detail-subissues";
|
||||
import { groupBy } from "../lib/groupBy";
|
||||
import {
|
||||
applyIssueFilters,
|
||||
|
|
@ -58,7 +65,8 @@ import { KanbanBoard } from "./KanbanBoard";
|
|||
import { buildIssueTree, countDescendants } from "../lib/issue-tree";
|
||||
import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
|
||||
import { statusBadge } from "../lib/status-colors";
|
||||
import { ISSUE_STATUSES, type Issue, type Project } from "@paperclipai/shared";
|
||||
import { workflowSort } from "../lib/workflow-sort";
|
||||
import { ISSUE_STATUSES, type Issue, type IssueStatus, type Project } from "@paperclipai/shared";
|
||||
const ISSUE_SEARCH_DEBOUNCE_MS = 250;
|
||||
const ISSUE_SEARCH_RESULT_LIMIT = 200;
|
||||
const ISSUE_BOARD_COLUMN_RESULT_LIMIT = 200;
|
||||
|
|
@ -66,11 +74,31 @@ const INITIAL_ISSUE_ROW_RENDER_LIMIT = 100;
|
|||
const ISSUE_ROW_RENDER_BATCH_SIZE = 150;
|
||||
const ISSUE_ROW_RENDER_BATCH_DELAY_MS = 0;
|
||||
const boardIssueStatuses = ISSUE_STATUSES;
|
||||
const issueStatusLabels: Record<IssueStatus, string> = {
|
||||
backlog: "Backlog",
|
||||
todo: "Todo",
|
||||
in_progress: "In progress",
|
||||
in_review: "In review",
|
||||
done: "Done",
|
||||
blocked: "Blocked",
|
||||
cancelled: "Cancelled",
|
||||
};
|
||||
const progressSegmentClasses: Record<IssueStatus, string> = {
|
||||
backlog: "bg-muted-foreground/40",
|
||||
todo: "bg-blue-500",
|
||||
in_progress: "bg-yellow-500",
|
||||
in_review: "bg-violet-500",
|
||||
done: "bg-green-500",
|
||||
blocked: "bg-red-500",
|
||||
cancelled: "bg-neutral-400",
|
||||
};
|
||||
|
||||
/* ── View state ── */
|
||||
|
||||
export type IssueSortField = "status" | "priority" | "title" | "created" | "updated" | "workflow";
|
||||
|
||||
export type IssueViewState = IssueFilterState & {
|
||||
sortField: "status" | "priority" | "title" | "created" | "updated";
|
||||
sortField: IssueSortField;
|
||||
sortDir: "asc" | "desc";
|
||||
groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none";
|
||||
viewMode: "list" | "board";
|
||||
|
|
@ -105,11 +133,19 @@ function saveViewState(key: string, state: IssueViewState) {
|
|||
localStorage.setItem(key, JSON.stringify(state));
|
||||
}
|
||||
|
||||
function getInitialViewState(key: string, initialAssignees?: string[]): IssueViewState {
|
||||
function getInitialViewState(
|
||||
key: string,
|
||||
initialAssignees?: string[],
|
||||
defaultSortField?: IssueSortField,
|
||||
): IssueViewState {
|
||||
const hasStored = hasStoredViewState(key);
|
||||
const stored = getViewState(key);
|
||||
if (!initialAssignees) return stored;
|
||||
const base = !hasStored && defaultSortField
|
||||
? { ...stored, sortField: defaultSortField, sortDir: "asc" as const }
|
||||
: stored;
|
||||
if (!initialAssignees) return base;
|
||||
return {
|
||||
...stored,
|
||||
...base,
|
||||
assignees: initialAssignees,
|
||||
statuses: [],
|
||||
};
|
||||
|
|
@ -119,8 +155,9 @@ function getInitialWorkspaceViewState(
|
|||
key: string,
|
||||
initialAssignees?: string[],
|
||||
initialWorkspaces?: string[],
|
||||
defaultSortField?: IssueSortField,
|
||||
): IssueViewState {
|
||||
const stored = getInitialViewState(key, initialAssignees);
|
||||
const stored = getInitialViewState(key, initialAssignees, defaultSortField);
|
||||
if (!initialWorkspaces) return stored;
|
||||
return {
|
||||
...stored,
|
||||
|
|
@ -129,6 +166,14 @@ function getInitialWorkspaceViewState(
|
|||
};
|
||||
}
|
||||
|
||||
function hasStoredViewState(key: string): boolean {
|
||||
try {
|
||||
return localStorage.getItem(key) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getIssueColumnsStorageKey(key: string): string {
|
||||
return `${key}:issue-columns`;
|
||||
}
|
||||
|
|
@ -157,6 +202,10 @@ function saveIssueColumns(key: string, columns: InboxIssueColumn[]) {
|
|||
}
|
||||
|
||||
function sortIssues(issues: Issue[], state: IssueViewState): Issue[] {
|
||||
if (state.sortField === "workflow") {
|
||||
const ordered = workflowSort(issues);
|
||||
return state.sortDir === "desc" ? [...ordered].reverse() : ordered;
|
||||
}
|
||||
const sorted = [...issues];
|
||||
const dir = state.sortDir === "asc" ? 1 : -1;
|
||||
sorted.sort((a, b) => {
|
||||
|
|
@ -187,6 +236,39 @@ 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";
|
||||
}
|
||||
|
||||
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 ── */
|
||||
|
||||
interface Agent {
|
||||
|
|
@ -221,6 +303,8 @@ interface IssuesListProps {
|
|||
searchWithinLoadedIssues?: boolean;
|
||||
baseCreateIssueDefaults?: Record<string, unknown>;
|
||||
createIssueLabel?: string;
|
||||
defaultSortField?: IssueSortField;
|
||||
showProgressSummary?: boolean;
|
||||
enableRoutineVisibilityFilter?: boolean;
|
||||
mutedIssueIds?: Set<string>;
|
||||
issueBadgeById?: Map<string, string>;
|
||||
|
|
@ -290,6 +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({
|
||||
issues,
|
||||
isLoading,
|
||||
|
|
@ -307,6 +472,8 @@ export function IssuesList({
|
|||
searchWithinLoadedIssues = false,
|
||||
baseCreateIssueDefaults,
|
||||
createIssueLabel,
|
||||
defaultSortField,
|
||||
showProgressSummary = false,
|
||||
enableRoutineVisibilityFilter = false,
|
||||
mutedIssueIds,
|
||||
issueBadgeById,
|
||||
|
|
@ -338,7 +505,7 @@ export function IssuesList({
|
|||
const initialWorkspacesKey = initialWorkspaces?.join("|") ?? "";
|
||||
|
||||
const [viewState, setViewState] = useState<IssueViewState>(() =>
|
||||
getInitialWorkspaceViewState(scopedKey, initialAssignees, initialWorkspaces),
|
||||
getInitialWorkspaceViewState(scopedKey, initialAssignees, initialWorkspaces, defaultSortField),
|
||||
);
|
||||
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
|
||||
const [assigneeSearch, setAssigneeSearch] = useState("");
|
||||
|
|
@ -358,9 +525,9 @@ export function IssuesList({
|
|||
const nextContextKey = `${scopedKey}::${initialAssigneesKey}::${initialWorkspacesKey}`;
|
||||
if (prevViewStateContextKey.current !== nextContextKey) {
|
||||
prevViewStateContextKey.current = nextContextKey;
|
||||
setViewState(getInitialWorkspaceViewState(scopedKey, initialAssignees, initialWorkspaces));
|
||||
setViewState(getInitialWorkspaceViewState(scopedKey, initialAssignees, initialWorkspaces, defaultSortField));
|
||||
}
|
||||
}, [scopedKey, initialAssignees, initialAssigneesKey, initialWorkspaces, initialWorkspacesKey]);
|
||||
}, [scopedKey, initialAssignees, initialAssigneesKey, initialWorkspaces, initialWorkspacesKey, defaultSortField]);
|
||||
|
||||
const prevColumnsScopedKey = useRef(scopedKey);
|
||||
useEffect(() => {
|
||||
|
|
@ -672,6 +839,47 @@ export function IssuesList({
|
|||
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({
|
||||
queryKey: queryKeys.issues.labels(selectedCompanyId!),
|
||||
queryFn: () => issuesApi.listLabels(selectedCompanyId!),
|
||||
|
|
@ -829,6 +1037,10 @@ export function IssuesList({
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{progressSummary ? (
|
||||
<SubIssueProgressSummaryStrip summary={progressSummary} issueLinkState={issueLinkState} />
|
||||
) : null}
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-2 sm:gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
|
||||
|
|
@ -911,6 +1123,7 @@ export function IssuesList({
|
|||
<PopoverContent align="end" className="w-48 p-0">
|
||||
<div className="p-2 space-y-0.5">
|
||||
{([
|
||||
["workflow", "Workflow"],
|
||||
["status", "Status"],
|
||||
["priority", "Priority"],
|
||||
["title", "Title"],
|
||||
|
|
@ -1083,6 +1296,44 @@ 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);
|
||||
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 (
|
||||
<div
|
||||
|
|
@ -1100,6 +1351,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 ? (
|
||||
|
|
@ -1155,6 +1411,7 @@ export function IssuesList({
|
|||
isLive={liveIssueIds?.has(issue.id) === true}
|
||||
showStatus={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")}
|
||||
showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
|
||||
checklistStepNumber={checklistStepNumber}
|
||||
statusSlot={(
|
||||
<span onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
||||
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue