feat: foldable PROJECTS section in sidebar with color support

- Add `color` (text) and `archivedAt` (timestamp) columns to projects table
- Add PROJECT_COLORS palette constant (10 colors) in shared package
- Add color/archivedAt to Project type interface and Zod validators
- Auto-assign next available color from palette on project creation
- New SidebarProjects component with:
  - Collapsible PROJECTS header above WORK section
  - Caret toggle visible on hover (left of header)
  - Always-visible plus button (right of header) opens NewProjectDialog
  - Lists non-archived projects with colored rounded squares
  - Active project highlighted based on URL match
- Remove Projects nav item from WORK section in sidebar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-23 09:14:08 -06:00
parent 3392f4dbfa
commit a57d3427f7
10 changed files with 224 additions and 10 deletions

View File

@@ -0,0 +1,2 @@
ALTER TABLE "projects" ADD COLUMN "color" text;
ALTER TABLE "projects" ADD COLUMN "archived_at" timestamp with time zone;

View File

@@ -99,6 +99,20 @@
"when": 1771623691139,
"tag": "0013_dashing_wasp",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1771691806349,
"tag": "0014_many_mikhail_rasputin",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1771865100000,
"tag": "0015_project_color_archived",
"breakpoints": true
}
]
}

View File

@@ -14,6 +14,8 @@ export const projects = pgTable(
status: text("status").notNull().default("backlog"),
leadAgentId: uuid("lead_agent_id").references(() => agents.id),
targetDate: date("target_date"),
color: text("color"),
archivedAt: timestamp("archived_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},

View File

@@ -1,6 +1,15 @@
export const COMPANY_STATUSES = ["active", "paused", "archived"] as const;
export type CompanyStatus = (typeof COMPANY_STATUSES)[number];
export const DEPLOYMENT_MODES = ["local_trusted", "authenticated"] as const;
export type DeploymentMode = (typeof DEPLOYMENT_MODES)[number];
export const DEPLOYMENT_EXPOSURES = ["private", "public"] as const;
export type DeploymentExposure = (typeof DEPLOYMENT_EXPOSURES)[number];
export const AUTH_BASE_URL_MODES = ["auto", "explicit"] as const;
export type AuthBaseUrlMode = (typeof AUTH_BASE_URL_MODES)[number];
export const AGENT_STATUSES = [
"active",
"paused",
@@ -59,6 +68,19 @@ export const PROJECT_STATUSES = [
] as const;
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
export const PROJECT_COLORS = [
"#6366f1", // indigo
"#8b5cf6", // violet
"#ec4899", // pink
"#ef4444", // red
"#f97316", // orange
"#eab308", // yellow
"#22c55e", // green
"#14b8a6", // teal
"#06b6d4", // cyan
"#3b82f6", // blue
] as const;
export const APPROVAL_TYPES = ["hire_agent", "approve_ceo_strategy"] as const;
export type ApprovalType = (typeof APPROVAL_TYPES)[number];
@@ -124,3 +146,34 @@ export const LIVE_EVENT_TYPES = [
"activity.logged",
] as const;
export type LiveEventType = (typeof LIVE_EVENT_TYPES)[number];
export const PRINCIPAL_TYPES = ["user", "agent"] as const;
export type PrincipalType = (typeof PRINCIPAL_TYPES)[number];
export const MEMBERSHIP_STATUSES = ["pending", "active", "suspended"] as const;
export type MembershipStatus = (typeof MEMBERSHIP_STATUSES)[number];
export const INSTANCE_USER_ROLES = ["instance_admin"] as const;
export type InstanceUserRole = (typeof INSTANCE_USER_ROLES)[number];
export const INVITE_TYPES = ["company_join", "bootstrap_ceo"] as const;
export type InviteType = (typeof INVITE_TYPES)[number];
export const INVITE_JOIN_TYPES = ["human", "agent", "both"] as const;
export type InviteJoinType = (typeof INVITE_JOIN_TYPES)[number];
export const JOIN_REQUEST_TYPES = ["human", "agent"] as const;
export type JoinRequestType = (typeof JOIN_REQUEST_TYPES)[number];
export const JOIN_REQUEST_STATUSES = ["pending_approval", "approved", "rejected"] as const;
export type JoinRequestStatus = (typeof JOIN_REQUEST_STATUSES)[number];
export const PERMISSION_KEYS = [
"agents:create",
"users:invite",
"users:manage_permissions",
"tasks:assign",
"tasks:assign_scope",
"joins:approve",
] as const;
export type PermissionKey = (typeof PERMISSION_KEYS)[number];

View File

@@ -1,5 +1,8 @@
export {
COMPANY_STATUSES,
DEPLOYMENT_MODES,
DEPLOYMENT_EXPOSURES,
AUTH_BASE_URL_MODES,
AGENT_STATUSES,
AGENT_ADAPTER_TYPES,
AGENT_ROLES,
@@ -8,6 +11,7 @@ export {
GOAL_LEVELS,
GOAL_STATUSES,
PROJECT_STATUSES,
PROJECT_COLORS,
APPROVAL_TYPES,
APPROVAL_STATUSES,
SECRET_PROVIDERS,
@@ -17,7 +21,18 @@ export {
WAKEUP_TRIGGER_DETAILS,
WAKEUP_REQUEST_STATUSES,
LIVE_EVENT_TYPES,
PRINCIPAL_TYPES,
MEMBERSHIP_STATUSES,
INSTANCE_USER_ROLES,
INVITE_TYPES,
INVITE_JOIN_TYPES,
JOIN_REQUEST_TYPES,
JOIN_REQUEST_STATUSES,
PERMISSION_KEYS,
type CompanyStatus,
type DeploymentMode,
type DeploymentExposure,
type AuthBaseUrlMode,
type AgentStatus,
type AgentAdapterType,
type AgentRole,
@@ -35,6 +50,14 @@ export {
type WakeupTriggerDetail,
type WakeupRequestStatus,
type LiveEventType,
type PrincipalType,
type MembershipStatus,
type InstanceUserRole,
type InviteType,
type InviteJoinType,
type JoinRequestType,
type JoinRequestStatus,
type PermissionKey,
} from "./constants.js";
export type {
@@ -68,6 +91,11 @@ export type {
DashboardSummary,
ActivityEvent,
SidebarBadges,
CompanyMembership,
PrincipalPermissionGrant,
Invite,
JoinRequest,
InstanceUserRoleGrant,
EnvBinding,
AgentEnvConfig,
CompanySecret,
@@ -139,9 +167,19 @@ export {
createCostEventSchema,
updateBudgetSchema,
createAssetImageMetadataSchema,
createCompanyInviteSchema,
acceptInviteSchema,
listJoinRequestsQuerySchema,
updateMemberPermissionsSchema,
updateUserCompanyAccessSchema,
type CreateCostEvent,
type UpdateBudget,
type CreateAssetImageMetadata,
type CreateCompanyInvite,
type AcceptInvite,
type ListJoinRequestsQuery,
type UpdateMemberPermissions,
type UpdateUserCompanyAccess,
} from "./validators/index.js";
export { API_PREFIX, API } from "./api.js";
@@ -153,6 +191,7 @@ export {
databaseConfigSchema,
loggingConfigSchema,
serverConfigSchema,
authConfigSchema,
secretsConfigSchema,
storageConfigSchema,
storageLocalDiskConfigSchema,
@@ -163,6 +202,7 @@ export {
type DatabaseConfig,
type LoggingConfig,
type ServerConfig,
type AuthConfig,
type StorageConfig,
type StorageLocalDiskConfig,
type StorageS3Config,

View File

@@ -17,6 +17,8 @@ export interface Project {
status: ProjectStatus;
leadAgentId: string | null;
targetDate: string | null;
color: string | null;
archivedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}

View File

@@ -10,6 +10,8 @@ export const createProjectSchema = z.object({
status: z.enum(PROJECT_STATUSES).optional().default("backlog"),
leadAgentId: z.string().uuid().optional().nullable(),
targetDate: z.string().optional().nullable(),
color: z.string().optional().nullable(),
archivedAt: z.string().datetime().optional().nullable(),
});
export type CreateProject = z.infer<typeof createProjectSchema>;

View File

@@ -1,7 +1,7 @@
import { eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { projects, projectGoals, goals } from "@paperclip/db";
import type { ProjectGoalRef } from "@paperclip/shared";
import { PROJECT_COLORS, type ProjectGoalRef } from "@paperclip/shared";
type ProjectRow = typeof projects.$inferSelect;
@@ -90,6 +90,14 @@ 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;
}
// Also write goalId to the legacy column (first goal or null)
const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null;

View File

@@ -1,7 +1,6 @@
import {
Inbox,
CircleDot,
Hexagon,
Target,
LayoutDashboard,
Bot,
@@ -9,7 +8,6 @@ import {
History,
Search,
SquarePen,
ShieldCheck,
BookOpen,
Paperclip,
} from "lucide-react";
@@ -17,6 +15,7 @@ import { useQuery } from "@tanstack/react-query";
import { CompanySwitcher } from "./CompanySwitcher";
import { SidebarSection } from "./SidebarSection";
import { SidebarNavItem } from "./SidebarNavItem";
import { SidebarProjects } from "./SidebarProjects";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { sidebarBadgesApi } from "../api/sidebarBadges";
@@ -82,20 +81,15 @@ export function Sidebar() {
/>
</div>
<SidebarProjects />
<SidebarSection label="Work">
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
<SidebarNavItem to="/projects" label="Projects" icon={Hexagon} />
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
</SidebarSection>
<SidebarSection label="Company">
<SidebarNavItem to="/agents" label="Agents" icon={Bot} />
<SidebarNavItem
to="/approvals"
label="Approvals"
icon={ShieldCheck}
badge={sidebarBadges?.approvals}
/>
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
<SidebarNavItem to="/activity" label="Activity" icon={History} />
</SidebarSection>

View File

@@ -0,0 +1,97 @@
import { useState } from "react";
import { NavLink, useLocation } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight, Plus } from "lucide-react";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useSidebar } from "../context/SidebarContext";
import { projectsApi } from "../api/projects";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import type { Project } from "@paperclip/shared";
export function SidebarProjects() {
const [open, setOpen] = useState(true);
const { selectedCompanyId } = useCompany();
const { openNewProject } = useDialog();
const { isMobile, setSidebarOpen } = useSidebar();
const location = useLocation();
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
// Filter out archived projects
const visibleProjects = (projects ?? []).filter(
(p: Project) => !p.archivedAt
);
// Extract current projectId from URL
const projectMatch = location.pathname.match(/^\/projects\/([^/]+)/);
const activeProjectId = projectMatch?.[1] ?? null;
return (
<Collapsible open={open} onOpenChange={setOpen}>
<div className="group">
<div className="flex items-center px-3 py-1.5">
<CollapsibleTrigger className="flex items-center gap-1 flex-1 min-w-0">
<ChevronRight
className={cn(
"h-3 w-3 text-muted-foreground/60 transition-transform opacity-0 group-hover:opacity-100",
open && "rotate-90"
)}
/>
<span className="text-[10px] font-medium uppercase tracking-widest font-mono text-muted-foreground/60">
Projects
</span>
</CollapsibleTrigger>
<button
onClick={(e) => {
e.stopPropagation();
openNewProject();
}}
className="flex items-center justify-center h-4 w-4 rounded text-muted-foreground/60 hover:text-foreground hover:bg-accent/50 transition-colors"
aria-label="New project"
>
<Plus className="h-3 w-3" />
</button>
</div>
</div>
<CollapsibleContent>
<div className="flex flex-col gap-0.5 mt-0.5">
{visibleProjects.map((project: Project) => (
<NavLink
key={project.id}
to={`/projects/${project.id}`}
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
className={cn(
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
activeProjectId === project.id
? "bg-accent text-foreground"
: "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",
}}
/>
<span className="flex-1 truncate">{project.name}</span>
</NavLink>
))}
</div>
</CollapsibleContent>
</Collapsible>
);
}