Information Architecture + project/agent visual refresh (experimental) (#7543)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The board UI is the control surface for issues, projects, agents,
goals, workspaces, and operator settings.
> - The existing navigation and list surfaces make several
high-frequency workflows feel harder to scan than they should,
especially around projects and agents.
> - The product direction is to improve those surfaces without breaking
the existing route model or forcing a new IA on every operator at once.
> - This pull request now keeps the dependent IA, project identity, and
agent-list visual refresh work together while the Issue-to-Task copy
migration is split into #7651.
> - The benefit is a clearer left nav, better project identity, denser
agent/project list rows, and brand-aligned status treatment while
preserving the classic default experience behind a flag.

## Linked Issues or Issue Description

Refs #7645
Refs #7651

Internal planning/work references: PAP-53, PAP-56, PAP-58, PAP-59,
PAP-60, PAP-61, PAP-68, PAP-69, PAP-70, PAP-71, PAP-72, PAP-75, PAP-76,
PAP-80, PAP-85, PAP-86, PAP-87, PAP-88, PAP-89.

## What Changed

- Adds `enableStreamlinedLeftNavigation`, defaulting off, and gates
sidebar presentation so classic navigation remains the default.
- Adds project icon persistence, validation, portability, picker UI, and
`ProjectTile` rendering while defaulting new projects to neutral gray.
- Adds projects-list task-count and budget summary data with focused
server/shared/UI coverage.
- Refreshes agent list rows, row actions, active/recent sidebar
behavior, and status capsule/chip styling for the approved brand state
system.
- Removes the placeholder Conference room and Artifacts nav/routes from
the finalized experimental nav direction.
- Removes `pnpm-lock.yaml` and the Issue-to-Task copy migration from
this PR diff; the copy migration now lives in #7651.

## Verification

- Existing branch verification from the authored commits: UI typecheck,
targeted unit tests, and light/dark visual checks for `/agents`, agent
detail, and design-guide status states.
- Maintainer cleanup verification on `75e34e5`: `git diff --check
origin/master...HEAD` passed, the `design/` diff is empty, and the PR
diff is 61 files, below Greptile's 100-file review limit.
- `pnpm --filter @paperclipai/ui build` passed.
- `NODE_ENV=test pnpm exec vitest run
ui/src/components/Sidebar.test.tsx` passed: 1 file, 8 tests.
- CI and Greptile should rerun on the latest push.

## Risks

- Broad UI surface area: the experimental flag keeps the classic nav
default, but changed shared components such as `EntityRow`,
`ProjectTile`, and agent status badges could affect multiple pages.
- Database migration: `projects.icon` is additive and nullable, but
migration ordering and portability import/export must stay aligned.
- The Issue-to-Task copy migration is now separated into #7651, so
reviewers should evaluate this PR as IA/project/agent presentation work
only.
- Visual regressions are possible across smaller widths because the PR
intentionally changes dense list-row layouts.

## Model Used

Claude Opus 4.8 assisted the original feature commits.
Paperclip-Paperclip agents assisted some planning/design commits. Codex
/ GPT-5-class coding agent with shell, GitHub CLI, and repository access
performed this PR-readiness cleanup and split.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [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: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Dotta <bippadotta@protonmail.com>
This commit is contained in:
scotttong
2026-06-06 07:17:27 -07:00
committed by GitHub
parent 4d5322c821
commit eaef47f4c7
60 changed files with 21179 additions and 390 deletions
+86 -86
View File
@@ -8,9 +8,9 @@ import { useDialogActions } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useSidebar } from "../context/SidebarContext";
import { queryKeys } from "../lib/queryKeys";
import { StatusBadge } from "../components/StatusBadge";
import { AgentStatusBadge, AgentStatusCapsule } from "../components/StatusBadge";
import { AgentActionButtons } from "../components/AgentActionButtons";
import { MembershipAction } from "../components/MembershipAction";
import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
import { EntityRow } from "../components/EntityRow";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
@@ -18,7 +18,7 @@ import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils";
import { PageTabBar } from "../components/PageTabBar";
import { Tabs } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Bot, Plus, List, GitBranch, SlidersHorizontal } from "lucide-react";
import { Bot, Plus, List, GitBranch } from "lucide-react";
import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
import {
resourceMembershipState,
@@ -32,8 +32,12 @@ const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
type FilterTab = "all" | "active" | "paused" | "error";
function matchesFilter(status: string, tab: FilterTab, showTerminated: boolean): boolean {
if (status === "terminated") return showTerminated;
// Agents in these states never appear in the agents list — `terminated` is
// hidden like an archived company, and `pending_approval` is a hiring gate that
// lives in the task thread, not an agent run state (PAP-75).
const HIDDEN_AGENT_STATUSES = new Set(["terminated", "pending_approval"]);
function matchesFilter(status: string, tab: FilterTab): boolean {
if (tab === "all") return true;
if (tab === "active") return status === "active" || status === "running" || status === "idle";
if (tab === "paused") return status === "paused";
@@ -41,9 +45,9 @@ function matchesFilter(status: string, tab: FilterTab, showTerminated: boolean):
return true;
}
function filterAgents(agents: Agent[], tab: FilterTab, showTerminated: boolean): Agent[] {
function filterAgents(agents: Agent[], tab: FilterTab): Agent[] {
return agents
.filter((a) => matchesFilter(a.status, tab, showTerminated))
.filter((a) => !HIDDEN_AGENT_STATUSES.has(a.status) && matchesFilter(a.status, tab))
.sort((a, b) => a.name.localeCompare(b.name));
}
@@ -54,11 +58,17 @@ function getConfiguredModel(agent: Agent): string | null {
return model.length > 0 ? model : null;
}
function filterOrgTree(nodes: OrgNode[], tab: FilterTab, showTerminated: boolean): OrgNode[] {
function filterOrgTree(nodes: OrgNode[], tab: FilterTab): OrgNode[] {
return nodes
.reduce<OrgNode[]>((acc, node) => {
const filteredReports = filterOrgTree(node.reports, tab, showTerminated);
if (matchesFilter(node.status, tab, showTerminated) || filteredReports.length > 0) {
const filteredReports = filterOrgTree(node.reports, tab);
// Hidden agents (terminated / pending_approval) never render as a row, but
// any visible reports are promoted so the tree doesn't lose live agents.
if (HIDDEN_AGENT_STATUSES.has(node.status)) {
acc.push(...filteredReports);
return acc;
}
if (matchesFilter(node.status, tab) || filteredReports.length > 0) {
acc.push({ ...node, reports: filteredReports });
}
return acc;
@@ -78,8 +88,6 @@ export function Agents() {
const [view, setView] = useState<"list" | "org">("org");
const forceListView = isMobile;
const effectiveView: "list" | "org" = forceListView ? "list" : view;
const [showTerminated, setShowTerminated] = useState(false);
const [filtersOpen, setFiltersOpen] = useState(false);
const { data: agents, isLoading, error } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
@@ -135,8 +143,8 @@ export function Agents() {
return <PageSkeleton variant="list" />;
}
const filtered = filterAgents(agents ?? [], tab, showTerminated);
const filteredOrg = filterOrgTree(orgTree ?? [], tab, showTerminated);
const filtered = filterAgents(agents ?? [], tab);
const filteredOrg = filterOrgTree(orgTree ?? [], tab);
return (
<div className="space-y-4">
@@ -154,36 +162,6 @@ export function Agents() {
/>
</Tabs>
<div className="flex items-center gap-2">
{/* Filters */}
<div className="relative">
<button
className={cn(
"flex items-center gap-1.5 px-2 py-1.5 text-xs transition-colors border border-border",
filtersOpen || showTerminated ? "text-foreground bg-accent" : "text-muted-foreground hover:bg-accent/50"
)}
onClick={() => setFiltersOpen(!filtersOpen)}
>
<SlidersHorizontal className="h-3 w-3" />
Filters
{showTerminated && <span className="ml-0.5 px-1 bg-foreground/10 rounded text-[10px]">1</span>}
</button>
{filtersOpen && (
<div className="absolute right-0 top-full mt-1 z-50 w-48 border border-border bg-popover shadow-md p-1">
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs text-left hover:bg-accent/50 transition-colors"
onClick={() => setShowTerminated(!showTerminated)}
>
<span className={cn(
"flex items-center justify-center h-3.5 w-3.5 border border-border rounded-sm",
showTerminated && "bg-foreground"
)}>
{showTerminated && <span className="text-background text-[10px] leading-none">&#10003;</span>}
</span>
Show terminated
</button>
</div>
)}
</div>
{/* View toggle */}
{!forceListView && (
<div className="flex items-center border border-border">
@@ -237,6 +215,11 @@ export function Agents() {
<EntityRow
key={agent.id}
title={agent.name}
// Fixed (truncating) title width so the `meta` group starts at a
// constant x on every row — that's what makes the model + timestamp
// columns line up vertically (PAP-86). Agent names vary in width, so
// a content-sized title (`min-w-[7rem]`) shifted meta's start per row.
titleClassName="w-56"
subtitle={`${roleLabels[agent.role] ?? agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
to={agentUrl(agent)}
className={cn(
@@ -244,12 +227,11 @@ export function Agents() {
agent.pausedAt && tab !== "paused" ? "opacity-50" : "",
resourceMembershipState(membershipsQuery.data, "agent", agent.id) === "left" ? "text-foreground/55" : "",
)}
leading={
<span className="relative flex h-2.5 w-2.5">
<span
className={`absolute inline-flex h-full w-full rounded-full ${agentStatusDot[agent.status] ?? agentStatusDotDefault}`}
/>
</span>
leading={<AgentStatusCapsule status={agent.status} />}
meta={
<div className="hidden xl:flex items-center gap-3">
<AgentMetaColumns agent={agent} />
</div>
}
trailing={
<div className="flex items-center gap-3">
@@ -261,7 +243,7 @@ export function Agents() {
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
/>
) : (
<StatusBadge status={agent.status} />
<AgentStatusBadge status={agent.status} />
)}
</span>
<div className="hidden sm:flex items-center gap-3">
@@ -272,22 +254,25 @@ export function Agents() {
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
/>
)}
<span className="w-28 whitespace-nowrap text-left font-mono text-xs text-muted-foreground">
{getAdapterLabel(agent.adapterType)}
</span>
<span
className="w-36 truncate text-left font-mono text-xs text-muted-foreground"
title={getConfiguredModel(agent) ?? undefined}
>
{getConfiguredModel(agent) ?? "—"}
</span>
<span className="text-xs text-muted-foreground w-16 text-right">
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
</span>
<span className="w-20 flex justify-end">
<StatusBadge status={agent.status} />
<AgentStatusBadge status={agent.status} />
</span>
</div>
{/* Row actions mirror the agent detail page; stop the click
from bubbling to the row link so buttons don't navigate. */}
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<AgentActionButtons
agent={agent}
companyId={selectedCompanyId}
runLabel="Run Heartbeat"
showStatus={false}
/>
</div>
<MembershipAction
state={resourceMembershipState(membershipsQuery.data, "agent", agent.id)}
pending={
@@ -386,8 +371,6 @@ function OrgTreeNode({
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === node.id;
const statusColor = agentStatusDot[node.status] ?? agentStatusDotDefault;
return (
<div style={{ paddingLeft: depth * 24 }}>
<Link
@@ -398,10 +381,8 @@ function OrgTreeNode({
membershipState === "left" && "text-foreground/55",
)}
>
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className={`absolute inline-flex h-full w-full rounded-full ${statusColor}`} />
</span>
<div className="flex-1 min-w-0">
<AgentStatusCapsule status={node.status} />
<div className="flex-1 min-w-[7rem]">
<span className="text-sm font-medium">{node.name}</span>
<span className="text-xs text-muted-foreground ml-2">
{roleLabels[node.role] ?? node.role}
@@ -417,7 +398,7 @@ function OrgTreeNode({
liveCount={liveRunByAgent.get(node.id)!.liveCount}
/>
) : (
<StatusBadge status={node.status} />
<AgentStatusBadge status={node.status} />
)}
</span>
<div className="hidden sm:flex items-center gap-3">
@@ -429,23 +410,12 @@ function OrgTreeNode({
/>
)}
{agent && (
<>
<span className="w-28 whitespace-nowrap text-left font-mono text-xs text-muted-foreground">
{getAdapterLabel(agent.adapterType)}
</span>
<span
className="w-36 truncate text-left font-mono text-xs text-muted-foreground"
title={getConfiguredModel(agent) ?? undefined}
>
{getConfiguredModel(agent) ?? "—"}
</span>
<span className="text-xs text-muted-foreground w-16 text-right">
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
</span>
</>
<div className="hidden xl:flex items-center gap-3">
<AgentMetaColumns agent={agent} />
</div>
)}
<span className="w-20 flex justify-end">
<StatusBadge status={node.status} />
<AgentStatusBadge status={node.status} />
</span>
</div>
<MembershipAction
@@ -488,6 +458,36 @@ function OrgTreeNode({
);
}
/**
* Provider/model + heartbeat columns shared by the list and org views. The
* model and adapter label share one fixed-width cell, each line truncating with
* an ellipsis so a long model id can never overlap the heartbeat column. The
* heartbeat is single-line (`whitespace-nowrap`) and wide enough for a full
* date like "Apr 30, 2026".
*/
function AgentMetaColumns({ agent }: { agent: Agent }) {
const model = getConfiguredModel(agent);
const adapterLabel = getAdapterLabel(agent.adapterType);
return (
<>
<div className="w-44 min-w-0 leading-tight">
<div
className="truncate font-mono text-xs text-muted-foreground"
title={model ?? undefined}
>
{model ?? "—"}
</div>
<div className="truncate font-mono text-[11px] text-muted-foreground/70" title={adapterLabel}>
{adapterLabel}
</div>
</div>
<span className="w-24 whitespace-nowrap text-right text-xs text-muted-foreground">
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
</span>
</>
);
}
function LiveRunIndicator({
agentRef,
runId,