From bb6157e79088bd6693ffd9364dba30afb89e2244 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Thu, 27 Aug 2026 18:55:11 -0400 Subject: [PATCH] feat(issues): make dependency blockers actionable --- DEVLOG.md | 13 + src/app/(app)/w/[slug]/issues/[id]/page.tsx | 7 + src/components/dashboard/operator-home.tsx | 51 +++- .../issue-detail/issue-blocker-control.tsx | 275 ++++++++++++++++++ tests/e2e/issue-blocker-controls.spec.ts | 77 +++++ tests/unit/issue-blocker-control.test.ts | 35 +++ 6 files changed, 446 insertions(+), 12 deletions(-) create mode 100644 src/components/issue-detail/issue-blocker-control.tsx create mode 100644 tests/e2e/issue-blocker-controls.spec.ts create mode 100644 tests/unit/issue-blocker-control.test.ts diff --git a/DEVLOG.md b/DEVLOG.md index 62d3b8dc..c9da6482 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,19 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-08-27 — Explicit native issue blockers + +- Added a direct issue-detail blocker control backed by the existing + `BLOCKED_BY` relation model, with searchable blocker selection, a visible + active-blocker banner, navigation, removal, and automatic terminal-state + clearing without introducing a second blocked-state model. +- Kept `/blocked ` as a separate operator-attention request and made + that distinction explicit in the blocker picker. +- Replaced the Dashboard Blocked tab's duplicate stalled-run rows with actual + dependency-blocked issues and removed those issues from ordinary work lanes. +- Added focused unit and production-build Playwright coverage for set, display, + dashboard reconciliation, deduplication, and removal behavior. + ## 2026-08-26 — v0.34.0 release preparation - Squash-merged AXI-107 / PR #100 after exact-head lint, typecheck, unit, diff --git a/src/app/(app)/w/[slug]/issues/[id]/page.tsx b/src/app/(app)/w/[slug]/issues/[id]/page.tsx index 1b84c7e0..d69facf9 100644 --- a/src/app/(app)/w/[slug]/issues/[id]/page.tsx +++ b/src/app/(app)/w/[slug]/issues/[id]/page.tsx @@ -51,6 +51,10 @@ import { IssueRail } from "@/components/issue-detail/issue-rail"; import { AgentRunStrip } from "@/components/issue-detail/agent-run-strip"; import { TerminalRunFailureBanner } from "@/components/issue-detail/run-failure-banner"; import { IssueFollowThroughBanner } from "@/components/issue-detail/issue-follow-through-banner"; +import { + IssueBlockerBanner, + IssueBlockerControl, +} from "@/components/issue-detail/issue-blocker-control"; import { RuntimePreflightBanner } from "@/components/issue-detail/runtime-preflight-banner"; import { GitHubLinksPanel } from "@/components/issue-detail/github-links-panel"; import { AiTriageCard } from "@/components/ai-triage-card"; @@ -551,6 +555,7 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri onChange={(userIds) => assign.mutate({ id: issue.id, userIds })} /> setAgentPickerOpen(true)} /> + } actions={siblingScope ? : null} @@ -572,6 +577,8 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri + + dependencyBlocked.data?.items ?? [], + [dependencyBlocked.data?.items], + ); + const blockedIssueIds = useMemo( + () => new Set(blockedIssueRows.map((issue) => issue.id)), + [blockedIssueRows], + ); const lanes = useMemo( - () => buildOperatorLanes(myWork.data?.focus ?? [], myWork.data?.resume ?? []), - [myWork.data?.focus, myWork.data?.resume], + () => + buildOperatorLanes( + (myWork.data?.focus ?? []).filter((issue) => !blockedIssueIds.has(issue.id)), + (myWork.data?.resume ?? []).filter((issue) => !blockedIssueIds.has(issue.id)), + ), + [blockedIssueIds, myWork.data?.focus, myWork.data?.resume], ); const firstName = (me?.user.name ?? me?.user.email ?? "").split(/[\s@]/)[0] || "there"; @@ -138,7 +154,11 @@ export function OperatorHome() { aria-label="Live operations" data-testid="dashboard-live-operations" > - + @@ -369,7 +389,15 @@ function WorkRow({ type AttentionTab = "decisions" | "exceptions" | "blocked"; -function AttentionRail({ slug }: { slug: string }) { +function AttentionRail({ + slug, + blockedIssues, + blockedLoading, +}: { + slug: string; + blockedIssues: Array<{ id: string; title: string }>; + blockedLoading: boolean; +}) { const { data, isLoading } = trpc.commandCenter.summary.useQuery( { limit: 8 }, { refetchOnWindowFocus: true, staleTime: 30_000 }, @@ -422,13 +450,12 @@ function AttentionRail({ slug }: { slug: string }) { ]; }, [data, slug]); - const blocked = - data?.stalledRuns.map((run) => ({ - id: `blocked-${run.id}`, - title: run.issue.title, - detail: run.recoveryDetail ?? "Work is blocked", - href: `/w/${slug}/issues/${run.issue.id}`, - })) ?? []; + const blocked = blockedIssues.map((issue) => ({ + id: `blocked-${issue.id}`, + title: issue.title, + detail: "Blocked by an open issue dependency", + href: `/w/${slug}/issues/${issue.id}`, + })); const activeItems = tab === "decisions" ? decisions : tab === "exceptions" ? exceptions : blocked; const total = decisions.length + exceptions.length + blocked.length; @@ -474,7 +501,7 @@ function AttentionRail({ slug }: { slug: string }) {
- {isLoading ? ( + {isLoading || blockedLoading ? (
) : activeItems.length === 0 ? (
diff --git a/src/components/issue-detail/issue-blocker-control.tsx b/src/components/issue-detail/issue-blocker-control.tsx new file mode 100644 index 00000000..c4cf6408 --- /dev/null +++ b/src/components/issue-detail/issue-blocker-control.tsx @@ -0,0 +1,275 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Link from "next/link"; +import { RelationKind } from "@prisma/client"; +import { ArrowRight, CheckCircle2, Plus, ShieldAlert, X } from "lucide-react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { useWorkspace } from "@/hooks/use-workspace"; +import { trpc } from "@/lib/trpc"; +import { formatIssueId } from "@/lib/utils"; + +export type IssueBlockerRelation = { + relationId: string; + target: { + id: string; + number: number; + title: string; + statusCategory: string; + }; +}; + +export function activeIssueBlockers(rows: IssueBlockerRelation[]): IssueBlockerRelation[] { + return rows.filter( + (row) => row.target.statusCategory !== "DONE" && row.target.statusCategory !== "CANCELED", + ); +} + +function useIssueBlockers(issueId: string) { + const query = trpc.relation.listForIssue.useQuery( + { issueId }, + { + refetchInterval: 30_000, + refetchIntervalInBackground: false, + refetchOnWindowFocus: true, + }, + ); + const all = (query.data?.[RelationKind.BLOCKED_BY] ?? []) as IssueBlockerRelation[]; + return { ...query, all, active: activeIssueBlockers(all) }; +} + +export function IssueBlockerControl({ issueId }: { issueId: string }) { + const [open, setOpen] = useState(false); + const { active, all } = useIssueBlockers(issueId); + + return ( + <> + + setOpen(false)} + existingTargetIds={new Set(all.map((row) => row.target.id))} + /> + + ); +} + +export function IssueBlockerBanner({ issueId }: { issueId: string }) { + const workspace = useWorkspace(); + const utils = trpc.useUtils(); + const [dialogOpen, setDialogOpen] = useState(false); + const { active, all, isLoading } = useIssueBlockers(issueId); + const remove = trpc.relation.remove.useMutation({ + onSuccess: () => { + void utils.relation.listForIssue.invalidate({ issueId }); + void utils.issue.byId.invalidate({ id: issueId }); + void utils.issue.list.invalidate(); + toast.success("Blocker removed."); + }, + onError: (error) => toast.error(error.message), + }); + + if (isLoading || active.length === 0) return null; + + return ( + <> +
+
+ + + +
+
+

+ Blocked by {active.length} open {active.length === 1 ? "issue" : "issues"} +

+ + Clears automatically when every blocker is done or canceled. + +
+
    + {active.map((row) => ( +
  • + + + {formatIssueId(workspace.key, row.target.number)} + + {row.target.title} + + + +
  • + ))} +
+ +
+
+
+ setDialogOpen(false)} + existingTargetIds={new Set(all.map((row) => row.target.id))} + /> + + ); +} + +function BlockerDialog({ + issueId, + open, + onClose, + existingTargetIds, +}: { + issueId: string; + open: boolean; + onClose: () => void; + existingTargetIds: Set; +}) { + const workspace = useWorkspace(); + const utils = trpc.useUtils(); + const [query, setQuery] = useState(""); + const search = trpc.issue.list.useQuery( + { query: query.trim() || undefined, includeDone: false, limit: 10 }, + { enabled: open, staleTime: 15_000 }, + ); + const candidates = useMemo( + () => + (search.data?.items ?? []).filter( + (candidate) => candidate.id !== issueId && !existingTargetIds.has(candidate.id), + ), + [existingTargetIds, issueId, search.data?.items], + ); + const add = trpc.relation.add.useMutation({ + onSuccess: () => { + void utils.relation.listForIssue.invalidate({ issueId }); + void utils.issue.byId.invalidate({ id: issueId }); + void utils.issue.list.invalidate(); + setQuery(""); + onClose(); + toast.success("Issue marked blocked."); + }, + onError: (error) => toast.error(error.message), + }); + + return ( + +
+
+ + + +
+

Set an issue blocker

+

+ This issue stays blocked until the selected issue is done, canceled, or removed. +

+
+ +
+
+
+ setQuery(event.target.value)} + placeholder="Search by issue key or title…" + aria-label="Search issues to use as a blocker" + autoFocus + /> +
+ {search.isLoading ? ( +
Loading issues…
+ ) : candidates.length === 0 ? ( +
+ + {query.trim() ? "No matching open issues." : "No additional open issues available."} +
+ ) : ( +
    + {candidates.map((candidate) => ( +
  • + +
  • + ))} +
+ )} +
+

+ Need a person or decision instead? Use /blocked reason{" "} + in a comment to open an operator-attention request. +

+
+
+ ); +} diff --git a/tests/e2e/issue-blocker-controls.spec.ts b/tests/e2e/issue-blocker-controls.spec.ts new file mode 100644 index 00000000..97ba179d --- /dev/null +++ b/tests/e2e/issue-blocker-controls.spec.ts @@ -0,0 +1,77 @@ +import { expect, test } from "@playwright/test"; +import { PrismaClient, RelationKind } from "@prisma/client"; + +const prisma = new PrismaClient({ + datasourceUrl: process.env.DATABASE_URL, +}); + +async function blockerFixture() { + const [blocked, blocker] = await Promise.all([ + prisma.issue.findFirstOrThrow({ + where: { workspace: { slug: "forge" }, number: 22 }, + select: { id: true, title: true }, + }), + prisma.issue.findFirstOrThrow({ + where: { workspace: { slug: "forge" }, number: 18 }, + select: { id: true, title: true }, + }), + ]); + return { blocked, blocker }; +} + +async function clearFixtureRelations() { + const { blocked, blocker } = await blockerFixture(); + await prisma.issueRelation.deleteMany({ + where: { + OR: [ + { + fromIssueId: blocked.id, + toIssueId: blocker.id, + kind: RelationKind.BLOCKED_BY, + }, + { + fromIssueId: blocker.id, + toIssueId: blocked.id, + kind: RelationKind.BLOCKS, + }, + ], + }, + }); +} + +test.beforeEach(async () => { + await clearFixtureRelations(); +}); + +test.afterEach(async () => { + await clearFixtureRelations(); +}); + +test.afterAll(async () => { + await prisma.$disconnect(); +}); + +test("sets, surfaces, and removes a native dependency blocker", async ({ page }) => { + const { blocked } = await blockerFixture(); + await page.goto(`/w/forge/issues/${blocked.id}`); + + await page.getByRole("button", { name: "Set blocker", exact: true }).click(); + await page.getByLabel("Search issues to use as a blocker").fill("FRG-18"); + await page.getByText("FRG-18", { exact: true }).click(); + + const banner = page.getByTestId("issue-blocker-banner"); + await expect(banner).toBeVisible(); + await expect(banner).toContainText("Blocked by 1 open issue"); + await expect(banner).toContainText("Workspace switcher onboarding tour"); + await expect(page.getByRole("button", { name: "Blocked · 1" })).toBeVisible(); + + await page.goto("/w/forge/dashboard"); + await page.getByRole("tab", { name: /Blocked/ }).click(); + await expect(page.getByRole("tabpanel")).toContainText(blocked.title); + await expect(page.getByTestId("dashboard-work-lanes")).not.toContainText(blocked.title); + + await page.goto(`/w/forge/issues/${blocked.id}`); + await page.getByRole("button", { name: "Remove blocker FRG-18" }).click(); + await expect(page.getByTestId("issue-blocker-banner")).toHaveCount(0); + await expect(page.getByRole("button", { name: "Set blocker", exact: true })).toBeVisible(); +}); diff --git a/tests/unit/issue-blocker-control.test.ts b/tests/unit/issue-blocker-control.test.ts new file mode 100644 index 00000000..cedc8a49 --- /dev/null +++ b/tests/unit/issue-blocker-control.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + activeIssueBlockers, + type IssueBlockerRelation, +} from "@/components/issue-detail/issue-blocker-control"; + +function blocker(id: string, statusCategory: string): IssueBlockerRelation { + return { + relationId: `relation-${id}`, + target: { + id, + number: Number(id), + title: `Blocker ${id}`, + statusCategory, + }, + }; +} + +describe("activeIssueBlockers", () => { + it("keeps every open dependency and ignores terminal blockers", () => { + const rows = [ + blocker("1", "TODO"), + blocker("2", "IN_PROGRESS"), + blocker("3", "IN_REVIEW"), + blocker("4", "DONE"), + blocker("5", "CANCELED"), + ]; + + expect(activeIssueBlockers(rows).map((row) => row.target.id)).toEqual(["1", "2", "3"]); + }); + + it("returns an empty set once every dependency is terminal", () => { + expect(activeIssueBlockers([blocker("1", "DONE"), blocker("2", "CANCELED")])).toEqual([]); + }); +});