Neutralize selected inbox accents

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-28 17:18:59 -05:00
parent 1871a602df
commit 50577b8c63
4 changed files with 258 additions and 71 deletions

View file

@ -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> = {}): 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(<IssueRow issue={issue} selected />);
@ -81,4 +87,30 @@ describe("IssueRow", () => {
root.unmount();
});
});
it("neutralizes selected status and unread dot accents", () => {
const root = createRoot(container);
act(() => {
root.render(<IssueRow issue={createIssue()} selected unreadState="visible" />);
});
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();
});
});
});

View file

@ -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 (
<Link
@ -58,7 +59,7 @@ export function IssueRow({
)}
>
<span className="shrink-0 pt-px sm:hidden">
{mobileLeading ?? <StatusIcon status={issue.status} />}
{mobileLeading ?? <StatusIcon status={issue.status} className={selectedStatusClass} />}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
<span className="line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none">
@ -71,7 +72,7 @@ export function IssueRow({
{desktopMetaLeading ?? (
<>
<span className="hidden shrink-0 sm:inline-flex">
<StatusIcon status={issue.status} />
<StatusIcon status={issue.status} className={selectedStatusClass} />
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{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"
>
<span
className={cn(
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
"block h-2 w-2 rounded-full transition-opacity duration-300",
selected ? "bg-muted-foreground/70" : "bg-blue-600 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100",
)}
/>

View file

@ -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> = {}): 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(<InboxIssueMetaLeading issue={createIssue()} selected isLive />);
});
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();
});
});
});

View file

@ -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 (
<>
<span className="hidden shrink-0 sm:inline-flex">
<StatusIcon
status={issue.status}
className={selected ? selectedInboxAccentClass : undefined}
/>
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{isLive && (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 sm:gap-1.5 sm:px-2",
selected ? "bg-muted" : "bg-blue-500/10",
)}
>
<span className="relative flex h-2 w-2">
{!selected ? (
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
) : null}
<span
className={cn(
"relative inline-flex h-2 w-2 rounded-full",
selected ? "bg-muted-foreground/70" : "bg-blue-500",
)}
/>
</span>
<span
className={cn(
"hidden text-[11px] font-medium sm:inline",
selected ? "text-muted-foreground" : "text-blue-600 dark:text-blue-400",
)}
>
Live
</span>
</span>
)}
</>
);
}
export function FailedRunInboxRow({
run,
@ -148,11 +209,15 @@ export function FailedRunInboxRow({
<button
type="button"
onClick={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",
getSelectedUnreadButtonClass(selected),
)}
aria-label="Mark as read"
>
<span className={cn(
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
"block h-2 w-2 rounded-full transition-opacity duration-300",
getSelectedUnreadDotClass(selected),
unreadState === "fading" ? "opacity-0" : "opacity-100",
)} />
</button>
@ -300,11 +365,15 @@ function ApprovalInboxRow({
<button
type="button"
onClick={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",
getSelectedUnreadButtonClass(selected),
)}
aria-label="Mark as read"
>
<span className={cn(
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
"block h-2 w-2 rounded-full transition-opacity duration-300",
getSelectedUnreadDotClass(selected),
unreadState === "fading" ? "opacity-0" : "opacity-100",
)} />
</button>
@ -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({
<button
type="button"
onClick={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",
getSelectedUnreadButtonClass(selected),
)}
aria-label="Mark as read"
>
<span className={cn(
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
"block h-2 w-2 rounded-full transition-opacity duration-300",
getSelectedUnreadDotClass(selected),
unreadState === "fading" ? "opacity-0" : "opacity-100",
)} />
</button>
@ -1351,6 +1426,7 @@ export function Inbox() {
<JoinRequestInboxRow
key={joinKey}
joinRequest={item.joinRequest}
selected={isSelected}
onApprove={() => 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={(
<>
<span className="hidden shrink-0 sm:inline-flex">
<StatusIcon status={issue.status} />
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{liveIssueIds.has(issue.id) && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
</span>
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
Live
</span>
</span>
)}
</>
)}
desktopMetaLeading={
<InboxIssueMetaLeading
issue={issue}
selected={isSelected}
isLive={liveIssueIds.has(issue.id)}
/>
}
mobileMeta={
issue.lastExternalCommentAt
? `commented ${timeAgo(issue.lastExternalCommentAt)}`