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:
parent
e384d0a2bd
commit
9e9f744f58
|
|
@ -951,6 +951,7 @@ export type {
|
|||
IssueSubtreeDiagnosticEdge,
|
||||
IssueSubtreeDiagnosticsResponse,
|
||||
IssueBlockerAttention,
|
||||
IssueBlockerAttentionIssueSummary,
|
||||
IssueBlockerAttentionReason,
|
||||
IssueBlockerAttentionState,
|
||||
IssueReviewAttention,
|
||||
|
|
|
|||
|
|
@ -604,6 +604,7 @@ export type {
|
|||
IssueSubtreeDiagnosticEdge,
|
||||
IssueSubtreeDiagnosticsResponse,
|
||||
IssueBlockerAttention,
|
||||
IssueBlockerAttentionIssueSummary,
|
||||
IssueBlockerAttentionReason,
|
||||
IssueBlockerAttentionState,
|
||||
IssueReviewAttention,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: () => (
|
||||
|
|
|
|||
Loading…
Reference in New Issue