From 9feabfee4852f3ca6d29295f2cf292a14f6c633c Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Fri, 6 Nov 2026 17:26:59 -0600
Subject: [PATCH] fix(ui): use the shared status glyph in inline task links and
the live-work strip (#19636)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## 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 '
---
ui/src/components/MarkdownBody.test.tsx | 33 +++-
ui/src/components/MarkdownBody.tsx | 8 +-
ui/src/components/TaskChatThread.test.tsx | 10 +-
ui/src/components/TaskChatThread.tsx | 4 +-
.../task-chat/TaskChatBlockerLinks.test.tsx | 151 ++++++++++++++++++
.../task-chat/TaskChatBlockerLinks.tsx | 102 +++++++-----
.../foundations/status-language.stories.tsx | 76 ++++++++-
7 files changed, 338 insertions(+), 46 deletions(-)
create mode 100644 ui/src/components/task-chat/TaskChatBlockerLinks.test.tsx
diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx
index dd5d6163149..d1597c163c8 100644
--- a/ui/src/components/MarkdownBody.test.tsx
+++ b/ui/src/components/MarkdownBody.test.tsx
@@ -15,6 +15,9 @@ import {
} from "@paperclipai/shared";
import { ThemeProvider } from "../context/ThemeContext";
import { MarkdownBody } from "./MarkdownBody";
+import { StatusIcon } from "./StatusIcon";
+import { taskStatusIconProps } from "../lib/task-status-presentation";
+import type { IssueBlockerAttention } from "@paperclipai/shared";
import { MarkdownWordStreaming } from "./MarkdownWordStreaming";
import { queryKeys } from "../lib/queryKeys";
import type { WorkspaceFileAvailabilityTarget } from "../lib/workspace-file-availability";
@@ -65,7 +68,7 @@ afterEach(() => {
function renderMarkdown(
children: string,
- seededIssues: Array<{ identifier: string; status: string; title?: string; cacheKey?: string }> = [],
+ seededIssues: Array<{ identifier: string; status: string; title?: string; cacheKey?: string; blockerAttention?: IssueBlockerAttention }> = [],
props: Partial
> = {},
streaming = false,
) {
@@ -83,6 +86,7 @@ function renderMarkdown(
identifier: issue.identifier,
status: issue.status,
title: issue.title,
+ ...(issue.blockerAttention ? { blockerAttention: issue.blockerAttention } : {}),
});
}
@@ -311,6 +315,33 @@ describe("MarkdownBody", () => {
expect(html).not.toContain("paperclip-mention-chip--issue");
});
+ it("draws a blocked task mention with the same glyph and colour token as the task list (BUG-07)", () => {
+ // A task blocked on active sub-tasks is "covered": the list, breadcrumb and
+ // sidebar draw the blue in-queue glyph. Inline mentions must match.
+ const blockerAttention: IssueBlockerAttention = {
+ state: "covered",
+ reason: "active_child",
+ unresolvedBlockerCount: 4,
+ coveredBlockerCount: 4,
+ stalledBlockerCount: 0,
+ attentionBlockerCount: 0,
+ sampleBlockerIdentifier: null,
+ sampleStalledBlockerIdentifier: null,
+ };
+ const html = renderMarkdown("This stays open until all four are done (BRA-1).", [
+ { identifier: "BRA-1", status: "blocked", title: "Paperclip onboarding", blockerAttention },
+ ]);
+ const listGlyph = renderToStaticMarkup(
+ ,
+ );
+
+ expect(listGlyph).toContain("var(--status-task-icon-in_queue)");
+ expect(html).toContain('href="/issues/BRA-1"');
+ expect(html).toContain("var(--status-task-icon-in_queue)");
+ expect(html).not.toContain("var(--status-task-icon-blocked)");
+ expect(html).toContain('aria-label="Blocked · waiting on 4 active sub-tasks"');
+ });
+
it("holds the quicklook preview until an issue mention is confirmed readable (PAP-16070)", () => {
// While the fetch is in flight we don't know if the viewer can read the
// issue. The mention stays a clickable link, but the IssueLinkQuicklook
diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx
index 1d9d7cca954..638f4c1436d 100644
--- a/ui/src/components/MarkdownBody.tsx
+++ b/ui/src/components/MarkdownBody.tsx
@@ -39,6 +39,7 @@ import {
} from "../lib/remark-workspace-file-refs";
import { remarkSoftBreaks } from "../lib/remark-soft-breaks";
import { StatusIcon } from "./StatusIcon";
+import { taskStatusIconProps } from "../lib/task-status-presentation";
import { WorkspaceFileLink } from "./WorkspaceFileLink";
import { ExternalObjectStatusIcon } from "./ExternalObjectStatusIcon";
import {
@@ -193,6 +194,9 @@ function MarkdownIssueLink({
const identifier = data?.identifier ?? issuePathId;
const title = data?.title ?? identifier;
const status = data?.status;
+ // Same presentation inputs as every task view, so a covered blocked task is
+ // the blue in-queue glyph here too, not the red blocked one (BUG-07).
+ const statusIconProps = data && status ? taskStatusIconProps({ ...data, status }) : null;
const issueLabel = title !== identifier ? `Issue ${identifier}: ${title}` : `Issue ${identifier}`;
// Until the fetch settles we don't yet know whether this viewer can read the
@@ -222,8 +226,8 @@ function MarkdownIssueLink({
title={title}
aria-label={issueLabel}
>
- {status ? (
-
+ {statusIconProps ? (
+
) : null}
{children}
diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx
index fd18a489f84..c857320f3df 100644
--- a/ui/src/components/TaskChatThread.test.tsx
+++ b/ui/src/components/TaskChatThread.test.tsx
@@ -4456,13 +4456,15 @@ describe("TaskChatThread blocker links", () => {
'[data-testid="task-chat-live-work-step"] a',
),
].map((link) => link.textContent);
+ // Each step leads with the shared task status glyph (its is
+ // the status label), the same glyph the task list draws (BUG-07).
expect(orderedLinks).toEqual([
- "PAP-17424Run the guarded cutover",
- "PAP-17425Verify the live projection",
- "PAP-17427Verify the completed projection",
+ "DonePAP-17424Run the guarded cutover",
+ "In ProgressPAP-17425Verify the live projection",
+ "TodoPAP-17427Verify the completed projection",
]);
expect(notice.textContent).toContain(
- "Now runningPAP-17426Restore live alias projection",
+ "Now runningIn ProgressPAP-17426Restore live alias projection",
);
expect(
notice.querySelector('a[href="/issues/PAP-17426"]'),
diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx
index bd605379bd9..b5ff2ac7dd9 100644
--- a/ui/src/components/TaskChatThread.tsx
+++ b/ui/src/components/TaskChatThread.tsx
@@ -865,7 +865,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
<>
{threadHeader}
{liveWorkLinks ? (
-
+
) : blockerLinks ? (
+
) : blockerLinks ? (
vi.fn());
+
+vi.mock("@/api/issues", () => ({
+ issuesApi: { summaries: mockSummaries },
+}));
+
+vi.mock("@/lib/router", () => ({
+ Link: ({ to, children, ...props }: { to: string; children?: ReactNode }) => {children},
+}));
+
+vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({
+ useCoordinatorWorkspaceEnabled: () => ({ enabled: false, userId: "user-1" }),
+}));
+
+const covered: IssueBlockerAttention = {
+ state: "covered",
+ reason: "active_dependency",
+ unresolvedBlockerCount: 1,
+ coveredBlockerCount: 1,
+ stalledBlockerCount: 0,
+ attentionBlockerCount: 0,
+ sampleBlockerIdentifier: null,
+ sampleStalledBlockerIdentifier: null,
+};
+
+function relation(id: string, identifier: string, status: IssueRelationIssueSummary["status"]): IssueRelationIssueSummary {
+ return { id, identifier, title: `Task ${identifier}`, status, priority: "medium", assigneeAgentId: "agent-1", assigneeUserId: null };
+}
+
+// The onboarding walk: BRA-1 waits on BRA-2 (running), todo tasks and
+// blocked tasks that are "covered" in the task list. Six steps plus a live
+// terminal blocker exceed one summaries request (at most five IDs).
+const terminal = relation("bra-9", "BRA-9", "in_progress");
+const blockers = [
+ { ...relation("bra-2", "BRA-2", "in_progress"), terminalBlockers: [terminal] },
+ relation("bra-3", "BRA-3", "todo"),
+ relation("bra-4", "BRA-4", "todo"),
+ relation("bra-5", "BRA-5", "blocked"),
+ relation("bra-6", "BRA-6", "todo"),
+ relation("bra-7", "BRA-7", "blocked"),
+];
+
+const summaries: IssueSummary[] = [...blockers, terminal].map((blocker) => ({
+ id: blocker.id,
+ companyId: "company-1",
+ title: blocker.title,
+ identifier: blocker.identifier,
+ status: blocker.status,
+ updatedAt: new Date("2026-11-06T00:00:00Z"),
+ externalConversationState: null,
+ ...(blocker.status === "blocked" ? { blockerAttention: covered } : {}),
+}));
+
+function listGlyph(summary: IssueSummary) {
+ return renderToStaticMarkup();
+}
+
+function glyphToken(markup: string): string | null {
+ return markup.match(/var\((--status-task-icon-[a-z_]+)\)/)?.[1] ?? null;
+}
+
+let container: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ mockSummaries.mockReset();
+ mockSummaries.mockImplementation(async (_companyId: string, ids: readonly string[]) => {
+ if (ids.length > ISSUE_SUMMARIES_MAX_IDS) throw new Error("Too many issue summary IDs");
+ return summaries.filter((summary) => ids.includes(summary.id));
+ });
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+});
+
+async function renderStrip(streamlined: boolean) {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const liveWork = resolveTaskChatLiveWork(blockers, new Set(["bra-2", "bra-9"]));
+ if (!liveWork) throw new Error("expected live work");
+ await act(async () => {
+ root.render(
+
+
+
+
+ ,
+ );
+ });
+ await vi.waitFor(() => {
+ expect(container.innerHTML).not.toContain("var(--status-task-icon-blocked)");
+ });
+ return container.innerHTML;
+}
+
+describe("TaskChatLiveWorkLinks status glyphs (BUG-07)", () => {
+ for (const streamlined of [true, false]) {
+ it(`draws each waiting task with the task list's glyph and colour token (${streamlined ? "streamlined" : "production"})`, async () => {
+ const html = await renderStrip(streamlined);
+ const steps = [...container.querySelectorAll('[data-testid="task-chat-live-work-step"]')].map((step) => step.outerHTML);
+ expect(steps).toHaveLength(blockers.length);
+
+ for (const summary of summaries.filter((candidate) => candidate.id !== terminal.id)) {
+ const step = steps.find((markup) => markup.includes(`>${summary.identifier}<`));
+ expect(step, summary.identifier!).toBeDefined();
+ const expected = listGlyph(summary);
+ expect(glyphToken(step!), summary.identifier!).toBe(glyphToken(expected));
+ const expectedLabel = expected.match(/aria-label="([^"]+)"/)?.[1];
+ expect(step!, summary.identifier!).toContain(`aria-label="${expectedLabel}"`);
+ }
+
+ // The live terminal blocker ("Now running") draws the shared glyph too.
+ const nowRunning = [...container.querySelectorAll("a")].find((link) => link.textContent?.includes("BRA-9"));
+ const nowRunningRow = (streamlined ? nowRunning : nowRunning?.parentElement)?.outerHTML ?? "";
+ expect(glyphToken(nowRunningRow)).toBe(glyphToken(listGlyph(summaries.at(-1)!)));
+
+ // Blocked-but-covered tasks: blue in-queue glyph, never the red blocked one.
+ expect(html).toContain("var(--status-task-icon-in_queue)");
+ expect(html).not.toContain("var(--status-task-icon-blocked)");
+ // Every request stays within the summaries endpoint limit.
+ for (const [, ids] of mockSummaries.mock.calls) {
+ expect((ids as string[]).length).toBeLessThanOrEqual(ISSUE_SUMMARIES_MAX_IDS);
+ }
+ });
+ }
+});
diff --git a/ui/src/components/task-chat/TaskChatBlockerLinks.tsx b/ui/src/components/task-chat/TaskChatBlockerLinks.tsx
index e4d2576a367..38dcf74538e 100644
--- a/ui/src/components/task-chat/TaskChatBlockerLinks.tsx
+++ b/ui/src/components/task-chat/TaskChatBlockerLinks.tsx
@@ -2,11 +2,17 @@ import type {
IssueBlockerAttentionIssueSummary,
IssueRelationIssueSummary,
} from "@paperclipai/shared";
-import { CheckCircle2, Circle } from "lucide-react";
+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 WaitingBlockerStatus,
type WaitingBlockerStep,
} from "@/lib/issue-blockers";
import { Link } from "@/lib/router";
@@ -129,40 +135,56 @@ function BlockerRow({
);
}
-function LiveWorkGlyph({ status }: { status: WaitingBlockerStatus }) {
- const label = status === "done" ? "Done" : status === "running" ? "Running" : "Waiting";
- if (status === "done") {
- return (
-
- );
- }
- if (status === "running") {
- return (
-
-
-
- );
- }
- return (
-
- );
+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 ;
}
function LiveWorkLink({
blocker,
- status,
+ presentation,
label,
}: {
- blocker: IssueRelationIssueSummary | IssueBlockerAttentionIssueSummary;
- status: WaitingBlockerStatus;
+ blocker: LiveWorkBlocker;
+ presentation: TaskStatusPresentationInput;
label?: string;
}) {
const streamlined = useStreamlinedTaskChatPresentation();
@@ -176,7 +198,7 @@ function LiveWorkLink({
title={`${blocker.identifier ?? blocker.id.slice(0, 8)} — ${blocker.title}`}
>
{streamlined && label ? {label} : null}
- {streamlined ? : null}
+ {streamlined ? : null}
{blocker.identifier ?? blocker.id.slice(0, 8)}
{blocker.title}
@@ -215,11 +237,18 @@ export function TaskChatBlockerLinks({
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 (
- {liveWork.steps.map(({ blocker, status }, index) => (
+ {liveWork.steps.map(({ blocker }, index) => (
-
{index + 1}.
-
+
>
) : null}
-
+
))}
{liveWork.nowRunning.map((blocker) => streamlined ? (
-
+
) : (
Now running
-
+
+
))}
diff --git a/ui/storybook/stories/foundations/status-language.stories.tsx b/ui/storybook/stories/foundations/status-language.stories.tsx
index 20c5963d70d..12739b9f4ad 100644
--- a/ui/storybook/stories/foundations/status-language.stories.tsx
+++ b/ui/storybook/stories/foundations/status-language.stories.tsx
@@ -1,4 +1,6 @@
import { useState } from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { IssueSummary } from "@paperclipai/shared";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AGENT_STATUSES, ISSUE_PRIORITIES, ISSUE_STATUSES } from "@paperclipai/shared";
import type {
@@ -16,8 +18,11 @@ import { PriorityIcon } from "@/components/PriorityIcon";
import { QuotaBar } from "@/components/QuotaBar";
import { IssueStatusBadge, StatusBadge } from "@/components/StatusBadge";
import { StatusIcon } from "@/components/StatusIcon";
+import { MarkdownBody } from "@/components/MarkdownBody";
+import { resolveTaskChatLiveWork, TaskChatLiveWorkLinks } from "@/components/task-chat/TaskChatBlockerLinks";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
-import type { TaskStatusPresentationInput } from "@/lib/task-status-presentation";
+import { queryKeys } from "@/lib/queryKeys";
+import { taskStatusIconProps, type TaskStatusPresentationInput } from "@/lib/task-status-presentation";
import { createIssue } from "../../fixtures/paperclipData";
const sameStateAcrossViews: Array<{ label: string; state: TaskStatusPresentationInput }> = [
@@ -35,6 +40,71 @@ const sameStateAcrossViews: Array<{ label: string; state: TaskStatusPresentation
{ label: "In progress, monitoring", state: { status: "in_progress", displayStatus: "monitoring" } },
];
+const conversationCovered: IssueBlockerAttention = {
+ state: "covered", reason: "active_child", unresolvedBlockerCount: 4, coveredBlockerCount: 4,
+ attentionBlockerCount: 0, stalledBlockerCount: 0, sampleBlockerIdentifier: null, sampleStalledBlockerIdentifier: null,
+};
+
+const conversationBlockers: IssueRelationIssueSummary[] = [
+ { id: "pap-902", identifier: "PAP-902", title: "Creative brief + meme trend research", status: "in_progress", priority: "medium", assigneeAgentId: "agent-1", assigneeUserId: null },
+ { id: "pap-903", identifier: "PAP-903", title: "15-20 tee design concepts", status: "todo", priority: "medium", assigneeAgentId: "agent-2", assigneeUserId: null },
+ { id: "pap-905", identifier: "PAP-905", title: "Launch listing copy", status: "blocked", priority: "medium", assigneeAgentId: "agent-1", assigneeUserId: null },
+];
+
+const conversationSummaries: IssueSummary[] = conversationBlockers.map((blocker) => ({
+ id: blocker.id,
+ companyId: "company-storybook",
+ identifier: blocker.identifier,
+ title: blocker.title,
+ status: blocker.status,
+ updatedAt: new Date("2026-11-06T00:00:00Z"),
+ externalConversationState: null,
+ ...(blocker.status === "blocked" ? { blockerAttention: conversationCovered } : {}),
+}));
+
+/** Inline task mentions and the live-work strip use the same glyphs as the task list (BUG-07). */
+function ConversationStatusSurfaces() {
+ const [queryClient] = useState(() => {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } } });
+ client.setQueryData(queryKeys.auth.session, { session: { id: "session-storybook", userId: "user-storybook" }, user: { id: "user-storybook" } });
+ client.setQueryData(queryKeys.issues.detail("PAP-901"), {
+ id: "pap-901", identifier: "PAP-901", title: "Paperclip onboarding", status: "blocked", blockerAttention: conversationCovered,
+ });
+ client.setQueryData(queryKeys.issues.detail("PAP-905"), { ...conversationSummaries[2] });
+ client.setQueryData(
+ queryKeys.issues.summaryBatch("company-storybook", "user-storybook", conversationBlockers.map((b) => b.id).sort()),
+ conversationSummaries,
+ );
+ return client;
+ });
+ const liveWork = resolveTaskChatLiveWork(conversationBlockers, new Set(["pap-902"]));
+ return (
+
+
+
+
Task list row
+ {conversationSummaries.map((summary) => (
+
+ {summary.identifier}
+
+ ))}
+
+ PAP-901
+
+
+
+
Inline task mentions in a message
+
{"PAP-901 stays open until all four are done. PAP-905 waits on the designs."}
+
+
+
Waiting on live work strip
+ {liveWork ?
: null}
+
+
+
+ );
+}
+
function Section({
eyebrow,
title,
@@ -450,6 +520,10 @@ function StatusLanguage() {
+
+
{coveredBlockedMatrix.map((item) => (