diff --git a/ui/src/components/IssueBlockedNotice.tsx b/ui/src/components/IssueBlockedNotice.tsx index 9968bf545a..a6de3c344e 100644 --- a/ui/src/components/IssueBlockedNotice.tsx +++ b/ui/src/components/IssueBlockedNotice.tsx @@ -15,7 +15,11 @@ import { formatMonitorOffset } from "../lib/issue-monitor"; import { useRetryNowMutation } from "../hooks/useRetryNowMutation"; import { IssueLinkQuicklook } from "./IssueLinkQuicklook"; import { RetryErrorBand } from "./IssueScheduledRetryCard"; -import { isAssignedBacklogBlocker } from "../lib/issue-blockers"; +import { + isAssignedBacklogBlocker, + orderWaitingBlockers, + type WaitingBlockerStatus, +} from "../lib/issue-blockers"; import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff"; import { Badge } from "@/components/ui/badge"; import { @@ -114,27 +118,6 @@ function SuccessfulRunRetryNowControl({ const EMPTY_LIVE_IDS: ReadonlySet = new Set(); -type WaitingStepStatus = "done" | "running" | "queued"; - -function classifyWaitingStep( - blocker: IssueRelationIssueSummary, - liveIds: ReadonlySet, -): WaitingStepStatus { - // A resolved blocker (done/cancelled) is a completed step; a blocker with a - // live run is the one currently being worked; everything else is queued. - if (blocker.status === "done" || blocker.status === "cancelled") return "done"; - if (liveIds.has(blocker.id)) return "running"; - return "queued"; -} - -// Ordering heuristic (plan §3): done → running → queued, tie-break by identifier -// (P1…Pn plan naming). The payload doesn't carry explicit chain order. -const WAITING_STEP_RANK: Record = { - done: 0, - running: 1, - queued: 2, -}; - function waitingTaskStatusLabel(status: string): string { return status.replace(/_/g, " ").replace(/\b\w/g, (character) => character.toUpperCase()); } @@ -171,7 +154,7 @@ function WaitingChipLink({ ); } -function WaitingStepGlyph({ status }: { status: WaitingStepStatus }) { +function WaitingStepGlyph({ status }: { status: WaitingBlockerStatus }) { if (status === "done") { return ; } @@ -255,15 +238,7 @@ function WaitingOnLiveWorkNotice({ parkedBlockers: IssueRelationIssueSummary[]; renderParkedChip: (blocker: IssueRelationIssueSummary) => ReactNode; }) { - const steps = chainBlockers - .map((blocker) => ({ blocker, status: classifyWaitingStep(blocker, liveIds) })) - .sort((a, b) => { - const rank = WAITING_STEP_RANK[a.status] - WAITING_STEP_RANK[b.status]; - if (rank !== 0) return rank; - const aKey = a.blocker.identifier ?? a.blocker.id; - const bKey = b.blocker.identifier ?? b.blocker.id; - return aKey.localeCompare(bKey, undefined, { numeric: true }); - }); + const steps = orderWaitingBlockers(chainBlockers, liveIds); const total = steps.length; const doneCount = steps.filter((step) => step.status === "done").length; const runningCount = steps.filter((step) => step.status === "running").length; diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index d962b3df53..1f3ec7d5b8 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -196,6 +196,123 @@ describe("TaskChatThread blocker links", () => { expect(container.textContent).not.toContain("This task resumes automatically"); }); + it("shows the ordered live-work queue at the top and bottom", () => { + const terminalBlocker = { + id: "terminal-running", + identifier: "PAP-17426", + title: "Restore live alias projection", + status: "in_progress" as const, + priority: "high" as const, + assigneeAgentId: "agent-3", + assigneeUserId: null, + }; + + render( + {}} + issueStatus="blocked" + liveIssueIds={new Set(["direct-running", "terminal-running"])} + blockerAttention={{ + state: "covered", + reason: "active_dependency", + unresolvedBlockerCount: 2, + coveredBlockerCount: 2, + stalledBlockerCount: 0, + attentionBlockerCount: 0, + sampleBlockerIdentifier: "PAP-17426", + sampleStalledBlockerIdentifier: null, + blockingTreeLive: true, + directBlockerIssueId: "direct-running", + terminalBlockerIssueId: terminalBlocker.id, + terminalBlocker, + }} + blockedBy={[ + { + id: "direct-queued", + identifier: "PAP-17427", + title: "Verify the completed projection", + status: "todo", + priority: "medium", + assigneeAgentId: "agent-4", + assigneeUserId: null, + }, + { + id: "direct-running", + identifier: "PAP-17425", + title: "Verify the live projection", + status: "in_progress", + priority: "medium", + assigneeAgentId: "agent-2", + assigneeUserId: null, + terminalBlockers: [terminalBlocker], + }, + { + id: "direct-done", + identifier: "PAP-17424", + title: "Run the guarded cutover", + status: "done", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + }, + ]} + />, + ); + + const notices = container.querySelectorAll('[data-testid="task-chat-live-work-links"]'); + expect(notices).toHaveLength(2); + expect(notices[0]?.getAttribute("data-placement")).toBe("top"); + expect(notices[1]?.getAttribute("data-placement")).toBe("bottom"); + for (const notice of notices) { + expect(notice.textContent).toContain("Waiting on live work"); + const orderedLinks = [...notice.querySelectorAll('[data-testid="task-chat-live-work-step"] a')] + .map((link) => link.textContent); + expect(orderedLinks).toEqual([ + "PAP-17424Run the guarded cutover", + "PAP-17425Verify the live projection", + "PAP-17427Verify the completed projection", + ]); + expect(notice.textContent).toContain("Now runningPAP-17426Restore live alias projection"); + expect(notice.querySelector('a[href="/issues/PAP-17426"]')).not.toBeNull(); + } + expect(container.querySelector('[data-testid="task-chat-blocker-links"]')).toBeNull(); + }); + + it("keeps the compact blocker rows when covered work is no longer live", () => { + render( + {}} + issueStatus="blocked" + liveIssueIds={new Set()} + blockerAttention={{ + state: "covered", + reason: "active_dependency", + unresolvedBlockerCount: 1, + coveredBlockerCount: 1, + stalledBlockerCount: 0, + attentionBlockerCount: 0, + sampleBlockerIdentifier: "PAP-500", + sampleStalledBlockerIdentifier: null, + blockingTreeLive: false, + }} + blockedBy={[{ + id: "direct-1", + identifier: "PAP-500", + title: "Direct dependency", + status: "todo", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + }]} + />, + ); + + expect(container.querySelector('[data-testid="task-chat-live-work-links"]')).toBeNull(); + expect(container.querySelectorAll('[data-testid="task-chat-blocker-links"]')).toHaveLength(2); + }); + it("shows only the direct row when the blocker has no deeper unresolved leaf", () => { render( = new Set(); export type TaskChatThreadProps = ComponentProps; @@ -132,10 +135,18 @@ export function TaskChatThread(props: TaskChatThreadProps) { interruptingQueuedRunId, blockedBy = [], blockerAttention, + liveIssueIds, } = props; + const liveWorkLinks = useMemo( + () => issueStatus === "blocked" && blockerAttention?.state === "covered" + ? resolveTaskChatLiveWork(blockedBy, liveIssueIds ?? EMPTY_LIVE_ISSUE_IDS, blockerAttention.terminalBlocker) + : null, + [blockedBy, blockerAttention?.state, blockerAttention?.terminalBlocker, issueStatus, liveIssueIds], + ); + const blockerLinks = useMemo( - () => issueStatus === "blocked" + () => issueStatus === "blocked" && !liveWorkLinks ? resolveTaskChatBlockers( blockedBy, blockerAttention?.terminalBlockerIssueId, @@ -149,13 +160,16 @@ export function TaskChatThread(props: TaskChatThreadProps) { blockerAttention?.terminalBlocker, blockerAttention?.terminalBlockerIssueId, issueStatus, + liveWorkLinks, ], ); - const threadHeaderWithBlockers = threadHeader || blockerLinks ? ( + const threadHeaderWithBlockers = threadHeader || blockerLinks || liveWorkLinks ? ( <> {threadHeader} - {blockerLinks ? ( + {liveWorkLinks ? ( + + ) : blockerLinks ? ( ) : undefined; - const bottomBlockerLinks = blockerLinks ? ( + const bottomBlockerLinks = liveWorkLinks ? ( + + ) : blockerLinks ? ( `${step.blocker.id}:${step.status}`).join(",")}:${liveWorkLinks.nowRunning.map((blocker) => blocker.id).join(",")}` + : ""; const threadContentKey = `${taskChatContentKey(items)}:${tailContentKey}:${blockerContentKey}`; // Status-pill inputs for the tail (PAP-461, A1): the run's start, its finish diff --git a/ui/src/components/task-chat/TaskChatBlockerLinks.tsx b/ui/src/components/task-chat/TaskChatBlockerLinks.tsx index d5f76a96d9..0e55b80785 100644 --- a/ui/src/components/task-chat/TaskChatBlockerLinks.tsx +++ b/ui/src/components/task-chat/TaskChatBlockerLinks.tsx @@ -2,7 +2,13 @@ import type { IssueBlockerAttentionIssueSummary, IssueRelationIssueSummary, } from "@paperclipai/shared"; +import { CheckCircle2, Circle } from "lucide-react"; import { createIssueDetailPath } from "@/lib/issueDetailBreadcrumb"; +import { + orderWaitingBlockers, + type WaitingBlockerStatus, + type WaitingBlockerStep, +} from "@/lib/issue-blockers"; import { Link } from "@/lib/router"; function isUnresolved(blocker: IssueRelationIssueSummary): boolean { @@ -55,6 +61,38 @@ export function resolveTaskChatBlockers( }; } +export interface ResolvedTaskChatLiveWork { + steps: WaitingBlockerStep[]; + nowRunning: Array; +} + +export function resolveTaskChatLiveWork( + blockers: IssueRelationIssueSummary[], + liveIssueIds: ReadonlySet, + selectedTerminalBlocker?: IssueBlockerAttentionIssueSummary | null, +): ResolvedTaskChatLiveWork | null { + if (blockers.length === 0) return null; + + const steps = orderWaitingBlockers(blockers, liveIssueIds); + const stepIds = new Set(steps.map((step) => step.blocker.id)); + const terminalCandidates: Array = []; + for (const blocker of blockers) terminalCandidates.push(...(blocker.terminalBlockers ?? [])); + if (selectedTerminalBlocker) terminalCandidates.push(selectedTerminalBlocker); + + const nowRunning: Array = []; + const seen = new Set(); + for (const blocker of terminalCandidates) { + if (!liveIssueIds.has(blocker.id) || stepIds.has(blocker.id) || seen.has(blocker.id)) continue; + seen.add(blocker.id); + nowRunning.push(blocker); + } + + const hasLiveStep = steps.some((step) => step.status === "running"); + if (!hasLiveStep && nowRunning.length === 0) return null; + + return { steps, nowRunning }; +} + function BlockerRow({ label, blocker, @@ -79,6 +117,51 @@ function BlockerRow({ ); } +function LiveWorkGlyph({ status }: { status: WaitingBlockerStatus }) { + const label = status === "done" ? "Done" : status === "running" ? "Running" : "Waiting"; + if (status === "done") { + return ( + + ); + } + if (status === "running") { + return ( + + + + ); + } + return ( + + ); +} + +function LiveWorkLink({ + blocker, +}: { + blocker: IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary; +}) { + const issuePathId = blocker.identifier ?? blocker.id; + return ( + + {blocker.identifier ?? blocker.id.slice(0, 8)} + {blocker.title} + + ); +} + export function TaskChatBlockerLinks({ directBlocker, ultimateBlocker, @@ -102,3 +185,48 @@ export function TaskChatBlockerLinks({ ); } + +export function TaskChatLiveWorkLinks({ + liveWork, + placement, +}: { + liveWork: ResolvedTaskChatLiveWork; + placement: "top" | "bottom"; +}) { + return ( +
+
+ + + + Waiting on live work +
+
    + {liveWork.steps.map(({ blocker, status }, index) => ( +
  1. + + {index + 1}. + + + +
  2. + ))} +
