Files
PaperClipAI/ui/src/components/task-chat/TaskChatBlockerLinks.tsx
T
DottaandClaude Opus 5.5 9feabfee48 fix(ui): use the shared status glyph in inline task links and the live-work strip (#19636)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task status is the main signal on every task surface, including
agent messages and the task chat.
> - #19329 put the status-to-glyph mapping in one resolver. Two
conversation surfaces did not use all of its inputs.
> - Inline task links in a message passed only the bare status. A
blocked task that waits on active sub-tasks showed a red glyph there and
a blue glyph in the list, breadcrumb and sidebar.
> - The "Waiting on live work" strip above the composer drew its own
filled and hollow dots, not task glyphs.
> - This pull request makes both surfaces resolve through the shared
mapping.
> - The benefit is that one task state looks the same in every view.

## Linked Issues or Issue Description

Refs #19329

**What happened?**

In a hands-on onboarding walk, the onboarding task was blocked on four
active sub-tasks. The breadcrumb, the sidebar Recents row, the Tasks
list and the "Blocked" chip used the blue in-queue glyph. Inline task
links in the agent message used the same glyph in red. The "Still
waiting on live work" strip showed the same tasks as plain dots.

**Expected behavior**

The same task state uses the same glyph and colour token in every view,
including inline links and the live-work strip.

**Steps to reproduce**

1. Create a task with sub-tasks, and block the parent on them while a
sub-task runs.
2. Mention the parent in a message (for example "(PAP-1)").
3. Compare the inline link glyph with the task list glyph. The link is
red, the list is blue.
4. Open the parent task. Compare the "Still waiting on live work" strip
with the task list. The strip shows dots.

**Paperclip version or commit**

`master` at e482f0f632.

## What Changed

- `MarkdownBody` issue mentions spread `taskStatusIconProps(issue)` into
`StatusIcon`. They now pass `blockerAttention`, `displayStatus` and
`externalConversationState`.
- `TaskChatLiveWorkLinks` draws `StatusIcon` for each task, in place of
the private dot glyphs. Relation summaries do not carry blocker
attention or the waiting-conversation state, so the strip reads them
from the same `/issues/summaries` batch that the sidebar and
created-task cards use. Until that loads, it uses the relation status.
- `TaskChatThread` passes `companyId` to the strip.
- The strip fetches summaries in batches of `ISSUE_SUMMARIES_MAX_IDS`
(5), and production-mode "Now running" rows draw the glyph too (Greptile
review).
- New tests: `TaskChatBlockerLinks.test.tsx` (strip with seven tasks
through the real fetch path, both presentation modes, "Now running" row)
and a BUG-07 case in `MarkdownBody.test.tsx`. One assertion in
`TaskChatThread.test.tsx` now expects the glyph label text.
- Storybook: "Same state in conversations" section in Foundations /
Status Language shows the list row, inline mentions and the strip side
by side.

## Verification

Red on master (fix reverted, new tests kept):

```
 ❯ src/components/task-chat/TaskChatBlockerLinks.test.tsx (2 tests | 2 failed)
     × draws each waiting task with the task list's glyph and colour token (streamlined)
     × draws each waiting task with the task list's glyph and colour token (production)
 ❯ src/components/MarkdownBody.test.tsx (66 tests | 1 failed | 65 skipped)
     × draws a blocked task mention with the same glyph and colour token as the task list (BUG-07)
AssertionError: expected '<div class="paperclip-markdown …' to contain 'var(--status-task-icon-in_queue)'
  … style="color:var(--status-task-icon-blocked)" role="img" aria-label="Blocked" …
AssertionError: BRA-2: expected null to be '--status-task-icon-in_progress'
 Test Files  2 failed (2)
      Tests  3 failed | 65 skipped (68)
```

Green with the fix:

```
cd ui && npx vitest run src/components/task-chat/TaskChatBlockerLinks.test.tsx src/components/MarkdownBody.test.tsx -t "BUG-07"
 Test Files  2 passed (2)
      Tests  3 passed | 65 skipped (68)
```

Neighbouring suites (`src/components/task-chat`,
`MarkdownBody.test.tsx`, `TaskChatThread.test.tsx`, `StatusIcon*`): 64
files, 1364 passed, 1 skipped. `ui` tsc reports no errors in touched
files. `pnpm check:token-gates`: all gates clean. `node
scripts/storybook-catalog.mjs`: OK.

Storybook (Foundations / Status Language / Full matrix, "Same state in
conversations"):

| Before (master) | After |
| --- | --- |
|
![before](https://raw.githubusercontent.com/paperclipai/paperclip/93b50f09d37d4023903e1da67c340f162dbcf7d0/shots/before.png)
|
![after](https://raw.githubusercontent.com/paperclipai/paperclip/93b50f09d37d4023903e1da67c340f162dbcf7d0/shots/after.png)
|

Before: inline mentions are red, the strip shows dots. After: inline
mentions and the strip use the same blue in-queue, in-progress and todo
glyphs as the task list row.

## Risks

- Low risk. The change is presentation only.
- The strip issues one `/issues/summaries` request for its listed task
IDs (at most a few). The request is shared between the top and bottom
strip by query key. If it fails, the strip uses the relation status.
- Step links now include the glyph's accessible title in their text
content.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edit, browser).

## 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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 17:26:59 -06:00

297 lines
12 KiB
TypeScript

import type {
IssueBlockerAttentionIssueSummary,
IssueRelationIssueSummary,
} from "@paperclipai/shared";
import { useMemo } from "react";
import { useQueries } from "@tanstack/react-query";
import { ISSUE_SUMMARIES_MAX_IDS } from "@paperclipai/shared";
import { issuesApi } from "@/api/issues";
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
import { createIssueDetailPath } from "@/lib/issueDetailBreadcrumb";
import { queryKeys } from "@/lib/queryKeys";
import { taskStatusIconProps, type TaskStatusPresentationInput } from "@/lib/task-status-presentation";
import { StatusIcon } from "../StatusIcon";
import {
orderWaitingBlockers,
type WaitingBlockerStep,
} from "@/lib/issue-blockers";
import { Link } from "@/lib/router";
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
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,
};
}
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,
}: {
label: string;
blocker: IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary;
}) {
const streamlined = useStreamlinedTaskChatPresentation();
const issuePathId = blocker.identifier ?? blocker.id;
return streamlined ? (
<Link
to={createIssueDetailPath(issuePathId)}
className="flex min-w-0 items-baseline gap-1.5 rounded px-1 py-0.5 text-amber-800 underline-offset-2 transition-colors hover:bg-accent/50 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-200"
title={`${blocker.identifier ?? blocker.id.slice(0, 8)} — ${blocker.title}`}
>
<span className="shrink-0 font-medium">{label}</span>
<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 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>
);
}
type LiveWorkBlocker = IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary;
/**
* The strip shows tasks, so it draws the shared task status glyph. Relation
* summaries do not carry blocker attention or the waiting-conversation state,
* so the same `/issues/summaries` projection that the sidebar and created-task
* cards use supplies them; until it loads, the relation status is used.
*/
function useLiveWorkStatusPresentation(
companyId: string | null | undefined,
blockers: LiveWorkBlocker[],
): (blocker: LiveWorkBlocker) => TaskStatusPresentationInput {
const { userId } = useCoordinatorWorkspaceEnabled();
// The summaries endpoint accepts at most ISSUE_SUMMARIES_MAX_IDS per request.
const batches = useMemo(() => {
const ids = [...new Set(blockers.map((blocker) => blocker.id))].sort();
const chunks: string[][] = [];
for (let index = 0; index < ids.length; index += ISSUE_SUMMARIES_MAX_IDS) {
chunks.push(ids.slice(index, index + ISSUE_SUMMARIES_MAX_IDS));
}
return chunks;
}, [blockers]);
const results = useQueries({
queries: batches.map((ids) => ({
queryKey: queryKeys.issues.summaryBatch(companyId ?? "__none__", userId, ids),
queryFn: ({ signal }: { signal: AbortSignal }) => issuesApi.summaries(companyId!, ids, { signal }),
enabled: Boolean(companyId),
})),
});
const summaries = results.flatMap((result) => result.data ?? []);
return (blocker) => {
const summary = summaries.find((candidate) => candidate.id === blocker.id);
if (summary) return taskStatusIconProps(summary);
if ("status" in blocker) return taskStatusIconProps(blocker);
// A sampled terminal blocker carries no status; it is only listed while live.
return { status: "in_progress" };
};
}
function LiveWorkGlyph({ presentation }: { presentation: TaskStatusPresentationInput }) {
return <StatusIcon {...presentation} size="sm" />;
}
function LiveWorkLink({
blocker,
presentation,
label,
}: {
blocker: LiveWorkBlocker;
presentation: TaskStatusPresentationInput;
label?: string;
}) {
const streamlined = useStreamlinedTaskChatPresentation();
const issuePathId = blocker.identifier ?? blocker.id;
return (
<Link
to={createIssueDetailPath(issuePathId)}
className={streamlined
? "flex min-w-0 items-center gap-1.5 rounded px-1 py-0.5 text-blue-800 underline-offset-2 transition-colors hover:bg-accent/50 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:text-blue-200"
: "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}`}
>
{streamlined && label ? <span className="shrink-0 font-medium">{label}</span> : null}
{streamlined ? <LiveWorkGlyph presentation={presentation} /> : null}
<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,
placement,
}: {
directBlocker: IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary;
ultimateBlocker: IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary | null;
placement: "top" | "bottom";
}) {
const streamlined = useStreamlinedTaskChatPresentation();
const directLabel = streamlined && placement === "bottom" ? "Still blocked by" : "Blocked by";
const rootLabel = streamlined
? placement === "bottom" ? "Root blocker remains" : "Root blocker"
: "Ultimately blocked by";
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={directLabel} blocker={directBlocker} />
{ultimateBlocker ? (
<BlockerRow label={rootLabel} blocker={ultimateBlocker} />
) : null}
</div>
);
}
export function TaskChatLiveWorkLinks({
liveWork,
placement,
companyId,
}: {
liveWork: ResolvedTaskChatLiveWork;
placement: "top" | "bottom";
companyId?: string | null;
}) {
const streamlined = useStreamlinedTaskChatPresentation();
const listedBlockers = useMemo(
() => [...liveWork.steps.map((step) => step.blocker), ...liveWork.nowRunning],
[liveWork],
);
const presentationFor = useLiveWorkStatusPresentation(companyId, listedBlockers);
const heading = streamlined && placement === "bottom" ? "Still waiting on live work" : "Waiting on live work";
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>
{heading}
</div>
<ol className="flex min-w-0 flex-col gap-1">
{liveWork.steps.map(({ blocker }, index) => (
<li
key={blocker.id}
data-testid="task-chat-live-work-step"
className={streamlined ? "min-w-0 whitespace-nowrap" : "flex min-w-0 items-center gap-1.5 whitespace-nowrap"}
>
{!streamlined ? (
<>
<span className="w-4 shrink-0 text-right font-mono text-blue-500/80" aria-hidden>
{index + 1}.
</span>
<LiveWorkGlyph presentation={presentationFor(blocker)} />
</>
) : null}
<LiveWorkLink blocker={blocker} presentation={presentationFor(blocker)} />
</li>
))}
</ol>
{liveWork.nowRunning.map((blocker) => streamlined ? (
<LiveWorkLink key={blocker.id} blocker={blocker} presentation={presentationFor(blocker)} label="Now running" />
) : (
<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>
<LiveWorkGlyph presentation={presentationFor(blocker)} />
<LiveWorkLink blocker={blocker} presentation={presentationFor(blocker)} />
</div>
))}
</div>
);
}