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
+2
View File
@@ -407,6 +407,7 @@ describe("renderCompanyImportPreview", () => {
leadAgentSlug: null,
targetDate: null,
color: null,
icon: null,
status: null,
executionWorkspacePolicy: null,
workspaces: [],
@@ -626,6 +627,7 @@ describe("import selection catalog", () => {
leadAgentSlug: null,
targetDate: null,
color: null,
icon: null,
status: null,
executionWorkspacePolicy: null,
workspaces: [],
@@ -0,0 +1 @@
ALTER TABLE "projects" ADD COLUMN "icon" text;
File diff suppressed because it is too large Load Diff
@@ -687,6 +687,13 @@
"when": 1780534000000,
"tag": "0097_low_trust_source_trust",
"breakpoints": true
},
{
"idx": 98,
"version": "7",
"when": 1780534200000,
"tag": "0098_project_icon",
"breakpoints": true
}
]
}
+1
View File
@@ -16,6 +16,7 @@ export const projects = pgTable(
leadAgentId: uuid("lead_agent_id").references(() => agents.id),
targetDate: date("target_date"),
color: text("color"),
icon: text("icon"),
env: jsonb("env").$type<AgentEnvConfig>(),
pauseReason: text("pause_reason"),
pausedAt: timestamp("paused_at", { withTimezone: true }),
@@ -532,6 +532,7 @@ function existingProject(): Project {
leadAgentId: null,
targetDate: null,
color: "#0f766e",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
+1
View File
@@ -1008,6 +1008,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
leadAgentId: null,
targetDate: null,
color: declaration.color ?? null,
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
+50
View File
@@ -124,6 +124,56 @@ export const AGENT_ICON_NAMES = [
] as const;
export type AgentIconName = (typeof AGENT_ICON_NAMES)[number];
/**
* Curated Lucide icon set for projects (PAP-68 part 3).
*
* The first entry, `"folder"`, is the default for any project without an
* explicit icon. The remaining entries reuse much of the agent icon set plus a
* handful of folder/structure icons that read well at small tile sizes.
*/
export const PROJECT_ICON_NAMES = [
"folder",
"rocket",
"code",
"terminal",
"database",
"globe",
"package",
"boxes",
"box",
"layers",
"briefcase",
"compass",
"target",
"flame",
"zap",
"star",
"bug",
"wrench",
"hammer",
"lightbulb",
"sparkles",
"shield",
"lock",
"search",
"cog",
"brain",
"cpu",
"git-branch",
"file-code",
"puzzle",
"gem",
"atom",
"heart",
"mail",
"message-square",
"crown",
"radar",
"telescope",
"hexagon",
] as const;
export type ProjectIconName = (typeof PROJECT_ICON_NAMES)[number];
export const ISSUE_STATUSES = [
"backlog",
"todo",
+3
View File
@@ -41,6 +41,7 @@ export {
WORKSPACE_BRANCH_ROUTINE_VARIABLE,
MODEL_PROFILE_KEYS,
AGENT_ICON_NAMES,
PROJECT_ICON_NAMES,
ISSUE_STATUSES,
INBOX_MINE_ISSUE_STATUSES,
INBOX_MINE_ISSUE_STATUS_FILTER,
@@ -169,6 +170,7 @@ export {
type AgentRole,
type ModelProfileKey,
type AgentIconName,
type ProjectIconName,
type IssueStatus,
type IssuePriority,
type IssueWorkMode,
@@ -399,6 +401,7 @@ export type {
AdapterEnvironmentTestResult,
AssetImage,
Project,
ProjectBudgetSummary,
ProjectCodebase,
ProjectCodebaseOrigin,
ProjectGoalRef,
@@ -58,6 +58,7 @@ export interface CompanyPortabilityProjectManifestEntry {
leadAgentSlug: string | null;
targetDate: string | null;
color: string | null;
icon: string | null;
status: string | null;
env: AgentEnvConfig | null;
executionWorkspacePolicy: Record<string, unknown> | null;
+1 -1
View File
@@ -159,7 +159,7 @@ export type {
DocumentTextRange,
UpdateDocumentAnnotationThreadRequest,
} from "./document-annotation.js";
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js";
export type { Project, ProjectBudgetSummary, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js";
export type {
CompanySearchHighlight,
CompanySearchArtifactSummary,
+1
View File
@@ -29,6 +29,7 @@ export interface InstanceGeneralSettings {
export interface InstanceExperimentalSettings {
enableEnvironments: boolean;
enableIsolatedWorkspaces: boolean;
enableStreamlinedLeftNavigation: boolean;
enableIssuePlanDecompositions: boolean;
enableCloudSync: boolean;
autoRestartDevServerWhenIdle: boolean;
+23 -1
View File
@@ -1,4 +1,4 @@
import type { PauseReason, ProjectStatus } from "../constants.js";
import type { BudgetWindowKind, PauseReason, ProjectStatus } from "../constants.js";
import type {
ProjectExecutionWorkspacePolicy,
ProjectWorkspaceRuntimeConfig,
@@ -14,6 +14,17 @@ export interface ProjectGoalRef {
title: string;
}
/**
* Lightweight per-project budget summary surfaced on the projects list payload
* (IA Phase 4 PAP-60). Reflects the active `billed_cents` budget policy scoped
* to the project, when one is set.
*/
export interface ProjectBudgetSummary {
/** Budget limit in cents. */
amountCents: number;
windowKind: BudgetWindowKind;
}
export interface ProjectWorkspace {
id: string;
companyId: string;
@@ -78,6 +89,7 @@ export interface Project {
leadAgentId: string | null;
targetDate: string | null;
color: string | null;
icon: string | null;
env: AgentEnvConfig | null;
pauseReason: PauseReason | null;
pausedAt: Date | null;
@@ -86,6 +98,16 @@ export interface Project {
workspaces: ProjectWorkspace[];
primaryWorkspace: ProjectWorkspace | null;
managedByPlugin?: ProjectManagedByPlugin | null;
/**
* Number of tasks (issues) in the project. Populated by the projects list
* endpoint (IA Phase 4 PAP-60); omitted on single-project payloads.
*/
taskCount?: number;
/**
* Active budget for the project, when set. Populated by the projects list
* endpoint (IA Phase 4 PAP-60); omitted on single-project payloads.
*/
budget?: ProjectBudgetSummary | null;
archivedAt: Date | null;
createdAt: Date;
updatedAt: Date;
@@ -38,6 +38,7 @@ export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.
export const instanceExperimentalSettingsSchema = z.object({
enableEnvironments: z.boolean().default(false),
enableIsolatedWorkspaces: z.boolean().default(false),
enableStreamlinedLeftNavigation: z.boolean().default(false),
enableIssuePlanDecompositions: z.boolean().default(false),
enableCloudSync: z.boolean().default(false),
autoRestartDevServerWhenIdle: z.boolean().default(false),
+2 -1
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { PROJECT_STATUSES } from "../constants.js";
import { PROJECT_STATUSES, PROJECT_ICON_NAMES } from "../constants.js";
import { envConfigSchema } from "./secret.js";
import { trustAuthorizationPolicySchema } from "./trust-policy.js";
@@ -107,6 +107,7 @@ const projectFields = {
leadAgentId: z.string().uuid().optional().nullable(),
targetDate: z.string().optional().nullable(),
color: z.string().optional().nullable(),
icon: z.enum(PROJECT_ICON_NAMES).optional().nullable(),
env: envConfigSchema.optional().nullable(),
executionWorkspacePolicy: projectExecutionWorkspacePolicySchema.optional().nullable(),
archivedAt: z.string().datetime().optional().nullable(),
@@ -969,6 +969,7 @@ describe("company portability", () => {
leadAgentId: "agent-1",
targetDate: "2026-03-31",
color: "#123456",
icon: "rocket",
status: "planned",
executionWorkspacePolicy: {
enabled: true,
@@ -1057,6 +1058,7 @@ describe("company portability", () => {
});
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain('icon: "rocket"');
expect(extension).toContain("workspaces:");
expect(extension).toContain("main-repo:");
expect(extension).toContain('repoUrl: "https://github.com/paperclipai/paperclip.git"');
@@ -1144,6 +1146,9 @@ describe("company portability", () => {
defaultProjectWorkspaceId: "workspace-imported",
}),
}));
expect(projectSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
icon: "rocket",
}));
expect(issueSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
projectId: "project-imported",
projectWorkspaceId: "workspace-imported",
@@ -1151,6 +1156,57 @@ describe("company portability", () => {
}));
});
it("normalizes invalid imported project icon names to null", async () => {
const portability = companyPortabilityService({} as any);
companySvc.create.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
});
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.list.mockResolvedValue([]);
projectSvc.list.mockResolvedValue([]);
projectSvc.create.mockResolvedValue({
id: "project-imported",
name: "Launch",
urlKey: "launch",
});
const files = {
"COMPANY.md": [
"---",
'schema: "agentcompanies/v1"',
'name: "Imported Paperclip"',
"---",
"",
].join("\n"),
"projects/launch/PROJECT.md": [
"---",
'name: "Launch"',
"---",
"",
].join("\n"),
".paperclip.yaml": [
'schema: "paperclip/v1"',
"projects:",
" launch:",
' icon: "not-a-project-icon"',
"",
].join("\n"),
};
await portability.importBundle({
source: { type: "inline", rootPath: "paperclip-demo", files },
include: { company: true, agents: false, projects: true, issues: false },
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
collisionStrategy: "rename",
}, "user-1");
expect(projectSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
icon: null,
}));
});
it("infers portable git metadata from a local checkout without task warning fan-out", async () => {
const portability = companyPortabilityService({} as any);
const repoDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-portability-git-"));
@@ -15,6 +15,7 @@ describe("instance settings service", () => {
})).toEqual({
enableEnvironments: true,
enableIsolatedWorkspaces: true,
enableStreamlinedLeftNavigation: false,
enableIssuePlanDecompositions: true,
enableCloudSync: true,
autoRestartDevServerWhenIdle: true,
@@ -0,0 +1,95 @@
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { companies, createDb, projects as projectsTable } from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { projectService } from "../services/projects.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres project icon tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
// Verifies the PAP-69 data layer: the 0094 migration applies cleanly (the
// embedded harness runs all pending migrations on startup) and the `icon`
// column persists + round-trips through the projects service.
describeEmbeddedPostgres("project icon persistence", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let companyId!: string;
let prefixCounter = 0;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-project-icon-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(projectsTable);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedCompany(): Promise<string> {
prefixCounter += 1;
const [company] = await db
.insert(companies)
.values({ name: "Icon Co", issuePrefix: `ICN${prefixCounter}` })
.returning();
return company.id;
}
it("persists and round-trips a project icon on create", async () => {
companyId = await seedCompany();
const projects = projectService(db);
const created = await projects.create(companyId, { name: "Rocket", icon: "rocket" });
expect(created.icon).toBe("rocket");
const fetched = await projects.getById(created.id);
expect(fetched?.icon).toBe("rocket");
});
it("defaults icon to null when none is provided", async () => {
companyId = await seedCompany();
const projects = projectService(db);
const created = await projects.create(companyId, { name: "Plain" });
expect(created.icon).toBeNull();
const fetched = await projects.getById(created.id);
expect(fetched?.icon).toBeNull();
});
// PAP-71: new projects must NOT auto-assign a color — they stay neutral gray
// (color = null) unless an explicit color is supplied on create.
it("defaults color to null when none is provided (no auto-assign)", async () => {
companyId = await seedCompany();
const projects = projectService(db);
const created = await projects.create(companyId, { name: "Gray" });
expect(created.color).toBeNull();
const fetched = await projects.getById(created.id);
expect(fetched?.color).toBeNull();
});
it("still persists an explicit color when one is supplied", async () => {
companyId = await seedCompany();
const projects = projectService(db);
const created = await projects.create(companyId, { name: "Blue", color: "#3b82f6" });
expect(created.color).toBe("#3b82f6");
const fetched = await projects.getById(created.id);
expect(fetched?.color).toBe("#3b82f6");
});
});
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { buildProjectListMetricMaps } from "../services/projects.ts";
describe("buildProjectListMetricMaps", () => {
it("maps task counts by project, coercing string counts to numbers", () => {
const { taskCountByProjectId } = buildProjectListMetricMaps(
[
{ projectId: "p1", count: 24 },
{ projectId: "p2", count: 11 as unknown as number },
],
[],
);
expect(taskCountByProjectId.get("p1")).toBe(24);
expect(taskCountByProjectId.get("p2")).toBe(11);
});
it("ignores task-count rows with a null project id", () => {
const { taskCountByProjectId } = buildProjectListMetricMaps(
[{ projectId: null, count: 5 }],
[],
);
expect(taskCountByProjectId.size).toBe(0);
});
it("maps positive budgets with their window kind", () => {
const { budgetByProjectId } = buildProjectListMetricMaps(
[],
[
{ scopeId: "p1", amount: 120_000, windowKind: "calendar_month_utc" },
{ scopeId: "p2", amount: 50_000, windowKind: "lifetime" },
],
);
expect(budgetByProjectId.get("p1")).toEqual({ amountCents: 120_000, windowKind: "calendar_month_utc" });
expect(budgetByProjectId.get("p2")).toEqual({ amountCents: 50_000, windowKind: "lifetime" });
});
it("omits zero/negative budgets so they do not surface as 'set'", () => {
const { budgetByProjectId } = buildProjectListMetricMaps(
[],
[
{ scopeId: "p1", amount: 0, windowKind: "lifetime" },
{ scopeId: "p2", amount: -10, windowKind: "lifetime" },
],
);
expect(budgetByProjectId.size).toBe(0);
});
});
@@ -35,6 +35,7 @@ import {
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
ISSUE_PRIORITIES,
ISSUE_STATUSES,
PROJECT_ICON_NAMES,
PROJECT_STATUSES,
ROUTINE_CATCH_UP_POLICIES,
ROUTINE_CONCURRENCY_POLICIES,
@@ -471,6 +472,10 @@ function normalizePortableProjectEnv(value: unknown): AgentEnvConfig | null {
return parsed.success ? parsed.data : null;
}
function normalizeProjectIconName(value: string | null | undefined): string | null {
return value && PROJECT_ICON_NAMES.includes(value as typeof PROJECT_ICON_NAMES[number]) ? value : null;
}
function extractPortableScopedEnvInputs(
scope: {
label: string;
@@ -579,6 +584,7 @@ type ProjectLike = {
leadAgentId: string | null;
targetDate: string | null;
color: string | null;
icon: string | null;
status: string;
env: Record<string, unknown> | null;
executionWorkspacePolicy: Record<string, unknown> | null;
@@ -2842,6 +2848,7 @@ function buildManifestFromPackageFiles(
leadAgentSlug: asString(extension.leadAgentSlug),
targetDate: asString(extension.targetDate),
color: asString(extension.color),
icon: asString(extension.icon),
status: asString(extension.status),
env: normalizePortableProjectEnv(extension.env),
executionWorkspacePolicy: isPlainRecord(extension.executionWorkspacePolicy)
@@ -3617,6 +3624,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
leadAgentSlug: project.leadAgentId ? (idToSlug.get(project.leadAgentId) ?? null) : null,
targetDate: project.targetDate ?? null,
color: project.color ?? null,
icon: project.icon ?? null,
status: project.status,
executionWorkspacePolicy: exportPortableProjectExecutionWorkspacePolicy(
slug,
@@ -4708,6 +4716,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
leadAgentId: projectLeadAgentId,
targetDate: manifestProject.targetDate,
color: manifestProject.color,
icon: normalizeProjectIconName(manifestProject.icon),
status: manifestProject.status && PROJECT_STATUSES.includes(manifestProject.status as any)
? manifestProject.status as typeof PROJECT_STATUSES[number]
: "backlog",
+2
View File
@@ -43,6 +43,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
return {
enableEnvironments: parsed.data.enableEnvironments ?? false,
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? false,
enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false,
enableCloudSync: parsed.data.enableCloudSync ?? false,
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
@@ -55,6 +56,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
return {
enableEnvironments: false,
enableIsolatedWorkspaces: false,
enableStreamlinedLeftNavigation: false,
enableIssuePlanDecompositions: false,
enableCloudSync: false,
autoRestartDevServerWhenIdle: false,
+91 -10
View File
@@ -1,20 +1,23 @@
import { and, asc, desc, eq, inArray } from "drizzle-orm";
import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
projects,
projectGoals,
goals,
issues,
budgetPolicies,
pluginManagedResources,
plugins,
projectWorkspaces,
workspaceRuntimeServices,
} from "@paperclipai/db";
import {
PROJECT_COLORS,
deriveProjectUrlKey,
hasNonAsciiContent,
isUuidLike,
normalizeProjectUrlKey,
type BudgetWindowKind,
type ProjectBudgetSummary,
type ProjectCodebase,
type ProjectExecutionWorkspacePolicy,
type ProjectGoalRef,
@@ -62,6 +65,8 @@ interface ProjectWithGoals extends Omit<ProjectRow, "executionWorkspacePolicy">
workspaces: ProjectWorkspace[];
primaryWorkspace: ProjectWorkspace | null;
managedByPlugin: ProjectManagedByPlugin | null;
taskCount?: number;
budget?: ProjectBudgetSummary | null;
}
interface ProjectShortnameRow {
@@ -315,6 +320,86 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<Proje
});
}
type TaskCountRow = { projectId: string | null; count: number };
type ProjectBudgetRow = { scopeId: string; amount: number; windowKind: string };
/**
* Build the per-project task-count and budget lookups from the aggregate query
* rows. Pure (no DB) so the merge logic can be unit-tested in isolation.
* Only active policies with a positive amount surface as a budget.
*/
export function buildProjectListMetricMaps(taskCountRows: TaskCountRow[], budgetRows: ProjectBudgetRow[]) {
const taskCountByProjectId = new Map<string, number>();
for (const row of taskCountRows) {
if (row.projectId) taskCountByProjectId.set(row.projectId, Number(row.count) || 0);
}
const budgetByProjectId = new Map<string, ProjectBudgetSummary>();
for (const row of budgetRows) {
if (row.amount > 0) {
budgetByProjectId.set(row.scopeId, {
amountCents: row.amount,
windowKind: row.windowKind as BudgetWindowKind,
});
}
}
return { taskCountByProjectId, budgetByProjectId };
}
/**
* Attach lightweight list-only metrics (task count + budget) to a set of
* projects using two aggregate queries (no N+1). Used by the projects list
* view (IA Phase 4 PAP-60).
*/
async function attachListMetrics(
db: Db,
companyId: string,
rows: ProjectWithGoals[],
): Promise<ProjectWithGoals[]> {
if (rows.length === 0) return rows;
const projectIds = rows.map((r) => r.id);
const [taskCountRows, budgetRows] = await Promise.all([
db
.select({
projectId: issues.projectId,
count: sql<number>`count(*)::int`,
})
.from(issues)
.where(and(eq(issues.companyId, companyId), inArray(issues.projectId, projectIds)))
.groupBy(issues.projectId),
db
.select({
scopeId: budgetPolicies.scopeId,
amount: budgetPolicies.amount,
windowKind: budgetPolicies.windowKind,
})
.from(budgetPolicies)
.where(
and(
eq(budgetPolicies.companyId, companyId),
eq(budgetPolicies.scopeType, "project"),
eq(budgetPolicies.metric, "billed_cents"),
eq(budgetPolicies.isActive, true),
inArray(budgetPolicies.scopeId, projectIds),
),
),
]);
const { taskCountByProjectId, budgetByProjectId } = buildProjectListMetricMaps(
taskCountRows,
budgetRows,
);
return rows.map((row) => ({
...row,
taskCount: taskCountByProjectId.get(row.id) ?? 0,
budget: budgetByProjectId.get(row.id) ?? null,
}));
}
/** Sync the project_goals join table for a single project. */
async function syncGoalLinks(db: Db, projectId: string, companyId: string, goalIds: string[]) {
// Delete existing links
@@ -463,13 +548,8 @@ export function projectService(db: Db) {
const { goalIds: inputGoalIds, ...projectData } = data;
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
// Auto-assign a color from the palette if none provided
if (!projectData.color) {
const existing = await db.select({ color: projects.color }).from(projects).where(eq(projects.companyId, companyId));
const usedColors = new Set(existing.map((r) => r.color).filter(Boolean));
const nextColor = PROJECT_COLORS.find((c) => !usedColors.has(c)) ?? PROJECT_COLORS[existing.length % PROJECT_COLORS.length];
projectData.color = nextColor;
}
// Note: color is intentionally NOT auto-assigned. New projects default to
// `color = null` (neutral gray) unless an explicit color is supplied. See PAP-68.
const existingProjects = await db
.select({ id: projects.id, name: projects.name })
@@ -512,7 +592,8 @@ export function projectService(db: Db) {
list: async (companyId: string): Promise<ProjectWithGoals[]> => {
const rows = await db.select().from(projects).where(eq(projects.companyId, companyId));
const withGoals = await attachGoals(db, rows);
return attachWorkspaces(db, withGoals);
const withWorkspaces = await attachWorkspaces(db, withGoals);
return attachListMetrics(db, companyId, withWorkspaces);
},
listByIds: async (companyId: string, ids: string[]): Promise<ProjectWithGoals[]> => {
+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)}
/>
);
}
+29
View File
@@ -404,6 +404,35 @@
}
}
/* Agent heartbeat capsule motion (PAP-75).
`hb-pulse` marks a running agent (live); `hb-blink` marks a broken agent
(error). The blink alternates the fill between brand red and the page
background so the capsule reads as a faltering heartbeat. */
@keyframes hb-pulse {
0%, 100% { opacity: 0.4; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1); }
}
@keyframes hb-blink {
0%, 49.9% { background-color: #DC2626; }
50%, 100% { background-color: var(--background); }
}
.hb-pulse {
animation: hb-pulse 1.6s ease-in-out infinite;
}
.hb-blink {
animation: hb-blink 1.2s step-end infinite;
}
@media (prefers-reduced-motion: reduce) {
.hb-pulse,
.hb-blink {
animation: none;
}
}
/* MDXEditor theme integration */
.paperclip-mdxeditor-scope,
.paperclip-mdxeditor {
@@ -46,6 +46,7 @@ function makeProject(id: string, name: string): Project {
leadAgentId: null,
targetDate: null,
color: null,
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
+1
View File
@@ -82,6 +82,7 @@ describe("issueDetailBreadcrumb", () => {
leadAgentId: null,
targetDate: null,
color: null,
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
@@ -594,6 +594,7 @@ describe("optimistic issue comments", () => {
leadAgentId: null,
targetDate: null,
color: null,
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { formatProjectBudget } from "./utils";
describe("formatProjectBudget", () => {
it("renders a /mo suffix for monthly budgets", () => {
expect(formatProjectBudget({ amountCents: 120_000, windowKind: "calendar_month_utc" })).toBe("$1,200.00/mo");
});
it("renders the bare amount for lifetime budgets", () => {
expect(formatProjectBudget({ amountCents: 50_000, windowKind: "lifetime" })).toBe("$500.00");
});
it("formats sub-dollar amounts with cents", () => {
expect(formatProjectBudget({ amountCents: 150, windowKind: "lifetime" })).toBe("$1.50");
});
});
+94
View File
@@ -0,0 +1,94 @@
import {
Atom,
Box,
Boxes,
Brain,
Briefcase,
Bug,
Code,
Cog,
Compass,
Cpu,
Crown,
Database,
FileCode,
Flame,
Folder,
Gem,
GitBranch,
Globe,
Hammer,
Heart,
Hexagon,
Layers,
Lightbulb,
Lock,
Mail,
MessageSquare,
Package,
Puzzle,
Radar,
Rocket,
Search,
Shield,
Sparkles,
Star,
Target,
Telescope,
Terminal,
Wrench,
Zap,
type LucideIcon,
} from "lucide-react";
import { PROJECT_ICON_NAMES, type ProjectIconName } from "@paperclipai/shared";
export const PROJECT_ICONS: Record<ProjectIconName, LucideIcon> = {
folder: Folder,
rocket: Rocket,
code: Code,
terminal: Terminal,
database: Database,
globe: Globe,
package: Package,
boxes: Boxes,
box: Box,
layers: Layers,
briefcase: Briefcase,
compass: Compass,
target: Target,
flame: Flame,
zap: Zap,
star: Star,
bug: Bug,
wrench: Wrench,
hammer: Hammer,
lightbulb: Lightbulb,
sparkles: Sparkles,
shield: Shield,
lock: Lock,
search: Search,
cog: Cog,
brain: Brain,
cpu: Cpu,
"git-branch": GitBranch,
"file-code": FileCode,
puzzle: Puzzle,
gem: Gem,
atom: Atom,
heart: Heart,
mail: Mail,
"message-square": MessageSquare,
crown: Crown,
radar: Radar,
telescope: Telescope,
hexagon: Hexagon,
};
const DEFAULT_ICON: ProjectIconName = "folder";
export function getProjectIcon(iconName: string | null | undefined): LucideIcon {
if (iconName && PROJECT_ICON_NAMES.includes(iconName as ProjectIconName)) {
return PROJECT_ICONS[iconName as ProjectIconName];
}
return PROJECT_ICONS[DEFAULT_ICON];
}
+40 -10
View File
@@ -83,20 +83,50 @@ export const statusBadge: Record<string, string> = {
export const statusBadgeDefault = "bg-muted text-muted-foreground";
// ---------------------------------------------------------------------------
// Agent status dot — solid background for small indicator dots
// Agent status — brand state system (PAP-75)
//
// The agents section shows four states only. Each maps to a brand colour name;
// the indicator is a brand heartbeat capsule (8×16, r4) and the row chip is a
// brand `.task-chip` (1px border, light/dark variants). `active` is dead code
// that folds into idle; `running` pulses and `error` blinks. `terminated` and
// `pending_approval` are excluded from the list but may still render on detail
// pages, where they fall through to the gray default.
// ---------------------------------------------------------------------------
export const agentStatusDot: Record<string, string> = {
running: "bg-cyan-400 animate-pulse",
active: "bg-green-400",
paused: "bg-yellow-400",
idle: "bg-yellow-400",
pending_approval: "bg-amber-400",
error: "bg-red-400",
archived: "bg-neutral-400",
export type AgentBadgeColor = "gray" | "blue" | "amber" | "red";
/** Agent status → brand colour name. `active` aliases idle (never assigned). */
export const agentStatusColor: Record<string, AgentBadgeColor> = {
idle: "gray",
active: "gray",
running: "blue",
paused: "amber",
error: "red",
};
export const agentStatusDotDefault = "bg-neutral-400";
export const agentStatusColorDefault: AgentBadgeColor = "gray";
/** Brand `.task-chip` styles (1px border) per colour name — light + dark. */
export const agentStatusBadge: Record<AgentBadgeColor, string> = {
gray: "bg-[#F5F3F0] text-[#52585D] border-[#A8AEB2] dark:bg-[#6e696024] dark:text-[#9A958A] dark:border-[#9e958a73]",
blue: "bg-[#DBEAFE] text-[#1D4ED8] border-[#2563EB] dark:bg-[#2563eb2e] dark:text-[#2563EB] dark:border-[#2563eb73]",
amber: "bg-[#FEF3C7] text-[#B45309] border-[#F59E0B] dark:bg-[#f59e0b24] dark:text-[#F59E0B] dark:border-[#f59e0b73]",
red: "bg-[#FEE2E2] text-[#991B1B] border-[#DC2626] dark:bg-[#dc26262e] dark:text-[#DC2626] dark:border-[#dc262673]",
};
/** Heartbeat-capsule fill (solid) per colour name. gray darkens in dark mode. */
export const agentStatusCapsule: Record<AgentBadgeColor, string> = {
gray: "bg-[#A8AEB2] dark:bg-[#6E6960]",
blue: "bg-[#2563EB]",
amber: "bg-[#F59E0B]",
red: "bg-[#DC2626]",
};
/** Per-status capsule motion (running pulses, error blinks). Honors reduced-motion. */
export const agentStatusMotion: Record<string, string> = {
running: "hb-pulse",
error: "hb-blink",
};
// ---------------------------------------------------------------------------
// Priority colors
+9
View File
@@ -29,6 +29,15 @@ export function formatNumber(n: number): string {
return n.toLocaleString("en-US");
}
/**
* Format a project's budget for the projects list view (IA Phase 4 PAP-60).
* Monthly budgets render a `/mo` suffix; lifetime budgets show the bare amount.
*/
export function formatProjectBudget(budget: { amountCents: number; windowKind: string }): string {
const amount = formatCents(budget.amountCents);
return budget.windowKind === "calendar_month_utc" ? `${amount}/mo` : amount;
}
export function formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString("en-US", {
month: "short",
+24 -201
View File
@@ -20,7 +20,6 @@ import { usePanel } from "../context/PanelContext";
import { useSidebar } from "../context/SidebarContext";
import { useCompany } from "../context/CompanyContext";
import { useToastActions } from "../context/ToastContext";
import { useDialogActions } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { AgentConfigForm } from "../components/AgentConfigForm";
@@ -33,14 +32,13 @@ import { MarkdownEditor } from "../components/MarkdownEditor";
import { assetsApi } from "../api/assets";
import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters";
import { StatusBadge } from "../components/StatusBadge";
import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
import { MarkdownBody } from "../components/MarkdownBody";
import { CopyText } from "../components/CopyText";
import { EntityRow } from "../components/EntityRow";
import { MembershipAction } from "../components/MembershipAction";
import { Identity } from "../components/Identity";
import { PageSkeleton } from "../components/PageSkeleton";
import { RunButton, PauseResumeButton } from "../components/AgentActionButtons";
import { AgentActionButtons } from "../components/AgentActionButtons";
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
import { TrustPresetSection } from "../components/TrustPresetSection";
import { FileTree, buildFileTree } from "../components/FileTree";
@@ -52,18 +50,11 @@ import { buildSameOriginWebSocketUrl } from "../lib/websocket-url";
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { cn } from "../lib/utils";
import { describeRunRetryState } from "../lib/runRetryState";
import { buildDuplicateAgentPayload, duplicateAgentName, type DuplicateInstructionsBundle } from "../lib/duplicate-agent-payload";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
MoreHorizontal,
CheckCircle2,
XCircle,
Clock,
@@ -71,7 +62,6 @@ import {
Loader2,
Slash,
RotateCcw,
Trash2,
Plus,
Key,
Eye,
@@ -91,8 +81,6 @@ import { RunTranscriptView, type TranscriptMode } from "../components/transcript
import {
isUuidLike,
type Agent,
type AgentInstructionsBundle,
type AgentInstructionsFileSummary,
type AgentSkillEntry,
type AgentSkillSnapshot,
type AgentDetail as AgentDetailRecord,
@@ -117,34 +105,6 @@ import {
isReadOnlyUnmanagedSkillEntry,
} from "../lib/agent-skills-state";
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;
}
function duplicateInstructionFilePath(
_bundle: AgentInstructionsBundle,
summary: AgentInstructionsFileSummary,
): string | null {
if (summary.deprecated || summary.virtual) return null;
return summary.path;
}
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" },
failed: { icon: XCircle, color: "text-red-600 dark:text-red-400" },
@@ -678,14 +638,11 @@ export function AgentDetail() {
}>();
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
const { closePanel } = usePanel();
const { openNewIssue } = useDialogActions();
const { pushToast } = useToastActions();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [actionError, setActionError] = useState<string | null>(null);
const [dismissedLeftAgentIds, setDismissedLeftAgentIds] = useState<Set<string>>(() => new Set());
const [moreOpen, setMoreOpen] = useState(false);
const activeView = urlRunId ? "runs" as AgentDetailView : parseAgentDetailView(urlTab ?? null);
const needsDashboardData = activeView === "dashboard";
const needsRunData = activeView === "runs" || Boolean(urlRunId);
@@ -824,18 +781,17 @@ export function AgentDetail() {
setSelectedCompanyId(agent.companyId, { source: "route_sync" });
}, [agent?.companyId, selectedCompanyId, setSelectedCompanyId]);
// Invoke / pause / resume / terminate / duplicate / reset live in the shared
// AgentActionButtons component. The detail header keeps only "approve" here,
// which is surfaced via the pending-approval banner below.
const agentAction = useMutation({
mutationFn: async (action: "invoke" | "pause" | "resume" | "approve" | "terminate") => {
mutationFn: async (action: "approve") => {
if (!agentLookupRef) return Promise.reject(new Error("No agent reference"));
switch (action) {
case "invoke": return agentsApi.invoke(agentLookupRef, resolvedCompanyId ?? undefined);
case "pause": return agentsApi.pause(agentLookupRef, resolvedCompanyId ?? undefined);
case "resume": return agentsApi.resume(agentLookupRef, resolvedCompanyId ?? undefined);
case "approve": return agentsApi.approve(agentLookupRef, resolvedCompanyId ?? undefined);
case "terminate": return agentsApi.terminate(agentLookupRef, resolvedCompanyId ?? undefined);
if (action === "approve") {
return agentsApi.approve(agentLookupRef, resolvedCompanyId ?? undefined);
}
},
onSuccess: (data, action) => {
onSuccess: () => {
setActionError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) });
@@ -847,66 +803,12 @@ export function AgentDetail() {
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(resolvedCompanyId, agent.id) });
}
}
if (action === "invoke" && data && typeof data === "object" && "id" in data) {
navigate(`/agents/${canonicalAgentRef}/runs/${(data as HeartbeatRun).id}`);
}
},
onError: (err) => {
setActionError(err instanceof Error ? err.message : "Action failed");
},
});
const duplicateAgent = useMutation({
mutationFn: async () => {
if (!agent?.id || !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) => {
setActionError(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";
setActionError(message);
pushToast({
title: "Could not duplicate agent",
body: message,
tone: "error",
});
},
});
const handleDuplicateAgent = useCallback(() => {
if (!agent || 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, duplicateAgent]);
const budgetMutation = useMutation({
mutationFn: (amount: number) =>
budgetsApi.upsertPolicy(resolvedCompanyId!, {
@@ -936,19 +838,6 @@ export function AgentDetail() {
},
});
const resetTaskSession = useMutation({
mutationFn: (taskKey: string | null) =>
agentsApi.resetSession(agentLookupRef, taskKey, resolvedCompanyId ?? undefined),
onSuccess: () => {
setActionError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentLookupRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentLookupRef) });
},
onError: (err) => {
setActionError(err instanceof Error ? err.message : "Failed to reset session");
},
});
const updatePermissions = useMutation({
mutationFn: (permissions: AgentPermissionUpdate) =>
agentsApi.updatePermissions(agentLookupRef, permissions, resolvedCompanyId ?? undefined),
@@ -1086,27 +975,14 @@ export function AgentDetail() {
</p>
</div>
</div>
<div className="flex items-center gap-1 sm:gap-2 shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => openNewIssue({ assigneeAgentId: agent.id })}
>
<Plus className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">Assign Task</span>
</Button>
<RunButton
onClick={() => agentAction.mutate("invoke")}
disabled={agentAction.isPending || isPendingApproval}
label="Run Heartbeat"
/>
<PauseResumeButton
isPaused={agent.status === "paused"}
onPause={() => agentAction.mutate("pause")}
onResume={() => agentAction.mutate("resume")}
disabled={agentAction.isPending || isPendingApproval}
/>
<span className="hidden sm:inline"><StatusBadge status={agent.status} /></span>
<AgentActionButtons
agent={agent}
companyId={resolvedCompanyId}
assignLabel="Assign Task"
runLabel="Run Heartbeat"
actionsDisabled={agentAction.isPending}
onActionError={setActionError}
>
{mobileLiveRun && (
<Link
to={`/agents/${canonicalAgentRef}/runs/${mobileLiveRun.id}`}
@@ -1119,60 +995,7 @@ export function AgentDetail() {
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">Live</span>
</Link>
)}
{/* Overflow menu */}
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon-xs">
<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(null);
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>
</AgentActionButtons>
</div>
{!urlRunId && (
@@ -1454,10 +1277,10 @@ function AgentOverview({
<ChartCard title="Run Activity" subtitle="Last 14 days">
<RunActivityChart runs={runs} />
</ChartCard>
<ChartCard title="Issues by Priority" subtitle="Last 14 days">
<ChartCard title="Tasks by Priority" subtitle="Last 14 days">
<PriorityChart issues={assignedIssues} />
</ChartCard>
<ChartCard title="Issues by Status" subtitle="Last 14 days">
<ChartCard title="Tasks by Status" subtitle="Last 14 days">
<IssueStatusChart issues={assignedIssues} />
</ChartCard>
<ChartCard title="Success Rate" subtitle="Last 14 days">
@@ -1468,7 +1291,7 @@ function AgentOverview({
{/* Recent Issues */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Recent Issues</h3>
<h3 className="text-sm font-medium">Recent Tasks</h3>
<Link
to={`/issues?participantAgentId=${agentId}`}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
@@ -1477,7 +1300,7 @@ function AgentOverview({
</Link>
</div>
{assignedIssues.length === 0 ? (
<p className="text-sm text-muted-foreground">No recent issues.</p>
<p className="text-sm text-muted-foreground">No recent tasks.</p>
) : (
<div className="border border-border rounded-lg">
{assignedIssues.slice(0, 10).map((issue) => (
@@ -1491,7 +1314,7 @@ function AgentOverview({
))}
{assignedIssues.length > 10 && (
<div className="px-3 py-2 text-xs text-muted-foreground text-center border-t border-border">
+{assignedIssues.length - 10} more issues
+{assignedIssues.length - 10} more tasks
</div>
)}
</div>
@@ -3651,7 +3474,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
>
{clearSessionsForTouchedIssues.isPending
? "clearing session..."
: "clear session for these issues"}
: "clear session for these tasks"}
</button>
{clearSessionsForTouchedIssues.isError && (
<p className="text-[11px] text-destructive mt-1">
@@ -3671,7 +3494,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
{/* Issues touched by this run */}
{touchedIssues && touchedIssues.length > 0 && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground">Issues Touched ({touchedIssues.length})</span>
<span className="text-xs font-medium text-muted-foreground">Tasks Touched ({touchedIssues.length})</span>
<div className="border border-border rounded-lg divide-y divide-border">
{touchedIssues.map((issue) => (
<Link
+44 -1
View File
@@ -126,7 +126,11 @@ describe("Agents", () => {
});
mockAgentsApi.list.mockResolvedValue([
makeAgent({ adapterConfig: { model: "gpt-5.4" } }),
makeAgent({
adapterConfig: { model: "gpt-5.4" },
// Old enough that relativeTime() falls back to an absolute date string.
lastHeartbeatAt: new Date("2026-01-15T00:00:00Z"),
}),
]);
mockAgentsApi.org.mockResolvedValue([
{
@@ -180,5 +184,44 @@ describe("Agents", () => {
expect(container.textContent).toContain("codex_local");
expect(container.textContent).toContain("gpt-5.4");
// The heartbeat cell must render on a single line so full dates like
// "Apr 30, 2026" never wrap (PAP-85 defect #2).
const heartbeatCell = container.querySelector(".whitespace-nowrap.w-24");
expect(heartbeatCell).not.toBeNull();
expect(heartbeatCell?.textContent).not.toContain("\n");
});
it("gives list-view rows a fixed-width title so meta columns align (PAP-86)", async () => {
root = createRoot(container);
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<Agents />
</ToastProvider>
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
// Switch from the default org view to the list view.
const listToggle = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.querySelector("svg.lucide-list"),
);
expect(listToggle).toBeDefined();
await act(async () => {
listToggle!.click();
});
await flushReact();
// The title cell carries a constant width (`w-56`), not a content-sized
// `min-w-[7rem]`, so the `meta` group starts at the same x on every row and
// the model + timestamp columns line up vertically.
const titleCell = container.querySelector(".w-56");
expect(titleCell).not.toBeNull();
expect(titleCell?.textContent).toContain("Alpha");
expect(container.querySelector(".min-w-\\[7rem\\]")).toBeNull();
});
});
+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,
+12 -9
View File
@@ -112,10 +112,9 @@ import {
AvatarGroup,
AvatarGroupCount,
} from "@/components/ui/avatar";
import { StatusBadge } from "@/components/StatusBadge";
import { StatusBadge, AgentStatusBadge, AgentStatusCapsule } from "@/components/StatusBadge";
import { StatusIcon } from "@/components/StatusIcon";
import { PriorityIcon } from "@/components/PriorityIcon";
import { agentStatusDot, agentStatusDotDefault } from "@/lib/status-colors";
import { EntityRow } from "@/components/EntityRow";
import { EmptyState } from "@/components/EmptyState";
import { MetricCard } from "@/components/MetricCard";
@@ -551,14 +550,18 @@ export function DesignGuide() {
</div>
</SubSection>
<SubSection title="Agent status dots">
<div className="flex items-center gap-4 flex-wrap">
{(["running", "active", "paused", "error", "archived"] as const).map((label) => (
<SubSection title="Agent status (capsule + chip)">
<p className="text-xs text-muted-foreground mb-3 max-w-prose">
The agents section uses a brand heartbeat capsule (8×16) plus a brand
<code className="mx-1">.task-chip</code>. Four states only: idle (gray),
running (blue, pulses), paused (amber), error (red, blinks). Motion
honors <code>prefers-reduced-motion</code>.
</p>
<div className="flex items-center gap-6 flex-wrap">
{(["idle", "running", "paused", "error"] as const).map((label) => (
<div key={label} className="flex items-center gap-2">
<span className="relative flex h-2.5 w-2.5">
<span className={`inline-flex h-full w-full rounded-full ${agentStatusDot[label] ?? agentStatusDotDefault}`} />
</span>
<span className="text-xs text-muted-foreground">{label}</span>
<AgentStatusCapsule status={label} />
<AgentStatusBadge status={label} />
</div>
))}
</div>
@@ -155,6 +155,7 @@ function project(overrides: Partial<Project> = {}): Project {
leadAgentId: null,
targetDate: null,
color: "#14b8a6",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
@@ -205,6 +205,8 @@ export function InstanceExperimentalSettings() {
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
const enableStreamlinedLeftNavigation =
experimentalQuery.data?.enableStreamlinedLeftNavigation === true;
const enableIssuePlanDecompositions =
experimentalQuery.data?.enableIssuePlanDecompositions === true;
const enableCloudSync = experimentalQuery.data?.enableCloudSync === true;
@@ -301,6 +303,28 @@ export function InstanceExperimentalSettings() {
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Streamlined Left Navigation Bar</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Reduces the maximum number of items in the left navigation bar nests Projects under Work with a
dedicated Projects page, and shows only active agents (max 5 recently-active) in the sidebar.
</p>
</div>
<ToggleSwitch
checked={enableStreamlinedLeftNavigation}
onCheckedChange={() =>
toggleMutation.mutate({
enableStreamlinedLeftNavigation: !enableStreamlinedLeftNavigation,
})
}
disabled={toggleMutation.isPending}
aria-label="Toggle streamlined left navigation experimental setting"
/>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
+1
View File
@@ -116,6 +116,7 @@ function project(overrides: Partial<Project> = {}): Project {
leadAgentId: null,
targetDate: null,
color: "#14b8a6",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
+104 -41
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { Link, useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary } from "@paperclipai/shared";
import { PROJECT_COLORS, PROJECT_ICON_NAMES, isUuidLike, type BudgetPolicySummary } from "@paperclipai/shared";
import { budgetsApi } from "../api/budgets";
import { executionWorkspacesApi } from "../api/execution-workspaces";
import { instanceSettingsApi } from "../api/instanceSettings";
@@ -18,6 +18,7 @@ import { queryKeys } from "../lib/queryKeys";
import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties";
import { InlineEditor } from "../components/InlineEditor";
import { StatusBadge } from "../components/StatusBadge";
import { ProjectTile } from "../components/ProjectTile";
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
import { IssuesList } from "../components/IssuesList";
import { PageSkeleton } from "../components/PageSkeleton";
@@ -27,7 +28,11 @@ import { MembershipAction } from "../components/MembershipAction";
import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab";
import { collectLiveIssueIds } from "../lib/liveIssueIds";
import { projectRouteRef } from "../lib/utils";
import { PROJECT_ICONS } from "../lib/project-icons";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { Tabs } from "@/components/ui/tabs";
import { PluginLauncherOutlet } from "@/plugins/launchers";
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
@@ -103,60 +108,116 @@ function OverviewContent({
);
}
/* ── Color picker popover ── */
/* ── Combined icon + color picker popover (PAP-72 / PAP-68 part 4) ── */
function ColorPicker({
currentColor,
onSelect,
const DEFAULT_PROJECT_ICON = "folder";
function ProjectTilePicker({
color,
icon,
onSelectIcon,
onSelectColor,
}: {
currentColor: string;
onSelect: (color: string) => void;
color: string | null;
icon: string | null;
onSelectIcon: (icon: string) => void;
onSelectColor: (color: string | null) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const [search, setSearch] = useState("");
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
const filteredIcons = useMemo(() => {
const entries = PROJECT_ICON_NAMES.map((name) => [name, PROJECT_ICONS[name]] as const);
if (!search) return entries;
const q = search.toLowerCase();
return entries.filter(([name]) => name.includes(q));
}, [search]);
// Keep the popover open across selections so the user can pick both an icon
// and a color in one pass; reset the search when it closes.
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(!open)}
className="shrink-0 h-5 w-5 rounded-md cursor-pointer hover:ring-2 hover:ring-foreground/20 transition-[box-shadow]"
style={{ backgroundColor: currentColor }}
aria-label="Change project color"
/>
{open && (
<div className="absolute top-full left-0 mt-2 p-2 bg-popover border border-border rounded-lg shadow-lg z-50 w-max">
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setSearch("");
}}
>
<PopoverTrigger asChild>
<button
type="button"
className="shrink-0 rounded-lg cursor-pointer hover:ring-2 hover:ring-foreground/20 transition-[box-shadow]"
aria-label="Change project icon and color"
>
<ProjectTile color={color} icon={icon} size="md" />
</button>
</PopoverTrigger>
<PopoverContent className="w-72 p-3" align="start">
{/* Icon search + grid */}
<p className="text-xs font-medium text-muted-foreground mb-2">Icon</p>
<Input
placeholder="Search icons..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="mb-2 h-8 text-sm"
autoFocus
/>
<div className="grid grid-cols-7 gap-1 max-h-40 overflow-y-auto">
{filteredIcons.map(([name, Icon]) => (
<button
key={name}
type="button"
onClick={() => onSelectIcon(name)}
className={cn(
"flex items-center justify-center h-8 w-8 rounded hover:bg-accent transition-colors",
(icon ?? DEFAULT_PROJECT_ICON) === name && "bg-accent ring-1 ring-primary",
)}
title={name}
>
<Icon className="h-4 w-4" />
</button>
))}
{filteredIcons.length === 0 && (
<p className="col-span-7 text-xs text-muted-foreground text-center py-2">No icons match</p>
)}
</div>
{/* Color swatches */}
<div className="mt-3 border-t border-border pt-3">
<p className="text-xs font-medium text-muted-foreground mb-2">Color</p>
<div className="grid grid-cols-5 gap-1.5">
{PROJECT_COLORS.map((color) => (
{/* Neutral / reset-to-gray option */}
<button
type="button"
onClick={() => onSelectColor(null)}
className={`h-6 w-6 cursor-pointer transition-[transform,box-shadow] duration-150 hover:scale-110 ${
color === null
? "ring-2 ring-foreground ring-offset-1 ring-offset-background rounded-md"
: ""
}`}
aria-label="Reset to neutral gray"
title="Neutral (default)"
>
<ProjectTile color={null} size="sm" />
</button>
{PROJECT_COLORS.map((swatch) => (
<button
key={color}
onClick={() => {
onSelect(color);
setOpen(false);
}}
key={swatch}
type="button"
onClick={() => onSelectColor(swatch)}
className={`h-6 w-6 rounded-md cursor-pointer transition-[transform,box-shadow] duration-150 hover:scale-110 ${
color === currentColor
swatch === color
? "ring-2 ring-foreground ring-offset-1 ring-offset-background"
: "hover:ring-2 hover:ring-foreground/30"
}`}
style={{ backgroundColor: color }}
aria-label={`Select color ${color}`}
style={{ backgroundColor: swatch }}
aria-label={`Select color ${swatch}`}
/>
))}
</div>
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}
@@ -698,9 +759,11 @@ export function ProjectDetail() {
) : null}
<div className="flex items-start gap-3">
<div className="h-7 flex items-center">
<ColorPicker
currentColor={project.color ?? "#6366f1"}
onSelect={(color) => updateProject.mutate({ color })}
<ProjectTilePicker
color={project.color ?? null}
icon={project.icon ?? null}
onSelectIcon={(icon) => updateProject.mutate({ icon })}
onSelectColor={(color) => updateProject.mutate({ color })}
/>
</div>
<div className="min-w-0 space-y-2">
@@ -137,6 +137,7 @@ function project(overrides: Partial<Project> = {}): Project {
leadAgentId: null,
targetDate: null,
color: "#14b8a6",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
+1
View File
@@ -76,6 +76,7 @@ function makeProject(overrides: Partial<Project>): Project {
leadAgentId: null,
targetDate: null,
color: "#ef4444",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
+15 -2
View File
@@ -7,11 +7,12 @@ import { useDialogActions } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { EntityRow } from "../components/EntityRow";
import { ProjectTile } from "../components/ProjectTile";
import { StatusBadge } from "../components/StatusBadge";
import { MembershipAction } from "../components/MembershipAction";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { formatDate, projectUrl } from "../lib/utils";
import { formatDate, formatNumber, formatProjectBudget, projectUrl } from "../lib/utils";
import {
resourceMembershipState,
useResourceMembershipMutation,
@@ -206,6 +207,7 @@ export function Projects() {
return (
<EntityRow
key={project.id}
leading={<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="sm" />}
title={project.name}
subtitle={project.description ?? undefined}
reserveSubtitleSpace
@@ -213,8 +215,19 @@ export function Projects() {
className={state === "left" ? "group text-foreground/55" : "group"}
trailing={
<div className="flex items-center gap-3">
<span
className="hidden text-xs text-muted-foreground tabular-nums sm:inline"
title={`${formatNumber(project.taskCount ?? 0)} task${(project.taskCount ?? 0) === 1 ? "" : "s"}`}
>
{formatNumber(project.taskCount ?? 0)} task{(project.taskCount ?? 0) === 1 ? "" : "s"}
</span>
{project.budget && (
<span className="hidden text-xs text-muted-foreground tabular-nums sm:inline">
{formatProjectBudget(project.budget)}
</span>
)}
{project.targetDate && (
<span className="text-xs text-muted-foreground">
<span className="hidden text-xs text-muted-foreground md:inline">
{formatDate(project.targetDate)}
</span>
)}
+2 -2
View File
@@ -346,7 +346,7 @@ export function Search() {
}
}
}}
placeholder="Search issues, comments, documents, artifacts, agents, projects…"
placeholder="Search issues, comments, documents, agents, projects…"
aria-label="Search query"
className="h-10 pl-9 pr-20 text-sm"
/>
@@ -456,7 +456,7 @@ function SearchTabContent({
<div>
<h2 className="text-lg font-semibold">Type to search company memory.</h2>
<p className="mt-1 text-sm text-muted-foreground">
Issues, comments, plan documents, artifacts, agents, projects same surface, ranked by relevance.
Issues, comments, plan documents, agents, projects same surface, ranked by relevance.
</p>
</div>
{recentSearches.length > 0 ? (
+1
View File
@@ -590,6 +590,7 @@ function createProject(overrides: Partial<Project> = {}): Project {
leadAgentId: "agent-codex",
targetDate: "2026-04-30",
color: "#0f766e",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
@@ -296,6 +296,7 @@ const storybookProject: Project = {
leadAgentId: "agent-codex",
targetDate: null,
color: "#0f766e",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,