mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 11:23:40 +01:00
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:
1 parent
03eb49053f
commit
2c0cc8ee64
37 files changed
+418
-130
No files matched your search
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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} />)}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
Reference in new issue
Block a user