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);
});
});