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:
+86
-86
@@ -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">✓</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,
|
||||
|
||||
Reference in New Issue
Block a user