fix(ui): resolve task status glyphs through one shared mapping (BUG-07)

The same task state rendered different status icons across views. A blocked
task covered by an active sub-task ("in queue") showed the blue queued glyph
in the task header and status trigger, but the status menu's current row, the
chat chip and the coordinator sidebar recents/project rows showed the red
blocked glyph, because those surfaces dropped blockerAttention (and some
dropped externalConversationState) before calling StatusIcon.

- Add ui/src/lib/task-status-presentation.ts: one resolver for glyph, label
  and accessible copy, plus taskStatusIconProps() so call sites pass every
  presentation field from a task record.
- StatusIcon and IssueStatusBadge use the resolver; the picker draws the
  current row with the trigger's presentation.
- Task call sites (sidebar, lists, inbox, properties, pills, search, chat
  chips, status cards) spread taskStatusIconProps.
- /issues/summaries returns blockerAttention for blocked rows; recent-task
  snapshots store and refresh it; sidebar project rows use the rows projection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 committed 2026-11-05 13:40:26 -06:00
1 parent 03eb49053f
commit 2c0cc8ee64
37 files changed
+418 -130

No files matched your search

+2
View File
@@ -855,6 +855,8 @@ export type IssueSummary = Pick<
"id" | "companyId" | "title" | "identifier" | "status" | "displayStatus" | "updatedAt"
> & {
externalConversationState: "active" | "waiting" | null;
/** Present for blocked tasks so summary views resolve the same status glyph as detail views. */
blockerAttention?: IssueBlockerAttention;
};
export interface Issue {
@@ -4,7 +4,7 @@ import request from "supertest";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, expect, it, vi } from "vitest";
import {
agents, chatConversations, chatEndpoints, issues, toolApplications, toolConnections,
agents, chatConversations, chatEndpoints, heartbeatRuns, issueRelations, issues, toolApplications, toolConnections,
} from "@paperclipai/db";
import { issueRoutes } from "../routes/issues.js";
import * as issueServices from "../services/issues.js";
@@ -110,4 +110,22 @@ describeEmbeddedPostgres("bounded issue summaries", () => {
expect(active.externalConversationState).toBe("active");
expect(active.updatedAt).toBe(waiting.updatedAt);
});
it("projects the covered-blocker state so every task view picks the same queued glyph (BUG-07)", async () => {
const f = await fixture();
const [agent] = await ctx.db.insert(agents).values({ companyId: f.companyId, name: "Builder", role: "engineer", adapterType: "process" }).returning();
const [parent] = await ctx.db.insert(issues).values({ companyId: f.companyId, title: "Parent", status: "blocked" }).returning();
const [child] = await ctx.db.insert(issues).values({
companyId: f.companyId, title: "Running child", identifier: "SUM-9", status: "todo", parentId: parent!.id, assigneeAgentId: agent!.id,
}).returning();
await ctx.db.insert(issueRelations).values({ companyId: f.companyId, issueId: child!.id, relatedIssueId: parent!.id, type: "blocks" });
const runId = randomUUID();
await ctx.db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: agent!.id, status: "running", contextSnapshot: { issueId: child!.id } });
await ctx.db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, child!.id));
const res = await request(app(f.actor)).get(url(f.companyId)).query({ ids: `${parent!.id},${f.rows[0]!.id}` }).expect(200);
expect(res.body[0].blockerAttention).toMatchObject({ state: "covered", reason: "active_child", sampleBlockerIdentifier: "SUM-9" });
// Unblocked tasks keep the bounded summary shape.
expect(res.body[1]).not.toHaveProperty("blockerAttention");
});
});
+21 -11
View File
@@ -12,7 +12,7 @@ import { getHeartbeatRunReadDecisions, redactHeartbeatRunListRow } from "../serv
import { taskInterruptionPresentation, taskInterruptionPresentations } from "../services/task-interruption-presentation.js";
import { parseObject } from "../adapters/utils.js";
import { prepareRestrictedWorkspaceRetry } from "../services/restricted-workspace-retry.js";
import { issueSummariesQuerySchema, type IssueSummary } from "@paperclipai/shared";
import { issueSummariesQuerySchema, type IssueBlockerAttention, type IssueSummary } from "@paperclipai/shared";
import { listIssueSummaries } from "../services/issue-summaries.js";
import { assertNoCaseHandoffMutation } from "../services/heartbeat/case-handoff-lifecycle.js";
import { assertLegacyRunTaskOwnership } from "../legacy-run-task-ownership.js";
@@ -8418,16 +8418,26 @@ export function issueRoutes(
}
const rows = await listIssueSummaries(db, companyId, parsed.data.ids, await issueReadSqlCondition(db, req.actor));
const visible = await filterIssuesForActor(req, rows);
const summaries: IssueSummary[] = visible.map((row) => ({
id: row.id,
companyId: row.companyId,
title: row.title,
identifier: row.identifier,
status: row.status as IssueSummary["status"],
displayStatus: row.displayStatus,
updatedAt: row.updatedAt,
externalConversationState: row.externalConversationState,
}));
// Blocked rows carry their blocker state so every task view resolves the
// same glyph (covered → "in queue") as the task pane. At most five IDs.
const blockedRows = visible.filter((row) => row.status === "blocked");
const blockerAttention = blockedRows.length > 0
? await svc.listBlockerAttention(companyId, blockedRows)
: new Map<string, IssueBlockerAttention>();
const summaries: IssueSummary[] = visible.map((row) => {
const attention = blockerAttention.get(row.id);
return {
id: row.id,
companyId: row.companyId,
title: row.title,
identifier: row.identifier,
status: row.status as IssueSummary["status"],
displayStatus: row.displayStatus,
updatedAt: row.updatedAt,
externalConversationState: row.externalConversationState,
...(attention ? { blockerAttention: attention } : {}),
};
});
res.setHeader("Cache-Control", "private, no-store");
res.json(summaries);
});
+4 -3
View File
@@ -27,6 +27,7 @@ import { Identity } from "./Identity";
import { StatusIcon } from "./StatusIcon";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
interface BlockedInboxViewProps {
companyId: string;
@@ -377,7 +378,7 @@ function BlockedInboxRow({
showDivider={presentation === "legacy"}
statusSlot={presentation === "task"
? showStatusColumn
? <StatusIcon status={row.issue.status} displayStatus={row.issue.displayStatus} blockerAttention={blockerAttention} />
? <StatusIcon {...taskStatusIconProps(row.issue)} blockerAttention={blockerAttention} />
: <span className="inline-flex size-4" aria-hidden="true" />
: undefined}
showIdentifier={presentation === "task" ? showIdentifierColumn : undefined}
@@ -391,7 +392,7 @@ function BlockedInboxRow({
) : undefined}
mobileLeading={
<span className="flex shrink-0 items-center gap-1.5 pt-px">
<StatusIcon status={row.issue.status} displayStatus={row.issue.displayStatus} blockerAttention={blockerAttention} />
<StatusIcon {...taskStatusIconProps(row.issue)} blockerAttention={blockerAttention} />
</span>
}
titleSuffix={
@@ -423,7 +424,7 @@ function BlockedRowDesktopMeta({
const identifier = row.issue.identifier ?? row.issue.id.slice(0, 8);
return (
<span className="hidden shrink-0 items-center gap-2 sm:inline-flex">
{showStatusColumn ? <StatusIcon status={row.issue.status} displayStatus={row.issue.displayStatus} blockerAttention={blockerAttention} /> : null}
{showStatusColumn ? <StatusIcon {...taskStatusIconProps(row.issue)} blockerAttention={blockerAttention} /> : null}
{showIdentifierColumn ? <span className="font-mono text-xs text-muted-foreground">{identifier}</span> : null}
</span>
);
+2 -1
View File
@@ -14,6 +14,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn, relativeTime } from "@/lib/utils";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
const EVENT_LABEL: Record<CaseEventKind, string> = {
created: "created",
@@ -75,7 +76,7 @@ export function CaseEventRow({ event, compact = false }: { event: CaseEvent; com
className="inline-flex min-w-0 items-center gap-1 text-foreground/80 hover:underline"
title={event.issue.title}
>
<StatusIcon status={event.issue.status} size="sm" />
<StatusIcon {...taskStatusIconProps(event.issue)} size="sm" />
<span className="shrink-0 font-mono">{event.issue.identifier}</span>
<span className="min-w-0 truncate">{event.issue.title}</span>
</Link>
+39 -4
View File
@@ -10,15 +10,17 @@ import { coordinatorAttentionQueryKey, coordinatorAttentionSummaryQueryKey } fro
import { queryKeys } from "@/lib/queryKeys";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const fixture = vi.hoisted(() => ({ attention: vi.fn(), summary: vi.fn(), openNewIssue: vi.fn(), pathname: "/COO/issues", collapsed: false, userId: "reviewer", chats: [] as Array<{ isUnreadForMe: boolean }> }));
const fixture = vi.hoisted(() => ({ attention: vi.fn(), summary: vi.fn(), openNewIssue: vi.fn(), pathname: "/COO/issues", collapsed: false, userId: "reviewer", chats: [] as Array<{ isUnreadForMe: boolean }>,
recentTasks: [] as Array<Record<string, unknown>>, projects: [] as Array<Record<string, unknown>>, projectTasks: vi.fn() }));
vi.mock("@/hooks/useAgentChatNavigation", () => ({ useAgentChatNavigation: () => ({
companyId: "company", userId: fixture.userId, agents: { data: [] }, chats: { data: fixture.chats }, session: { isSuccess: true },
}) }));
vi.mock("@/hooks/useRecentTasks", () => ({ useRecentTasks: () => ({ entries: [] }) }));
vi.mock("@/hooks/useRecentTasks", () => ({ useRecentTasks: () => ({ entries: fixture.recentTasks }) }));
vi.mock("@/api/issues", () => ({ issuesApi: { listCompact: fixture.projectTasks } }));
vi.mock("@/context/DialogContext", () => ({ useDialogActions: () => ({ openNewIssue: fixture.openNewIssue }) }));
vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ collapsed: fixture.collapsed, peeking: false, isMobile: false, setSidebarOpen: vi.fn() }) }));
vi.mock("@/api/attention", () => ({ attentionApi: { list: fixture.attention, summary: fixture.summary } }));
vi.mock("@/api/projects", () => ({ projectsApi: { list: async () => [] } }));
vi.mock("@/api/projects", () => ({ projectsApi: { list: async () => fixture.projects } }));
vi.mock("./SidebarCompanyMenu", () => ({ SidebarCompanyMenu: () => null }));
vi.mock("@/lib/router", () => ({
useLocation: () => ({ pathname: fixture.pathname, search: "" }),
@@ -43,7 +45,7 @@ function summary(items: AttentionItem[]) {
let root: Root, container: HTMLDivElement, client: QueryClient;
beforeEach(() => {
vi.clearAllMocks(); localStorage.clear(); fixture.collapsed = false; fixture.userId = "reviewer"; fixture.pathname = "/COO/issues";
fixture.chats = [];
fixture.chats = []; fixture.recentTasks = []; fixture.projects = [];
fixture.summary.mockResolvedValue(summary([pending("question"), pending("review")]));
container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -214,3 +216,36 @@ describe("Coordinator collection reads", () => {
expect(decisionLink().querySelector('[data-slot="badge"]')).toBeNull();
});
});
describe("Coordinator task status glyphs (BUG-07)", () => {
const covered = {
state: "covered", reason: "active_child", unresolvedBlockerCount: 1, coveredBlockerCount: 1,
attentionBlockerCount: 0, stalledBlockerCount: 0, sampleBlockerIdentifier: "ONB-3", sampleStalledBlockerIdentifier: null,
};
const glyph = (href: string) => container.querySelector<SVGElement>(`a[href="${href}"] svg[role="img"]`);
it("shows a covered-blocked recent task with the same queued glyph and label as the task pane", async () => {
fixture.recentTasks = [{ id: "task-1", companyId: "company", identifier: "ONB-1", title: "Paperclip onboarding",
status: "blocked", displayStatus: "blocked", externalConversationState: null, blockerAttention: covered, recordedAt: 1 }];
await renderSidebar();
expect(glyph("/issues/ONB-1")?.style.color).toBe("var(--status-task-icon-in_queue)");
expect(glyph("/issues/ONB-1")?.getAttribute("aria-label")).toBe("Blocked · waiting on active sub-task ONB-3");
});
it("shows project tasks with their full status presentation", async () => {
fixture.projects = [{ id: "project-1", companyId: "company", name: "Onboarding", urlKey: "onboarding" }];
fixture.projectTasks.mockResolvedValue([
{ id: "task-1", identifier: "ONB-1", title: "Blocked parent", status: "blocked", displayStatus: "blocked", blockerAttention: covered },
{ id: "task-3", identifier: "ONB-3", title: "Waiting review", status: "in_review", displayStatus: "in_review", externalConversationState: "waiting" },
]);
fixture.pathname = "/COO/projects/onboarding";
await renderSidebar();
const expand = container.querySelector<HTMLButtonElement>('button[aria-label="Expand Onboarding"]');
expect(expand).not.toBeNull();
await act(async () => expand!.click());
await flush();
expect(fixture.projectTasks).toHaveBeenCalledWith("company", expect.objectContaining({ projectId: "project-1", projection: "rows" }));
expect(glyph("/issues/ONB-1")?.style.color).toBe("var(--status-task-icon-in_queue)");
expect(glyph("/issues/ONB-3")?.getAttribute("aria-label")).toBe("Idle");
});
});
+4 -3
View File
@@ -22,6 +22,7 @@ import { Flyout, FlyoutContent, FlyoutTrigger, FlyoutAppearanceProvider } from "
import { Button } from "./ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { StatusIcon } from "./StatusIcon";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
const moreDestinations = [
{ label: "Search", to: "/search", icon: Search },
@@ -41,7 +42,7 @@ function ProjectRecent({ project }: { project: Project }) {
const [expanded, setExpanded] = useState(false);
const { data: tasks, isPending, error } = useQuery({
queryKey: [...queryKeys.issues.listByProject(project.companyId, project.id), "coordinator-recents"],
queryFn: () => issuesApi.listCompact(project.companyId, { projection: "index", projectId: project.id, limit: 12, sortField: "updated", sortDir: "desc" }),
queryFn: () => issuesApi.listCompact(project.companyId, { projection: "rows", projectId: project.id, limit: 12, sortField: "updated", sortDir: "desc" }),
enabled: expanded,
});
return <div>
@@ -54,7 +55,7 @@ function ProjectRecent({ project }: { project: Project }) {
</div>
{expanded && <div className="ml-5 border-l border-border pl-1">
{isPending ? <p className="px-3 py-2 text-xs text-muted-foreground" role="status">Loading tasks…</p> : error ? <p className="px-3 py-2 text-xs text-muted-foreground">Couldn’t load tasks.</p> : tasks?.length ? tasks.map(task =>
<SidebarNavItem className="design-sidebar-item" key={task.id} to={`/issues/${task.identifier ?? task.id}`} label={task.title} iconNode={<StatusIcon status={task.status} displayStatus={task.displayStatus} />} />)
<SidebarNavItem className="design-sidebar-item" key={task.id} to={`/issues/${task.identifier ?? task.id}`} label={task.title} iconNode={<StatusIcon {...taskStatusIconProps(task)} />} />)
: <p className="px-3 py-2 text-xs text-muted-foreground">No tasks yet.</p>}
</div>}
</div>;
@@ -148,7 +149,7 @@ export function CoordinatorSidebar({ children }: { children?: ReactNode }) {
{!rail && session.isSuccess && recents.length > 0 && <div className="design-sidebar-recents"><SidebarSection label="Recents">
<div className="design-sidebar-recent-list scrollbar-while-scrolling" onScroll={handleRecentScroll} role="region" aria-label="Recents" tabIndex={0}>
{recents.map(item => item.kind === "task"
? <SidebarNavItem className="design-sidebar-item" key={item.id} to={`/issues/${item.task.identifier ?? item.id}`} label={item.task.title} iconNode={<StatusIcon status={item.task.status} displayStatus={item.task.displayStatus} externalConversationState={item.task.externalConversationState} />} />
? <SidebarNavItem className="design-sidebar-item" key={item.id} to={`/issues/${item.task.identifier ?? item.id}`} label={item.task.title} iconNode={<StatusIcon {...taskStatusIconProps(item.task)} />} />
: item.kind === "chat"
? <SidebarNavItem className="design-sidebar-item" key={item.id} to={`/chats/${encodeURIComponent(agentRouteRef(item.agent))}`} label={item.agent.name} icon={MessageCircle} />
: <ProjectRecent key={item.id} project={item.project} />)}
+2 -1
View File
@@ -22,6 +22,7 @@ import { timeAgo } from "../lib/timeAgo";
import { Identity } from "./Identity";
import { StatusIcon } from "./StatusIcon";
import { Badge } from "@/components/ui/badge";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
export const issueTrailingColumns: InboxIssueColumn[] = ["assignee", "kickedOffBy", "project", "workspace", "parent", "labels", "updated"];
@@ -202,7 +203,7 @@ export function InboxIssueMetaLeading({
<>
{showStatus ? (
<span className="hidden shrink-0 items-center sm:inline-flex">
{statusSlot ?? <StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} />}
{statusSlot ?? <StatusIcon {...taskStatusIconProps(issue)} />}
</span>
) : null}
{checklistStepNumber !== null ? (
+2 -1
View File
@@ -6,6 +6,7 @@ import { cn } from "../lib/utils";
import { LockedIssueChip, isLockedIssueStub } from "./LockedIssueChip";
import { badgeVariants } from "./ui/badge";
import { StatusIcon } from "./StatusIcon";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
export function IssueReferencePill({
issue,
@@ -42,7 +43,7 @@ export function IssueReferencePill({
);
const content = (
<>
{issue.status ? <StatusIcon status={issue.status} displayStatus={issue.displayStatus} className="h-3 w-3 shrink-0" /> : null}
{issue.status ? <StatusIcon {...taskStatusIconProps({ ...issue, status: issue.status })} className="h-3 w-3 shrink-0" /> : null}
{children !== undefined ? children : <span className="min-w-0 truncate">{issue.identifier ?? issue.title}</span>}
</>
);
+4 -3
View File
@@ -26,6 +26,7 @@ import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { TaskRowColumnCells, useTaskListColumns, type TaskRowColumnValues } from "./TaskListColumns";
import { CellMarquee, HoveredCellContext, useCellMarqueeHover } from "./CellMarquee";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
export type IssueRowUnreadState = "hidden" | "visible" | "fading";
export type IssueRowPresentation = "legacy" | "task";
@@ -240,7 +241,7 @@ export function IssueRow({
const isUnread = unreadState === "visible" || unreadState === "fading";
const refreshed = appearance === "refreshed";
const statusLabel = getStatusIconLabel(issue.status, issue.externalConversationState, issue.blockerAttention, issue.displayStatus);
const statusIcon = <StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} size="md" className={selectedStatusClass} />;
const statusIcon = <StatusIcon {...taskStatusIconProps(issue)} size="md" className={selectedStatusClass} />;
const status = statusSlot ?? (refreshed ? (
<Tooltip>
<TooltipTrigger asChild>
@@ -487,7 +488,7 @@ export function IssueRow({
<span className="sr-only">Open {identifier}: {issue.title}</span>
</Link>
<span className="flex shrink-0 items-center gap-1 pt-px sm:hidden">
{mobileLeading ?? <StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} size="md" className={selectedStatusClass} />}
{mobileLeading ?? <StatusIcon {...taskStatusIconProps(issue)} size="md" className={selectedStatusClass} />}
{parkedBlockerIndicator}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
@@ -560,7 +561,7 @@ export function IssueRow({
{desktopMetaLeading ?? (
<>
<span className="hidden shrink-0 items-center gap-1 sm:inline-flex">
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} size="md" className={selectedStatusClass} />
<StatusIcon {...taskStatusIconProps(issue)} size="md" className={selectedStatusClass} />
</span>
{checklistStep}
<span className="shrink-0 font-mono text-xs text-muted-foreground">
+2 -1
View File
@@ -5,6 +5,7 @@ import { createIssueDetailPath, withIssueDetailHeaderSeed } from "@/lib/issueDet
import { cn } from "@/lib/utils";
import { Link } from "@/lib/router";
import { StatusIcon } from "./StatusIcon";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
type IssueSiblingNavigationProps = {
navigation: IssueSiblingNavigationState | null;
@@ -79,7 +80,7 @@ function SiblingLink({
"flex min-w-0 items-center gap-1.5 text-xs font-mono text-muted-foreground transition-colors group-hover:text-foreground",
direction === "next" && "sm:justify-end",
)}>
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} />
<StatusIcon {...taskStatusIconProps(issue)} />
<span className="shrink-0">{identifier}</span>
</div>
<div className="truncate text-sm text-foreground">
+4 -3
View File
@@ -106,6 +106,7 @@ import {
import { taskDateGroup, taskDateGroupSeparator, type TaskDateGroup } from "../lib/task-date-groups";
import { deriveOriginatingActor, ISSUE_STATUSES, ISSUE_DISPLAY_STATUSES, type Issue, type IssueDisplayStatus, type IssueStatus, type Project } from "@paperclipai/shared";
import { Badge } from "@/components/ui/badge";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
const ISSUE_SEARCH_DEBOUNCE_MS = 250;
const ISSUE_SEARCH_RESULT_LIMIT = 200;
const ISSUE_BOARD_COLUMN_RESULT_LIMIT = 200;
@@ -2354,7 +2355,7 @@ function StreamlinedIssuesList({
) : undefined}
statusSlot={rowPresentation === "task" ? (
<span className="relative inline-flex items-start self-stretch sm:items-center" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} size="md" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
<StatusIcon {...taskStatusIconProps(issue)} size="md" onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
{hasChildren && isExpanded ? (
<span aria-hidden="true" className="pointer-events-none absolute top-5 -bottom-2.5 left-1/2 w-px bg-border sm:hidden" />
) : null}
@@ -2378,7 +2379,7 @@ function StreamlinedIssuesList({
</button>
) : (
<span className="inline-flex items-center" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} size="md" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
<StatusIcon {...taskStatusIconProps(issue)} size="md" onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
</span>
)
}
@@ -2405,7 +2406,7 @@ function StreamlinedIssuesList({
checklistStepNumber={checklistStepNumber}
statusSlot={(
<span className="inline-flex items-center" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} size="md" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
<StatusIcon {...taskStatusIconProps(issue)} size="md" onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
</span>
)}
/>
+3 -2
View File
@@ -95,6 +95,7 @@ import { statusBadge } from "../lib/status-colors";
import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff";
import { deriveOriginatingActor, ISSUE_STATUSES, ISSUE_DISPLAY_STATUSES, type Issue, type IssueDisplayStatus, type IssueStatus, type Project } from "@paperclipai/shared";
import { Badge } from "@/components/ui/badge";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
const ISSUE_SEARCH_DEBOUNCE_MS = 250;
const ISSUE_SEARCH_RESULT_LIMIT = 200;
const ISSUE_BOARD_COLUMN_RESULT_LIMIT = 200;
@@ -2196,7 +2197,7 @@ export function IssuesList({
</button>
) : (
<span className="inline-flex items-center" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} size="md" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
<StatusIcon {...taskStatusIconProps(issue)} size="md" onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
</span>
)
}
@@ -2223,7 +2224,7 @@ export function IssuesList({
checklistStepNumber={checklistStepNumber}
statusSlot={(
<span className="inline-flex items-center" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} size="md" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
<StatusIcon {...taskStatusIconProps(issue)} size="md" onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
</span>
)}
/>
+2 -1
View File
@@ -31,6 +31,7 @@ import { Input } from "@/components/ui/input";
import { StatusIcon } from "./StatusIcon";
import { SidebarSection } from "./SidebarSection";
import { SidebarNavItem } from "./SidebarNavItem";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
const RECENT_TASK_MENU_ITEM_CLASS =
"h-(--profile-popover-row-height) gap-(--profile-popover-row-gap) rounded-lg px-2.5 py-0 text-(length:--text-compact) font-medium leading-(--profile-popover-label-line-height) focus:bg-accent/50 focus:text-foreground";
@@ -252,7 +253,7 @@ function RecentTasksList({
<SidebarNavItem
to={`/issues/${entry.id}`}
label={entry.title}
iconNode={entry.displayStatus === "monitoring" && !liveIssueIds.has(entry.id) ? <StatusIcon status={entry.status} displayStatus={entry.displayStatus} /> : undefined}
iconNode={entry.displayStatus === "monitoring" && !liveIssueIds.has(entry.id) ? <StatusIcon {...taskStatusIconProps(entry)} /> : undefined}
trailing={entry.status === "in_review" && entry.externalConversationState === "waiting"
? <span className="text-xs text-muted-foreground">Idle</span> : undefined}
trailingLabel={entry.status === "in_review" && entry.externalConversationState === "waiting" ? "Idle" : undefined}
+8 -6
View File
@@ -1,4 +1,5 @@
import type { CSSProperties } from "react";
import { resolveTaskStatusPresentation, type TaskStatusPresentationInput } from "../lib/task-status-presentation";
import { cn } from "../lib/utils";
import {
statusBadge,
@@ -84,19 +85,20 @@ export function AgentStatusCapsule({ status }: { status: string }) {
* Distinct from the generic {@link StatusBadge} so run/goal/approval badges are
* unaffected.
*/
export function IssueStatusBadge({ status: taskStatus, displayStatus, externalConversationState }: { status: string; displayStatus?: string | null; externalConversationState?: "active" | "waiting" | null }) {
const status = taskStatus === "in_review" && externalConversationState === "waiting" ? "idle" : taskStatus === "in_progress" && displayStatus === "monitoring" ? "monitoring" : taskStatus;
const cssVar = taskStatusVar[status] ?? taskStatusVarDefault;
export function IssueStatusBadge(props: TaskStatusPresentationInput) {
const { glyphStatus, label, ariaLabel } = resolveTaskStatusPresentation(props);
const cssVar = taskStatusVar[glyphStatus] ?? taskStatusVarDefault;
return (
<span
className={cn(
"status-chip inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-normal leading-none whitespace-nowrap shrink-0",
status === "cancelled" && "line-through"
glyphStatus === "cancelled" && "line-through"
)}
style={scStyle(cssVar)}
title={ariaLabel === label ? undefined : ariaLabel}
>
<StatusGlyph status={status} size="sm" />
{sentenceCaseStatus(status)}
<StatusGlyph status={glyphStatus} size="sm" />
{sentenceCaseStatus(label.toLowerCase())}
</span>
);
}
@@ -0,0 +1,76 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { IssueBlockerAttention } from "@paperclipai/shared";
import { StatusIcon } from "./StatusIcon";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
/**
* BUG-07: the status picker trigger and the picker's current row are the same
* task state, so they must render the same glyph and colour. A blocked task
* that is covered by an active sub-task reads "in queue" (blue) on the
* trigger; the menu must not repaint that row as plain red "Blocked".
*/
const covered: IssueBlockerAttention = {
state: "covered",
reason: "active_child",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
attentionBlockerCount: 0,
stalledBlockerCount: 0,
sampleBlockerIdentifier: "ONB-3",
sampleStalledBlockerIdentifier: null,
};
let root: Root;
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
document.body.innerHTML = "";
});
const iconName = (svg: Element | null | undefined) => svg?.getAttribute("class")?.match(/lucide-[a-z-]+/)?.[0];
const glyphColor = (svg: Element | null | undefined) => (svg as SVGElement | null)?.style.color;
async function openPicker(props: Parameters<typeof StatusIcon>[0]) {
await act(async () => root.render(<StatusIcon {...props} />));
const trigger = container.querySelector<HTMLButtonElement>("button[aria-label^='Change status']")!;
await act(async () => {
trigger.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
trigger.click();
});
const currentRow = Array.from(document.querySelectorAll<HTMLButtonElement>("[data-slot='popover-content'] button"))
.find((button) => button.classList.contains("bg-accent"));
return { trigger, currentRow };
}
describe("StatusIcon picker (BUG-07)", () => {
it("marks the current row with the same glyph as the trigger for a covered-blocked task", async () => {
const { trigger, currentRow } = await openPicker({ status: "blocked", blockerAttention: covered, onChange: () => {} });
expect(currentRow?.textContent).toContain("Blocked");
expect(glyphColor(trigger.querySelector("svg"))).toBe("var(--status-task-icon-in_queue)");
expect(glyphColor(currentRow?.querySelector("svg"))).toBe(glyphColor(trigger.querySelector("svg")));
});
it("marks the current row with the same glyph as the trigger for a waiting review and a monitored task", async () => {
for (const props of [
{ status: "in_review", externalConversationState: "waiting" as const },
{ status: "in_progress", displayStatus: "monitoring" },
]) {
const { trigger, currentRow } = await openPicker({ ...props, onChange: () => {} });
expect(currentRow).toBeDefined();
expect(glyphColor(currentRow?.querySelector("svg"))).toBe(glyphColor(trigger.querySelector("svg")));
expect(iconName(currentRow?.querySelector("svg"))).toBe(iconName(trigger.querySelector("svg")));
await act(async () => root.render(<></>));
document.querySelectorAll("[data-slot='popover-content']").forEach((node) => node.remove());
}
});
});
+10 -62
View File
@@ -4,19 +4,11 @@ import { cn } from "../lib/utils";
import { StatusGlyph, type StatusGlyphSize } from "./StatusGlyph";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { resolveTaskStatusPresentation, taskStatusLabel, type TaskStatusPresentationInput } from "../lib/task-status-presentation";
const allStatuses = ["backlog", "todo", "in_progress", "in_review", "done", "cancelled", "blocked"];
function statusLabel(status: string): string {
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
interface StatusIconProps {
status: string;
/** Server-derived presentation; mutations still use the canonical status. */
displayStatus?: string | null;
externalConversationState?: "active" | "waiting" | null;
blockerAttention?: IssueBlockerAttention | null;
interface StatusIconProps extends TaskStatusPresentationInput {
onChange?: (status: string) => void;
className?: string;
/** Optional layout wrapper around the glyph. Does not change glyph dimensions. */
@@ -26,52 +18,9 @@ interface StatusIconProps {
size?: StatusGlyphSize;
}
function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null | undefined) {
if (!blockerAttention || blockerAttention.state === "none") return "Blocked";
if (blockerAttention.reason === "active_child") {
const count = blockerAttention.coveredBlockerCount;
if (count === 1 && blockerAttention.sampleBlockerIdentifier) {
return `Blocked · waiting on active sub-task ${blockerAttention.sampleBlockerIdentifier}`;
}
if (count === 1) return "Blocked · waiting on 1 active sub-task";
return `Blocked · waiting on ${count} active sub-tasks`;
}
if (blockerAttention.reason === "active_dependency") {
const count = blockerAttention.coveredBlockerCount;
if (count === 1 && blockerAttention.sampleBlockerIdentifier) {
return `Blocked · covered by active dependency ${blockerAttention.sampleBlockerIdentifier}`;
}
if (count === 1) return "Blocked · covered by 1 active dependency";
return `Blocked · covered by ${count} active dependencies`;
}
if (blockerAttention.reason === "stalled_review") {
const count = blockerAttention.stalledBlockerCount;
const leaf = blockerAttention.sampleStalledBlockerIdentifier ?? blockerAttention.sampleBlockerIdentifier;
if (count === 1 && leaf) return `Blocked · review stalled on ${leaf}`;
if (count === 1) return "Blocked · review stalled with no clear next step";
return `Blocked · ${count} reviews stalled with no clear next step`;
}
if (blockerAttention.reason === "attention_required") {
const count = blockerAttention.attentionBlockerCount || blockerAttention.unresolvedBlockerCount;
const attentionCopy = `${count} ${count === 1 ? "blocker needs" : "blockers need"} attention`;
const coveredCount = blockerAttention.coveredBlockerCount;
if (coveredCount > 0) {
return `Blocked · ${attentionCopy}; ${coveredCount} covered by active work`;
}
return `Blocked · ${attentionCopy}`;
}
return "Blocked";
}
/** Shared accessible/tooltip copy, including waiting and covered-blocker states. */
export function getStatusIconLabel(status: string, externalConversationState?: "active" | "waiting" | null, blockerAttention?: IssueBlockerAttention | null, presentationStatus?: string | null): string {
const displayStatus = status === "in_review" && externalConversationState === "waiting" ? "idle" : status === "in_progress" && presentationStatus === "monitoring" ? "monitoring" : status;
return status === "blocked" ? blockedAttentionLabel(blockerAttention) : statusLabel(displayStatus);
return resolveTaskStatusPresentation({ status, displayStatus: presentationStatus, externalConversationState, blockerAttention }).ariaLabel;
}
/**
@@ -88,10 +37,8 @@ export function getStatusIconLabel(status: string, externalConversationState?: "
*/
export function StatusIcon({ status, displayStatus: presentationStatus, externalConversationState, blockerAttention, onChange, className, glyphContainerClassName, showLabel, size = "md" }: StatusIconProps) {
const [open, setOpen] = useState(false);
const displayStatus = status === "in_review" && externalConversationState === "waiting" ? "idle" : status === "in_progress" && presentationStatus === "monitoring" ? "monitoring" : status;
const isCoveredBlocked = status === "blocked" && blockerAttention?.state === "covered";
const ariaLabel = getStatusIconLabel(status, externalConversationState, blockerAttention, presentationStatus);
const glyphStatus = isCoveredBlocked ? "in_queue" : displayStatus;
const presentation = { status, displayStatus: presentationStatus, externalConversationState, blockerAttention };
const { glyphStatus, label, ariaLabel } = resolveTaskStatusPresentation(presentation);
const glyphIcon = (
<StatusGlyph
@@ -111,7 +58,7 @@ export function StatusIcon({ status, displayStatus: presentationStatus, external
return showLabel ? (
<span className="inline-flex items-center gap-1.5">
{glyph}
<span className="text-sm">{statusLabel(displayStatus)}</span>
<span className="text-sm">{label}</span>
</span>
) : (
glyph
@@ -125,7 +72,7 @@ export function StatusIcon({ status, displayStatus: presentationStatus, external
className="inline-flex min-h-5 items-center gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors"
>
{glyph}
<span className="text-sm">{statusLabel(displayStatus)}</span>
<span className="text-sm">{label}</span>
</button>
) : (
<button
@@ -153,8 +100,9 @@ export function StatusIcon({ status, displayStatus: presentationStatus, external
setOpen(false);
}}
>
<StatusIcon status={s} size="lg" />
{statusLabel(s)}
{/* The current row is the task's state: draw it exactly like the trigger. */}
<StatusIcon {...(s === status ? presentation : { status: s })} size="lg" />
{taskStatusLabel(s)}
</Button>
))}
</PopoverContent>
+2 -1
View File
@@ -5,6 +5,7 @@ import type { AvatarAgent } from "@/components/AgentAvatar";
import { IssueReferencePill } from "@/components/IssueReferencePill";
import { IssueStatusBadge } from "@/components/StatusBadge";
import { PropertyRow } from "@/components/issue-properties/primitives";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
export type CaseAgentMap = ReadonlyMap<string, AvatarAgent & { status?: string }>;
@@ -34,7 +35,7 @@ export function CaseWorkSummary({ caseData, pendingHandoff, agents }: {
<PropertyRow label="Workflow"><span className="text-sm">{caseData.workflow?.name ?? "Case instructions"}</span></PropertyRow>
{caseData.phase && <PropertyRow label="Phase"><span className="text-sm">{caseData.phase}</span></PropertyRow>}
<PropertyRow label="Agent">{assignee ? <AgentIdentity agent={assignee} size="xs" /> : <span className="text-sm text-muted-foreground">Unassigned</span>}</PropertyRow>
<PropertyRow label="Task status"><IssueStatusBadge status={task.status} /></PropertyRow>
<PropertyRow label="Task status"><IssueStatusBadge {...taskStatusIconProps(task)} /></PropertyRow>
</div> : <p className="text-sm text-muted-foreground">The primary task is unavailable.</p>}
{pending && pending.state !== "delivered" && pending.state !== "completed" && pending.state !== "cancelled" && <div className="flex items-start gap-2 text-sm">
<Clock3 aria-hidden className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
+2 -2
View File
@@ -21,6 +21,7 @@ import {
import { queryKeys } from "@/lib/queryKeys";
import { Link } from "@/lib/router";
import { cn, formatDate, relativeTime } from "@/lib/utils";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
/**
* The agent-scoped side panels shown beside an agent chat: every task the
@@ -86,8 +87,7 @@ export function AgentTaskCard({ task }: { task: Issue }) {
>
<div className="flex min-w-0 items-start gap-2">
<StatusIcon
status={task.status} displayStatus={task.displayStatus}
blockerAttention={task.blockerAttention}
{...taskStatusIconProps(task)}
className="mt-0.5 shrink-0"
/>
<span className="min-w-0 flex-1 line-clamp-2 text-sm font-medium text-foreground">
@@ -9,6 +9,7 @@ import { Link } from "@/lib/router";
import { StatusIcon } from "@/components/StatusIcon";
import { CoordinatorRelativeTime } from "./CoordinatorRelativeTime";
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
// Presentation-only memory. It contains no task text or authority, and prevents
// revisiting a conversation from replaying its task-creation celebration.
@@ -125,7 +126,7 @@ export function CoordinatorCreatedTaskLink({ task, createdAt }: {
task: Pick<ConversationTaskHandoff, "issueId" | "identifier" | "title"> & { status?: ConversationTaskHandoff["status"] };
}) {
return <Link to={`/issues/${task.identifier ?? task.issueId}`} className="coordinator-created-task">
{task.status ? <StatusIcon status={task.status} size="lg" /> : <FileCheck2 className="size-5 shrink-0 text-muted-foreground" aria-label="Task created" />}
{task.status ? <StatusIcon {...taskStatusIconProps({ ...task, status: task.status })} size="lg" /> : <FileCheck2 className="size-5 shrink-0 text-muted-foreground" aria-label="Task created" />}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{task.title}</span>
<span className="block truncate text-xs text-muted-foreground"><span className="font-mono">{task.identifier ?? task.issueId}</span> · Created</span>
@@ -134,6 +134,7 @@ import {
TaskDetailSubtasksPanel,
type TaskDetailRelationItem,
} from "../task-detail/TaskDetailRelationsPanel";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
function splitMiddleTruncation(value: string): { prefix: string; suffix: string } | null {
const splitAt = Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\"));
@@ -2187,7 +2188,7 @@ export function IssueProperties({
setParentOpen(false);
}}
>
<StatusIcon status={candidate.status} displayStatus={candidate.displayStatus} className="h-3 w-3" />
<StatusIcon {...taskStatusIconProps(candidate)} className="h-3 w-3" />
<span className="truncate">
{candidate.identifier ? `${candidate.identifier} ` : ""}
{candidate.title}
@@ -2274,7 +2275,7 @@ export function IssueProperties({
)}
onClick={() => toggleBlockedBy(candidate.id)}
>
<StatusIcon status={candidate.status} displayStatus={candidate.displayStatus} className="h-3 w-3" />
<StatusIcon {...taskStatusIconProps(candidate)} className="h-3 w-3" />
<span className="truncate">
{candidate.identifier ? `${candidate.identifier} ` : ""}
{candidate.title}
@@ -2312,7 +2313,7 @@ export function IssueProperties({
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50"
onClick={() => setSubtasksOpen(false)}
>
<StatusIcon status={child.status} displayStatus={child.displayStatus} className="h-3 w-3" />
<StatusIcon {...taskStatusIconProps(child)} className="h-3 w-3" />
<span className="min-w-0 truncate">
{child.identifier ? `${child.identifier} ` : ""}
{child.title}
@@ -2349,8 +2350,7 @@ export function IssueProperties({
>
<PropertyRow label="Status">
<StatusIcon
status={issue.status} displayStatus={issue.displayStatus}
externalConversationState={issue.externalConversationState}
{...taskStatusIconProps(issue)}
glyphContainerClassName="inline-flex size-6 shrink-0 items-center justify-center"
blockerAttention={issue.blockerAttention}
onChange={(status) => onUpdate({ status })}
@@ -21,6 +21,7 @@ import {
import { ArrowUpRight, X } from "lucide-react";
import { cn } from "../../lib/utils";
import { StatusIcon } from "../StatusIcon";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
export function RemovableIssueReferencePill({
issue,
@@ -53,7 +54,7 @@ export function RemovableIssueReferencePill({
<LockedIssueChip identifier={issue.identifier} />
) : (
<>
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} className="h-3 w-3 shrink-0" />
<StatusIcon {...taskStatusIconProps(issue)} className="h-3 w-3 shrink-0" />
<span className="truncate">{issueLabel}</span>
</>
);
+2 -1
View File
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils";
import { StatusIcon } from "../StatusIcon";
import { Identity } from "../Identity";
import { HighlightedText, type HighlightedTextProps } from "./HighlightedText";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
type SnippetStyle = {
Icon: ComponentType<SVGProps<SVGSVGElement>>;
@@ -178,7 +179,7 @@ function SearchResultRowImpl({
data-result-type="issue"
>
<div className="mt-1 shrink-0">
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} />
<StatusIcon {...taskStatusIconProps(issue)} />
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2.5 gap-y-1">
@@ -2,6 +2,7 @@ import { ArrowUpRight, ListTree } from "lucide-react";
import { Link } from "@/lib/router";
import { IssueStatusBadge } from "@/components/StatusBadge";
import type { TaskChatTaskCreatedItem } from "./task-chat-model";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
export function TaskChatTaskCreatedCard({ item }: { item: TaskChatTaskCreatedItem }) {
const { task } = item;
@@ -18,7 +19,7 @@ export function TaskChatTaskCreatedCard({ item }: { item: TaskChatTaskCreatedIte
<span className="block break-words text-sm font-medium">{task.title}</span>
<span className="flex flex-wrap items-center gap-2">
{task.identifier && <span className="font-mono text-xs text-muted-foreground">{task.identifier}</span>}
<IssueStatusBadge status={task.status} />
<IssueStatusBadge {...taskStatusIconProps(task)} />
</span>
</span>
<ArrowUpRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
@@ -9,6 +9,7 @@ import {
rememberIssueDetailLocationState,
} from "@/lib/issueDetailBreadcrumb";
import { Link } from "@/lib/router";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
export interface TaskDetailRelationItem {
id: string;
@@ -50,7 +51,7 @@ export function RelationNavigationList({
title={`${item.identifier ? `${item.identifier} — ` : ""}${item.title}`}
>
{item.status ? (
<StatusIcon status={item.status} displayStatus={item.displayStatus} className="h-3.5 w-3.5 shrink-0" />
<StatusIcon {...taskStatusIconProps({ ...item, status: item.status })} className="h-3.5 w-3.5 shrink-0" />
) : null}
<span className="min-w-0 flex-1 truncate">{item.title}</span>
{item.identifier ? (
+15
View File
@@ -63,6 +63,21 @@ describe("recent task persistence", () => {
expect(readRecentTasks(storageKey, "company-1")[0]?.externalConversationState).toBe("active");
});
it("keeps the covered-blocker state so the sidebar shows the same queued glyph as the task pane (BUG-07)", () => {
const covered = {
state: "covered" as const, reason: "active_child" as const, unresolvedBlockerCount: 1, coveredBlockerCount: 1,
attentionBlockerCount: 0, stalledBlockerCount: 0, sampleBlockerIdentifier: "ONB-3", sampleStalledBlockerIdentifier: null,
};
const blocked = { ...issue("1"), status: "blocked" as const, updatedAt: new Date(10) };
recordRecentTask({ ...blocked, blockerAttention: covered }, "user-1");
const storageKey = getRecentTasksStorageKey("company-1", "user-1");
expect(readRecentTasks(storageKey, "company-1")[0]?.blockerAttention).toEqual(covered);
// A same-version summary refresh can change only the derived blocker state.
const attention = { ...covered, state: "needs_attention" as const, reason: "attention_required" as const, coveredBlockerCount: 0, attentionBlockerCount: 1 };
updateRecentTaskSnapshots(storageKey, "company-1", [{ ...blocked, externalConversationState: null, blockerAttention: attention }]);
expect(readRecentTasks(storageKey, "company-1")[0]?.blockerAttention).toEqual(attention);
});
it("accepts server settlement after an optimistic run-lock clear", () => {
const running = { ...issue("1"), status: "in_progress" as const, externalConversationState: "active" as const, executionRunId: "run-1", updatedAt: new Date(10) } as Issue;
recordRecentTask(running, "user-1");
+12 -2
View File
@@ -13,6 +13,8 @@ export interface RecentTaskEntry {
status: IssueStatus;
displayStatus?: Issue["displayStatus"];
externalConversationState?: Issue["externalConversationState"];
/** Blocked tasks covered by active work render the "in queue" glyph everywhere. */
blockerAttention?: Issue["blockerAttention"];
recordedAt: number;
// Server version of the title/status snapshot, independent of comment activity.
// Legacy entries have no version until a detail query refreshes them.
@@ -107,6 +109,10 @@ export function writeRecentTasks(storageKey: string, entries: RecentTaskEntry[])
type TaskSnapshot = Omit<IssueSummary, "externalConversationState"> & Pick<Issue, "externalConversationState">;
function sameBlockerAttention(a: RecentTaskEntry["blockerAttention"], b: RecentTaskEntry["blockerAttention"]) {
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
}
export function mergeRecentTaskSnapshot(entry: RecentTaskEntry, issue: TaskSnapshot): RecentTaskEntry {
if (entry.id !== issue.id || entry.companyId !== issue.companyId) return entry;
const snapshotUpdatedAt = new Date(issue.updatedAt).getTime();
@@ -117,7 +123,8 @@ export function mergeRecentTaskSnapshot(entry: RecentTaskEntry, issue: TaskSnaps
if (snapshotUpdatedAt === entry.snapshotUpdatedAt) {
return (issue.externalConversationState !== undefined
&& issue.externalConversationState !== entry.externalConversationState) || issue.displayStatus !== entry.displayStatus
? { ...entry, status: issue.status, displayStatus: issue.displayStatus, externalConversationState: issue.externalConversationState }
|| !sameBlockerAttention(issue.blockerAttention, entry.blockerAttention)
? { ...entry, status: issue.status, displayStatus: issue.displayStatus, externalConversationState: issue.externalConversationState, blockerAttention: issue.blockerAttention }
: entry;
}
return {
@@ -127,13 +134,14 @@ export function mergeRecentTaskSnapshot(entry: RecentTaskEntry, issue: TaskSnaps
status: issue.status,
displayStatus: issue.displayStatus,
externalConversationState: issue.externalConversationState,
blockerAttention: issue.blockerAttention,
snapshotUpdatedAt,
recordedAt: Math.max(entry.recordedAt, snapshotUpdatedAt),
};
}
export function recordRecentTask(
issue: Pick<Issue, "id" | "companyId" | "title" | "identifier" | "status" | "displayStatus" | "externalConversationState" | "updatedAt" | "conversationAgentId">,
issue: Pick<Issue, "id" | "companyId" | "title" | "identifier" | "status" | "displayStatus" | "externalConversationState" | "blockerAttention" | "updatedAt" | "conversationAgentId">,
userId: string | null | undefined,
recordedAt = new Date(issue.updatedAt).getTime(),
) {
@@ -153,6 +161,7 @@ export function recordRecentTask(
status: issue.status,
displayStatus: issue.displayStatus,
externalConversationState: issue.externalConversationState,
blockerAttention: issue.blockerAttention,
recordedAt: activityAt,
...(Number.isFinite(snapshotUpdatedAt) ? { snapshotUpdatedAt } : {}),
};
@@ -168,6 +177,7 @@ export function recordRecentTask(
&& existing.status === entry.status
&& existing.displayStatus === entry.displayStatus
&& existing.externalConversationState === entry.externalConversationState
&& sameBlockerAttention(existing.blockerAttention, entry.blockerAttention)
&& existing.recordedAt === entry.recordedAt
&& existing.snapshotUpdatedAt === entry.snapshotUpdatedAt
) return;
+2
View File
@@ -261,6 +261,8 @@ export const taskStatusVar: Record<string, string> = {
done: "--status-task-done",
blocked: "--status-task-blocked",
cancelled: "--status-task-cancelled",
// Covered-blocked: the blocked shape on the in-progress blue (mirrors the icon var).
in_queue: "--status-task-in_progress",
};
export const taskStatusVarDefault = "--status-task-backlog";
+113
View File
@@ -0,0 +1,113 @@
import type { IssueBlockerAttention } from "@paperclipai/shared";
/**
* Everything a task status surface needs to pick its glyph and label. Every
* view (sidebar rows, task header, properties, status picker, lists, inbox,
* pills, search) resolves through {@link resolveTaskStatusPresentation}, so one
* underlying state always renders the same icon and label (BUG-07).
*/
export interface TaskStatusPresentationInput {
status: string;
/** Server-derived presentation; mutations still use the canonical status. */
displayStatus?: string | null;
externalConversationState?: "active" | "waiting" | null;
blockerAttention?: IssueBlockerAttention | null;
}
export interface TaskStatusPresentation {
/** Key into the unified StatusGlyph icon/colour tables. */
glyphStatus: string;
/** Short visible label ("Blocked", "Idle", "Monitoring", …). */
label: string;
/** Full accessible/tooltip copy, including the blocked reason. */
ariaLabel: string;
}
export function taskStatusLabel(status: string): string {
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null | undefined) {
if (!blockerAttention || blockerAttention.state === "none") return "Blocked";
if (blockerAttention.reason === "active_child") {
const count = blockerAttention.coveredBlockerCount;
if (count === 1 && blockerAttention.sampleBlockerIdentifier) {
return `Blocked · waiting on active sub-task ${blockerAttention.sampleBlockerIdentifier}`;
}
if (count === 1) return "Blocked · waiting on 1 active sub-task";
return `Blocked · waiting on ${count} active sub-tasks`;
}
if (blockerAttention.reason === "active_dependency") {
const count = blockerAttention.coveredBlockerCount;
if (count === 1 && blockerAttention.sampleBlockerIdentifier) {
return `Blocked · covered by active dependency ${blockerAttention.sampleBlockerIdentifier}`;
}
if (count === 1) return "Blocked · covered by 1 active dependency";
return `Blocked · covered by ${count} active dependencies`;
}
if (blockerAttention.reason === "stalled_review") {
const count = blockerAttention.stalledBlockerCount;
const leaf = blockerAttention.sampleStalledBlockerIdentifier ?? blockerAttention.sampleBlockerIdentifier;
if (count === 1 && leaf) return `Blocked · review stalled on ${leaf}`;
if (count === 1) return "Blocked · review stalled with no clear next step";
return `Blocked · ${count} reviews stalled with no clear next step`;
}
if (blockerAttention.reason === "attention_required") {
const count = blockerAttention.attentionBlockerCount || blockerAttention.unresolvedBlockerCount;
const attentionCopy = `${count} ${count === 1 ? "blocker needs" : "blockers need"} attention`;
const coveredCount = blockerAttention.coveredBlockerCount;
if (coveredCount > 0) {
return `Blocked · ${attentionCopy}; ${coveredCount} covered by active work`;
}
return `Blocked · ${attentionCopy}`;
}
return "Blocked";
}
/**
* The one state → glyph/label mapping:
* - in_review whose external conversation is waiting → "Idle"
* - in_progress presented as monitoring → "Monitoring" (clock)
* - blocked and covered by active work → the blue "in queue" glyph, with the
* blocked reason on the accessible label
*/
export function resolveTaskStatusPresentation(input: TaskStatusPresentationInput): TaskStatusPresentation {
const { status, displayStatus, externalConversationState, blockerAttention } = input;
const visibleStatus = status === "in_review" && externalConversationState === "waiting"
? "idle"
: status === "in_progress" && displayStatus === "monitoring"
? "monitoring"
: status;
const isCoveredBlocked = status === "blocked" && blockerAttention?.state === "covered";
return {
glyphStatus: isCoveredBlocked ? "in_queue" : visibleStatus,
label: taskStatusLabel(visibleStatus),
ariaLabel: status === "blocked" ? blockedAttentionLabel(blockerAttention) : taskStatusLabel(visibleStatus),
};
}
type TaskStatusSource = {
status: string;
displayStatus?: string | null;
externalConversationState?: "active" | "waiting" | null;
blockerAttention?: IssueBlockerAttention | null;
};
/**
* Pick every status-presentation field from a task record so a view cannot
* silently drop one (e.g. `blockerAttention`) and render a different glyph
* than the task pane. Spread into `<StatusIcon {...taskStatusIconProps(task)} />`.
*/
export function taskStatusIconProps(task: TaskStatusSource): TaskStatusPresentationInput {
return {
status: task.status,
displayStatus: task.displayStatus ?? null,
externalConversationState: task.externalConversationState ?? null,
blockerAttention: task.blockerAttention ?? null,
};
}
+2 -1
View File
@@ -42,6 +42,7 @@ import { InlineBanner } from "../components/InlineBanner";
import type { Agent, Issue } from "@paperclipai/shared";
import { PluginSlotOutlet } from "@/plugins/slots";
import { SmokeLabDashboardCard } from "../components/SmokeLabDashboardCard";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
const DASHBOARD_ACTIVITY_LIMIT = 10;
@@ -545,7 +546,7 @@ export function Dashboard() {
>
<div className="flex items-start gap-2 @xl:grid @xl:grid-cols-(--dashboard-task-list-columns) @xl:items-baseline">
<span className="flex size-6 shrink-0 items-center justify-end @xl:self-center">
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} />
<StatusIcon {...taskStatusIconProps(issue)} />
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 @xl:contents">
<span className="flex min-w-0 items-baseline gap-2 @xl:contents">
+3 -2
View File
@@ -196,6 +196,7 @@ import {
taskDateGroupSeparator,
type TaskDateGroup,
} from "../lib/task-date-groups";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
const INBOX_HEARTBEAT_RUN_LIMIT = 200;
const INBOX_ISSUE_LIST_LIMIT = 100;
@@ -2995,7 +2996,7 @@ function StreamlinedInbox({
&& blockerAttention?.state === "covered"
);
const rowStatusIcon = (
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={blockerAttention} size="md" />
<StatusIcon {...taskStatusIconProps(issue)} blockerAttention={blockerAttention} size="md" />
);
return (
<IssueRow
@@ -3095,7 +3096,7 @@ function StreamlinedInbox({
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
</button>
) : (
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={blockerAttention} size="md" />
<StatusIcon {...taskStatusIconProps(issue)} blockerAttention={blockerAttention} size="md" />
)
) : undefined}
unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
+3 -3
View File
@@ -402,6 +402,7 @@ import {
workspaceFileRefSchema,
isUuidLike,
} from "@paperclipai/shared";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
// Stable empty array for React Query `data` defaults. A literal `= []` default
// creates a new array reference on every render while `data` is undefined
@@ -997,8 +998,7 @@ export function IssueDetailLoadingState({
{headerSeed ? (
<>
<StatusIcon
status={headerSeed.status} displayStatus={headerSeed.displayStatus}
blockerAttention={headerSeed.blockerAttention}
{...taskStatusIconProps(headerSeed)}
/>
{/* PAP-411: priority UI hidden behind SHOW_TASK_PRIORITY_UI. */}
{SHOW_TASK_PRIORITY_UI && (
@@ -7586,7 +7586,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
const issueStatusControl = (
<StatusIcon
status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState}
{...taskStatusIconProps(issue)}
size={proposedTaskHeader ? "md" : "lg"}
blockerAttention={issue.blockerAttention}
onChange={(status) => updateIssue.mutate({ status })}
+3 -2
View File
@@ -184,6 +184,7 @@ import {
reconcileInboxOrderPin,
type InboxOrderPin,
} from "../lib/inboxOrderPin";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
const INBOX_HEARTBEAT_RUN_LIMIT = 200;
const INBOX_ISSUE_LIST_LIMIT = 500;
@@ -2698,7 +2699,7 @@ export function Inbox() {
&& blockerAttention?.state === "covered"
);
const rowStatusIcon = (
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={blockerAttention} size="md" />
<StatusIcon {...taskStatusIconProps(issue)} blockerAttention={blockerAttention} size="md" />
);
return (
<IssueRow
@@ -2768,7 +2769,7 @@ export function Inbox() {
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
</button>
) : (
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={blockerAttention} size="md" />
<StatusIcon {...taskStatusIconProps(issue)} blockerAttention={blockerAttention} size="md" />
)
}
unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
+2 -1
View File
@@ -13,6 +13,7 @@ import { QueryErrorState, useQueryView } from "../components/QueryView";
import { formatDate } from "../lib/utils";
import { ListTodo } from "lucide-react";
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
export function MyIssues() {
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
@@ -79,7 +80,7 @@ export function MyIssues() {
title={issue.title}
to={`/issues/${issue.identifier ?? issue.id}`}
leading={
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} />
<StatusIcon {...taskStatusIconProps(issue)} />
}
trailing={
<span className="text-xs text-muted-foreground">
@@ -40,6 +40,7 @@ import {
updateKindLabel,
} from "./format";
import type { StatusCardView } from "./types";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
export function StatusCardDetailDrawer({
card,
@@ -584,7 +585,7 @@ function WatchedIssueRow({ issue }: { issue: CompanySearchIssueSummary }) {
>
{issue.identifier ?? issue.id.slice(0, 8)}
</Link>
<IssueStatusBadge status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} />
<IssueStatusBadge {...taskStatusIconProps(issue)} />
<span className="min-w-0 flex-1 truncate">{issue.title}</span>
<span className="shrink-0 text-muted-foreground">{relativeTime(issue.updatedAt)}</span>
</div>
+2 -1
View File
@@ -28,6 +28,7 @@ import {
providerDisplayName,
relativeTime,
} from "../lib/utils";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
const NO_COMPANY = "__none__";
const PROFILE_SETTINGS_PATH = "/company/settings/instance/profile";
@@ -342,7 +343,7 @@ export function UserProfile() {
<span className="font-mono text-xs text-muted-foreground">{issue.identifier ?? issue.id.slice(0, 8)}</span>
<span className="truncate text-sm">{issue.title}</span>
<span className="flex items-center gap-3 sm:justify-end">
<IssueStatusBadge status={issue.status} displayStatus={issue.displayStatus} />
<IssueStatusBadge {...taskStatusIconProps(issue)} />
<span className="text-xs tabular-nums text-muted-foreground">{relativeTime(issue.updatedAt)}</span>
</span>
</Link>
@@ -14,11 +14,27 @@ import { IssueRow } from "@/components/IssueRow";
import { MetricCard } from "@/components/MetricCard";
import { PriorityIcon } from "@/components/PriorityIcon";
import { QuotaBar } from "@/components/QuotaBar";
import { StatusBadge } from "@/components/StatusBadge";
import { IssueStatusBadge, StatusBadge } from "@/components/StatusBadge";
import { StatusIcon } from "@/components/StatusIcon";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import type { TaskStatusPresentationInput } from "@/lib/task-status-presentation";
import { createIssue } from "../../fixtures/paperclipData";
const sameStateAcrossViews: Array<{ label: string; state: TaskStatusPresentationInput }> = [
{
label: "Blocked, covered by an active sub-task (in queue)",
state: {
status: "blocked",
blockerAttention: {
state: "covered", reason: "active_child", unresolvedBlockerCount: 1, coveredBlockerCount: 1,
attentionBlockerCount: 0, stalledBlockerCount: 0, sampleBlockerIdentifier: "ONB-3", sampleStalledBlockerIdentifier: null,
},
},
},
{ label: "In review, conversation waiting (idle)", state: { status: "in_review", externalConversationState: "waiting" } },
{ label: "In progress, monitoring", state: { status: "in_progress", displayStatus: "monitoring" } },
];
function Section({
eyebrow,
title,
@@ -415,6 +431,25 @@ function StatusLanguage() {
</div>
</Section>
<Section eyebrow="One mapping" title="Same state, every view">
<div className="grid gap-3 md:grid-cols-3">
{sameStateAcrossViews.map((item) => (
<div key={item.label} className="space-y-3 rounded-lg border border-border bg-background/70 p-4">
<div className="text-sm font-medium">{item.label}</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<StatusIcon {...item.state} /> Sidebar / list row
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<IssueStatusBadge {...item.state} /> Chat card chip
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<StatusIcon {...item.state} showLabel onChange={() => {}} /> Status menu (open to compare the current row)
</div>
</div>
))}
</div>
</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) => (