+ {liveWork.nowRunning.map((blocker) => ( +
+ Now running + +
+ ))} +
+ ); +} diff --git a/ui/src/lib/issue-blockers.ts b/ui/src/lib/issue-blockers.ts index 569c557f39..1ba09676e0 100644 --- a/ui/src/lib/issue-blockers.ts +++ b/ui/src/lib/issue-blockers.ts @@ -1,5 +1,49 @@ import type { IssueRelationIssueSummary } from "@paperclipai/shared"; +export type WaitingBlockerStatus = "done" | "running" | "queued"; + +export interface WaitingBlockerStep { + blocker: IssueRelationIssueSummary; + status: WaitingBlockerStatus; +} + +export function classifyWaitingBlocker( + blocker: IssueRelationIssueSummary, + liveIssueIds: ReadonlySet, +): WaitingBlockerStatus { + if (blocker.status === "done" || blocker.status === "cancelled") return "done"; + if (liveIssueIds.has(blocker.id)) return "running"; + return "queued"; +} + +const WAITING_BLOCKER_RANK: Record = { + done: 0, + running: 1, + queued: 2, +}; + +/** + * Orders the explicit blocker queue the same way in every task surface. + * + * The blocker payload does not carry an explicit sequence. Status gives the + * operational order (completed work, current work, queued work), while the + * identifier is the stable tie-break used by plan-generated task chains. + */ +export function orderWaitingBlockers( + blockers: IssueRelationIssueSummary[], + liveIssueIds: ReadonlySet, +): WaitingBlockerStep[] { + return blockers + .map((blocker) => ({ blocker, status: classifyWaitingBlocker(blocker, liveIssueIds) })) + .sort((a, b) => { + const rank = WAITING_BLOCKER_RANK[a.status] - WAITING_BLOCKER_RANK[b.status]; + if (rank !== 0) return rank; + const aKey = a.blocker.identifier ?? a.blocker.id; + const bKey = b.blocker.identifier ?? b.blocker.id; + return aKey.localeCompare(bKey, undefined, { numeric: true }); + }); +} + export function isAssignedBacklogBlocker(blocker: IssueRelationIssueSummary): boolean { return blocker.status === "backlog" && Boolean(blocker.assigneeAgentId); } diff --git a/ui/storybook/stories/issue-blocked-notice.stories.tsx b/ui/storybook/stories/issue-blocked-notice.stories.tsx index e1333722d4..a84bed3544 100644 --- a/ui/storybook/stories/issue-blocked-notice.stories.tsx +++ b/ui/storybook/stories/issue-blocked-notice.stories.tsx @@ -1,7 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { IssueRelationIssueSummary } from "@paperclipai/shared"; import { IssueBlockedNotice } from "@/components/IssueBlockedNotice"; -import { TaskChatBlockerLinks } from "@/components/task-chat/TaskChatBlockerLinks"; +import { + resolveTaskChatLiveWork, + TaskChatBlockerLinks, + TaskChatLiveWorkLinks, +} from "@/components/task-chat/TaskChatBlockerLinks"; // Rule C (PAP-13554): when a human comment on a `blocked` issue does not reopen // it, the blocked notice must state why and name the unresolved blocker leaf. @@ -123,6 +127,37 @@ export const TaskChatCompactRows: Story = { ), }; +export const TaskChatLiveWorkRows: Story = { + name: "Task chat · ordered live-work rows", + render: () => { + const terminal = blocker({ + id: "t1", + identifier: "PAP-603", + title: "Restore the live projection", + status: "in_progress", + assigneeAgentId: "agent-3", + }); + const liveWork = resolveTaskChatLiveWork([ + blocker({ id: "b1", identifier: "PAP-601", title: "Run the guarded cutover", status: "done" }), + blocker({ + id: "b2", + identifier: "PAP-602", + title: "Verify the live projection", + status: "in_progress", + assigneeAgentId: "agent-2", + terminalBlockers: [terminal], + }), + blocker({ id: "b3", identifier: "PAP-604", title: "Complete final QA", status: "todo" }), + ], new Set(["b2", "t1"]), terminal); + + return ( + + {liveWork ? : null} + + ); + }, +}; + export const RuleCMultipleBlockers: Story = { name: "Rule C · several unresolved blockers", render: () => (