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
@@ -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[]> => {