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:
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "projects" ADD COLUMN "color" text;
|
||||
ALTER TABLE "projects" ADD COLUMN "archived_at" timestamp with time zone;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
97
ui/src/components/SidebarProjects.tsx
Normal file
97
ui/src/components/SidebarProjects.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user