Show ordered live blocker work in task chat (#11487)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The task view shows operators why work cannot continue
> - The redesigned task thread now shows direct and ultimate blockers
> - But it does not show the ordered task queue while a blocker chain
has live work
> - This pull request adds a compact ordered live-work queue to the
redesigned thread
> - The benefit is that operators can see completed, running, and queued
dependencies without opening the larger legacy notice

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The redesigned task thread blocker summary is improved. The merged
predecessor is #11456.

**Current behavior**

The redesigned task thread shows compact direct and ultimate blocker
links. It does not show the ordered queue when the blocker tree has live
work. The legacy task view shows this queue in a larger notice.

**Proposed behavior**

Show a compact blue live-work queue at both ends of the redesigned task
thread. Order completed tasks first, then running tasks, then queued
tasks. Show a live terminal leaf as `Now running`. Return to the amber
blocker links when no live dependency remains.

**Reason and benefit**

Operators can see the active dependency order without leaving the
redesigned task view. The compact presentation preserves the new
thread's low-chrome layout.

**Breaking changes**

None. The change only adds UI for blocker data that the task view
already receives.

## What Changed

- Shared the live blocker ordering helper between the legacy notice and
the redesigned task thread.
- Added compact ordered dependency links at the top and bottom of the
redesigned thread.
- Added a separate `Now running` link for a live terminal blocker leaf.
- Preserved the compact amber blocker rows when live work is not
present.
- Added component tests and a Storybook state for the new presentation.

## Verification

- `pnpm exec vitest run ui/src/components/TaskChatThread.test.tsx
ui/src/components/IssueBlockedNotice.test.tsx`
- `pnpm check:token-gates`
- `pnpm -r typecheck`
- `pnpm test:run`
- `pnpm build`
- `pnpm --filter @paperclipai/ui build-storybook`
- Captured and reviewed the new Storybook state in a headless browser.

## Risks

- Low risk. The queue appears only for blocked tasks whose blocker
attention state is `covered` and whose dependency set contains live
work.
- The API does not provide an explicit queue position. The UI preserves
the existing legacy ordering rule: completed, running, queued, then
numeric task identifier.

> 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, GPT-5. The runtime does not expose the exact snapshot or
context-window size. Reasoning, code execution, repository tools, and
browser automation were enabled.

## 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
- [ ] All Paperclip CI gates are green
- [ ] 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 15:10:05 -04:00 committed by GitHub
parent ac91b7f3b2
commit fd472d02ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 355 additions and 38 deletions

View File

@ -15,7 +15,11 @@ import { formatMonitorOffset } from "../lib/issue-monitor";
import { useRetryNowMutation } from "../hooks/useRetryNowMutation";
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
import { RetryErrorBand } from "./IssueScheduledRetryCard";
import { isAssignedBacklogBlocker } from "../lib/issue-blockers";
import {
isAssignedBacklogBlocker,
orderWaitingBlockers,
type WaitingBlockerStatus,
} from "../lib/issue-blockers";
import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff";
import { Badge } from "@/components/ui/badge";
import {
@ -114,27 +118,6 @@ function SuccessfulRunRetryNowControl({
const EMPTY_LIVE_IDS: ReadonlySet<string> = new Set<string>();
type WaitingStepStatus = "done" | "running" | "queued";
function classifyWaitingStep(
blocker: IssueRelationIssueSummary,
liveIds: ReadonlySet<string>,
): WaitingStepStatus {
// A resolved blocker (done/cancelled) is a completed step; a blocker with a
// live run is the one currently being worked; everything else is queued.
if (blocker.status === "done" || blocker.status === "cancelled") return "done";
if (liveIds.has(blocker.id)) return "running";
return "queued";
}
// Ordering heuristic (plan §3): done → running → queued, tie-break by identifier
// (P1…Pn plan naming). The payload doesn't carry explicit chain order.
const WAITING_STEP_RANK: Record<WaitingStepStatus, number> = {
done: 0,
running: 1,
queued: 2,
};
function waitingTaskStatusLabel(status: string): string {
return status.replace(/_/g, " ").replace(/\b\w/g, (character) => character.toUpperCase());
}
@ -171,7 +154,7 @@ function WaitingChipLink({
);
}
function WaitingStepGlyph({ status }: { status: WaitingStepStatus }) {
function WaitingStepGlyph({ status }: { status: WaitingBlockerStatus }) {
if (status === "done") {
return <CheckCircle2 className="h-3.5 w-3.5 text-blue-500 dark:text-blue-400" aria-hidden />;
}
@ -255,15 +238,7 @@ function WaitingOnLiveWorkNotice({
parkedBlockers: IssueRelationIssueSummary[];
renderParkedChip: (blocker: IssueRelationIssueSummary) => ReactNode;
}) {
const steps = chainBlockers
.map((blocker) => ({ blocker, status: classifyWaitingStep(blocker, liveIds) }))
.sort((a, b) => {
const rank = WAITING_STEP_RANK[a.status] - WAITING_STEP_RANK[b.status];
if (rank !== 0) return rank;
const aKey = a.blocker.identifier ?? a.blocker.id;
const bKey = b.blocker.identifier ?? b.blocker.id;
return aKey.localeCompare(bKey, undefined, { numeric: true });
});
const steps = orderWaitingBlockers(chainBlockers, liveIds);
const total = steps.length;
const doneCount = steps.filter((step) => step.status === "done").length;
const runningCount = steps.filter((step) => step.status === "running").length;

View File

@ -196,6 +196,123 @@ describe("TaskChatThread blocker links", () => {
expect(container.textContent).not.toContain("This task resumes automatically");
});
it("shows the ordered live-work queue at the top and bottom", () => {
const terminalBlocker = {
id: "terminal-running",
identifier: "PAP-17426",
title: "Restore live alias projection",
status: "in_progress" as const,
priority: "high" as const,
assigneeAgentId: "agent-3",
assigneeUserId: null,
};
render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
issueStatus="blocked"
liveIssueIds={new Set(["direct-running", "terminal-running"])}
blockerAttention={{
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 2,
coveredBlockerCount: 2,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-17426",
sampleStalledBlockerIdentifier: null,
blockingTreeLive: true,
directBlockerIssueId: "direct-running",
terminalBlockerIssueId: terminalBlocker.id,
terminalBlocker,
}}
blockedBy={[
{
id: "direct-queued",
identifier: "PAP-17427",
title: "Verify the completed projection",
status: "todo",
priority: "medium",
assigneeAgentId: "agent-4",
assigneeUserId: null,
},
{
id: "direct-running",
identifier: "PAP-17425",
title: "Verify the live projection",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-2",
assigneeUserId: null,
terminalBlockers: [terminalBlocker],
},
{
id: "direct-done",
identifier: "PAP-17424",
title: "Run the guarded cutover",
status: "done",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
]}
/>,
);
const notices = container.querySelectorAll('[data-testid="task-chat-live-work-links"]');
expect(notices).toHaveLength(2);
expect(notices[0]?.getAttribute("data-placement")).toBe("top");
expect(notices[1]?.getAttribute("data-placement")).toBe("bottom");
for (const notice of notices) {
expect(notice.textContent).toContain("Waiting on live work");
const orderedLinks = [...notice.querySelectorAll('[data-testid="task-chat-live-work-step"] a')]
.map((link) => link.textContent);
expect(orderedLinks).toEqual([
"PAP-17424Run the guarded cutover",
"PAP-17425Verify the live projection",
"PAP-17427Verify the completed projection",
]);
expect(notice.textContent).toContain("Now runningPAP-17426Restore live alias projection");
expect(notice.querySelector('a[href="/issues/PAP-17426"]')).not.toBeNull();
}
expect(container.querySelector('[data-testid="task-chat-blocker-links"]')).toBeNull();
});
it("keeps the compact blocker rows when covered work is no longer live", () => {
render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
issueStatus="blocked"
liveIssueIds={new Set()}
blockerAttention={{
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-500",
sampleStalledBlockerIdentifier: null,
blockingTreeLive: false,
}}
blockedBy={[{
id: "direct-1",
identifier: "PAP-500",
title: "Direct dependency",
status: "todo",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
}]}
/>,
);
expect(container.querySelector('[data-testid="task-chat-live-work-links"]')).toBeNull();
expect(container.querySelectorAll('[data-testid="task-chat-blocker-links"]')).toHaveLength(2);
});
it("shows only the direct row when the blocker has no deeper unresolved leaf", () => {
render(
<TaskChatThread

View File

@ -47,7 +47,9 @@ import { latestSameRunHandoffTimestamp } from "@/lib/issue-chat-messages";
import { isLiveIssueRun, isTerminalIssueStatus } from "@/lib/liveIssueIds";
import {
resolveTaskChatBlockers,
resolveTaskChatLiveWork,
TaskChatBlockerLinks,
TaskChatLiveWorkLinks,
} from "@/components/task-chat/TaskChatBlockerLinks";
function toMs(value: Date | string | null | undefined): number {
@ -61,6 +63,7 @@ function toMs(value: Date | string | null | undefined): number {
// off to (e.g. a stopped run with no tool activity). Normal completions hand off
// well within this as soon as the settled turn/comment lands.
const SETTLING_TAIL_MAX_MS = 15_000;
const EMPTY_LIVE_ISSUE_IDS: ReadonlySet<string> = new Set<string>();
export type TaskChatThreadProps = ComponentProps<typeof IssueChatThread>;
@ -132,10 +135,18 @@ export function TaskChatThread(props: TaskChatThreadProps) {
interruptingQueuedRunId,
blockedBy = [],
blockerAttention,
liveIssueIds,
} = props;
const liveWorkLinks = useMemo(
() => issueStatus === "blocked" && blockerAttention?.state === "covered"
? resolveTaskChatLiveWork(blockedBy, liveIssueIds ?? EMPTY_LIVE_ISSUE_IDS, blockerAttention.terminalBlocker)
: null,
[blockedBy, blockerAttention?.state, blockerAttention?.terminalBlocker, issueStatus, liveIssueIds],
);
const blockerLinks = useMemo(
() => issueStatus === "blocked"
() => issueStatus === "blocked" && !liveWorkLinks
? resolveTaskChatBlockers(
blockedBy,
blockerAttention?.terminalBlockerIssueId,
@ -149,13 +160,16 @@ export function TaskChatThread(props: TaskChatThreadProps) {
blockerAttention?.terminalBlocker,
blockerAttention?.terminalBlockerIssueId,
issueStatus,
liveWorkLinks,
],
);
const threadHeaderWithBlockers = threadHeader || blockerLinks ? (
const threadHeaderWithBlockers = threadHeader || blockerLinks || liveWorkLinks ? (
<>
{threadHeader}
{blockerLinks ? (
{liveWorkLinks ? (
<TaskChatLiveWorkLinks liveWork={liveWorkLinks} placement="top" />
) : blockerLinks ? (
<TaskChatBlockerLinks
directBlocker={blockerLinks.directBlocker}
ultimateBlocker={blockerLinks.ultimateBlocker}
@ -165,7 +179,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
</>
) : undefined;
const bottomBlockerLinks = blockerLinks ? (
const bottomBlockerLinks = liveWorkLinks ? (
<TaskChatLiveWorkLinks liveWork={liveWorkLinks} placement="bottom" />
) : blockerLinks ? (
<TaskChatBlockerLinks
directBlocker={blockerLinks.directBlocker}
ultimateBlocker={blockerLinks.ultimateBlocker}
@ -483,7 +499,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
}, tailEntries.length);
const blockerContentKey = blockerLinks
? `${blockerLinks.directBlocker.id}:${blockerLinks.ultimateBlocker?.id ?? ""}`
: "";
: liveWorkLinks
? `live:${liveWorkLinks.steps.map((step) => `${step.blocker.id}:${step.status}`).join(",")}:${liveWorkLinks.nowRunning.map((blocker) => blocker.id).join(",")}`
: "";
const threadContentKey = `${taskChatContentKey(items)}:${tailContentKey}:${blockerContentKey}`;
// Status-pill inputs for the tail (PAP-461, A1): the run's start, its finish

View File

@ -2,7 +2,13 @@ import type {
IssueBlockerAttentionIssueSummary,
IssueRelationIssueSummary,
} from "@paperclipai/shared";
import { CheckCircle2, Circle } from "lucide-react";
import { createIssueDetailPath } from "@/lib/issueDetailBreadcrumb";
import {
orderWaitingBlockers,
type WaitingBlockerStatus,
type WaitingBlockerStep,
} from "@/lib/issue-blockers";
import { Link } from "@/lib/router";
function isUnresolved(blocker: IssueRelationIssueSummary): boolean {
@ -55,6 +61,38 @@ export function resolveTaskChatBlockers(
};
}
export interface ResolvedTaskChatLiveWork {
steps: WaitingBlockerStep[];
nowRunning: Array<IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary>;
}
export function resolveTaskChatLiveWork(
blockers: IssueRelationIssueSummary[],
liveIssueIds: ReadonlySet<string>,
selectedTerminalBlocker?: IssueBlockerAttentionIssueSummary | null,
): ResolvedTaskChatLiveWork | null {
if (blockers.length === 0) return null;
const steps = orderWaitingBlockers(blockers, liveIssueIds);
const stepIds = new Set(steps.map((step) => step.blocker.id));
const terminalCandidates: Array<IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary> = [];
for (const blocker of blockers) terminalCandidates.push(...(blocker.terminalBlockers ?? []));
if (selectedTerminalBlocker) terminalCandidates.push(selectedTerminalBlocker);
const nowRunning: Array<IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary> = [];
const seen = new Set<string>();
for (const blocker of terminalCandidates) {
if (!liveIssueIds.has(blocker.id) || stepIds.has(blocker.id) || seen.has(blocker.id)) continue;
seen.add(blocker.id);
nowRunning.push(blocker);
}
const hasLiveStep = steps.some((step) => step.status === "running");
if (!hasLiveStep && nowRunning.length === 0) return null;
return { steps, nowRunning };
}
function BlockerRow({
label,
blocker,
@ -79,6 +117,51 @@ function BlockerRow({
);
}
function LiveWorkGlyph({ status }: { status: WaitingBlockerStatus }) {
const label = status === "done" ? "Done" : status === "running" ? "Running" : "Waiting";
if (status === "done") {
return (
<CheckCircle2
className="h-3.5 w-3.5 text-blue-500 dark:text-blue-400"
role="img"
aria-label={label}
/>
);
}
if (status === "running") {
return (
<span className="flex h-3.5 w-3.5 items-center justify-center" role="img" aria-label={label}>
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-blue-400" aria-hidden />
</span>
);
}
return (
<Circle
className="h-3.5 w-3.5 text-blue-300 dark:text-blue-500/50"
role="img"
aria-label={label}
/>
);
}
function LiveWorkLink({
blocker,
}: {
blocker: IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary;
}) {
const issuePathId = blocker.identifier ?? blocker.id;
return (
<Link
to={createIssueDetailPath(issuePathId)}
className="flex min-w-0 items-baseline gap-1 text-blue-800 underline-offset-2 hover:underline dark:text-blue-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-blue-700/80 dark:text-blue-300/80">{blocker.title}</span>
</Link>
);
}
export function TaskChatBlockerLinks({
directBlocker,
ultimateBlocker,
@ -102,3 +185,48 @@ export function TaskChatBlockerLinks({
</div>
);
}
export function TaskChatLiveWorkLinks({
liveWork,
placement,
}: {
liveWork: ResolvedTaskChatLiveWork;
placement: "top" | "bottom";
}) {
return (
<div
aria-label="Tasks waiting on live work"
data-placement={placement}
data-testid="task-chat-live-work-links"
className="flex min-w-0 flex-col gap-1.5 overflow-hidden text-(length:--text-micro) leading-4 text-blue-700 dark:text-blue-300"
>
<div className="flex items-center gap-1.5 font-medium">
<span className="flex h-3.5 w-3.5 items-center justify-center" aria-hidden>
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-blue-400" />
</span>
Waiting on live work
</div>
<ol className="flex min-w-0 flex-col gap-1">
{liveWork.steps.map(({ blocker, status }, index) => (
<li
key={blocker.id}
data-testid="task-chat-live-work-step"
className="flex min-w-0 items-center gap-1.5 whitespace-nowrap"
>
<span className="w-4 shrink-0 text-right font-mono text-blue-500/80" aria-hidden>
{index + 1}.
</span>
<LiveWorkGlyph status={status} />
<LiveWorkLink blocker={blocker} />
</li>
))}
</ol>
{liveWork.nowRunning.map((blocker) => (
<div key={blocker.id} className="flex min-w-0 items-center gap-1.5 whitespace-nowrap">
<span className="shrink-0 font-medium">Now running</span>
<LiveWorkLink blocker={blocker} />
</div>
))}
</div>
);
}

View File

@ -1,5 +1,49 @@
import type { IssueRelationIssueSummary } from "@paperclipai/shared";
export type WaitingBlockerStatus = "done" | "running" | "queued";
export interface WaitingBlockerStep {
blocker: IssueRelationIssueSummary;
status: WaitingBlockerStatus;
}
export function classifyWaitingBlocker(
blocker: IssueRelationIssueSummary,
liveIssueIds: ReadonlySet<string>,
): WaitingBlockerStatus {
if (blocker.status === "done" || blocker.status === "cancelled") return "done";
if (liveIssueIds.has(blocker.id)) return "running";
return "queued";
}
const WAITING_BLOCKER_RANK: Record<WaitingBlockerStatus, number> = {
done: 0,
running: 1,
queued: 2,
};
/**
* Orders the explicit blocker queue the same way in every task surface.
*
* The blocker payload does not carry an explicit sequence. Status gives the
* operational order (completed work, current work, queued work), while the
* identifier is the stable tie-break used by plan-generated task chains.
*/
export function orderWaitingBlockers(
blockers: IssueRelationIssueSummary[],
liveIssueIds: ReadonlySet<string>,
): WaitingBlockerStep[] {
return blockers
.map((blocker) => ({ blocker, status: classifyWaitingBlocker(blocker, liveIssueIds) }))
.sort((a, b) => {
const rank = WAITING_BLOCKER_RANK[a.status] - WAITING_BLOCKER_RANK[b.status];
if (rank !== 0) return rank;
const aKey = a.blocker.identifier ?? a.blocker.id;
const bKey = b.blocker.identifier ?? b.blocker.id;
return aKey.localeCompare(bKey, undefined, { numeric: true });
});
}
export function isAssignedBacklogBlocker(blocker: IssueRelationIssueSummary): boolean {
return blocker.status === "backlog" && Boolean(blocker.assigneeAgentId);
}

View File

@ -1,7 +1,11 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { IssueRelationIssueSummary } from "@paperclipai/shared";
import { IssueBlockedNotice } from "@/components/IssueBlockedNotice";
import { TaskChatBlockerLinks } from "@/components/task-chat/TaskChatBlockerLinks";
import {
resolveTaskChatLiveWork,
TaskChatBlockerLinks,
TaskChatLiveWorkLinks,
} from "@/components/task-chat/TaskChatBlockerLinks";
// Rule C (PAP-13554): when a human comment on a `blocked` issue does not reopen
// it, the blocked notice must state why and name the unresolved blocker leaf.
@ -123,6 +127,37 @@ export const TaskChatCompactRows: Story = {
),
};
export const TaskChatLiveWorkRows: Story = {
name: "Task chat · ordered live-work rows",
render: () => {
const terminal = blocker({
id: "t1",
identifier: "PAP-603",
title: "Restore the live projection",
status: "in_progress",
assigneeAgentId: "agent-3",
});
const liveWork = resolveTaskChatLiveWork([
blocker({ id: "b1", identifier: "PAP-601", title: "Run the guarded cutover", status: "done" }),
blocker({
id: "b2",
identifier: "PAP-602",
title: "Verify the live projection",
status: "in_progress",
assigneeAgentId: "agent-2",
terminalBlockers: [terminal],
}),
blocker({ id: "b3", identifier: "PAP-604", title: "Complete final QA", status: "todo" }),
], new Set(["b2", "t1"]), terminal);
return (
<Frame label="Task chat · compact ordered live-work links">
{liveWork ? <TaskChatLiveWorkLinks placement="top" liveWork={liveWork} /> : null}
</Frame>
);
},
};
export const RuleCMultipleBlockers: Story = {
name: "Rule C · several unresolved blockers",
render: () => (