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