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:
authored and GitHub committed 2026-06-06 09:17:27 -05:00
1 parent 4d5322c821
commit eaef47f4c7
60 files changed
+21179 -390

No files matched your search

+295 -1
View File
@@ -1,5 +1,40 @@
import { Pause, Play } from "lucide-react";
import { useCallback, useState } from "react";
import { useNavigate } from "@/lib/router";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Pause,
Play,
Plus,
MoreHorizontal,
Loader2,
Copy,
RotateCcw,
Trash2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { AgentStatusBadge } from "./StatusBadge";
import { agentsApi } from "../api/agents";
import { ApiError } from "../api/client";
import { queryKeys } from "../lib/queryKeys";
import { agentRouteRef } from "../lib/utils";
import { useDialogActions } from "../context/DialogContext";
import { useToastActions } from "../context/ToastContext";
import {
buildDuplicateAgentPayload,
duplicateAgentName,
type DuplicateInstructionsBundle,
} from "../lib/duplicate-agent-payload";
import type {
Agent,
AgentInstructionsBundle,
AgentInstructionsFileSummary,
HeartbeatRun,
} from "@paperclipai/shared";
export function RunButton({
onClick,
@@ -49,3 +84,262 @@ export function PauseResumeButton({
</Button>
);
}
function duplicateInstructionFilePath(
_bundle: AgentInstructionsBundle,
summary: AgentInstructionsFileSummary,
): string | null {
if (summary.deprecated || summary.virtual) return null;
return summary.path;
}
export async function loadDuplicateInstructionsBundle(
agentId: string,
companyId?: string,
): Promise<DuplicateInstructionsBundle | null> {
const bundle = await agentsApi.instructionsBundle(agentId, companyId);
const files: Record<string, string> = {};
for (const summary of bundle.files) {
const path = duplicateInstructionFilePath(bundle, summary);
if (!path) continue;
const file = await agentsApi.instructionsFile(agentId, summary.path, companyId);
files[path] = file.content;
}
const entryFile = Object.prototype.hasOwnProperty.call(files, bundle.entryFile)
? bundle.entryFile
: Object.keys(files)[0] ?? "AGENTS.md";
return Object.keys(files).length > 0 ? { entryFile, files } : null;
}
/**
* Shared agent action cluster used by both the agent detail header and the
* agents list rows. Encapsulates the invoke / pause / resume / terminate /
* duplicate / reset-session mutations so callers do not diverge in behavior.
*/
export function AgentActionButtons({
agent,
companyId,
size = "sm",
assignLabel = "Assign Task",
runLabel = "Run now",
showStatus = true,
actionsDisabled = false,
navigateToRunOnInvoke = true,
onActionError,
children,
className,
}: {
agent: Agent;
companyId?: string | null;
size?: "sm" | "default";
assignLabel?: string;
runLabel?: string;
showStatus?: boolean;
actionsDisabled?: boolean;
navigateToRunOnInvoke?: boolean;
/**
* Optional inline error reporter. When provided it is used instead of a toast
* for action failures (preserves the detail page's inline error banner). When
* omitted, failures surface as toasts (used by the list view).
*/
onActionError?: (message: string | null) => void;
/** Extra content rendered just before the overflow menu (e.g. live-run link). */
children?: React.ReactNode;
className?: string;
}) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { openNewIssue } = useDialogActions();
const { pushToast } = useToastActions();
const [moreOpen, setMoreOpen] = useState(false);
const resolvedCompanyId = companyId ?? agent.companyId;
const canonicalAgentRef = agentRouteRef(agent);
const isPaused = agent.status === "paused";
const reportError = useCallback(
(message: string) => {
if (onActionError) {
onActionError(message);
} else {
pushToast({ title: "Action failed", body: message, tone: "error" });
}
},
[onActionError, pushToast],
);
const invalidateAgent = useCallback(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(canonicalAgentRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agent.id) });
if (resolvedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.liveRuns(resolvedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(resolvedCompanyId, agent.id) });
}
}, [agent.id, canonicalAgentRef, queryClient, resolvedCompanyId]);
const agentAction = useMutation({
mutationFn: async (action: "invoke" | "pause" | "resume" | "approve" | "terminate") => {
switch (action) {
case "invoke": return agentsApi.invoke(agent.id, resolvedCompanyId ?? undefined);
case "pause": return agentsApi.pause(agent.id, resolvedCompanyId ?? undefined);
case "resume": return agentsApi.resume(agent.id, resolvedCompanyId ?? undefined);
case "approve": return agentsApi.approve(agent.id, resolvedCompanyId ?? undefined);
case "terminate": return agentsApi.terminate(agent.id, resolvedCompanyId ?? undefined);
}
},
onSuccess: (data, action) => {
onActionError?.(null);
invalidateAgent();
if (action === "invoke" && navigateToRunOnInvoke && data && typeof data === "object" && "id" in data) {
navigate(`/agents/${canonicalAgentRef}/runs/${(data as HeartbeatRun).id}`);
}
},
onError: (err) => {
reportError(err instanceof Error ? err.message : "Action failed");
},
});
const duplicateAgent = useMutation({
mutationFn: async () => {
if (!resolvedCompanyId) {
throw new Error("Agent is not ready to duplicate");
}
const instructionsBundle = await loadDuplicateInstructionsBundle(agent.id, resolvedCompanyId);
const payload = buildDuplicateAgentPayload(agent, instructionsBundle);
try {
return await agentsApi.create(resolvedCompanyId, payload);
} catch (error) {
if (error instanceof ApiError && error.status === 409 && error.message.includes("requires board approval")) {
const hire = await agentsApi.hire(resolvedCompanyId, payload);
return hire.agent;
}
throw error;
}
},
onSuccess: async (createdAgent) => {
onActionError?.(null);
if (resolvedCompanyId) {
await queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
}
pushToast({ title: "Agent duplicated", body: createdAgent.name, tone: "success" });
navigate(`/agents/${agentRouteRef(createdAgent)}/dashboard`);
},
onError: (err) => {
const message = err instanceof Error ? err.message : "Failed to duplicate agent";
onActionError?.(message);
pushToast({ title: "Could not duplicate agent", body: message, tone: "error" });
},
});
const handleDuplicateAgent = useCallback(() => {
if (duplicateAgent.isPending) return;
const nextName = duplicateAgentName(agent.name);
const confirmed = window.confirm(`Duplicate ${agent.name} as ${nextName}?`);
setMoreOpen(false);
if (!confirmed) return;
duplicateAgent.mutate();
}, [agent.name, duplicateAgent]);
const resetTaskSession = useMutation({
mutationFn: () => agentsApi.resetSession(agent.id, null, resolvedCompanyId ?? undefined),
onSuccess: () => {
onActionError?.(null);
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agent.id) });
},
onError: (err) => {
reportError(err instanceof Error ? err.message : "Failed to reset session");
},
});
const isPendingApproval = agent.status === "pending_approval";
const disabled = actionsDisabled || agentAction.isPending;
return (
<div className={className ?? "flex items-center gap-1 sm:gap-2 shrink-0"}>
<Button
variant="outline"
size={size}
onClick={() => openNewIssue({ assigneeAgentId: agent.id })}
>
<Plus className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">{assignLabel}</span>
</Button>
<RunButton
onClick={() => agentAction.mutate("invoke")}
disabled={disabled || isPendingApproval}
label={runLabel}
size={size}
/>
<PauseResumeButton
isPaused={isPaused}
onPause={() => agentAction.mutate("pause")}
onResume={() => agentAction.mutate("resume")}
disabled={disabled || isPendingApproval}
size={size}
/>
{showStatus && (
<span className="hidden sm:inline">
<AgentStatusBadge status={agent.status} />
</span>
)}
{children}
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon-xs" aria-label={`Open actions for ${agent.name}`}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-44 p-1" align="end">
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
disabled={duplicateAgent.isPending}
onClick={handleDuplicateAgent}
>
{duplicateAgent.isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Copy className="h-3 w-3" />
)}
Duplicate Agent
</button>
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
onClick={() => {
navigator.clipboard.writeText(agent.id);
setMoreOpen(false);
}}
>
<Copy className="h-3 w-3" />
Copy Agent ID
</button>
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
onClick={() => {
resetTaskSession.mutate();
setMoreOpen(false);
}}
>
<RotateCcw className="h-3 w-3" />
Reset Sessions
</button>
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-destructive"
onClick={() => {
agentAction.mutate("terminate");
setMoreOpen(false);
}}
>
<Trash2 className="h-3 w-3" />
Terminate
</button>
</PopoverContent>
</Popover>
</div>
);
}
+2 -2
View File
@@ -5,7 +5,7 @@ import { agentsApi } from "../api/agents";
import { useCompany } from "../context/CompanyContext";
import { getAdapterLabel } from "../adapters/adapter-display-registry";
import { queryKeys } from "../lib/queryKeys";
import { StatusBadge } from "./StatusBadge";
import { AgentStatusBadge } from "./StatusBadge";
import { Identity } from "./Identity";
import { formatDate, agentUrl } from "../lib/utils";
import { Separator } from "@/components/ui/separator";
@@ -41,7 +41,7 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) {
<div className="space-y-4">
<div className="space-y-1">
<PropertyRow label="Status">
<StatusBadge status={agent.status} />
<AgentStatusBadge status={agent.status} />
</PropertyRow>
<PropertyRow label="Role">
<span className="text-sm">{roleLabels[agent.role] ?? agent.role}</span>
+23
View File
@@ -23,4 +23,27 @@ describe("EntityRow", () => {
expect(markup).toContain("text-foreground/55");
expect(markup).not.toContain("text-inherit");
});
it("renders an optional meta slot and stops the title from flex-growing", () => {
const markup = renderToStaticMarkup(
<EntityRow
title="Alpha"
meta={<span data-testid="meta-cell">gpt-5.4</span>}
trailing={<span data-testid="trailing-cell">badge</span>}
/>,
);
// meta content renders alongside trailing
expect(markup).toContain("meta-cell");
expect(markup).toContain("trailing-cell");
// a flex-1 spacer is inserted (between meta and trailing); the title block
// itself no longer flex-grows
expect(markup).toContain('class="flex-1"');
expect(markup).not.toContain("min-w-0 flex-1");
});
it("keeps the title flex-growing when no meta is provided", () => {
const markup = renderToStaticMarkup(<EntityRow title="Alpha" />);
expect(markup).toContain("min-w-0 flex-1");
});
});
+13 -2
View File
@@ -7,11 +7,18 @@ interface EntityRowProps {
identifier?: string;
title: string;
subtitle?: string;
/**
* Optional metadata columns rendered immediately after the title. When set,
* the title stops flex-growing and a spacer is inserted between `meta` and
* `trailing`, so meta sits next to the name while trailing stays pinned right.
*/
meta?: ReactNode;
trailing?: ReactNode;
selected?: boolean;
to?: string;
onClick?: () => void;
className?: string;
titleClassName?: string;
reserveSubtitleSpace?: boolean;
}
@@ -20,11 +27,13 @@ export function EntityRow({
identifier,
title,
subtitle,
meta,
trailing,
selected,
to,
onClick,
className,
titleClassName,
reserveSubtitleSpace,
}: EntityRowProps) {
const isClickable = !!(to || onClick);
@@ -38,14 +47,14 @@ export function EntityRow({
const content = (
<>
{leading && <div className="flex items-center gap-2 shrink-0">{leading}</div>}
<div className="flex-1 min-w-0">
<div className={cn("min-w-0", !meta && "flex-1", titleClassName)}>
<div className="flex items-center gap-2">
{identifier && (
<span className="text-xs text-muted-foreground font-mono shrink-0 relative top-[1px]">
{identifier}
</span>
)}
<span className="truncate">{title}</span>
<span className="truncate" title={title}>{title}</span>
</div>
{(subtitle || reserveSubtitleSpace) && (
<p
@@ -56,6 +65,8 @@ export function EntityRow({
</p>
)}
</div>
{meta && <div className="flex items-center gap-2 shrink-0">{meta}</div>}
{meta && <div className="flex-1" />}
{trailing && <div className="flex items-center gap-2 shrink-0">{trailing}</div>}
</>
);
@@ -294,6 +294,7 @@ function createProject(overrides: Partial<Project> = {}): Project {
leadAgentId: null,
targetDate: null,
color: "#6366f1",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
+1 -2
View File
@@ -33,7 +33,6 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { PROJECT_COLORS } from "@paperclipai/shared";
import { cn } from "../lib/utils";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
import { StatusBadge } from "./StatusBadge";
@@ -165,7 +164,7 @@ export function NewProjectDialog() {
name: name.trim(),
description: description.trim() || undefined,
status,
color: PROJECT_COLORS[Math.floor(Math.random() * PROJECT_COLORS.length)],
// No color is sent — new projects persist color = null (neutral gray). See PAP-68.
...(goalIds.length > 0 ? { goalIds } : {}),
...(targetDate ? { targetDate } : {}),
});
+46
View File
@@ -0,0 +1,46 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { ProjectTile } from "./ProjectTile";
describe("ProjectTile", () => {
it("renders a neutral gray folder tile by default", () => {
const markup = renderToStaticMarkup(<ProjectTile />);
// Neutral tile uses the muted token, never an inline background color.
expect(markup).toContain("bg-muted");
expect(markup).toContain("text-muted-foreground");
expect(markup).not.toContain("background-color");
// Folder icon is the default when no icon prop is supplied.
expect(markup).toContain("lucide-folder");
});
it("renders the chosen Lucide icon when an icon prop is provided", () => {
const markup = renderToStaticMarkup(<ProjectTile icon="rocket" />);
expect(markup).toContain("lucide-rocket");
expect(markup).not.toContain("lucide-folder");
});
it("falls back to the folder icon for an unknown icon name", () => {
const markup = renderToStaticMarkup(<ProjectTile icon="not-a-real-icon" />);
expect(markup).toContain("lucide-folder");
});
it("tints the background when a color is provided", () => {
const markup = renderToStaticMarkup(<ProjectTile color="#22c55e" />);
expect(markup).toContain("background-color:#22c55e");
expect(markup).toContain("text-white");
// Tinted tile drops the muted neutral background.
expect(markup).not.toContain("bg-muted");
expect(markup).toContain("lucide-folder");
});
it("treats null color as neutral (not tinted)", () => {
const markup = renderToStaticMarkup(<ProjectTile color={null} />);
expect(markup).toContain("bg-muted");
expect(markup).not.toContain("background-color");
});
it("applies size styles", () => {
const markup = renderToStaticMarkup(<ProjectTile size="lg" />);
expect(markup).toContain("h-9 w-9");
});
});
+52
View File
@@ -0,0 +1,52 @@
import { cn } from "../lib/utils";
import { getProjectIcon } from "../lib/project-icons";
/**
* Reusable project tile (IA Phase 3 — PAP-58; icon picker added PAP-68 part 3).
*
* Default render is a neutral gray rounded rectangle with a folder icon.
* An optional `color` tints the background; an optional `icon` selects which
* Lucide glyph to render (defaults to `folder`).
*
* Used by the Projects list rows and the project detail header. Both `color`
* and `icon` live on the project itself (`project.color` / `project.icon`).
*/
export type ProjectTileSize = "xs" | "sm" | "md" | "lg";
const SIZE_STYLES: Record<ProjectTileSize, { box: string; icon: string }> = {
xs: { box: "h-4 w-4 rounded-sm", icon: "h-2.5 w-2.5" },
sm: { box: "h-6 w-6 rounded-md", icon: "h-3.5 w-3.5" },
md: { box: "h-7 w-7 rounded-lg", icon: "h-4 w-4" },
lg: { box: "h-9 w-9 rounded-lg", icon: "h-5 w-5" },
};
export interface ProjectTileProps {
/** Optional project color. When unset, the tile stays neutral gray. */
color?: string | null;
/** Optional Lucide icon name. When unset, defaults to `folder`. */
icon?: string | null;
size?: ProjectTileSize;
className?: string;
}
export function ProjectTile({ color, icon, size = "md", className }: ProjectTileProps) {
const dims = SIZE_STYLES[size];
const tinted = Boolean(color);
const Icon = getProjectIcon(icon);
return (
<span
aria-hidden
className={cn(
"inline-flex shrink-0 items-center justify-center",
dims.box,
tinted ? "text-white" : "bg-muted text-muted-foreground",
className,
)}
style={tinted ? { backgroundColor: color ?? undefined } : undefined}
>
<Icon className={dims.icon} />
</span>
);
}
@@ -70,6 +70,7 @@ function createProject(): Project {
leadAgentId: null,
targetDate: null,
color: "#22c55e",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
+65 -6
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import type { ReactNode } from "react";
import { act, type ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -82,12 +82,14 @@ vi.mock("./SidebarCompanyMenu", () => ({
SidebarCompanyMenu: () => <div>Company menu</div>,
}));
vi.mock("./SidebarProjects", () => ({
SidebarProjects: () => null,
vi.mock("./SidebarAgents", () => ({
SidebarAgents: ({ streamlined }: { streamlined?: boolean }) => (
<div data-testid="sidebar-agents" data-streamlined={String(streamlined)} />
),
}));
vi.mock("./SidebarAgents", () => ({
SidebarAgents: () => null,
vi.mock("./SidebarProjects", () => ({
SidebarProjects: () => <div data-testid="sidebar-projects">Projects collapsible</div>,
}));
async function flushReact() {
@@ -146,7 +148,10 @@ describe("Sidebar", () => {
});
it("renders plugin sidebar launchers inside the Work section", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
enableStreamlinedLeftNavigation: true,
});
const root = await renderSidebar();
const workSection = [...container.querySelectorAll("nav [data-plugin-launcher-zone]")]
@@ -162,6 +167,60 @@ describe("Sidebar", () => {
});
});
it("streamlined (flag ON): keeps Issue wording, top-level Projects link, no per-project collapsible", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
enableStreamlinedLeftNavigation: true,
});
const root = await renderSidebar();
expect(container.textContent).toContain("New Issue");
expect(container.textContent).not.toContain("New Task");
const navLabels = [...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim());
expect(navLabels).toContain("Issues");
expect(navLabels).not.toContain("Tasks");
const projectsLink = [...container.querySelectorAll("nav a")].find((a) => a.textContent?.trim() === "Projects");
expect(projectsLink?.getAttribute("href")).toBe("/projects");
expect(container.querySelector('[data-testid="sidebar-projects"]')).toBeNull();
expect(
container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"),
).toBe("true");
await act(async () => {
root.unmount();
});
});
it("classic (flag OFF): New Issue button, Issues label, per-project collapsible, no top-level Projects link", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
enableStreamlinedLeftNavigation: false,
});
const root = await renderSidebar();
expect(container.textContent).toContain("New Issue");
expect(container.textContent).not.toContain("New Task");
const navLabels = [...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim());
expect(navLabels).toContain("Issues");
expect(navLabels).not.toContain("Tasks");
// No top-level Projects nav link in classic mode (D5 option A).
expect(navLabels).not.toContain("Projects");
// Per-project collapsible restored below Work.
expect(container.querySelector('[data-testid="sidebar-projects"]')).not.toBeNull();
expect(
container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"),
).toBe("false");
await act(async () => {
root.unmount();
});
});
it("renders plugin sidebar slots in Work below Workspaces", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
const root = await renderSidebar();
+14 -4
View File
@@ -13,13 +13,14 @@ import {
GitBranch,
Package,
Settings,
FolderOpen,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { NavLink } from "@/lib/router";
import { SidebarSection } from "./SidebarSection";
import { SidebarNavItem } from "./SidebarNavItem";
import { SidebarProjects } from "./SidebarProjects";
import { SidebarAgents } from "./SidebarAgents";
import { SidebarProjects } from "./SidebarProjects";
import { useDialogActions } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { heartbeatsApi } from "../api/heartbeats";
@@ -47,6 +48,11 @@ export function Sidebar() {
});
const liveRunCount = liveRuns?.length ?? 0;
const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true;
// IA flag (PAP-89): branch the sidebar nav presentation. Default OFF = classic
// (per-project collapsible, no Projects nav link). ON = streamlined
// (top-level Projects link). Issue/Task wording is split to PR #7651.
// Gating is navigation-only; all routes stay registered in both modes.
const streamlined = experimentalSettings?.enableStreamlinedLeftNavigation === true;
const pluginContext = {
companyId: selectedCompanyId,
@@ -78,7 +84,7 @@ export function Sidebar() {
<button
onClick={() => openNewIssue()}
data-slot="icon-button"
className="flex items-center gap-2.5 px-3 py-2 pointer-coarse:py-1.5 text-[13px] font-medium text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
className="flex items-center gap-2.5 px-3 py-2 pointer-coarse:py-1.5 text-[13px] font-medium text-foreground/80 hover:bg-accent/50 hover:text-foreground transition-colors"
>
<SquarePen className="h-4 w-4 shrink-0" />
<span className="truncate">New Issue</span>
@@ -102,6 +108,9 @@ export function Sidebar() {
{showWorkspacesLink ? (
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
) : null}
{streamlined ? (
<SidebarNavItem to="/projects" label="Projects" icon={FolderOpen} />
) : null}
<PluginSlotOutlet
slotTypes={["sidebar"]}
context={pluginContext}
@@ -117,9 +126,10 @@ export function Sidebar() {
/>
</SidebarSection>
<SidebarProjects />
{/* Classic mode restores the per-project collapsible below Work. */}
{streamlined ? null : <SidebarProjects />}
<SidebarAgents />
<SidebarAgents streamlined={streamlined} />
<SidebarSection label="Company">
<SidebarNavItem to="/org" label="Org" icon={Network} />
+121 -1
View File
@@ -186,10 +186,19 @@ async function chooseSortMode(label: string) {
function agentLinkLabels(container: HTMLElement) {
return Array.from(container.querySelectorAll('a[href^="/agents/"]'))
.filter((anchor) => anchor.getAttribute("href") !== "/agents/all")
.map((anchor) => anchor.textContent?.trim())
.filter(Boolean);
}
function seeAllAgentsLink(container: HTMLElement) {
return (
Array.from(container.querySelectorAll('a[href="/agents/all"]')).find((anchor) =>
anchor.textContent?.includes("See all agents"),
) ?? null
);
}
describe("SidebarAgents", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null;
@@ -249,7 +258,21 @@ describe("SidebarAgents", () => {
vi.clearAllMocks();
});
async function renderSidebarAgents() {
async function renderSidebarAgents(streamlined = true) {
const currentRoot = createRoot(container);
root = currentRoot;
await act(async () => {
currentRoot.render(
<QueryClientProvider client={queryClient}>
<SidebarAgents streamlined={streamlined} />
</QueryClientProvider>,
);
});
await flushReact();
}
async function renderSidebarAgentsWithDefaultProps() {
const currentRoot = createRoot(container);
root = currentRoot;
@@ -467,6 +490,103 @@ describe("SidebarAgents", () => {
expect(document.body.textContent).not.toContain("Updating...");
});
it("shows only active agents when any agent has a live run", async () => {
mockAgentsApi.list.mockResolvedValue([
makeAgent({ id: "agent-a", name: "Alpha", urlKey: "alpha" }),
makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" }),
makeAgent({ id: "agent-c", name: "Charlie", urlKey: "charlie" }),
]);
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([
{ id: "run-1", agentId: "agent-b", status: "running" },
]);
await renderSidebarAgents();
const labels = agentLinkLabels(container);
expect(labels).toHaveLength(1);
expect(labels[0]).toContain("Bravo");
// PAP-76: the full-list entry point stays visible even when only active
// agents are shown.
expect(seeAllAgentsLink(container)?.getAttribute("href")).toBe("/agents/all");
});
it("shows up to 5 recently-active agents plus a See all link when none are running", async () => {
mockAgentsApi.list.mockResolvedValue(
Array.from({ length: 7 }, (_, index) =>
makeAgent({
id: `agent-${index}`,
name: `Agent ${index}`,
urlKey: `agent-${index}`,
}),
),
);
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
await renderSidebarAgents();
expect(agentLinkLabels(container)).toHaveLength(5);
expect(seeAllAgentsLink(container)?.getAttribute("href")).toBe("/agents/all");
});
it("classic mode (flag OFF) shows all agents and no See all link even when one is running", async () => {
mockAgentsApi.list.mockResolvedValue([
makeAgent({ id: "agent-a", name: "Alpha", urlKey: "alpha" }),
makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" }),
makeAgent({ id: "agent-c", name: "Charlie", urlKey: "charlie" }),
]);
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([
{ id: "run-1", agentId: "agent-b", status: "running" },
]);
await renderSidebarAgents(false);
// Show-all: every agent is listed regardless of live-run state. (Bravo's
// label includes its live-run badge text, so match by prefix.)
const labels = agentLinkLabels(container);
expect(labels).toHaveLength(3);
expect(labels[0]).toBe("Alpha");
expect(labels[1]).toContain("Bravo");
expect(labels[2]).toBe("Charlie");
// No recent-5 truncation, so no "See all agents" link in classic mode.
expect(seeAllAgentsLink(container)).toBeNull();
});
it("classic mode (flag OFF) shows more than 5 agents without truncation", async () => {
mockAgentsApi.list.mockResolvedValue(
Array.from({ length: 7 }, (_, index) =>
makeAgent({
id: `agent-${index}`,
name: `Agent ${index}`,
urlKey: `agent-${index}`,
}),
),
);
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
await renderSidebarAgents(false);
expect(agentLinkLabels(container)).toHaveLength(7);
expect(seeAllAgentsLink(container)).toBeNull();
});
it("defaults to classic mode when rendered outside the Sidebar flag path", async () => {
mockAgentsApi.list.mockResolvedValue(
Array.from({ length: 7 }, (_, index) =>
makeAgent({
id: `agent-${index}`,
name: `Agent ${index}`,
urlKey: `agent-${index}`,
}),
),
);
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
await renderSidebarAgentsWithDefaultProps();
expect(agentLinkLabels(container)).toHaveLength(7);
expect(seeAllAgentsLink(container)).toBeNull();
});
it("does not offer sidebar resume for budget-paused agents", async () => {
mockAgentsApi.list.mockResolvedValue([
makeAgent({
+40 -2
View File
@@ -44,6 +44,12 @@ import {
} from "@/components/ui/dropdown-menu";
import type { Agent } from "@paperclipai/shared";
/**
* When no agent is running, the sidebar falls back to showing at most this many
* recently-active agents plus a "See all agents" link (IA Phase 5).
*/
const RECENT_AGENT_LIMIT = 5;
const AGENT_SORT_CHOICES: SidebarSectionRadioChoice[] = [
{ value: "top", label: "Top" },
{ value: "alphabetical", label: "Alphabetical" },
@@ -210,7 +216,7 @@ function SidebarAgentItem({
);
}
export function SidebarAgents() {
export function SidebarAgents({ streamlined = false }: { streamlined?: boolean } = {}) {
const [open, setOpen] = useState(true);
const [pendingAgentIds, setPendingAgentIds] = useState<Set<string>>(() => new Set());
const queryClient = useQueryClient();
@@ -277,6 +283,25 @@ export function SidebarAgents() {
[orderedAgents, sortMode],
);
// IA Phase 5 (streamlined): if any agent has a live run, show only those
// active agents. Otherwise fall back to up to RECENT_AGENT_LIMIT agents. Either
// way a "See all agents" link is shown so the full list is always reachable.
// Classic mode (PAP-89, flag OFF) restores the show-all behavior.
const runningAgents = useMemo(
() => sortedAgents.filter((agent: Agent) => (liveCountByAgent.get(agent.id) ?? 0) > 0),
[sortedAgents, liveCountByAgent],
);
const hasActiveAgents = runningAgents.length > 0;
const displayedAgents = !streamlined
? sortedAgents
: hasActiveAgents
? runningAgents
: sortedAgents.slice(0, RECENT_AGENT_LIMIT);
// Always expose "See all agents" whenever the displayed list is a subset of all
// agents, so users never lose the entry point to the full list. In classic mode
// every agent is already shown, so the link is unnecessary.
const showSeeAllLink = streamlined && sortedAgents.length > 0;
const agentMatch = location.pathname.match(/^\/(?:[^/]+\/)?agents\/([^/]+)(?:\/([^/]+))?/);
const activeAgentId = agentMatch?.[1] ?? null;
const activeTab = agentMatch?.[2] ?? null;
@@ -406,7 +431,7 @@ export function SidebarAgents() {
onRadioValueChange: persistSortMode,
}}
>
{sortedAgents.map((agent: Agent) => {
{displayedAgents.map((agent: Agent) => {
const runCount = liveCountByAgent.get(agent.id) ?? 0;
return (
<SidebarAgentItem
@@ -424,6 +449,19 @@ export function SidebarAgents() {
/>
);
})}
{showSeeAllLink && (
<Link
to="/agents/all"
state={SIDEBAR_SCROLL_RESET_STATE}
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
className="flex items-center gap-2.5 px-3 py-1.5 pointer-coarse:py-1 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<Users className="shrink-0 h-3.5 w-3.5" />
<span>See all agents</span>
</Link>
)}
</SidebarSection>
);
}
@@ -145,6 +145,7 @@ function makeProject(overrides: Partial<Project>): Project {
leadAgentId: null,
targetDate: null,
color: "#ef4444",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
+2 -4
View File
@@ -23,6 +23,7 @@ import { cn, projectRouteRef } from "../lib/utils";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships";
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
import { ProjectTile } from "./ProjectTile";
import { SidebarSection, type SidebarSectionRadioChoice } from "./SidebarSection";
import { Button } from "@/components/ui/button";
import {
@@ -139,10 +140,7 @@ function ProjectItem({
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
)}
>
<span
className="shrink-0 h-3.5 w-3.5 rounded-sm"
style={{ backgroundColor: project.color ?? "#6366f1" }}
/>
<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="xs" />
<span className="flex-1 truncate">{project.name}</span>
{project.pauseReason === "budget" ? <BudgetSidebarMarker title="Project paused by budget" /> : null}
</NavLink>
+45 -1
View File
@@ -1,5 +1,13 @@
import { cn } from "../lib/utils";
import { statusBadge, statusBadgeDefault } from "../lib/status-colors";
import {
statusBadge,
statusBadgeDefault,
agentStatusColor,
agentStatusColorDefault,
agentStatusBadge,
agentStatusCapsule,
agentStatusMotion,
} from "../lib/status-colors";
export function StatusBadge({ status }: { status: string }) {
return (
@@ -13,3 +21,39 @@ export function StatusBadge({ status }: { status: string }) {
</span>
);
}
/**
* Agent status chip — brand `.task-chip` (1px border, light/dark variants).
* Distinct from the shared {@link StatusBadge} so the agents section can carry
* the brand state colours without affecting run/issue/goal badges. `active`
* renders as "idle" (alias for dead code).
*/
export function AgentStatusBadge({ status }: { status: string }) {
const color = agentStatusColor[status] ?? agentStatusColorDefault;
const label = status === "active" ? "idle" : status;
return (
<span
className={cn(
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium leading-none whitespace-nowrap shrink-0",
agentStatusBadge[color]
)}
>
{label.replace(/_/g, " ")}
</span>
);
}
/**
* Agent status indicator — brand heartbeat capsule (vertical 8×16, r4). Running
* agents pulse, broken (error) agents blink; both honor `prefers-reduced-motion`.
*/
export function AgentStatusCapsule({ status }: { status: string }) {
const color = agentStatusColor[status] ?? agentStatusColorDefault;
const motion = agentStatusMotion[status] ?? "";
return (
<span
aria-hidden
className={cn("inline-block h-4 w-2 rounded-[4px] shrink-0", agentStatusCapsule[color], motion)}
/>
);
}