diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index eb81657f74..8a6c6c64ff 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -951,6 +951,7 @@ export type { IssueSubtreeDiagnosticEdge, IssueSubtreeDiagnosticsResponse, IssueBlockerAttention, + IssueBlockerAttentionIssueSummary, IssueBlockerAttentionReason, IssueBlockerAttentionState, IssueReviewAttention, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 52ff2ed3a6..e200afb70a 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -604,6 +604,7 @@ export type { IssueSubtreeDiagnosticEdge, IssueSubtreeDiagnosticsResponse, IssueBlockerAttention, + IssueBlockerAttentionIssueSummary, IssueBlockerAttentionReason, IssueBlockerAttentionState, IssueReviewAttention, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 27efaea158..769594118c 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -396,6 +396,12 @@ export type IssueBlockerAttentionReason = | "attention_required" | null; +export interface IssueBlockerAttentionIssueSummary { + id: string; + identifier: string | null; + title: string; +} + export interface IssueBlockerAttention { state: IssueBlockerAttentionState; reason: IssueBlockerAttentionReason; @@ -408,8 +414,12 @@ export interface IssueBlockerAttention { sampleStalledBlockerIdentifier: string | null; /** True when a blocker or one of its open descendants is actively progressing. */ blockingTreeLive?: boolean; - /** The sampled leaf blocker that requires action, rather than the blocked root. */ + /** The direct blocker whose chain contains the sampled terminal blocker. */ + directBlockerIssueId?: string | null; + /** The sampled blocker that requires action, rather than the blocked root. */ terminalBlockerIssueId?: string | null; + /** Link-ready details for the sampled blocker, including non-terminal intermediate nodes. */ + terminalBlocker?: IssueBlockerAttentionIssueSummary | null; } export type IssueReviewAttentionState = "none" | "covered" | "stalled"; diff --git a/server/src/__tests__/issue-blocker-attention.test.ts b/server/src/__tests__/issue-blocker-attention.test.ts index 5635320dd4..7cf294dc7e 100644 --- a/server/src/__tests__/issue-blocker-attention.test.ts +++ b/server/src/__tests__/issue-blocker-attention.test.ts @@ -447,6 +447,47 @@ describeEmbeddedPostgres("issue blocker attention", () => { }); }); + it("returns the direct path and link details when an intermediate blocker is selected", async () => { + const { companyId, agentId } = await createCompany("PBI"); + const rootId = await insertIssue({ companyId, identifier: "PBI-1", title: "Root", status: "blocked" }); + const directId = await insertIssue({ + companyId, + identifier: "PBI-2", + title: "Direct blocker", + status: "blocked", + }); + const intermediateId = await insertIssue({ + companyId, + identifier: "PBI-3", + title: "Stalled intermediate review", + status: "in_review", + assigneeAgentId: agentId, + }); + const leafId = await insertIssue({ + companyId, + identifier: "PBI-4", + title: "Downstream leaf", + status: "todo", + assigneeAgentId: agentId, + }); + await block({ companyId, blockerIssueId: directId, blockedIssueId: rootId }); + await block({ companyId, blockerIssueId: intermediateId, blockedIssueId: directId }); + await block({ companyId, blockerIssueId: leafId, blockedIssueId: intermediateId }); + + const root = (await svc.list(companyId, { status: "blocked" })).find((issue) => issue.id === rootId); + + expect(root?.blockerAttention).toMatchObject({ + state: "stalled", + directBlockerIssueId: directId, + terminalBlockerIssueId: intermediateId, + terminalBlocker: { + id: intermediateId, + identifier: "PBI-3", + title: "Stalled intermediate review", + }, + }); + }); + it("prefers needs_attention over stalled when the chain also has a hard attention case", async () => { const { companyId, agentId } = await createCompany("PBQ"); const parentId = await insertIssue({ companyId, identifier: "PBQ-1", title: "Parent", status: "blocked" }); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 057cf5a37e..f3efaeac5c 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -2094,7 +2094,9 @@ function createIssueBlockerAttention(input: Partial = {}) sampleBlockerIdentifier: input.sampleBlockerIdentifier ?? null, sampleStalledBlockerIdentifier: input.sampleStalledBlockerIdentifier ?? null, blockingTreeLive: input.blockingTreeLive ?? false, + directBlockerIssueId: input.directBlockerIssueId ?? null, terminalBlockerIssueId: input.terminalBlockerIssueId ?? null, + terminalBlocker: input.terminalBlocker ?? null, }; } @@ -2758,6 +2760,11 @@ async function listIssueBlockerAttentionMap( const sampledTerminalIdentifier = sampleEntry?.result.stalled ? sampleEntry.result.sampleStalledBlockerIdentifier ?? sampleEntry.result.sampleBlockerIdentifier : sampleEntry?.result.sampleBlockerIdentifier ?? blockerSampleIdentifier(sampleNode); + const terminalBlockerIssueId = + sampleEntry?.result.terminalBlockerIssueId ?? issueIdForSample(sampledTerminalIdentifier); + const terminalBlockerNode = terminalBlockerIssueId + ? nodesById.get(terminalBlockerIssueId) ?? null + : null; let state: IssueBlockerAttention["state"]; let reason: IssueBlockerAttention["reason"]; @@ -2788,8 +2795,15 @@ async function listIssueBlockerAttentionMap( sampleStalledBlockerIdentifier: stalledEntry?.result.sampleStalledBlockerIdentifier ?? sampleStalledFromChain ?? null, blockingTreeLive: topLevelEdges.some((edge) => pathHasLiveWork(edge.blockerIssueId, new Set([root.id]))), - terminalBlockerIssueId: - sampleEntry?.result.terminalBlockerIssueId ?? issueIdForSample(sampledTerminalIdentifier), + directBlockerIssueId: sampleEntry?.edge.blockerIssueId ?? null, + terminalBlockerIssueId, + terminalBlocker: terminalBlockerNode + ? { + id: terminalBlockerNode.id, + identifier: terminalBlockerNode.identifier, + title: terminalBlockerNode.title, + } + : null, })); } diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 43c9142762..d962b3df53 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -20,6 +20,11 @@ vi.mock("@/context/SidebarContext", () => ({ vi.mock("@/hooks/useIssuePlanDocument", () => ({ useIssuePlanDocument: () => ({ data: null }), })); +vi.mock("@/lib/router", () => ({ + Link: ({ to, children, ...props }: { to: string; children: React.ReactNode }) => ( + {children} + ), +})); vi.mock("@/components/MarkdownEditor", () => ({ MarkdownEditor: forwardRef(function MockMarkdownEditor( { value }: { value: string }, @@ -53,11 +58,39 @@ function render(ui: ReactElement) { flushSync(() => root!.render({ui})); } +function fakeScrollGeometry( + element: HTMLElement, + { scrollHeight = 1000, clientHeight = 400, scrollTop = 600 } = {}, +) { + let currentScrollTop = scrollTop; + Object.defineProperty(element, "scrollHeight", { value: scrollHeight, configurable: true }); + Object.defineProperty(element, "clientHeight", { value: clientHeight, configurable: true }); + Object.defineProperty(element, "scrollTop", { + get: () => currentScrollTop, + set: (value: number) => { + currentScrollTop = value; + }, + configurable: true, + }); +} + describe("TaskChatThread draft pass-through", () => { it("keeps the composer dock aligned with the thread's horizontal padding", () => { render( {}} />, ); @@ -96,6 +129,222 @@ describe("TaskChatThread composer alignment (PAP-498)", () => { }); }); +describe("TaskChatThread blocker links", () => { + it("shows the direct and server-selected terminal blocker at the top and bottom", () => { + const terminalBlocker = { + id: "terminal-2", + identifier: "PAP-777", + title: "Actual work", + status: "in_progress" as const, + priority: "high" as const, + assigneeAgentId: "agent-2", + assigneeUserId: null, + }; + const directBlocker = { + id: "direct-2", + identifier: "PAP-600", + title: "Waiting in review", + status: "in_review" as const, + priority: "medium" as const, + assigneeAgentId: "agent-1", + assigneeUserId: null, + terminalBlockers: [terminalBlocker], + }; + + render( + {}} + issueStatus="blocked" + blockedBy={[ + { + id: "direct-1", + identifier: "PAP-500", + title: "Different dependency", + status: "todo", + priority: "low", + assigneeAgentId: null, + assigneeUserId: null, + }, + directBlocker, + ]} + blockerAttention={{ + state: "needs_attention", + reason: "attention_required", + unresolvedBlockerCount: 2, + coveredBlockerCount: 0, + stalledBlockerCount: 0, + attentionBlockerCount: 1, + sampleBlockerIdentifier: "PAP-777", + sampleStalledBlockerIdentifier: null, + terminalBlockerIssueId: terminalBlocker.id, + }} + />, + ); + + const notices = container.querySelectorAll('[data-testid="task-chat-blocker-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("Blocked byPAP-600Waiting in review"); + expect(notice.textContent).toContain("Ultimately blocked byPAP-777Actual work"); + expect(notice.querySelector('a[href="/issues/PAP-600"]')).not.toBeNull(); + expect(notice.querySelector('a[href="/issues/PAP-777"]')).not.toBeNull(); + } + expect(container.textContent).not.toContain("Different dependency"); + expect(container.textContent).not.toContain("This task resumes automatically"); + }); + + it("shows only the direct row when the blocker has no deeper unresolved leaf", () => { + render( + {}} + issueStatus="blocked" + blockedBy={[{ + id: "direct-1", + identifier: "PAP-500", + title: "Direct dependency", + status: "in_progress", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + }]} + />, + ); + + expect(container.querySelectorAll('[data-testid="task-chat-blocker-links"]')).toHaveLength(2); + expect(container.textContent).toContain("Blocked byPAP-500Direct dependency"); + expect(container.textContent).not.toContain("Ultimately blocked by"); + }); + + it("keeps a server-selected intermediate blocker on its direct chain", () => { + const selectedIntermediate = { + id: "intermediate-2", + identifier: "PAP-650", + title: "Stalled intermediate review", + }; + const selectedDirect = { + id: "direct-2", + identifier: "PAP-600", + title: "Selected dependency", + status: "blocked" as const, + priority: "medium" as const, + assigneeAgentId: "agent-1", + assigneeUserId: null, + terminalBlockers: [{ + id: "leaf-2", + identifier: "PAP-700", + title: "Deeper structural leaf", + status: "todo" as const, + priority: "medium" as const, + assigneeAgentId: "agent-2", + assigneeUserId: null, + }], + }; + + render( + {}} + issueStatus="blocked" + blockedBy={[ + { + id: "direct-1", + identifier: "PAP-500", + title: "Unrelated dependency", + status: "todo", + priority: "low", + assigneeAgentId: null, + assigneeUserId: null, + }, + selectedDirect, + ]} + blockerAttention={{ + state: "stalled", + reason: "stalled_review", + unresolvedBlockerCount: 2, + coveredBlockerCount: 0, + stalledBlockerCount: 1, + attentionBlockerCount: 1, + sampleBlockerIdentifier: "PAP-650", + sampleStalledBlockerIdentifier: "PAP-650", + directBlockerIssueId: selectedDirect.id, + terminalBlockerIssueId: selectedIntermediate.id, + terminalBlocker: selectedIntermediate, + }} + />, + ); + + for (const notice of container.querySelectorAll('[data-testid="task-chat-blocker-links"]')) { + expect(notice.textContent).toContain("Blocked byPAP-600Selected dependency"); + expect(notice.textContent).toContain("Ultimately blocked byPAP-650Stalled intermediate review"); + } + expect(container.textContent).not.toContain("Unrelated dependency"); + expect(container.textContent).not.toContain("Deeper structural leaf"); + }); + + it("auto-follows the new bottom blocker row when a pinned thread becomes blocked", () => { + const comment = { + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorType: "user" as const, + authorAgentId: null, + authorUserId: "user-1", + body: "Waiting for the dependency.", + presentation: null, + metadata: null, + createdAt: new Date("2026-08-15T12:00:00.000Z"), + updatedAt: new Date("2026-08-15T12:00:00.000Z"), + }; + const directBlocker = { + id: "direct-1", + identifier: "PAP-500", + title: "Direct dependency", + status: "in_progress" as const, + priority: "medium" as const, + assigneeAgentId: "agent-1", + assigneeUserId: null, + }; + const baseProps = { + comments: [comment], + onAdd: async () => {}, + blockedBy: [directBlocker], + }; + + render(); + const scroller = container.querySelector('[data-testid="task-chat-scroller"]')!; + fakeScrollGeometry(scroller); + + render(); + + expect(scroller.scrollTop).toBe(scroller.scrollHeight); + }); + + it("does not show blocker rows outside the blocked state", () => { + render( + {}} + issueStatus="in_progress" + blockedBy={[{ + id: "direct-1", + identifier: "PAP-500", + title: "Direct dependency", + status: "in_progress", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + }]} + />, + ); + + expect(container.querySelector('[data-testid="task-chat-blocker-links"]')).toBeNull(); + }); +}); + describe("TaskChatThread queued message actions", () => { it("interrupts the exact run that a persisted queued message is waiting behind", () => { const onInterruptQueued = vi.fn(async () => {}); diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 86ce55de92..dcf69d4214 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -45,6 +45,10 @@ import { Button } from "@/components/ui/button"; import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument"; import { latestSameRunHandoffTimestamp } from "@/lib/issue-chat-messages"; import { isLiveIssueRun, isTerminalIssueStatus } from "@/lib/liveIssueIds"; +import { + resolveTaskChatBlockers, + TaskChatBlockerLinks, +} from "@/components/task-chat/TaskChatBlockerLinks"; function toMs(value: Date | string | null | undefined): number { if (!value) return 0; @@ -126,8 +130,49 @@ export function TaskChatThread(props: TaskChatThreadProps) { draftKey, onInterruptQueued, interruptingQueuedRunId, + blockedBy = [], + blockerAttention, } = props; + const blockerLinks = useMemo( + () => issueStatus === "blocked" + ? resolveTaskChatBlockers( + blockedBy, + blockerAttention?.terminalBlockerIssueId, + blockerAttention?.directBlockerIssueId, + blockerAttention?.terminalBlocker, + ) + : null, + [ + blockedBy, + blockerAttention?.directBlockerIssueId, + blockerAttention?.terminalBlocker, + blockerAttention?.terminalBlockerIssueId, + issueStatus, + ], + ); + + const threadHeaderWithBlockers = threadHeader || blockerLinks ? ( + <> + {threadHeader} + {blockerLinks ? ( + + ) : null} + + ) : undefined; + + const bottomBlockerLinks = blockerLinks ? ( + + ) : null; + const linkedRunMetaById = useMemo(() => { const map = new Map[number]>(); for (const run of linkedRuns ?? []) map.set(run.runId, run); @@ -436,7 +481,10 @@ export function TaskChatThread(props: TaskChatThreadProps) { if ("content" in entry) return total + entry.content.length; return total + entry.kind.length; }, tailEntries.length); - const threadContentKey = taskChatContentKey(items) + tailContentKey; + const blockerContentKey = blockerLinks + ? `${blockerLinks.directBlocker.id}:${blockerLinks.ultimateBlocker?.id ?? ""}` + : ""; + const threadContentKey = `${taskChatContentKey(items)}:${tailContentKey}:${blockerContentKey}`; // Status-pill inputs for the tail (PAP-461, A1): the run's start, its finish // (once terminal), and the "called N tools" summary. Memoized on the @@ -592,41 +640,51 @@ export function TaskChatThread(props: TaskChatThreadProps) {
{items.length === 0 && !tailRunId ? (
- {threadHeader ? ( + {threadHeaderWithBlockers ? (
- {threadHeader} + {threadHeaderWithBlockers}
) : null}
{emptyMessage}
+ {bottomBlockerLinks ? ( +
+ {bottomBlockerLinks} +
+ ) : null}
) : ( : undefined} renderMessageActions={renderMessageActions} renderQueuedAction={renderQueuedAction} - tail={tailRunId ? ( -
- - -
+ tail={tailRunId || bottomBlockerLinks ? ( + <> + {tailRunId ? ( +
+ + +
+ ) : null} + {bottomBlockerLinks} + ) : null} contentKey={threadContentKey} scroll={!isMobile} diff --git a/ui/src/components/task-chat/TaskChatBlockerLinks.tsx b/ui/src/components/task-chat/TaskChatBlockerLinks.tsx new file mode 100644 index 0000000000..d5f76a96d9 --- /dev/null +++ b/ui/src/components/task-chat/TaskChatBlockerLinks.tsx @@ -0,0 +1,104 @@ +import type { + IssueBlockerAttentionIssueSummary, + IssueRelationIssueSummary, +} from "@paperclipai/shared"; +import { createIssueDetailPath } from "@/lib/issueDetailBreadcrumb"; +import { Link } from "@/lib/router"; + +function isUnresolved(blocker: IssueRelationIssueSummary): boolean { + return blocker.status !== "done" && blocker.status !== "cancelled"; +} + +export function resolveTaskChatBlockers( + blockers: IssueRelationIssueSummary[], + terminalBlockerIssueId?: string | null, + directBlockerIssueId?: string | null, + terminalBlocker?: IssueBlockerAttentionIssueSummary | null, +): { + directBlocker: IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary; + ultimateBlocker: IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary | null; +} | null { + const unresolvedBlockers = blockers.filter(isUnresolved); + if (unresolvedBlockers.length === 0) return null; + + const directBlocker = directBlockerIssueId + ? unresolvedBlockers.find((blocker) => blocker.id === directBlockerIssueId) + : terminalBlockerIssueId + ? unresolvedBlockers.find((blocker) => ( + blocker.id === terminalBlockerIssueId + || blocker.terminalBlockers?.some((terminal) => terminal.id === terminalBlockerIssueId) + )) + : unresolvedBlockers[0]; + + // A selected intermediate blocker is not part of `terminalBlockers`, which + // intentionally contains only structural leaves. If its direct path is not + // in this payload (for example a child-derived attention path), show the + // selected task itself instead of falling back to an unrelated blocker. + if (!directBlocker) { + if (!terminalBlocker) return null; + return { + directBlocker: terminalBlocker, + ultimateBlocker: null, + }; + } + + const terminalBlockers = directBlocker.terminalBlockers?.filter(isUnresolved) ?? []; + const ultimateBlocker = terminalBlockerIssueId + ? terminalBlocker?.id === terminalBlockerIssueId + ? terminalBlocker + : terminalBlockers.find((blocker) => blocker.id === terminalBlockerIssueId) ?? null + : terminalBlockers[0] ?? null; + + return { + directBlocker, + ultimateBlocker: ultimateBlocker?.id === directBlocker.id ? null : ultimateBlocker, + }; +} + +function BlockerRow({ + label, + blocker, +}: { + label: string; + blocker: IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary; +}) { + const issuePathId = blocker.identifier ?? blocker.id; + + return ( +
+ {label} + + {blocker.identifier ?? blocker.id.slice(0, 8)} + {blocker.title} + +
+ ); +} + +export function TaskChatBlockerLinks({ + directBlocker, + ultimateBlocker, + placement, +}: { + directBlocker: IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary; + ultimateBlocker: IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary | null; + placement: "top" | "bottom"; +}) { + return ( +
+ + {ultimateBlocker ? ( + + ) : null} +
+ ); +} diff --git a/ui/src/components/task-chat/TaskChatThreadView.tsx b/ui/src/components/task-chat/TaskChatThreadView.tsx index c503bcef84..a3bb398c3b 100644 --- a/ui/src/components/task-chat/TaskChatThreadView.tsx +++ b/ui/src/components/task-chat/TaskChatThreadView.tsx @@ -46,7 +46,7 @@ interface TaskChatThreadViewProps { /** Content appended inside the transcript scroller after the settled thread. */ tail?: ReactNode; /** Optional streaming-aware key when `tail` changes without changing `items`. */ - contentKey?: number; + contentKey?: unknown; className?: string; /** When false, render the list without the scroll container (e.g. previews). */ scroll?: boolean; diff --git a/ui/storybook/stories/issue-blocked-notice.stories.tsx b/ui/storybook/stories/issue-blocked-notice.stories.tsx index 54b5120241..e1333722d4 100644 --- a/ui/storybook/stories/issue-blocked-notice.stories.tsx +++ b/ui/storybook/stories/issue-blocked-notice.stories.tsx @@ -1,6 +1,7 @@ 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"; // 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. @@ -98,6 +99,30 @@ export const RuleCChainNamesLeaf: Story = { ), }; +export const TaskChatCompactRows: Story = { + name: "Task chat · compact direct and ultimate blocker rows", + render: () => ( + + + + ), +}; + export const RuleCMultipleBlockers: Story = { name: "Rule C · several unresolved blockers", render: () => (