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 ? ( - <TaskChatLiveWorkLinks liveWork={liveWorkLinks} placement="top" /> + <TaskChatLiveWorkLinks liveWork={liveWorkLinks} placement="top" companyId={companyId} /> ) : blockerLinks ? ( <TaskChatBlockerLinks directBlocker={blockerLinks.directBlocker} @@ -2551,7 +2551,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { !streamlinedUiEnabled || shouldRepeatTaskChatBlockers(items); const bottomBlockerLinks = repeatBlockersAtBottom ? ( liveWorkLinks ? ( - <TaskChatLiveWorkLinks liveWork={liveWorkLinks} placement="bottom" /> + <TaskChatLiveWorkLinks liveWork={liveWorkLinks} placement="bottom" companyId={companyId} /> ) : blockerLinks ? ( <TaskChatBlockerLinks directBlocker={blockerLinks.directBlocker} diff --git a/ui/src/components/task-chat/TaskChatBlockerLinks.test.tsx b/ui/src/components/task-chat/TaskChatBlockerLinks.test.tsx new file mode 100644 index 00000000000..e1dc2eee54b --- /dev/null +++ b/ui/src/components/task-chat/TaskChatBlockerLinks.test.tsx @@ -0,0 +1,151 @@ +// @vitest-environment jsdom + +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderToStaticMarkup } from "react-dom/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + ISSUE_SUMMARIES_MAX_IDS, + type IssueBlockerAttention, + type IssueRelationIssueSummary, + type IssueSummary, +} from "@paperclipai/shared"; +import { StatusIcon } from "../StatusIcon"; +import { taskStatusIconProps } from "@/lib/task-status-presentation"; +import { TaskChatPresentationProvider } from "./presentation-mode"; +import { resolveTaskChatLiveWork, TaskChatLiveWorkLinks } from "./TaskChatBlockerLinks"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const mockSummaries = vi.hoisted(() => vi.fn()); + +vi.mock("@/api/issues", () => ({ + issuesApi: { summaries: mockSummaries }, +})); + +vi.mock("@/lib/router", () => ({ + Link: ({ to, children, ...props }: { to: string; children?: ReactNode }) => <a href={to} {...props}>{children}</a>, +})); + +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(<StatusIcon {...taskStatusIconProps(summary)} />); +} + +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( + <QueryClientProvider client={queryClient}> + <TaskChatPresentationProvider mode={streamlined ? "streamlined" : "production"}> + <TaskChatLiveWorkLinks liveWork={liveWork} placement="bottom" companyId="company-1" /> + </TaskChatPresentationProvider> + </QueryClientProvider>, + ); + }); + 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 ( - <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} - /> - ); +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, - 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 ? <span className="shrink-0 font-medium">{label}</span> : null} - {streamlined ? <LiveWorkGlyph status={status} /> : 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> @@ -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 ( <div @@ -235,7 +264,7 @@ export function TaskChatLiveWorkLinks({ {heading} </div> <ol className="flex min-w-0 flex-col gap-1"> - {liveWork.steps.map(({ blocker, status }, index) => ( + {liveWork.steps.map(({ blocker }, index) => ( <li key={blocker.id} data-testid="task-chat-live-work-step" @@ -246,19 +275,20 @@ export function TaskChatLiveWorkLinks({ <span className="w-4 shrink-0 text-right font-mono text-blue-500/80" aria-hidden> {index + 1}. </span> - <LiveWorkGlyph status={status} /> + <LiveWorkGlyph presentation={presentationFor(blocker)} /> </> ) : null} - <LiveWorkLink blocker={blocker} status={status} /> + <LiveWorkLink blocker={blocker} presentation={presentationFor(blocker)} /> </li> ))} </ol> {liveWork.nowRunning.map((blocker) => streamlined ? ( - <LiveWorkLink key={blocker.id} blocker={blocker} status="running" label="Now running" /> + <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> - <LiveWorkLink blocker={blocker} status="running" /> + <LiveWorkGlyph presentation={presentationFor(blocker)} /> + <LiveWorkLink blocker={blocker} presentation={presentationFor(blocker)} /> </div> ))} </div> 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 ( + <QueryClientProvider client={queryClient}> + <div className="grid gap-3 md:grid-cols-3"> + <div className="space-y-3 rounded-lg border border-border bg-background/70 p-4"> + <div className="text-sm font-medium">Task list row</div> + {conversationSummaries.map((summary) => ( + <div key={summary.id} className="flex items-center gap-2 text-xs text-muted-foreground"> + <StatusIcon {...taskStatusIconProps(summary)} /> {summary.identifier} + </div> + ))} + <div className="flex items-center gap-2 text-xs text-muted-foreground"> + <StatusIcon status="blocked" blockerAttention={conversationCovered} /> PAP-901 + </div> + </div> + <div className="space-y-3 rounded-lg border border-border bg-background/70 p-4"> + <div className="text-sm font-medium">Inline task mentions in a message</div> + <MarkdownBody>{"PAP-901 stays open until all four are done. PAP-905 waits on the designs."}</MarkdownBody> + </div> + <div className="space-y-3 rounded-lg border border-border bg-background/70 p-4"> + <div className="text-sm font-medium">Waiting on live work strip</div> + {liveWork ? <TaskChatLiveWorkLinks liveWork={liveWork} placement="bottom" companyId="company-storybook" /> : null} + </div> + </div> + </QueryClientProvider> + ); +} + function Section({ eyebrow, title, @@ -450,6 +520,10 @@ function StatusLanguage() { </div> </Section> + <Section eyebrow="One mapping" title="Same state in conversations"> + <ConversationStatusSurfaces /> + </Section> + <Section eyebrow="Covered blocked" title="Blocked attention state matrix"> <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4"> {coveredBlockedMatrix.map((item) => (