Skip to content

Commit 41d802f

Browse files
fix(ui): preserve selected task blocker chains
Co-Authored-By: Paperclip <noreply@paperclip.ing>
1 parent c269db9 commit 41d802f

6 files changed

Lines changed: 239 additions & 19 deletions

File tree

packages/shared/src/types/issue.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,12 @@ export type IssueBlockerAttentionReason =
396396
| "attention_required"
397397
| null;
398398

399+
export interface IssueBlockerAttentionIssueSummary {
400+
id: string;
401+
identifier: string | null;
402+
title: string;
403+
}
404+
399405
export interface IssueBlockerAttention {
400406
state: IssueBlockerAttentionState;
401407
reason: IssueBlockerAttentionReason;
@@ -408,8 +414,12 @@ export interface IssueBlockerAttention {
408414
sampleStalledBlockerIdentifier: string | null;
409415
/** True when a blocker or one of its open descendants is actively progressing. */
410416
blockingTreeLive?: boolean;
411-
/** The sampled leaf blocker that requires action, rather than the blocked root. */
417+
/** The direct blocker whose chain contains the sampled terminal blocker. */
418+
directBlockerIssueId?: string | null;
419+
/** The sampled blocker that requires action, rather than the blocked root. */
412420
terminalBlockerIssueId?: string | null;
421+
/** Link-ready details for the sampled blocker, including non-terminal intermediate nodes. */
422+
terminalBlocker?: IssueBlockerAttentionIssueSummary | null;
413423
}
414424

415425
export type IssueReviewAttentionState = "none" | "covered" | "stalled";

server/src/__tests__/issue-blocker-attention.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,47 @@ describeEmbeddedPostgres("issue blocker attention", () => {
447447
});
448448
});
449449

450+
it("returns the direct path and link details when an intermediate blocker is selected", async () => {
451+
const { companyId, agentId } = await createCompany("PBI");
452+
const rootId = await insertIssue({ companyId, identifier: "PBI-1", title: "Root", status: "blocked" });
453+
const directId = await insertIssue({
454+
companyId,
455+
identifier: "PBI-2",
456+
title: "Direct blocker",
457+
status: "blocked",
458+
});
459+
const intermediateId = await insertIssue({
460+
companyId,
461+
identifier: "PBI-3",
462+
title: "Stalled intermediate review",
463+
status: "in_review",
464+
assigneeAgentId: agentId,
465+
});
466+
const leafId = await insertIssue({
467+
companyId,
468+
identifier: "PBI-4",
469+
title: "Downstream leaf",
470+
status: "todo",
471+
assigneeAgentId: agentId,
472+
});
473+
await block({ companyId, blockerIssueId: directId, blockedIssueId: rootId });
474+
await block({ companyId, blockerIssueId: intermediateId, blockedIssueId: directId });
475+
await block({ companyId, blockerIssueId: leafId, blockedIssueId: intermediateId });
476+
477+
const root = (await svc.list(companyId, { status: "blocked" })).find((issue) => issue.id === rootId);
478+
479+
expect(root?.blockerAttention).toMatchObject({
480+
state: "stalled",
481+
directBlockerIssueId: directId,
482+
terminalBlockerIssueId: intermediateId,
483+
terminalBlocker: {
484+
id: intermediateId,
485+
identifier: "PBI-3",
486+
title: "Stalled intermediate review",
487+
},
488+
});
489+
});
490+
450491
it("prefers needs_attention over stalled when the chain also has a hard attention case", async () => {
451492
const { companyId, agentId } = await createCompany("PBQ");
452493
const parentId = await insertIssue({ companyId, identifier: "PBQ-1", title: "Parent", status: "blocked" });

server/src/services/issues.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2094,7 +2094,9 @@ function createIssueBlockerAttention(input: Partial<IssueBlockerAttention> = {})
20942094
sampleBlockerIdentifier: input.sampleBlockerIdentifier ?? null,
20952095
sampleStalledBlockerIdentifier: input.sampleStalledBlockerIdentifier ?? null,
20962096
blockingTreeLive: input.blockingTreeLive ?? false,
2097+
directBlockerIssueId: input.directBlockerIssueId ?? null,
20972098
terminalBlockerIssueId: input.terminalBlockerIssueId ?? null,
2099+
terminalBlocker: input.terminalBlocker ?? null,
20982100
};
20992101
}
21002102

