Show blocker links in the task chat (#11456)

<!-- Write all pull request text in Simplified Technical English
(ASD-STE100): short sentences, one instruction per sentence, simple
approved vocabulary, and the active voice. -->

## Thinking Path

> - Paperclip helps operators supervise agent work through tasks and
task threads.
> - The redesigned task thread shows the current work and its state.
> - A blocked task did not show the dependency that prevented progress.
> - Operators had to leave the thread to find the direct and final
blockers.
> - This pull request adds compact blocker links at the top and bottom
of the task thread.
> - The benefit is that operators can identify and open the relevant
tasks without adding a large notice to the thread.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The redesigned task thread did not show which task directly blocked the
current task or which task ultimately blocked its dependency chain.

**Subsystem affected**

`server/`, `packages/shared/`, and `ui/` task-blocker presentation.

**Current behavior**

A blocked task can open in the redesigned thread without a visible
dependency link at the top or bottom of the conversation.

**Proposed behavior**

Show one compact amber row for the direct blocker. Show a second row for
the selected final blocker when one exists. Render the rows at both ends
of the thread.

**Reason and benefit**

Operators can see the reason for the blocked state and open the relevant
task from the conversation. The compact rows preserve thread density.

**Breaking changes**

None. The new blocker-attention fields are optional. Existing clients
remain compatible.

## What Changed

- Added a compact task-chat component for direct and selected final
blocker links.
- Added the blocker rows to the top and bottom of populated and empty
task threads.
- Added link-ready blocker-attention details so an intermediate selected
task stays on its correct direct chain.
- Included blocker-link changes in the thread content key so pinned
threads follow a newly added bottom row.
- Added component, scrolling, server contract, and Storybook coverage
for the new states.

## Verification

- `pnpm exec vitest run ui/src/components/TaskChatThread.test.tsx
server/src/__tests__/issue-blocker-attention.test.ts` (38 tests passed)
- `pnpm --filter @paperclipai/shared typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm check:token-gates`

## Risks

- Low risk. The rows only render while the task status is `blocked` and
an unresolved blocker is available.
- Long titles are truncated to keep each blocker on one line. The full
task label remains available in the link title.
- Older server payloads keep the original leaf-selection behavior
because the new sampled details are optional.

> 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 with GPT-5. The run used tool-enabled reasoning and code
execution. The context-window size was not exposed to the run.

## 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-08-16 13:09:17 -04:00 committed by GitHub
parent e384d0a2bd
commit 9e9f744f58
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 529 additions and 26 deletions

View File

@ -951,6 +951,7 @@ export type {
IssueSubtreeDiagnosticEdge,
IssueSubtreeDiagnosticsResponse,
IssueBlockerAttention,
IssueBlockerAttentionIssueSummary,
IssueBlockerAttentionReason,
IssueBlockerAttentionState,
IssueReviewAttention,

View File

@ -604,6 +604,7 @@ export type {
IssueSubtreeDiagnosticEdge,
IssueSubtreeDiagnosticsResponse,
IssueBlockerAttention,
IssueBlockerAttentionIssueSummary,
IssueBlockerAttentionReason,
IssueBlockerAttentionState,
IssueReviewAttention,

View File

@ -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";

View File

@ -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" });

View File

@ -2094,7 +2094,9 @@ function createIssueBlockerAttention(input: Partial<IssueBlockerAttention> = {})
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,
}));
}

View File

