fix(ui): consolidate live/running blues; stop inbox unread badge indenting the row (#9383)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The web UI leans on a shared design-token + component system so surfaces stay visually consistent as they grow > - Two small inconsistencies had crept in: several distinct blues were used to signal "live/running" agent state across the sidebar, task header, and chat thread; and in the Inbox an unread task's mark-read dot was pushing that row's status icon and title one column right of read rows > - Both read as "not quite aligned" in daily use and undercut the polish of the lists work that just landed > - This pull request consolidates the live/running blues onto one shared recipe and stops the unread dot from indenting the row > - The benefit is one consistent "live" blue everywhere and Inbox rows that line up whether read or unread ## Linked Issues or Issue Description No public GitHub issue exists for this work; describing inline per the bug-report template. - **Problem**: (1) the same concept — an agent actively working — rendered in three visibly different blues: the sidebar `N live` dot, the task-detail "Live" badge, and the chat-thread "RUNNING" badge each used a different token/recipe. (2) In the Inbox, unread rows carry a leading mark-read dot that occupies the chevron column, but a per-row spacer was still rendering in that same column — so on unread rows the status icon + title were shifted one column (~24px) further right than read rows. Most visible when grouped by workspace. - **Steps to reproduce**: open the Inbox with a mix of read and unread tasks (group by workspace). The unread rows' status icons sit further right than the read rows'. Separately, compare the blue of the sidebar `N live` dot, a task's "Live" header badge, and a chat "RUNNING" badge — they don't match. - **Expected behavior**: unread and read rows align on the same status column, with the unread dot centered on the workspace group chevron; and all three "live/running" affordances share one blue. ## What Changed - Added a shared `liveBlueBadge` recipe in `ui/src/lib/status-colors.ts` and pointed the task-detail **Live** badge (`IssueDetail.tsx`) and the chat-thread **RUNNING** badge (`IssueChatThread.tsx`) at it; removed the now-redundant `brandChipBadge` usage from the chat thread and a stray `🔵` breadcrumb prefix. - Changed the sidebar **`N live`** dot (`SidebarNavItem.tsx`) to the same `blue-600 / dark:blue-400` as its adjacent label text. - **Inbox** (`Inbox.tsx`): skip the per-row leading spacer when the unread mark-read dot is present, so the dot alone fills the chevron column. Unread rows' status icon + title now sit in the same column as read rows, and the dot centers on the workspace group chevron. - **Test** (`Inbox.test.tsx`): added a regression test asserting an unread leaf row renders the mark-read dot and drops the spacer, while a read row keeps the spacer. ## Verification - `pnpm typecheck` — clean (all packages) - `pnpm check:token-gates` — 3/3 CLEAN - `cd ui && pnpm vitest run src/pages/Inbox.test.tsx` — 14/14 (includes the new regression test) - Full Storybook visual suite (514 stories, both themes) — green locally (CI cannot run this suite yet — the baseline-manifest archive is unpublished, a pre-existing condition from #9134) - Manual (workspace-grouped Inbox, 2× dark): measured the unread badge center at the same x as the workspace chevron (276 = 276) and the unread-row status icon at the same x as read-row status icons (292 = 292). Before/after screenshots in a PR comment below. ## Risks Low risk — presentation only. No data, routing, or state changes. The blue consolidation is a token/class swap; the Inbox change removes a redundant spacer element on unread rows only (read rows and non-grouped/mobile views are unaffected). The unread-row behavior is covered by the new unit test. ## Model Used Claude (Anthropic), Opus 4.8 — model id `claude-opus-4-8`; extended thinking + tool use, driving local verification (typecheck, token gates, vitest, Playwright visual suite + pixel measurements). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
8775bde4ce
commit
634ae1298f
|
|
@ -165,7 +165,7 @@ import {
|
|||
} from "../lib/transcriptPresentation";
|
||||
import { buildAgentMentionHref } from "@paperclipai/shared";
|
||||
import { cn, formatDateTime, formatShortDate } from "../lib/utils";
|
||||
import { brandChipBadge } from "../lib/status-colors";
|
||||
import { liveBlueBadge } from "../lib/status-colors";
|
||||
import { nextWorkMode, titleForPendingWorkMode, workModeMetaFor, workModeMetaList } from "../lib/work-mode-meta";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
|
@ -1928,12 +1928,12 @@ function IssueChatAssistantMessage({
|
|||
</Badge>
|
||||
) : null}
|
||||
{isRunning ? (
|
||||
// Gallery feedback r1: running chip uses the canonical brand blue
|
||||
// (brandChipBadge.blue), not cyan; layout/size classes unchanged.
|
||||
// Running chip shares the liveness-blue badge recipe with the
|
||||
// issue header's "Live" badge (one live/running blue).
|
||||
<Badge variant="outline"
|
||||
className={cn(
|
||||
"text-(length:--text-nano) uppercase tracking-(--tracking-eyebrow)",
|
||||
brandChipBadge.blue,
|
||||
liveBlueBadge,
|
||||
)}
|
||||
>
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
|
|
|
|||
|
|
@ -144,8 +144,8 @@ export function SidebarNavItem({
|
|||
icon. The icon markup is untouched so it stays pixel-aligned. */}
|
||||
{rail && !alert && hasLive && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-2 w-2" aria-hidden="true">
|
||||
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-(--status-agent-running) opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-(--status-agent-running) shadow-(--shadow-extract-12)" />
|
||||
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-600 dark:bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-600 dark:bg-blue-400 shadow-(--shadow-extract-12)" />
|
||||
</span>
|
||||
)}
|
||||
{rail && !alert && !hasLive && hasBadge && (
|
||||
|
|
@ -178,8 +178,8 @@ export function SidebarNavItem({
|
|||
{hasLive && (
|
||||
<>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-(--status-agent-running) opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-(--status-agent-running)" />
|
||||
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-600 dark:bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-600 dark:bg-blue-400" />
|
||||
</span>
|
||||
<span className="text-(length:--text-micro) font-medium text-blue-600 dark:text-blue-400">{liveCount} live</span>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -167,6 +167,13 @@ export const agentStatusMotion: Record<string, string> = {
|
|||
*/
|
||||
export const runningLabelText = "text-[#1D4ED8] dark:text-[#2563EB]";
|
||||
|
||||
/**
|
||||
* Liveness-blue badge recipe — the shared "Live" / "Running" pill treatment
|
||||
* (translucent blue fill + border + blue text). One source of truth so every
|
||||
* live/running indicator reads as the same blue.
|
||||
*/
|
||||
export const liveBlueBadge = "bg-blue-500/10 border-blue-500/30 text-blue-600 dark:text-blue-400";
|
||||
|
||||
/**
|
||||
* Issue/task status → brand colour name (PAP-75). `in_progress` is blue
|
||||
* (liveness), `todo` amber (queued), `in_review` violet (awaiting review),
|
||||
|
|
|
|||
|
|
@ -438,6 +438,68 @@ describe("Inbox toolbar", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("does not double-indent unread rows: the mark-read dot replaces the leading spacer", async () => {
|
||||
routerMock.location.pathname = "/inbox/mine";
|
||||
// Two sibling leaf rows, one unread and one read, so their leading columns
|
||||
// are directly comparable.
|
||||
const unread = createIssue({
|
||||
id: "issue-unread",
|
||||
identifier: "PAP-2001",
|
||||
title: "Unread inbox row",
|
||||
isUnreadForMe: true,
|
||||
});
|
||||
const read = createIssue({
|
||||
id: "issue-read",
|
||||
identifier: "PAP-2002",
|
||||
title: "Read inbox row",
|
||||
isUnreadForMe: false,
|
||||
});
|
||||
apiMocks.issuesList.mockResolvedValue([unread, read]);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
||||
});
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Inbox />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("Unread inbox row");
|
||||
expect(container.textContent).toContain("Read inbox row");
|
||||
});
|
||||
|
||||
const rows = Array.from(container.querySelectorAll("[data-inbox-item]"));
|
||||
const rowFor = (text: string) => rows.find((row) => row.textContent?.includes(text));
|
||||
const linkOf = (row: Element) => row.querySelector<HTMLAnchorElement>("a[data-inbox-issue-link]");
|
||||
const hasMarkReadDot = (row: Element) => !!row.querySelector('button[aria-label="Mark as read"]');
|
||||
// The empty spacer that reserves the chevron column on read rows. Excludes
|
||||
// the tree-guide span (`.self-stretch`), which only renders on nested rows.
|
||||
const hasLeadingSpacer = (row: Element) =>
|
||||
!!linkOf(row)?.querySelector("span.hidden.w-4.shrink-0.sm\\:block:not(.self-stretch)");
|
||||
|
||||
const unreadRow = rowFor("Unread inbox row")!;
|
||||
const readRow = rowFor("Read inbox row")!;
|
||||
|
||||
// Unread rows carry the mark-read dot in the chevron column; rendering the
|
||||
// spacer too would push the status icon + title one column further right
|
||||
// than read rows (the bug this fix addresses).
|
||||
expect(hasMarkReadDot(unreadRow)).toBe(true);
|
||||
expect(hasLeadingSpacer(unreadRow)).toBe(false);
|
||||
|
||||
// Read rows have no dot, so they keep the spacer to hold that same column.
|
||||
expect(hasMarkReadDot(readRow)).toBe(false);
|
||||
expect(hasLeadingSpacer(readRow)).toBe(true);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps other issue archive controls enabled while one archive is pending", async () => {
|
||||
routerMock.location.pathname = "/inbox/mine";
|
||||
const issueA = createIssue({ id: "issue-a", identifier: "PAP-1001", title: "First inbox row" });
|
||||
|
|
|
|||
|
|
@ -2566,6 +2566,12 @@ export function Inbox() {
|
|||
>
|
||||
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
|
||||
</button>
|
||||
) : (isUnread || isFading) ? (
|
||||
// Unread rows already carry the leading mark-read
|
||||
// dot (IssueRow, order-first) in the chevron
|
||||
// column, so skip the spacer — otherwise the dot
|
||||
// and this spacer would double-indent the status.
|
||||
null
|
||||
) : (
|
||||
<span className="hidden w-4 shrink-0 sm:block" />
|
||||
)
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ import {
|
|||
import { clearIssueExecutionRun, removeLiveRunById, upsertInterruptedRun } from "../lib/optimistic-issue-runs";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { relativeTime, cn, formatDurationMs, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||
import { liveBlueBadge } from "../lib/status-colors";
|
||||
import { ApprovalCard } from "../components/ApprovalCard";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import {
|
||||
|
|
@ -4158,7 +4159,7 @@ export function IssueDetail() {
|
|||
<span className="text-sm font-mono text-muted-foreground shrink-0">{issue.identifier ?? issue.id.slice(0, 8)}</span>
|
||||
|
||||
{hasLiveRuns && (
|
||||
<Badge variant="outline" className="gap-1.5 bg-blue-500/10 border-blue-500/30 text-(length:--text-nano) text-blue-600 dark:text-blue-400">
|
||||
<Badge variant="outline" className={cn("gap-1.5 text-(length:--text-nano)", liveBlueBadge)}>
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-blue-500" />
|
||||
|
|
|
|||
Loading…
Reference in New Issue