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:
scotttong 2026-07-13 08:53:03 -07:00 committed by GitHub
parent 8775bde4ce
commit 634ae1298f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 85 additions and 9 deletions

View File

@ -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" />

View File

@ -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>
</>

View File

@ -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),

View File

@ -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" });

View File

@ -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" />
)

View File

@ -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" />