@ -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 }) => (
<a href={to} {...props}>{children}</a>
),
}));
vi.mock("@/components/MarkdownEditor", () => ({
MarkdownEditor: forwardRef(function MockMarkdownEditor(
{ value }: { value: string },
@ -53,11 +58,39 @@ function render(ui: ReactElement) {
flushSync(() => root!.render(<ThemeProvider>{ui}</ThemeProvider>));
}
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(
<TaskChatThread
comments={[]}
comments={[{
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorType: "user",
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"),
}]}
onAdd={async () => {}}
/>,
);
@ -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(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
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(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
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(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
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(<TaskChatThread {...baseProps} issueStatus="in_progress" />);
const scroller = container.querySelector<HTMLElement>('[data-testid="task-chat-scroller"]')!;
fakeScrollGeometry(scroller);
render(<TaskChatThread {...baseProps} issueStatus="blocked" />);
expect(scroller.scrollTop).toBe(scroller.scrollHeight);
});
it("does not show blocker rows outside the blocked state", () => {
render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
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 () => {});

View File

@ -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 ? (
<TaskChatBlockerLinks
directBlocker={blockerLinks.directBlocker}
ultimateBlocker={blockerLinks.ultimateBlocker}
placement="top"
/>
) : null}
</>
) : undefined;
const bottomBlockerLinks = blockerLinks ? (
<TaskChatBlockerLinks
directBlocker={blockerLinks.directBlocker}
ultimateBlocker={blockerLinks.ultimateBlocker}
placement="bottom"
/>
) : null;
const linkedRunMetaById = useMemo(() => {
const map = new Map<string, NonNullable<TaskChatThreadProps["linkedRuns"]>[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) {
<div className={cn("flex flex-col", !isMobile && "min-h-0 flex-1")}>
{items.length === 0 && !tailRunId ? (
<div className={isMobile ? undefined : "min-h-0 flex-1 overflow-y-auto"}>
{threadHeader ? (
{threadHeaderWithBlockers ? (
<div
className="mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-6 px-4 pt-4"
data-testid="task-chat-thread-header"
>
{threadHeader}
{threadHeaderWithBlockers}
</div>
) : null}
<div className="px-3 py-10 text-center text-sm text-muted-foreground">{emptyMessage}</div>
{bottomBlockerLinks ? (
<div className="mx-auto w-full max-w-(--tc-shell-max-w) px-4 pb-4">
{bottomBlockerLinks}
</div>
) : null}
</div>
) : (
<TaskChatThreadView
items={items}
header={threadHeader}
header={threadHeaderWithBlockers}
renderInteraction={renderInteraction}
renderBrief={issueBrief ? () => <TaskChatDescriptionBubble brief={issueBrief} /> : undefined}
renderMessageActions={renderMessageActions}
renderQueuedAction={renderQueuedAction}
tail={tailRunId ? (
<div data-testid="task-chat-live-transcript">
<TaskChatLiveRunPill
status={tailStatus}
startedAtMs={tailStartedAtMs}
finishedAtMs={tailFinishedAtMs}
toolSummary={tailToolSummary}
/>
<TaskChatLiveTail
items={tailItems}
emptyMessage={
tailStatus === "queued"
? "Waiting to start..."
: "Waiting for transcript..."
}
/>
</div>
tail={tailRunId || bottomBlockerLinks ? (
<>
{tailRunId ? (
<div data-testid="task-chat-live-transcript">
<TaskChatLiveRunPill
status={tailStatus}
startedAtMs={tailStartedAtMs}
finishedAtMs={tailFinishedAtMs}
toolSummary={tailToolSummary}
/>
<TaskChatLiveTail
items={tailItems}
emptyMessage={
tailStatus === "queued"
? "Waiting to start..."
: "Waiting for transcript..."
}
/>
</div>
) : null}
{bottomBlockerLinks}
</>
) : null}
contentKey={threadContentKey}
scroll={!isMobile}

View File

@ -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 (
<div className="flex min-w-0 items-baseline gap-1.5 whitespace-nowrap">
<span className="shrink-0 font-medium">{label}</span>
<Link
to={createIssueDetailPath(issuePathId)}
className="flex min-w-0 items-baseline gap-1 text-amber-800 underline-offset-2 hover:underline dark:text-amber-200"
title={`${blocker.identifier ?? blocker.id.slice(0, 8)}${blocker.title}`}
>
<span className="shrink-0 font-mono">{blocker.identifier ?? blocker.id.slice(0, 8)}</span>
<span className="truncate text-amber-700/80 dark:text-amber-300/80">{blocker.title}</span>
</Link>
</div>
);
}
export function TaskChatBlockerLinks({
directBlocker,
ultimateBlocker,
placement,
}: {
directBlocker: IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary;
ultimateBlocker: IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary | null;
placement: "top" | "bottom";
}) {
return (
<div
aria-label="Task blockers"
data-placement={placement}
data-testid="task-chat-blocker-links"
className="flex min-w-0 flex-col gap-1 overflow-hidden text-(length:--text-micro) leading-4 text-amber-700 dark:text-amber-300"
>
<BlockerRow label="Blocked by" blocker={directBlocker} />
{ultimateBlocker ? (
<BlockerRow label="Ultimately blocked by" blocker={ultimateBlocker} />
) : null}
</div>
);
}

View File

@ -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;

View File

@ -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: () => (
<Frame label="Task chat · lightweight blocker links">
<TaskChatBlockerLinks
placement="top"
directBlocker={blocker({
id: "b1",
identifier: "PAP-600",
title: "Waiting in review",
status: "in_review",
})}
ultimateBlocker={blocker({
id: "t1",
identifier: "PAP-777",
title: "Actual work",
status: "in_progress",
assigneeAgentId: "agent-2",
})}
/>
</Frame>
),
};
export const RuleCMultipleBlockers: Story = {
name: "Rule C · several unresolved blockers",
render: () => (