@@ -2758,6 +2760,11 @@ async function listIssueBlockerAttentionMap(
27582760
const sampledTerminalIdentifier = sampleEntry?.result.stalled
27592761
? sampleEntry.result.sampleStalledBlockerIdentifier ?? sampleEntry.result.sampleBlockerIdentifier
27602762
: sampleEntry?.result.sampleBlockerIdentifier ?? blockerSampleIdentifier(sampleNode);
2763+
const terminalBlockerIssueId =
2764+
sampleEntry?.result.terminalBlockerIssueId ?? issueIdForSample(sampledTerminalIdentifier);
2765+
const terminalBlockerNode = terminalBlockerIssueId
2766+
? nodesById.get(terminalBlockerIssueId) ?? null
2767+
: null;
27612768

27622769
let state: IssueBlockerAttention["state"];
27632770
let reason: IssueBlockerAttention["reason"];
@@ -2788,8 +2795,15 @@ async function listIssueBlockerAttentionMap(
27882795
sampleStalledBlockerIdentifier:
27892796
stalledEntry?.result.sampleStalledBlockerIdentifier ?? sampleStalledFromChain ?? null,
27902797
blockingTreeLive: topLevelEdges.some((edge) => pathHasLiveWork(edge.blockerIssueId, new Set([root.id]))),
2791-
terminalBlockerIssueId:
2792-
sampleEntry?.result.terminalBlockerIssueId ?? issueIdForSample(sampledTerminalIdentifier),
2798+
directBlockerIssueId: sampleEntry?.edge.blockerIssueId ?? null,
2799+
terminalBlockerIssueId,
2800+
terminalBlocker: terminalBlockerNode
2801+
? {
2802+
id: terminalBlockerNode.id,
2803+
identifier: terminalBlockerNode.identifier,
2804+
title: terminalBlockerNode.title,
2805+
}
2806+
: null,
27932807
}));
27942808
}
27952809

ui/src/components/TaskChatThread.test.tsx

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,22 @@ function render(ui: ReactElement) {
5858
flushSync(() => root!.render(<ThemeProvider>{ui}</ThemeProvider>));
5959
}
6060

