feat(ui): refine dashboard cards, charts, and recent lists (#13269)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Operators use the dashboard and Live runs page to inspect current
and recent runs.
> - Large transcript cards take space and make it hard to compare run
states.
> - A compact card must show the agent, linked task, and time, with
access to run details.
> - This pull request applies the supplied card design to both views.
> - Operators can scan more runs and open a run or task for details.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The agent run cards on the dashboard and Live runs page, plus dashboard
charts and recent lists.

**Subsystem affected**

`ui/` — React board UI.

**Current behavior**

Both views show large cards with embedded transcripts. Queued and
running cards share a live treatment. The dashboard chart grid leaves an
empty column, recent lists use different row sizes, and some activity
labels expose raw event names.

**Proposed behavior**

Both views use the same compact grid. Each card shows the existing
initials avatar, the agent name, one linked task row, and a
right-aligned timestamp. There is no status chip next to the agent name.
The timestamp uses small Inter text and the same muted gray as dashboard
metric descriptions. Recovery details remain in the task view. Run
status remains in the header tooltip and accessible label, and run
transcripts remain in the run detail view. The shared in-progress task
icon is now an animated circular spinner across the app.

**Reason and benefit**

Operators can scan task states without scrolling through embedded
output. The in-progress spinner keeps the shared task icon sizes,
circular shape, and stroke width. It honors reduced-motion preferences.

**Breaking changes**

The dashboard and Live runs page no longer show transcripts inside
cards. Open the agent header to inspect a run. There are no API or
database changes.

**Additional context**

Related public work found during the duplicate search:

- Refs #4317 — related run-state display concerns. This change does not
change server state reconciliation.
- Refs #11394 — related live-run cache updates. This change retains the
existing data flow.
- Refs #2118 — related navigation from agent cards. The card header here
opens the specific run.

## What Changed

- Apply compact cards to the dashboard and Live runs page, with status
icons and theme tokens.
- Use 16 × 16px status icons in the shared cards, including the
missing-task clock.
- Keep the existing circular initials avatar. Remove the status chip
beside the agent name while preserving the run label for tooltips and
accessibility.
- Replace the shared in-progress task glyph with an animated open
circle. Use the same 10-unit radius and 2-unit stroke as the other task
glyphs, with a reduced-motion guard across all task status surfaces.
This is the requested workflow-status indicator across the app,
including between runs; live indicators separately report active
execution.
- Use the same Tailwind blue tokens as nav dots and Live labels for the
progress icon: blue-400 in dark mode and blue-600 in light mode.
Covered-blocked icons follow the same blue token.
- Match the Tasks by Status chart's In Progress bar and legend to the
spinner's theme-aware color token.
- Distribute the three visible dashboard charts across equal-width
desktop columns, preserving existing gaps, card padding, and page
margins. Retain four columns when the optional priority chart is
enabled.
- Use the done task icon's `--status-task-icon-done` token for green
bars in Run Activity, Tasks by Status, and Success Rate, including their
legends.
- Use the blocked task icon's `--status-task-icon-blocked` token for red
bars and legends in Run Activity, Tasks by Status, Success Rate, and
priority charts.
- Align Recent Tasks live counts to the sidebar's right edge. Reveal the
ellipsis over a fading row surface on hover, keyboard focus, and while
its menu is open, without moving the label or count.
- Keep the ellipsis backdrop synchronized with the row background during
fade-out, using the actual sidebar surface in both themes to prevent a
darker flash.
- On coarse-pointer devices, reserve space for the always-visible
ellipsis and disable its fade so the live count remains readable beside
it.
- Order desktop dashboard Recent Tasks as status icon, task title, agent
avatar/name, 11px monospace task ID, then timestamp. In narrow cards,
keep the ID right-aligned beside the truncated title and place the agent
and timestamp on the second line.
- Give both dashboard lists the same 48px desktop rows, 24px leading
slots, 80px desktop task ID columns, and 64px right-aligned timestamp
columns. Use an 8px icon-to-content gap matching the task detail
heading. Narrow layouts use matching 76px rows with intrinsic-width IDs
on the title line; activity timestamps stay below.
- Order dashboard Recent Activity as actor avatar, actor name, verb,
task title, 11px monospace task ID, then timestamp. Use direct verbs
such as “Board read …” instead of “issue read marked”. Truncate titles
to preserve IDs and timestamps; keep full names and titles in tooltips.
- Use 12px task status icons in the top breadcrumb and Properties status
row. Keep the main task title icon at 20px.
- Reduce monospace task IDs to the 11px micro type token in breadcrumbs
and shared run cards, aligning them to the adjacent task titles' text
baseline while keeping status icons centered.
- Show the task title and identifier in one bordered row. Keep
missing-task links usable and show lookup failures.
- Remove the recovery chip and place the timestamp below the task row.
- Keep the link to all runs available when the dashboard has four or
fewer runs.
- Avoid transcript polling for compact cards.
- Fix bundled Inter font URLs and update the design guide and Storybook
fixtures.
- Cover run navigation, task status, failed lookups, shared icon sizes
and stroke, and reduced-motion-safe animation in tests.

## Verification

- Latest review fixes: targeted SidebarRecentTasks, SidebarNavItem,
StatusGlyph, StatusIcon, ActiveAgentsPanel, activity-format,
ui-font-assets, and Dashboard suites — 65 tests pass, including
read/unread verbs and reduced-motion-safe task animation.
- `pnpm --filter @paperclipai/ui typecheck` — passes.
- `pnpm check:token-gates` — all gates pass.
- `git diff --check` — passes.
- Related component suites passed during development: run cards, status
glyphs, breadcrumbs, issue properties, charts, sidebar navigation,
recent-task actions, and settings sidebar.
- Full repository `pnpm -r typecheck` and `pnpm build` pass on the final
commit using the temporary Rust toolchain. Storybook build passed
earlier in preparation.
- All CI gates pass on `c85e949b12b471d425d216caa609aee41dc58bde`: all
general/workspace and serialized server test shards, all three browser
shards, build/runner verification, typecheck/release registry, canary
dry run, policy, and security checks. The duplicate local `pnpm
test:run` was stopped after CI completed the same test suites; it is not
claimed as a completed local pass. The two Storybook jobs are skipped by
the configured draft-PR workflow.
- Greptile reviewed the final commit at 5/5 with no actionable findings
and zero unresolved review threads.
- Browser measurements at 1654px, 860px, and 390px confirm matching 48px
desktop and 76px narrow rows (plus 1px dividers), right-aligned IDs on
the title line, 64px timestamp slots, and no horizontal overflow. Narrow
Recent Tasks rows retain readable agent names below the title. Activity
reads as actor, verb, title, ID, time. Additional reference details can
expand an activity row.
- Verified three equal-width desktop charts with 16px gaps and aligned
outer edges. Chart blues, greens, and reds resolve to the matching
task-icon tokens.
- Verified real queued, running, succeeded, failed, cancelled, and
timed-out runs in the dev dashboard. Checked compact cards, task/run
links, timestamps, light/dark themes, and narrow layout.
- To inspect: open Dashboard, then Live runs. Both pages use the same
small cards. Open an agent header for run output, or the task row for
task details.
- Verified the updated spinner on the real dashboard cards, Recent
tasks, and task list. Checked actual SVG circle radius, rendered size,
and stroke width. Confirmed the spinner, nav dots, and Live labels
resolve to the identical blue-400 color in the dark-mode preview; light
mode uses their shared blue-600 token.
- Verified 12px computed width and height in the task breadcrumb and
Properties pane, the restored 20px main task icon, and 11px task IDs
with baseline alignment in the breadcrumb and shared dashboard/Live runs
cards.
- Verified the chart bar and legend resolve to the spinner color; Recent
Tasks counts align with Dashboard's count. Checked the fading ellipsis
overlay with keyboard focus and its open-menu state, then dismissed the
menu.

## Risks

- Operators must open run details to read output that was previously
embedded.
- Run outcomes are available in the header tooltip and run details
instead of a visible status chip. An in-progress task icon reflects task
status, independently of an individual run's outcome.
- Long names and task titles truncate within the compact layout. Full
labels remain available through links and tooltips.
- Font loading changes affect all UI text that uses the bundled Inter
font. The files remain served from the existing public fonts directory.
- No schema, API, adapter, or execution-policy changes.

## Model Used

OpenAI Codex, GPT-6 Astra (`gpt-6-astra`), with extended reasoning,
repository tools, code execution, and browser verification. The session
does not expose the context-window size.

## 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: Scott Tong <scott@scotts-mbp-m5-max.local>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
scotttong 2026-09-11 17:02:40 -07:00 committed by GitHub
parent dbf5ea432d
commit 9b7bd41833
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 375 additions and 193 deletions

View File

@ -4,7 +4,7 @@ import { act, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ActiveAgentsPanel } from "./ActiveAgentsPanel";
import { ActiveAgentsPanel, AgentRunCard } from "./ActiveAgentsPanel";
const mockHeartbeatsApi = vi.hoisted(() => ({
liveRunsForCompany: vi.fn(),
@ -30,10 +30,6 @@ vi.mock("../api/issues", () => ({
issuesApi: mockIssuesApi,
}));
vi.mock("./Identity", () => ({
Identity: ({ name }: { name: string }) => <span>{name}</span>,
}));
vi.mock("./RunChatSurface", () => ({
RunChatSurface: () => <div>Run output</div>,
}));
@ -156,6 +152,7 @@ describe("ActiveAgentsPanel", () => {
anchor.textContent?.includes("more active/recent"),
);
expect(moreLink?.getAttribute("href")).toBe("/dashboard/live");
expect(container.textContent).not.toContain("Run output");
await act(async () => {
root.unmount();
@ -189,6 +186,7 @@ describe("ActiveAgentsPanel", () => {
limit: 50,
});
expect(container.textContent).not.toContain("more active/recent");
expect(container.textContent).not.toContain("Run output");
await act(async () => {
root.unmount();
@ -224,7 +222,8 @@ describe("ActiveAgentsPanel", () => {
const issueLink = [...container.querySelectorAll("a")].find((anchor) =>
anchor.textContent?.includes("Phase 4B"),
);
expect(issueLink?.textContent).toBe("PAP-3562 - Phase 4B: Implement LLM Wiki distillation UI");
expect(issueLink?.textContent).toContain("Phase 4B: Implement LLM Wiki distillation UI");
expect(issueLink?.textContent).toContain("PAP-3562");
expect(issueLink?.getAttribute("href")).toBe("/issues/PAP-3562");
});
@ -232,4 +231,65 @@ describe("ActiveAgentsPanel", () => {
root.unmount();
});
});
it("keeps run outcomes distinct from the linked task status", async () => {
const root = createRoot(container);
const statuses = ["running", "queued", "succeeded", "failed", "timed_out", "cancelled", "interrupted"];
await act(async () => {
root.render(<>{statuses.map((status, index) => (
<AgentRunCard
key={status}
companyId="company-1"
run={{ ...createIssueRun(index, "issue-1"), status }}
issue={{ title: "Review release notes", identifier: "PAP-559", status: "in_review" }}
/>
))}</>);
});
const headers = [...container.querySelectorAll('a[aria-label$=". View run"]')];
expect(headers.map((header) => header.getAttribute("aria-label"))).toEqual([
"Agent 0 — Running. View run", "Agent 1 — Queued. View run",
"Agent 2 — Succeeded. View run", "Agent 3 — Failed. View run",
"Agent 4 — Timed out. View run", "Agent 5 — Cancelled. View run",
"Agent 6 — Interrupted. View run",
]);
expect(headers.every((header) => header.querySelector("svg") === null)).toBe(true);
expect(container.querySelector(".status-chip")).toBeNull();
expect(container.querySelectorAll('[aria-label="Task in review"]')).toHaveLength(7);
expect(container.querySelectorAll(".motion-safe\\:animate-spin")).toHaveLength(0);
expect(container.querySelector('a[aria-label="Agent 0 — Running. View run"]')?.getAttribute("href"))
.toBe("/agents/agent-0/runs/run-0");
await act(async () => root.unmount());
});
it("keeps a failed task lookup navigable and shows a clear error", async () => {
const root = createRoot(container);
await act(async () => {
root.render(<AgentRunCard companyId="company-1" run={createIssueRun(1, "issue-missing")} issueLoadFailed />);
});
expect(container.textContent).toContain("Task unavailable");
expect(container.querySelector('a[href="/issues/issue-missing"]')).not.toBeNull();
await act(async () => root.unmount());
});
it("does not animate running records while execution is reconnecting", async () => {
const root = createRoot(container);
await act(async () => {
root.render(<AgentRunCard
companyId="company-1"
run={{
...createRun(0),
execution: {
phase: "reconnecting", label: "Reconnecting", cause: null,
lastConfirmedActivityAt: null, retryAt: null, attempt: 1, maxAttempts: 3,
recoveryOwner: "agent", nextAction: null, permittedActions: ["inspect_run"],
predecessorRunId: null, successorRunId: null,
},
}}
/>);
});
expect(container.querySelector('a[aria-label="Agent 0 — Running. View run"]')).not.toBeNull();
expect(container.querySelector(".status-chip")).toBeNull();
expect(container.querySelectorAll(".motion-safe\\:animate-spin")).toHaveLength(0);
await act(async () => root.unmount());
});
});

View File

@ -1,45 +1,18 @@
import { memo, useMemo } from "react";
import { Link } from "@/lib/router";
import { useQueries, useQuery } from "@tanstack/react-query";
import { requiresExecutionReconciliation, type Issue, type IssueRecoveryAction } from "@paperclipai/shared";
import type { Issue } from "@paperclipai/shared";
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
import type { TranscriptEntry } from "../adapters";
import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
import { cn, relativeTime } from "../lib/utils";
import {
deriveActiveRecoveryDisplayState,
RECOVERY_CHIP_DEFAULT_TONE,
} from "../lib/recovery-display";
import { ExternalLink } from "lucide-react";
import { Clock3 } from "lucide-react";
import { Identity } from "./Identity";
import { StatusGlyph } from "./StatusGlyph";
import { RunChatSurface } from "./RunChatSurface";
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
import { Badge } from "@/components/ui/badge";
function RunCardRecoveryChip({ action }: { action: IssueRecoveryAction }) {
const state = deriveActiveRecoveryDisplayState(action);
if (!state || requiresExecutionReconciliation(action.cause)) return null;
const tone = RECOVERY_CHIP_DEFAULT_TONE[state];
const Icon = tone.icon;
return (
<Badge variant="outline"
data-testid="active-agent-run-recovery-indicator"
data-recovery-state={state}
role="status"
aria-label={tone.label}
title={`${tone.label} — open the source task to act.`}
className={cn(
"gap-0.5 px-1.5 text-(length:--text-nano)",
tone.className,
)}
>
<Icon className="h-2.5 w-2.5" aria-hidden />
{tone.label}
</Badge>
);
}
const MIN_DASHBOARD_RUNS = 4;
const DASHBOARD_RUN_CARD_LIMIT = 4;
@ -47,10 +20,17 @@ const DASHBOARD_LOG_POLL_INTERVAL_MS = 15_000;
const DASHBOARD_LOG_READ_LIMIT_BYTES = 64_000;
const DASHBOARD_MAX_CHUNKS_PER_RUN = 40;
const EMPTY_TRANSCRIPT: TranscriptEntry[] = [];
const EMPTY_RUNS: LiveRunForIssue[] = [];
function isRunActive(run: LiveRunForIssue): boolean {
return run.status === "queued" || run.status === "running";
}
const runStatusLabels: Record<string, string> = {
running: "Running",
queued: "Queued",
succeeded: "Succeeded",
failed: "Failed",
timed_out: "Timed out",
cancelled: "Cancelled",
interrupted: "Interrupted",
};
interface ActiveAgentsPanelProps {
companyId: string;
@ -63,6 +43,7 @@ interface ActiveAgentsPanelProps {
emptyMessage?: string;
queryScope?: string;
showMoreLink?: boolean;
showTranscripts?: boolean;
}
export function ActiveAgentsPanel({
@ -76,6 +57,7 @@ export function ActiveAgentsPanel({
emptyMessage = "No recent agent runs.",
queryScope = "dashboard",
showMoreLink = true,
showTranscripts = false,
}: ActiveAgentsPanelProps) {
const liveRunsQueryKey = [...queryKeys.liveRuns(companyId), queryScope, { minRunCount, fetchLimit }] as const;
const sharedLiveRuns = useSharedPollingQuery({
@ -119,7 +101,7 @@ export function ActiveAgentsPanel({
}, [issueQueries]);
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
runs: visibleRuns,
runs: showTranscripts ? visibleRuns : EMPTY_RUNS,
companyId,
maxChunksPerRun: DASHBOARD_MAX_CHUNKS_PER_RUN,
logPollIntervalMs: DASHBOARD_LOG_POLL_INTERVAL_MS,
@ -137,7 +119,7 @@ export function ActiveAgentsPanel({
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
</div>
) : (
<div className={cn("grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-4 xl:grid-cols-4", gridClassName)}>
<div className={cn("grid grid-cols-1 items-start gap-2 sm:grid-cols-2 sm:gap-4 xl:grid-cols-4", gridClassName)}>
{visibleRuns.map((run) => (
<AgentRunCard
key={run.id}
@ -146,16 +128,19 @@ export function ActiveAgentsPanel({
issue={run.issueId ? issueById.get(run.issueId) : undefined}
transcript={transcriptByRun.get(run.id) ?? EMPTY_TRANSCRIPT}
hasOutput={hasOutputForRun(run.id)}
isActive={isRunActive(run)}
showTranscript={showTranscripts}
issueLoadFailed={issueQueries.some((query, index) => visibleIssueIds[index] === run.issueId && query.isError)}
className={cardClassName}
/>
))}
</div>
)}
{showMoreLink && hiddenRunCount > 0 && (
{showMoreLink && runs.length > 0 && (
<div className="mt-3 flex justify-end text-xs text-muted-foreground">
<Link to="/dashboard/live" className="hover:text-foreground hover:underline">
{hiddenRunCount} more active/recent run{hiddenRunCount === 1 ? "" : "s"}
{hiddenRunCount > 0
? `${hiddenRunCount} more active/recent run${hiddenRunCount === 1 ? "" : "s"}`
: "View all runs"}
</Link>
</div>
)}
@ -163,88 +148,94 @@ export function ActiveAgentsPanel({
);
}
const AgentRunCard = memo(function AgentRunCard({
export const AgentRunCard = memo(function AgentRunCard({
companyId,
run,
issue,
transcript,
hasOutput,
isActive,
transcript = EMPTY_TRANSCRIPT,
hasOutput = false,
showTranscript = false,
issueLoadFailed = false,
className,
}: {
companyId: string;
run: LiveRunForIssue;
issue?: Issue;
transcript: TranscriptEntry[];
hasOutput: boolean;
isActive: boolean;
issue?: Pick<Issue, "identifier" | "title" | "status">;
transcript?: TranscriptEntry[];
hasOutput?: boolean;
showTranscript?: boolean;
issueLoadFailed?: boolean;
className?: string;
}) {
const statusLabel = runStatusLabels[run.status] ?? run.status.replace(/[_-]/g, " ");
const runUrl = `/agents/${run.agentId}/runs/${run.id}`;
const timestamp = run.finishedAt
? `Finished ${relativeTime(run.finishedAt)}`
: run.startedAt ? `Started ${relativeTime(run.startedAt)}` : `Queued ${relativeTime(run.createdAt)}`;
const taskTitle = issue?.title ?? (issueLoadFailed ? "Task unavailable" : "Loading task…");
return (
<div className={cn(
"flex h-(--sz-320px) flex-col overflow-hidden rounded-xl border shadow-sm",
isActive
? "border-blue-500/25 bg-blue-500/[0.04] shadow-(--shadow-extract-1)"
"dashboard-agent-card flex min-w-0 flex-col overflow-hidden rounded-xl border",
showTranscript && "h-(--sz-320px)",
run.status === "running"
? "border-(--dashboard-run-border) bg-(--dashboard-run-background) shadow-(--shadow-extract-1)"
: "border-border bg-background/70",
className,
)}>
<div className="border-b border-border/60 px-3 py-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
{isActive && (!run.execution || run.execution.phase === "working") ? (
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className="absolute inline-flex h-full w-full motion-safe:animate-ping rounded-full bg-blue-400 opacity-70" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-blue-500" />
</span>
) : (
<span className="inline-flex h-2.5 w-2.5 rounded-full bg-muted-foreground/35" />
)}
<Identity name={run.agentName} size="sm" className="[&>span:last-child]:!text-(length:--text-micro)" />
</div>
<div className="mt-2 flex items-center gap-2 text-(length:--text-micro) text-muted-foreground">
<span>{isActive ? "Working" : run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`}</span>
</div>
</div>
)} data-run-status={run.status}>
<div className={cn("flex shrink-0 flex-col gap-3 p-3", showTranscript && "border-b border-border/60")}>
<Link
to={runUrl}
title={`${run.agentName}${statusLabel} · ${timestamp}`}
aria-label={`${run.agentName}${statusLabel}. View run`}
className="flex min-w-0 items-center gap-2 rounded-md text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Identity name={run.agentName} className="gap-2 font-medium" />
</Link>
{run.issueId ? (
<Link
to={`/agents/${run.agentId}/runs/${run.id}`}
className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-background/70 px-2 py-1 text-(length:--text-nano) text-muted-foreground transition-colors hover:text-foreground"
to={`/issues/${issue?.identifier ?? run.issueId}`}
className="min-w-0 rounded-lg border border-border/60 bg-background/60 px-2.5 py-2 text-sm text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title={issue ? `${issue.title} · ${issue.identifier}` : taskTitle}
>
<ExternalLink className="h-2.5 w-2.5" />
<span className="flex min-w-0 items-baseline gap-2">
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
<StatusGlyph
status={issue?.status ?? "backlog"}
size="md"
className="self-center"
title={issue ? `Task ${issue.status.replace(/_/g, " ")}` : undefined}
/>
<span className="truncate">{taskTitle}</span>
</span>
<span className="shrink-0 font-mono text-(length:--text-micro) text-muted-foreground">{issue?.identifier ?? run.issueId.slice(0, 8)}</span>
</span>
</Link>
) : (
<Link to={runUrl} className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background/60 px-2.5 py-2 text-sm text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Clock3 className="size-4 shrink-0" aria-hidden />
<span className="truncate">{run.invocationSource === "timer" ? "Scheduled heartbeat" : "No linked task"}</span>
</Link>
</div>
{run.issueId && (
<div className="mt-3 rounded-lg border border-border/60 bg-background/60 px-2.5 py-2 text-xs">
<Link
to={`/issues/${issue?.identifier ?? run.issueId}`}
className={cn(
"line-clamp-2 hover:underline",
isActive ? "text-blue-700 dark:text-blue-300" : "text-muted-foreground hover:text-foreground",
)}
title={issue?.title ? `${issue?.identifier ?? run.issueId.slice(0, 8)} - ${issue.title}` : issue?.identifier ?? run.issueId.slice(0, 8)}
>
{issue?.identifier ?? run.issueId.slice(0, 8)}
{issue?.title ? ` - ${issue.title}` : ""}
</Link>
{issue?.activeRecoveryAction ? (
<div className="mt-1.5">
<RunCardRecoveryChip action={issue.activeRecoveryAction} />
</div>
) : null}
</div>
)}
<time
dateTime={run.finishedAt ?? run.startedAt ?? run.createdAt}
className="text-right font-sans text-xs text-muted-foreground/70"
>
{timestamp}
</time>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<RunChatSurface
run={run}
transcript={transcript}
hasOutput={hasOutput}
companyId={companyId}
/>
</div>
{showTranscript && (
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<RunChatSurface
run={run}
transcript={transcript}
hasOutput={hasOutput}
companyId={companyId}
/>
</div>
)}
</div>
);
});

View File

@ -20,9 +20,9 @@ function emptyRunDay(date: string): DashboardRunActivityDay {
}
const runSegmentColors = {
succeeded: "var(--hex-10b981)",
succeeded: "var(--status-task-icon-done)",
recovered: "var(--status-task-todo)",
failed: "var(--hex-ef4444)",
failed: "var(--status-task-icon-blocked)",
other: "var(--hex-737373)",
} as const;
@ -166,7 +166,7 @@ export function RunActivityChart(props: RunChartProps) {
}
const priorityColors: Record<string, string> = {
critical: "var(--hex-ef4444)",
critical: "var(--status-task-icon-blocked)",
high: "var(--hex-f97316)",
medium: "var(--hex-eab308)",
low: "var(--hex-6b7280)",
@ -223,14 +223,15 @@ export function PriorityChart({ issues }: { issues: { priority: string; createdA
// status vocabulary; badge, row, chart, and log agree). Previously an
// independent palette (todo blue, in_progress violet, etc.). `backlog`
// deliberately keeps --project-none (pre-B5, per user ruling); the
// priority series and success-rate tints below are not status hues and
// are left alone.
// non-red priority series and warning success-rate tints retain their own hues.
// Progress, done, and blocked use the icon hues so bars and legends match
// the task icons in each theme.
const statusColors: Record<string, string> = {
todo: "var(--status-task-todo)",
in_progress: "var(--status-task-in_progress)",
in_progress: "var(--status-task-icon-in_progress)",
in_review: "var(--status-task-in_review)",
done: "var(--status-task-done)",
blocked: "var(--status-task-blocked)",
done: "var(--status-task-icon-done)",
blocked: "var(--status-task-icon-blocked)",
cancelled: "var(--status-task-cancelled)",
backlog: "var(--project-none)",
};
@ -309,7 +310,7 @@ export function SuccessRateChart(props: RunChartProps) {
// rather than dragging it down as failures.
const effectiveSucceeded = entry.succeeded + entry.recovered;
const rate = entry.total > 0 ? effectiveSucceeded / entry.total : 0;
const color = entry.total === 0 ? undefined : rate >= 0.8 ? "var(--hex-10b981)" : rate >= 0.5 ? "var(--hex-eab308)" : "var(--hex-ef4444)";
const color = entry.total === 0 ? undefined : rate >= 0.8 ? "var(--status-task-icon-done)" : rate >= 0.5 ? "var(--hex-eab308)" : "var(--status-task-icon-blocked)";
return (
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${entry.total > 0 ? Math.round(rate * 100) : 0}% (${effectiveSucceeded}/${entry.total})`}>
{entry.total > 0 ? (

View File

@ -53,27 +53,44 @@ export function ActivityRow({ event, agentMap, userProfileMap, entityNameMap, en
const inner = (
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<Avatar size="xs">
{actorAvatarUrl && <AvatarImage src={actorAvatarUrl} alt={actorName} />}
<AvatarFallback>{deriveInitials(actorName)}</AvatarFallback>
</Avatar>
<p className="min-w-0 flex-1 truncate">
<span>{actorName}</span>
<span className="text-muted-foreground"> {verb} </span>
{name && <span className="font-medium">{name}</span>}
{entityTitle && <span className="text-muted-foreground"> {entityTitle}</span>}
</p>
<div className="flex items-start gap-2 @xl:grid @xl:grid-cols-(--dashboard-activity-list-columns) @xl:items-baseline">
<Avatar size="sm" aria-hidden="true" className="@xl:self-center">
{actorAvatarUrl && <AvatarImage src={actorAvatarUrl} alt="" />}
<AvatarFallback>{deriveInitials(actorName)}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col gap-1 @xl:contents">
<div className="flex min-w-0 items-baseline gap-2 @xl:contents">
<p className="flex h-6 min-w-0 flex-1 items-center gap-1.5">
<span className="max-w-1/2 shrink-0 truncate" title={`${actorName} ${verb}`}>
<span>{actorName}</span>{" "}
<span className="text-muted-foreground">{verb}</span>
</span>
{event.entityType === "issue" ? (
<span className="min-w-0 flex-1 truncate" title={entityTitle}>{entityTitle}</span>
) : (
<span className="min-w-0 flex-1 truncate">
{name && <span className="font-medium">{name}</span>}
{entityTitle && <span className="text-muted-foreground"> {entityTitle}</span>}
</span>
)}
</p>
<span className="ml-auto shrink-0 truncate text-right font-mono text-(length:--text-micro) text-muted-foreground @xl:w-(--dashboard-list-id-width)">
{event.entityType === "issue" ? name : null}
</span>
</div>
<div className="flex min-h-6 min-w-0 items-center @xl:contents">
<span className="ml-auto w-(--dashboard-list-time-width) shrink-0 whitespace-nowrap text-right text-xs text-muted-foreground">
{timeAgo(event.createdAt)}
</span>
</div>
</div>
<span className="text-xs text-muted-foreground shrink-0">{timeAgo(event.createdAt)}</span>
</div>
<IssueReferenceActivitySummary event={event} />
</div>
);
const classes = cn(
"px-4 py-2 text-sm",
"dashboard-list-row text-sm",
link && "cursor-pointer hover:bg-accent/50 transition-colors",
className,
);

View File

@ -24,7 +24,7 @@ type GlobalToolbarContext = { companyId: string | null; companyPrefix: string |
function CrumbIdentifier({ identifier }: { identifier?: string }) {
if (!identifier) return null;
return (
<span data-slot="task-title-identifier" className="shrink-0 font-mono text-muted-foreground">
<span data-slot="task-title-identifier" className="shrink-0 font-mono text-(length:--text-micro) text-muted-foreground">
{identifier}
</span>
);
@ -113,9 +113,9 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
return (
<div className="h-(--sz-60px) shrink-0 flex items-center border-b border-border px-4">
{menuButton}
<h1 className="flex min-w-0 flex-1 items-center gap-1.5 text-sm">
<h1 className="flex min-w-0 flex-1 items-baseline gap-1.5 text-sm">
{currentCrumb.leading ? (
<span className="flex shrink-0 items-center">{currentCrumb.leading}</span>
<span className="flex shrink-0 items-center self-center">{currentCrumb.leading}</span>
) : null}
<span className="min-w-0 truncate" title={currentCrumb.label}>{currentCrumb.label}</span>
<CrumbIdentifier identifier={currentCrumb.identifier} />
@ -137,9 +137,9 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
<BreadcrumbItem className={isLast ? "min-w-0" : "shrink-0"}>
{isLast || !crumb.href ? (
crumb.leading || crumb.identifier ? (
<BreadcrumbPage className="flex min-w-0 items-center gap-1.5">
<BreadcrumbPage className="flex min-w-0 items-baseline gap-1.5">
{crumb.leading && (
<span className="flex shrink-0 items-center">{crumb.leading}</span>
<span className="flex shrink-0 items-center self-center">{crumb.leading}</span>
)}
{!taskDetailLayout ? <CrumbIdentifier identifier={crumb.identifier} /> : null}
<span className="min-w-0 truncate">{crumb.label}</span>
@ -154,12 +154,12 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
<Link
to={crumb.href}
className={cn(
"flex min-w-0 items-center gap-1.5",
"flex min-w-0 items-baseline gap-1.5",
i === 0 && "font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground",
)}
>
{crumb.leading && (
<span className="flex shrink-0 items-center">{crumb.leading}</span>
<span className="flex shrink-0 items-center self-center">{crumb.leading}</span>
)}
{!taskDetailLayout ? <CrumbIdentifier identifier={crumb.identifier} /> : null}
<span className="min-w-0 truncate">{crumb.label}</span>
@ -194,9 +194,9 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
{menuButton}
<div className="min-w-0 overflow-hidden flex-1">
{breadcrumbs[0].leading || breadcrumbs[0].identifier ? (
<h1 className="flex items-center gap-1.5 text-sm font-semibold uppercase tracking-wider">
<h1 className="flex items-baseline gap-1.5 text-sm font-semibold uppercase tracking-wider">
{breadcrumbs[0].leading && (
<span className="flex shrink-0 items-center">{breadcrumbs[0].leading}</span>
<span className="flex shrink-0 items-center self-center">{breadcrumbs[0].leading}</span>
)}
<CrumbIdentifier identifier={breadcrumbs[0].identifier} />
<span className="truncate">{breadcrumbs[0].label}</span>

View File

@ -144,8 +144,7 @@ describe("CompanySettingsSidebar", () => {
expect(container.textContent).not.toContain("Settings");
expect(container.querySelector('[aria-label="Back from Settings"]')).toBeNull();
const settingsSurface = container.querySelector('[data-contextual-sidebar="settings"]');
expect(settingsSurface?.classList).toContain("bg-border/50");
expect(settingsSurface?.classList).toContain("dark:bg-muted");
expect(settingsSurface?.classList).toContain("primary-sidebar-surface");
expect(container.querySelector('[data-slot="contextual-sidebar-nav"]')?.className).toBe(
primarySidebarStyles.nav,
);

View File

@ -173,8 +173,7 @@ describe("Sidebar", () => {
const sidebar = container.querySelector("aside");
expect(sidebar?.classList).not.toContain("border-r");
expect(sidebar?.classList).not.toContain("border-border");
expect(sidebar?.classList).toContain("bg-border/50");
expect(sidebar?.classList).toContain("dark:bg-muted");
expect(sidebar?.classList).toContain("primary-sidebar-surface");
flushSync(() => {
root.unmount();

View File

@ -182,7 +182,7 @@ export function SidebarNavItem({
</Badge>
)}
{!rail && (hasLive || liveAccessory) && (
<span className="ml-auto flex items-center gap-1.5">
<span className="ml-auto flex shrink-0 items-center gap-1.5 whitespace-nowrap">
{liveAccessory}
{hasLive && (
<>

View File

@ -250,11 +250,11 @@ function RecentTasksList({
<>
<SidebarSection label="Recent Tasks">
{entries.map((entry) => (
<div key={entry.id} className="group/recent-task relative">
<div key={entry.id} className="sidebar-action-row group/recent-task relative">
<SidebarNavItem
to={`/issues/${entry.id}`}
label={entry.title}
className={rail ? undefined : "pr-10"}
className={rail ? undefined : "sidebar-action-link pointer-coarse:pr-8"}
liveCount={liveIssueIds.has(entry.id) ? 1 : undefined}
/>
{!rail ? (
@ -265,7 +265,7 @@ function RecentTasksList({
variant="ghost"
size="icon-xs"
aria-label={`More actions for ${entry.title}`}
className="absolute right-2 top-(--pct-50) z-10 -translate-y-(--pct-50) text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 pointer-coarse:opacity-100 group-hover/recent-task:opacity-100 group-focus-within/recent-task:opacity-100 data-[state=open]:bg-accent data-[state=open]:text-foreground data-[state=open]:opacity-100"
className="sidebar-action-menu absolute right-2 top-(--pct-50) z-10 -translate-y-(--pct-50) text-muted-foreground pointer-events-none opacity-0 transition-opacity hover:bg-sidebar-accent dark:hover:bg-sidebar-accent hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 pointer-coarse:pointer-events-auto pointer-coarse:opacity-100 pointer-coarse:before:hidden group-hover/recent-task:pointer-events-auto group-hover/recent-task:opacity-100 group-focus-within/recent-task:pointer-events-auto group-focus-within/recent-task:opacity-100 data-[state=open]:pointer-events-auto data-[state=open]:bg-sidebar-accent data-[state=open]:text-foreground data-[state=open]:opacity-100"
>
<MoreHorizontal aria-hidden="true" />
</Button>

View File

@ -16,7 +16,7 @@ import { taskStatusIconVar } from "../lib/status-colors";
const STATUS_ICON_CLASS: Record<string, string> = {
backlog: "lucide-circle-dashed",
todo: "lucide-circle",
in_progress: "lucide-rotate-cw",
in_progress: "lucide-task-progress-spinner",
in_review: "lucide-circle-dot",
done: "lucide-circle-check",
blocked: "lucide-circle-minus",
@ -29,14 +29,17 @@ describe("StatusGlyph", () => {
for (const status of Object.keys(taskStatusIconVar)) {
const html = renderToStaticMarkup(<StatusGlyph status={status} />);
expect(html).toContain('viewBox="0 0 24 24"');
expect(html).toContain('stroke-width="2"');
expect(html).toContain("<svg");
}
});
it("maps sm/md/lg to 14/16/20 px", () => {
expect(renderToStaticMarkup(<StatusGlyph status="todo" size="sm" />)).toContain('width="14"');
expect(renderToStaticMarkup(<StatusGlyph status="todo" size="md" />)).toContain('width="16"');
expect(renderToStaticMarkup(<StatusGlyph status="todo" size="lg" />)).toContain('width="20"');
for (const status of Object.keys(taskStatusIconVar)) {
expect(renderToStaticMarkup(<StatusGlyph status={status} size="sm" />)).toContain('width="14"');
expect(renderToStaticMarkup(<StatusGlyph status={status} size="md" />)).toContain('width="16"');
expect(renderToStaticMarkup(<StatusGlyph status={status} size="lg" />)).toContain('width="20"');
}
// Default size is md.
expect(renderToStaticMarkup(<StatusGlyph status="todo" />)).toContain('width="16"');
});
@ -61,6 +64,24 @@ describe("StatusGlyph", () => {
}
});
it("animates only in-progress task icons and respects reduced motion", () => {
for (const status of Object.keys(taskStatusIconVar)) {
const html = renderToStaticMarkup(<StatusGlyph status={status} />);
expect(html.includes("motion-safe:animate-spin")).toBe(status === "in_progress");
}
});
it("uses the same circle radius and stroke for the spinner as other task icons", () => {
for (const status of ["in_progress", "todo", "done", "blocked", "cancelled"]) {
const html = renderToStaticMarkup(<StatusGlyph status={status} />);
expect(html).toContain('<circle cx="12" cy="12" r="10"');
expect(html).toContain('stroke-width="2"');
}
const spinner = renderToStaticMarkup(<StatusGlyph status="in_progress" />);
expect(spinner).toContain('pathLength="100"');
expect(spinner).toContain('stroke-dasharray="80 20"');
});
it("gives todo the plain circle (not a compound circle icon)", () => {
const html = renderToStaticMarkup(<StatusGlyph status="todo" />);
expect(html).toContain("lucide-circle");

View File

@ -6,7 +6,7 @@ import {
CircleDashed,
CircleDot,
CircleMinus,
RotateCw,
createLucideIcon,
type LucideIcon,
} from "lucide-react";
import { cn } from "../lib/utils";
@ -18,10 +18,14 @@ import { taskStatusIconVar, taskStatusIconVarDefault } from "../lib/status-color
* `viewBox="0 0 24 24"` so they scale proportionally at any size), so the whole
* set reads as one consistent icon family:
*
* backlog circle-dashed · todo circle · in_progress rotate-cw ·
* backlog circle-dashed · todo circle · in_progress animated open circle ·
* in_review circle-dot · done circle-check · blocked circle-minus ·
* cancelled ban · in_queue circle-minus (blocked recoloured blue).
*
* The in-progress animation represents task workflow status, independently of
* run execution. It remains between runs until the task status changes; live
* indicators and run details report whether an agent is currently executing.
*
* Colour comes from the `--status-task-icon-*` CSS vars (AA-tuned, mode-aware;
* see `index.css`). The glyph paints in `currentColor`, and the component
* defaults `color` to the status' icon var so it renders correctly
@ -44,11 +48,17 @@ export type StatusGlyphStatus =
| "cancelled"
| "in_queue";
// LoaderCircle uses a 9-unit radius. Keep its open arc, but use the same
// 10-unit circle and unscaled stroke as the other task glyphs.
const TaskProgressSpinner = createLucideIcon("TaskProgressSpinner", [
["circle", { cx: "12", cy: "12", r: "10", pathLength: "100", strokeDasharray: "80 20", key: "progress" }],
]);
/** Status → Lucide icon. `in_queue` borrows the blocked icon; its colour var resolves to blue. */
const STATUS_ICON: Record<string, LucideIcon> = {
backlog: CircleDashed,
todo: Circle,
in_progress: RotateCw,
in_progress: TaskProgressSpinner,
in_review: CircleDot,
done: CircleCheck,
blocked: CircleMinus,
@ -78,7 +88,7 @@ export function StatusGlyph({ status, size = "md", className, title }: StatusGly
return (
<Icon
size={px}
className={cn("inline-block shrink-0 align-middle", className)}
className={cn("inline-block shrink-0 align-middle", status === "in_progress" && "motion-safe:animate-spin", className)}
style={{ color: `var(${cssVar})` } as CSSProperties}
{...a11y}
>

View File

@ -2332,7 +2332,7 @@ export function IssueProperties({
<PropertyRow label="Status">
<StatusIcon
status={issue.status}
size="lg"
className="size-3"
blockerAttention={issue.blockerAttention}
onChange={(status) => onUpdate({ status })}
showLabel

View File

@ -3,7 +3,7 @@
* Settings reuses this contract when it takes over the global sidebar.
*/
export const primarySidebarStyles = {
surface: "bg-border/50 dark:bg-muted",
surface: "primary-sidebar-surface",
nav: "flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto scrollbar-auto-hide px-3 py-2 pointer-coarse:gap-3",
group: "flex flex-col gap-0.5",
} as const;

View File

@ -3,7 +3,7 @@
@font-face {
font-family: "InterVariable";
src: url("../fonts/InterVariable.woff2") format("woff2");
src: url("/fonts/InterVariable.woff2") format("woff2");
font-display: swap;
font-style: normal;
font-weight: 100 900;
@ -11,7 +11,7 @@
@font-face {
font-family: "InterVariable";
src: url("../fonts/InterVariable-Italic.woff2") format("woff2");
src: url("/fonts/InterVariable-Italic.woff2") format("woff2");
font-display: swap;
font-style: italic;
font-weight: 100 900;
@ -174,6 +174,15 @@
--status-agent-running: #2563eb;
--status-agent-paused: #f59e0b;
--status-agent-error: #dc2626;
/* Dashboard agent card: translucent running treatment from the Paper design. */
--dashboard-run-accent: oklch(62.3% 0.214 259.815);
--dashboard-run-background: color-mix(in oklab, var(--dashboard-run-accent) 4%, transparent);
--dashboard-run-border: color-mix(in oklab, var(--dashboard-run-accent) 25%, transparent);
--dashboard-list-leading-width: calc(var(--spacing) * 6);
--dashboard-list-id-width: calc(var(--spacing) * 20);
--dashboard-list-time-width: calc(var(--spacing) * 16);
--dashboard-task-list-columns: var(--dashboard-list-leading-width) minmax(0, 1fr) calc(var(--spacing) * 36) var(--dashboard-list-id-width) var(--dashboard-list-time-width);
--dashboard-activity-list-columns: var(--dashboard-list-leading-width) minmax(0, 1fr) var(--dashboard-list-id-width) var(--dashboard-list-time-width);
--status-task-backlog: #a8aeb2;
--status-task-todo: #f59e0b;
--status-task-in_progress: #2563eb;
@ -191,15 +200,15 @@
gray / amber / green statuses are pinned to the board-approved AA hexes.
`in_queue` is the BLOCKED shape recoloured to the in_progress blue
(replaces the bespoke teal "covered" state). Light values here; the
`.dark` block below overrides the four that need a mode-specific hue. */
`.dark` block below overrides the hues that need a mode-specific value. */
--status-task-icon-backlog: #52585d; /* gray darkened — 7.21:1 on paper */
--status-task-icon-todo: #cc7a00; /* amber darkened — clears 3:1 */
--status-task-icon-in_progress: var(--status-task-in_progress); /* #2563eb both modes */
--status-task-icon-in_progress: var(--color-blue-600); /* matches nav dots and live labels */
--status-task-icon-in_review: var(--status-task-in_review); /* #7c3aed (light) */
--status-task-icon-done: #16a34a; /* green darkened — clears 3:1 */
--status-task-icon-blocked: var(--status-task-blocked); /* #dc2626 both modes */
--status-task-icon-cancelled: #52585d;
--status-task-icon-in_queue: var(--status-task-in_progress); /* blocked shape, blue */
--status-task-icon-in_queue: var(--status-task-icon-in_progress); /* blocked shape, same mode-aware blue */
--folder-color-indigo: #6366f1;
--folder-color-violet: #8b5cf6;
@ -286,6 +295,7 @@
--side-panel-tab-label-max-width: var(--sz-100px);
--side-panel-tab-label-expanded-max-width: calc(var(--side-panel-tab-label-max-width) + var(--sz-18px));
--side-panel-tab-label-fade-width: var(--sz-32px);
--sidebar-nav-action-fade-width: calc(var(--spacing) * 4);
--side-panel-tab-radius: var(--radius-xl);
--side-panel-control-radius: var(--radius-xl);
--side-panel-launcher-width: var(--sz-320px);
@ -405,11 +415,12 @@
--chip-match-identifier-bg: var(--muted);
--chip-match-identifier-fg: var(--muted-foreground);
--chip-match-identifier-border: var(--border);
/* Dark-mode AA overrides for the status-icon hues that need a mode-specific
value (PAP-238). in_progress / blocked stay identical (same blue / red both
modes) and in_queue tracks in_progress, so they need no override. */
/* Dark-mode status-icon hues. The lighter progress blue gives bare glyphs
contrast comparable to the neighboring gray, purple, and green icons.
Blocked retains its base red; in_queue follows the progress icon blue. */
--status-task-icon-backlog: #9a958a;
--status-task-icon-todo: #fbbf24;
--status-task-icon-in_progress: var(--color-blue-400); /* matches nav dots and live labels */
--status-task-icon-in_review: #9474f0; /* deeper than violet-400, reads purple */
--status-task-icon-done: #34d06f;
--status-task-icon-cancelled: #9a958a;
@ -985,6 +996,48 @@
transition-duration: var(--motion-side-panel-tab);
transition-timing-function: var(--motion-ease-standard);
}
/* Sidebar actions cover the trailing live label without moving row content,
fading into the row surface like the task-detail tab close control. */
.primary-sidebar-surface {
--sidebar-action-rest-surface: color-mix(in srgb, var(--border) 50%, var(--background));
background-color: var(--sidebar-action-rest-surface);
}
.dark .primary-sidebar-surface {
--sidebar-action-rest-surface: var(--muted);
}
.sidebar-action-row {
--sidebar-action-surface: var(--sidebar-action-rest-surface, var(--background));
}
.sidebar-action-row:is(:hover, :focus-within),
.sidebar-action-row:has(.sidebar-action-link[aria-current="page"]),
.sidebar-action-row:has(.sidebar-action-menu[data-state="open"]) {
--sidebar-action-surface: var(--sidebar-accent);
}
.sidebar-action-link {
background-color: var(--sidebar-action-surface);
}
.sidebar-action-row:is(:hover, :focus-within) .sidebar-action-link,
.sidebar-action-row:has(.sidebar-action-menu[data-state="open"]) .sidebar-action-link {
color: var(--sidebar-accent-foreground);
}
.sidebar-action-menu {
isolation: isolate;
}
.sidebar-action-menu::before {
content: "";
position: absolute;
inset: 0;
left: calc(-1 * var(--sidebar-nav-action-fade-width));
z-index: -1;
border-radius: inherit;
/* Animate a solid surface in sync with the link; gradient color stops switch
immediately and flash the resting sidebar color during the opacity fade. */
background-color: var(--sidebar-action-surface);
mask-image: linear-gradient(to right, transparent, black var(--sidebar-nav-action-fade-width));
transition: background-color var(--default-transition-duration) var(--default-transition-timing-function);
pointer-events: none;
}
.side-panel-tab-label-fade,
.side-panel-tab-label-close-fade[data-truncated="true"],
[data-appearance="streamlined-task"]:is(:hover, :focus-within) .side-panel-tab-label-close-fade {
@ -1145,6 +1198,12 @@
}
}
/* Shared rhythm for dashboard activity and recent-task rows. */
.dashboard-list-row {
min-height: calc(var(--spacing) * 12);
padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4);
}
/* Dashboard activity row entry motion */
@keyframes dashboard-activity-enter {
0% {

View File

@ -8,6 +8,11 @@ describe("activity formatting", () => {
["agent-approver", { id: "agent-approver", name: "Approver Bot" } as Agent],
]);
it("uses readable verbs for task read-state changes", () => {
expect(formatActivityVerb("issue.read_marked")).toBe("read");
expect(formatActivityVerb("issue.read_unmarked")).toBe("marked unread");
});
it("formats blocker activity using linked issue identifiers", () => {
const details = {
addedBlockedByIssues: [

View File

@ -25,6 +25,8 @@ interface ActivityFormatOptions {
const ACTIVITY_ROW_VERBS: Record<string, string> = {
"issue.created": "created",
"issue.updated": "updated",
"issue.read_marked": "read",
"issue.read_unmarked": "marked unread",
"issue.checked_out": "checked out",
"issue.released": "released",
"issue.comment_added": "commented on",

View File

@ -18,7 +18,7 @@ describe("bundled UI font assets", () => {
expect(existsSync(fontPath), `${fileName} should exist in ui/public/fonts`).toBe(true);
expect(statSync(fontPath).isFile(), `${fileName} should be a file`).toBe(true);
expect(readFileSync(fontPath).subarray(0, 4).toString("ascii")).toBe("wOF2");
expect(css).toContain(`url("../fonts/${fileName}")`);
expect(css).toContain(`url("/fonts/${fileName}")`);
}
expect(css).toContain('--font-sans: "InterVariable"');

View File

@ -453,7 +453,7 @@ export function Dashboard() {
<SmokeLabDashboardCard companyId={selectedCompanyId!} />
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className={cn("grid grid-cols-2 gap-4", SHOW_TASK_PRIORITY_UI ? "lg:grid-cols-4" : "lg:grid-cols-3")}>
<ChartCard title="Run Activity" subtitle="Last 14 days">
<RunActivityChart activity={data.runActivity} />
</ChartCard>
@ -486,7 +486,7 @@ export function Dashboard() {
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Recent Activity
</h3>
<Card className="block py-0 divide-y divide-border overflow-hidden">
<Card className="@container block py-0 divide-y divide-border overflow-hidden">
{recentActivity.map((event) => (
<ActivityRow
key={event.id}
@ -512,37 +512,36 @@ export function Dashboard() {
<p className="text-sm text-muted-foreground">No tasks yet.</p>
</Card>
) : (
<Card className="block py-0 divide-y divide-border overflow-hidden">
<Card className="@container block py-0 divide-y divide-border overflow-hidden">
{recentIssues.slice(0, 10).map((issue) => (
<Link
key={issue.id}
to={`/issues/${issue.identifier ?? issue.id}`}
className="px-4 py-3 text-sm cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit block"
className="dashboard-list-row text-sm cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit block"
>
<div className="flex items-start gap-2 sm:items-center sm:gap-3">
{/* Status icon - left column on mobile */}
<span className="shrink-0 sm:hidden">
<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} blockerAttention={issue.blockerAttention} />
</span>
{/* Right column on mobile: title + metadata stacked */}
<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:flex-1 sm:min-w-0 sm:line-clamp-none sm:truncate">
{issue.title}
</span>
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} /></span>
<span className="text-xs font-mono text-muted-foreground">
<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">
<span className="min-w-0 flex-1 truncate text-sm leading-6" title={issue.title}>
{issue.title}
</span>
<span className="ml-auto shrink-0 truncate text-right font-mono text-(length:--text-micro) text-muted-foreground @xl:col-start-4 @xl:row-start-1 @xl:w-(--dashboard-list-id-width)">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name
? <span className="hidden sm:inline-flex"><Identity name={name} size="sm" /></span>
: null;
})()}
<span className="text-xs text-muted-foreground sm:hidden">&middot;</span>
<span className="text-xs text-muted-foreground shrink-0 sm:order-last">
</span>
<span className="flex min-h-6 min-w-0 items-center gap-2 @xl:contents">
<span className="flex min-w-0 flex-1 items-center @xl:col-start-3 @xl:row-start-1 @xl:self-center">
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name
? <Identity name={name} size="sm" className="max-w-32" />
: null;
})()}
</span>
<span className="ml-auto w-(--dashboard-list-time-width) shrink-0 whitespace-nowrap text-right text-xs text-muted-foreground">
{timeAgo(issue.updatedAt)}
</span>
</span>

View File

@ -53,8 +53,6 @@ export function DashboardLive() {
minRunCount={DASHBOARD_LIVE_RUN_LIMIT}
fetchLimit={DASHBOARD_LIVE_RUN_LIMIT}
cardLimit={DASHBOARD_LIVE_RUN_LIMIT}
gridClassName="gap-3 md:grid-cols-2 2xl:grid-cols-3"
cardClassName="h-(--sz-420px)"
emptyMessage="No active or recent agent runs."
queryScope="dashboard-live"
showMoreLink={false}

View File

@ -131,6 +131,7 @@ import {
AvatarGroupCount,
} from "@/components/ui/avatar";
import { AgentCapsule, AGENT_GRADIENT_COUNT } from "@/components/AgentCapsule";
import { AgentRunCard } from "@/components/ActiveAgentsPanel";
import { StatusBadge, IssueStatusBadge } from "@/components/StatusBadge";
import { StatusIcon } from "@/components/StatusIcon";
import { EnforcementBanner } from "@/components/EnforcementBanner";
@ -1203,6 +1204,23 @@ export function DesignGuide() {
{/* CARDS */}
{/* ============================================================ */}
<Section title="Cards">
<SubSection title="Dashboard agent runs">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{["running", "queued", "succeeded", "failed", "timed_out", "cancelled", "interrupted"].map((status) => (
<AgentRunCard
key={status}
companyId="design-guide"
run={{
id: `design-guide-${status}`, agentId: "design-guide-agent", agentName: "CodexCoder",
status, adapterType: "codex_local", invocationSource: "on_demand", triggerDetail: "manual",
startedAt: null, finishedAt: null, createdAt: "2026-09-11T12:00:00Z", issueId: "design-guide-task",
}}
issue={{ identifier: "PAP-559", title: "Recreate this wireframe on pages Paperclip", status: status === "succeeded" ? "done" : "in_progress" }}
/>
))}
</div>
<p className="text-xs text-muted-foreground">The dashboard and Live runs page use the same compact cards. In-progress task icons animate across the app, including between runs, to represent task workflow status. Live indicators report active execution. Open a run to view its status and transcript.</p>
</SubSection>
<SubSection title="Standard Card">
<Card>
<CardHeader>

View File

@ -3645,7 +3645,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
breadcrumbStatus ? (
<StatusIcon
status={breadcrumbStatus}
size="lg"
className="size-3"
blockerAttention={breadcrumbBlockerAttention}
/>
) : undefined,

View File

@ -456,7 +456,10 @@ function StorybookQueryFixtures({ children }: { children: ReactNode }) {
queryClient.setQueryData(queryKeys.adapters.all, adapterFixtures);
queryClient.setQueryData(queryKeys.issues.list(COMPANY_ID), storybookIssues);
queryClient.setQueryData([...queryKeys.issues.list(COMPANY_ID), "with-routine-executions"], storybookIssues);
queryClient.setQueryData([...queryKeys.liveRuns(COMPANY_ID), "dashboard"], liveRuns);
queryClient.setQueryData([...queryKeys.liveRuns(COMPANY_ID), "dashboard", { minRunCount: 4, fetchLimit: undefined }], liveRuns);
for (const issue of storybookIssues) {
queryClient.setQueryData(queryKeys.issues.detail(issue.id), issue);
}
queryClient.setQueryData(queryKeys.instance.generalSettings, { censorUsernameInLogs: false });
queryClient.setQueryData(queryKeys.agents.adapterModels(COMPANY_ID, "codex_local"), [
{ id: "gpt-5.4", label: "GPT-5.4" },