diff --git a/ui/src/components/IssueRow.test.tsx b/ui/src/components/IssueRow.test.tsx index e5276c7f..3273d96c 100644 --- a/ui/src/components/IssueRow.test.tsx +++ b/ui/src/components/IssueRow.test.tsx @@ -2,6 +2,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; +import type { Issue } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { IssueRow } from "./IssueRow"; @@ -14,6 +15,49 @@ vi.mock("@/lib/router", () => ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +function createIssue(overrides: Partial = {}): Issue { + return { + id: "issue-1", + identifier: "PAP-1", + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Inbox item", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: 1, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: new Date("2026-03-11T00:00:00.000Z"), + updatedAt: new Date("2026-03-11T00:00:00.000Z"), + labels: [], + labelIds: [], + myLastTouchAt: null, + lastExternalCommentAt: null, + isUnreadForMe: false, + ...overrides, + }; +} + describe("IssueRow", () => { let container: HTMLDivElement; @@ -28,45 +72,7 @@ describe("IssueRow", () => { it("suppresses accent hover styling when the row is selected", () => { const root = createRoot(container); - const issue = { - id: "issue-1", - identifier: "PAP-1", - companyId: "company-1", - projectId: null, - projectWorkspaceId: null, - goalId: null, - parentId: null, - title: "Inbox item", - description: null, - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - createdByAgentId: null, - createdByUserId: null, - issueNumber: 1, - requestDepth: 0, - billingCode: null, - assigneeAdapterOverrides: null, - executionWorkspaceId: null, - executionWorkspacePreference: null, - executionWorkspaceSettings: null, - checkoutRunId: null, - executionRunId: null, - executionAgentNameKey: null, - executionLockedAt: null, - startedAt: null, - completedAt: null, - cancelledAt: null, - hiddenAt: null, - createdAt: new Date("2026-03-11T00:00:00.000Z"), - updatedAt: new Date("2026-03-11T00:00:00.000Z"), - labels: [], - labelIds: [], - myLastTouchAt: null, - lastExternalCommentAt: null, - isUnreadForMe: false, - } as const; + const issue = createIssue(); act(() => { root.render(); @@ -81,4 +87,30 @@ describe("IssueRow", () => { root.unmount(); }); }); + + it("neutralizes selected status and unread dot accents", () => { + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const markReadButton = container.querySelector('button[aria-label="Mark as read"]'); + const unreadDot = markReadButton?.querySelector("span"); + const statusIcon = container.querySelector('span[class*="border-muted-foreground"]'); + + expect(markReadButton).not.toBeNull(); + expect(markReadButton?.className).toContain("hover:bg-muted/80"); + expect(markReadButton?.className).not.toContain("hover:bg-blue-500/20"); + expect(unreadDot).not.toBeNull(); + expect(unreadDot?.className).toContain("bg-muted-foreground/70"); + expect(unreadDot?.className).not.toContain("bg-blue-600"); + expect(statusIcon).not.toBeNull(); + expect(statusIcon?.className).toContain("!border-muted-foreground"); + expect(statusIcon?.className).toContain("!text-muted-foreground"); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index bcb3f2b2..8a01e585 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -45,6 +45,7 @@ export function IssueRow({ const identifier = issue.identifier ?? issue.id.slice(0, 8); const showUnreadSlot = unreadState !== null; const showUnreadDot = unreadState === "visible" || unreadState === "fading"; + const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined; return ( - {mobileLeading ?? } + {mobileLeading ?? } @@ -71,7 +72,7 @@ export function IssueRow({ {desktopMetaLeading ?? ( <> - + {identifier} @@ -113,12 +114,16 @@ export function IssueRow({ onMarkRead?.(); } }} - className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20" + className={cn( + "inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors", + selected ? "hover:bg-muted/80" : "hover:bg-blue-500/20", + )} aria-label="Mark as read" > diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx index fc7f9fea..c103a523 100644 --- a/ui/src/pages/Inbox.test.tsx +++ b/ui/src/pages/Inbox.test.tsx @@ -3,8 +3,9 @@ import { act } from "react"; import type { ComponentProps } from "react"; import { createRoot } from "react-dom/client"; +import type { Issue } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { FailedRunInboxRow } from "./Inbox"; +import { FailedRunInboxRow, InboxIssueMetaLeading } from "./Inbox"; vi.mock("@/lib/router", () => ({ Link: ({ children, className, ...props }: ComponentProps<"a">) => ( @@ -17,6 +18,49 @@ vi.mock("@/lib/router", () => ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +function createIssue(overrides: Partial = {}): Issue { + return { + id: "issue-1", + identifier: "PAP-904", + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Inbox item", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: 904, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: new Date("2026-03-11T00:00:00.000Z"), + updatedAt: new Date("2026-03-11T00:00:00.000Z"), + labels: [], + labelIds: [], + myLastTouchAt: null, + lastExternalCommentAt: null, + isUnreadForMe: false, + ...overrides, + }; +} + describe("FailedRunInboxRow", () => { let container: HTMLDivElement; @@ -91,3 +135,47 @@ describe("FailedRunInboxRow", () => { }); }); }); + +describe("InboxIssueMetaLeading", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("neutralizes selected status and live accents", () => { + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const statusIcon = container.querySelector('span[class*="border-muted-foreground"]'); + const liveBadge = container.querySelector('span[class*="px-1.5"][class*="bg-muted"]'); + const liveBadgeLabel = Array.from(container.querySelectorAll("span")).find( + (node) => node.textContent === "Live" && node.className.includes("text-"), + ); + const liveDot = container.querySelector('span[class*="bg-muted-foreground/70"]'); + const pulseRing = container.querySelector('span[class*="animate-pulse"]'); + + expect(statusIcon).not.toBeNull(); + expect(statusIcon?.className).toContain("!border-muted-foreground"); + expect(statusIcon?.className).toContain("!text-muted-foreground"); + expect(liveBadge).not.toBeNull(); + expect(liveBadge?.className).toContain("bg-muted"); + expect(liveBadgeLabel).not.toBeNull(); + expect(liveBadgeLabel?.className).toContain("text-muted-foreground"); + expect(liveBadgeLabel?.className).not.toContain("text-blue-600"); + expect(liveDot).not.toBeNull(); + expect(pulseRing).toBeNull(); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index d2fa614b..670469e6 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -100,6 +100,67 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null { type NonIssueUnreadState = "visible" | "fading" | "hidden" | null; +const selectedInboxAccentClass = "!text-muted-foreground !border-muted-foreground"; + +function getSelectedUnreadButtonClass(selected: boolean): string { + return selected ? "hover:bg-muted/80" : "hover:bg-blue-500/20"; +} + +function getSelectedUnreadDotClass(selected: boolean): string { + return selected ? "bg-muted-foreground/70" : "bg-blue-600 dark:bg-blue-400"; +} + +export function InboxIssueMetaLeading({ + issue, + selected, + isLive, +}: { + issue: Issue; + selected: boolean; + isLive: boolean; +}) { + return ( + <> + + + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {isLive && ( + + + {!selected ? ( + + ) : null} + + + + Live + + + )} + + ); +} export function FailedRunInboxRow({ run, @@ -148,11 +209,15 @@ export function FailedRunInboxRow({ @@ -300,11 +365,15 @@ function ApprovalInboxRow({ @@ -402,6 +471,7 @@ function JoinRequestInboxRow({ onMarkRead, onArchive, archiveDisabled, + selected = false, className, }: { joinRequest: JoinRequest; @@ -412,6 +482,7 @@ function JoinRequestInboxRow({ onMarkRead?: () => void; onArchive?: () => void; archiveDisabled?: boolean; + selected?: boolean; className?: string; }) { const label = @@ -433,11 +504,15 @@ function JoinRequestInboxRow({ @@ -1351,6 +1426,7 @@ export function Inbox() { approveJoinMutation.mutate(item.joinRequest)} onReject={() => rejectJoinMutation.mutate(item.joinRequest)} isPending={approveJoinMutation.isPending || rejectJoinMutation.isPending} @@ -1393,27 +1469,13 @@ export function Inbox() { ? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out" : "transition-all duration-200 ease-out" } - desktopMetaLeading={( - <> - - - - - {issue.identifier ?? issue.id.slice(0, 8)} - - {liveIssueIds.has(issue.id) && ( - - - - - - - Live - - - )} - - )} + desktopMetaLeading={ + + } mobileMeta={ issue.lastExternalCommentAt ? `commented ${timeAgo(issue.lastExternalCommentAt)}`