61+
function fakeScrollGeometry(
62+
element: HTMLElement,
63+
{ scrollHeight = 1000, clientHeight = 400, scrollTop = 600 } = {},
64+
) {
65+
let currentScrollTop = scrollTop;
66+
Object.defineProperty(element, "scrollHeight", { value: scrollHeight, configurable: true });
67+
Object.defineProperty(element, "clientHeight", { value: clientHeight, configurable: true });
68+
Object.defineProperty(element, "scrollTop", {
69+
get: () => currentScrollTop,
70+
set: (value: number) => {
71+
currentScrollTop = value;
72+
},
73+
configurable: true,
74+
});
75+
}
76+
6177
describe("TaskChatThread draft pass-through", () => {
6278
it("keeps the composer dock aligned with the thread's horizontal padding", () => {
6379
render(
@@ -203,6 +219,110 @@ describe("TaskChatThread blocker links", () => {
203219
expect(container.textContent).not.toContain("Ultimately blocked by");
204220
});
205221

222+
it("keeps a server-selected intermediate blocker on its direct chain", () => {
223+
const selectedIntermediate = {
224+
id: "intermediate-2",
225+
identifier: "PAP-650",
226+
title: "Stalled intermediate review",
227+
};
228+
const selectedDirect = {
229+
id: "direct-2",
230+
identifier: "PAP-600",
231+
title: "Selected dependency",
232+
status: "blocked" as const,
233+
priority: "medium" as const,
234+
assigneeAgentId: "agent-1",
235+
assigneeUserId: null,
236+
terminalBlockers: [{
237+
id: "leaf-2",
238+
identifier: "PAP-700",
239+
title: "Deeper structural leaf",
240+
status: "todo" as const,
241+
priority: "medium" as const,
242+
assigneeAgentId: "agent-2",
243+
assigneeUserId: null,
244+
}],
245+
};
246+
247+
render(
248+
<TaskChatThread
249+
comments={[]}
250+
onAdd={async () => {}}
251+
issueStatus="blocked"
252+
blockedBy={[
253+
{
254+
id: "direct-1",
255+
identifier: "PAP-500",
256+
title: "Unrelated dependency",
257+
status: "todo",
258+
priority: "low",
259+
assigneeAgentId: null,
260+
assigneeUserId: null,
261+
},
262+
selectedDirect,
263+
]}
264+
blockerAttention={{
265+
state: "stalled",
266+
reason: "stalled_review",
267+
unresolvedBlockerCount: 2,
268+
coveredBlockerCount: 0,
269+
stalledBlockerCount: 1,
270+
attentionBlockerCount: 1,
271+
sampleBlockerIdentifier: "PAP-650",
272+
sampleStalledBlockerIdentifier: "PAP-650",
273+
directBlockerIssueId: selectedDirect.id,
274+
terminalBlockerIssueId: selectedIntermediate.id,
275+
terminalBlocker: selectedIntermediate,
276+
}}
277+
/>,
278+
);
279+
280+
for (const notice of container.querySelectorAll('[data-testid="task-chat-blocker-links"]')) {
281+
expect(notice.textContent).toContain("Blocked byPAP-600Selected dependency");
282+
expect(notice.textContent).toContain("Ultimately blocked byPAP-650Stalled intermediate review");
283+
}
284+
expect(container.textContent).not.toContain("Unrelated dependency");
285+
expect(container.textContent).not.toContain("Deeper structural leaf");
286+
});
287+
288+
it("auto-follows the new bottom blocker row when a pinned thread becomes blocked", () => {
289+
const comment = {
290+
id: "comment-1",
291+
companyId: "company-1",
292+
issueId: "issue-1",
293+
authorType: "user" as const,
294+
authorAgentId: null,
295+
authorUserId: "user-1",
296+
body: "Waiting for the dependency.",
297+
presentation: null,
298+
metadata: null,
299+
createdAt: new Date("2026-08-15T12:00:00.000Z"),
300+
updatedAt: new Date("2026-08-15T12:00:00.000Z"),
301+
};
302+
const directBlocker = {
303+
id: "direct-1",
304+
identifier: "PAP-500",
305+
title: "Direct dependency",
306+
status: "in_progress" as const,
307+
priority: "medium" as const,
308+
assigneeAgentId: "agent-1",
309+
assigneeUserId: null,
310+
};
311+
const baseProps = {
312+
comments: [comment],
313+
onAdd: async () => {},
314+
blockedBy: [directBlocker],
315+
};
316+
317+
render(<TaskChatThread {...baseProps} issueStatus="in_progress" />);
318+
const scroller = container.querySelector<HTMLElement>('[data-testid="task-chat-scroller"]')!;
319+
fakeScrollGeometry(scroller);
320+
321+
render(<TaskChatThread {...baseProps} issueStatus="blocked" />);
322+
323+
expect(scroller.scrollTop).toBe(scroller.scrollHeight);
324+
});
325+
206326
it("does not show blocker rows outside the blocked state", () => {
207327
render(
208328
<TaskChatThread

ui/src/components/TaskChatThread.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,20 @@ export function TaskChatThread(props: TaskChatThreadProps) {
136136

137137
const blockerLinks = useMemo(
138138
() => issueStatus === "blocked"
139-
? resolveTaskChatBlockers(blockedBy, blockerAttention?.terminalBlockerIssueId)
139+
? resolveTaskChatBlockers(
140+
blockedBy,
141+
blockerAttention?.terminalBlockerIssueId,
142+
blockerAttention?.directBlockerIssueId,
143+
blockerAttention?.terminalBlocker,
144+
)
140145
: null,
141-
[blockedBy, blockerAttention?.terminalBlockerIssueId, issueStatus],
146+
[
147+
blockedBy,
148+
blockerAttention?.directBlockerIssueId,
149+
blockerAttention?.terminalBlocker,
150+
blockerAttention?.terminalBlockerIssueId,
151+
issueStatus,
152+
],
142153
);
143154

144155
const threadHeaderWithBlockers = threadHeader || blockerLinks ? (
@@ -470,7 +481,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
470481
if ("content" in entry) return total + entry.content.length;
471482
return total + entry.kind.length;
472483
}, tailEntries.length);
473-
const threadContentKey = taskChatContentKey(items) + tailContentKey;
484+
const blockerContentKey = blockerLinks
485+
? `${blockerLinks.directBlocker.id}:${blockerLinks.ultimateBlocker?.id ?? ""}`
486+
: "";
487+
const threadContentKey = `${taskChatContentKey(items)}:${tailContentKey}:${blockerContentKey}`;
474488

475489
// Status-pill inputs for the tail (PAP-461, A1): the run's start, its finish
476490
// (once terminal), and the "called N tools" summary. Memoized on the

ui/src/components/task-chat/TaskChatBlockerLinks.tsx

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import type { IssueRelationIssueSummary } from "@paperclipai/shared";
1+
import type {
2+
IssueBlockerAttentionIssueSummary,
3+
IssueRelationIssueSummary,
4+
} from "@paperclipai/shared";
25
import { createIssueDetailPath } from "@/lib/issueDetailBreadcrumb";
36
import { Link } from "@/lib/router";
47

@@ -9,23 +12,41 @@ function isUnresolved(blocker: IssueRelationIssueSummary): boolean {
912
export function resolveTaskChatBlockers(
1013
blockers: IssueRelationIssueSummary[],
1114
terminalBlockerIssueId?: string | null,
15+
directBlockerIssueId?: string | null,
16+
terminalBlocker?: IssueBlockerAttentionIssueSummary | null,
1217
): {
13-
directBlocker: IssueRelationIssueSummary;
14-
ultimateBlocker: IssueRelationIssueSummary | null;
18+
directBlocker: IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary;
19+
ultimateBlocker: IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary | null;
1520
} | null {
1621
const unresolvedBlockers = blockers.filter(isUnresolved);
1722
if (unresolvedBlockers.length === 0) return null;
1823

19-
const directBlocker = terminalBlockerIssueId
20-
? unresolvedBlockers.find((blocker) => (
21-
blocker.id === terminalBlockerIssueId
22-
|| blocker.terminalBlockers?.some((terminal) => terminal.id === terminalBlockerIssueId)
23-
)) ?? unresolvedBlockers[0]
24-
: unresolvedBlockers[0];
24+
const directBlocker = directBlockerIssueId
25+
? unresolvedBlockers.find((blocker) => blocker.id === directBlockerIssueId)
26+
: terminalBlockerIssueId
27+
? unresolvedBlockers.find((blocker) => (
28+
blocker.id === terminalBlockerIssueId
29+
|| blocker.terminalBlockers?.some((terminal) => terminal.id === terminalBlockerIssueId)
30+
))
31+
: unresolvedBlockers[0];
32+
33+
// A selected intermediate blocker is not part of `terminalBlockers`, which
34+
// intentionally contains only structural leaves. If its direct path is not
35+
// in this payload (for example a child-derived attention path), show the
36+
// selected task itself instead of falling back to an unrelated blocker.
37+
if (!directBlocker) {
38+
if (!terminalBlocker) return null;
39+
return {
40+
directBlocker: terminalBlocker,
41+
ultimateBlocker: null,
42+
};
43+
}
2544

2645
const terminalBlockers = directBlocker.terminalBlockers?.filter(isUnresolved) ?? [];
2746
const ultimateBlocker = terminalBlockerIssueId
28-
? terminalBlockers.find((blocker) => blocker.id === terminalBlockerIssueId) ?? null
47+
? terminalBlocker?.id === terminalBlockerIssueId
48+
? terminalBlocker
49+
: terminalBlockers.find((blocker) => blocker.id === terminalBlockerIssueId) ?? null
2950
: terminalBlockers[0] ?? null;
3051

3152
return {
@@ -39,7 +60,7 @@ function BlockerRow({
3960
blocker,
4061
}: {
4162
label: string;
42-
blocker: IssueRelationIssueSummary;
63+
blocker: IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary;
4364
}) {
4465
const issuePathId = blocker.identifier ?? blocker.id;
4566

@@ -63,8 +84,8 @@ export function TaskChatBlockerLinks({
6384
ultimateBlocker,
6485
placement,
6586
}: {
66-
directBlocker: IssueRelationIssueSummary;
67-
ultimateBlocker: IssueRelationIssueSummary | null;
87+
directBlocker: IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary;
88+
ultimateBlocker: IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary | null;
6889
placement: "top" | "bottom";
6990
}) {
7091
return (

0 commit comments

Comments
 (0)