diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index fb58a85b0b1..8fa504ff8d1 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -124,6 +124,56 @@ export const AGENT_ICON_NAMES = [ ] as const; export type AgentIconName = (typeof AGENT_ICON_NAMES)[number]; +/** + * Curated Lucide icon set for projects (PAP-68 part 3). + * + * The first entry, `"folder"`, is the default for any project without an + * explicit icon. The remaining entries reuse much of the agent icon set plus a + * handful of folder/structure icons that read well at small tile sizes. + */ +export const PROJECT_ICON_NAMES = [ + "folder", + "rocket", + "code", + "terminal", + "database", + "globe", + "package", + "boxes", + "box", + "layers", + "briefcase", + "compass", + "target", + "flame", + "zap", + "star", + "bug", + "wrench", + "hammer", + "lightbulb", + "sparkles", + "shield", + "lock", + "search", + "cog", + "brain", + "cpu", + "git-branch", + "file-code", + "puzzle", + "gem", + "atom", + "heart", + "mail", + "message-square", + "crown", + "radar", + "telescope", + "hexagon", +] as const; +export type ProjectIconName = (typeof PROJECT_ICON_NAMES)[number]; + export const ISSUE_STATUSES = [ "backlog", "todo", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index dcb3a3d9cea..f5cf5f84a55 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -41,6 +41,7 @@ export { WORKSPACE_BRANCH_ROUTINE_VARIABLE, MODEL_PROFILE_KEYS, AGENT_ICON_NAMES, + PROJECT_ICON_NAMES, ISSUE_STATUSES, INBOX_MINE_ISSUE_STATUSES, INBOX_MINE_ISSUE_STATUS_FILTER, @@ -169,6 +170,7 @@ export { type AgentRole, type ModelProfileKey, type AgentIconName, + type ProjectIconName, type IssueStatus, type IssuePriority, type IssueWorkMode, diff --git a/packages/shared/src/validators/project.ts b/packages/shared/src/validators/project.ts index 9750d551c09..45b2d0e146d 100644 --- a/packages/shared/src/validators/project.ts +++ b/packages/shared/src/validators/project.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { PROJECT_STATUSES } from "../constants.js"; +import { PROJECT_STATUSES, PROJECT_ICON_NAMES } from "../constants.js"; import { envConfigSchema } from "./secret.js"; import { trustAuthorizationPolicySchema } from "./trust-policy.js"; @@ -107,9 +107,7 @@ const projectFields = { leadAgentId: z.string().uuid().optional().nullable(), targetDate: z.string().optional().nullable(), color: z.string().optional().nullable(), - // TODO(PAP-68 part 3): tighten to `z.enum(PROJECT_ICON_NAMES)` once the icon - // name enum lands. Loosely typed as a string until then. - icon: z.string().optional().nullable(), + icon: z.enum(PROJECT_ICON_NAMES).optional().nullable(), env: envConfigSchema.optional().nullable(), executionWorkspacePolicy: projectExecutionWorkspacePolicySchema.optional().nullable(), archivedAt: z.string().datetime().optional().nullable(), diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 51e78c8596b..4ee639326bd 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -294,6 +294,7 @@ function createProject(overrides: Partial = {}): Project { leadAgentId: null, targetDate: null, color: "#6366f1", + icon: null, env: null, pauseReason: null, pausedAt: null, diff --git a/ui/src/components/ProjectIconPicker.tsx b/ui/src/components/ProjectIconPicker.tsx new file mode 100644 index 00000000000..094319ade3d --- /dev/null +++ b/ui/src/components/ProjectIconPicker.tsx @@ -0,0 +1,77 @@ +import { useState, useMemo } from "react"; +import { PROJECT_ICON_NAMES, type ProjectIconName } from "@paperclipai/shared"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { PROJECT_ICONS, getProjectIcon } from "../lib/project-icons"; + +const DEFAULT_ICON: ProjectIconName = "folder"; + +interface ProjectIconProps { + icon: string | null | undefined; + className?: string; +} + +export function ProjectIcon({ icon, className }: ProjectIconProps) { + const Icon = getProjectIcon(icon); + return ; +} + +interface ProjectIconPickerProps { + value: string | null | undefined; + onChange: (icon: string) => void; + children: React.ReactNode; +} + +export function ProjectIconPicker({ value, onChange, children }: ProjectIconPickerProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + + const filtered = useMemo(() => { + const entries = PROJECT_ICON_NAMES.map((name) => [name, PROJECT_ICONS[name]] as const); + if (!search) return entries; + const q = search.toLowerCase(); + return entries.filter(([name]) => name.includes(q)); + }, [search]); + + return ( + + {children} + + setSearch(e.target.value)} + className="mb-2 h-8 text-sm" + autoFocus + /> +
+ {filtered.map(([name, Icon]) => ( + + ))} + {filtered.length === 0 && ( +

No icons match

+ )} +
+
+
+ ); +} diff --git a/ui/src/components/ProjectTile.test.tsx b/ui/src/components/ProjectTile.test.tsx index dc560e428f3..056b7ccc7a5 100644 --- a/ui/src/components/ProjectTile.test.tsx +++ b/ui/src/components/ProjectTile.test.tsx @@ -9,7 +9,18 @@ describe("ProjectTile", () => { expect(markup).toContain("bg-muted"); expect(markup).toContain("text-muted-foreground"); expect(markup).not.toContain("background-color"); - // Folder icon is always present (fixed for this phase). + // Folder icon is the default when no icon prop is supplied. + expect(markup).toContain("lucide-folder"); + }); + + it("renders the chosen Lucide icon when an icon prop is provided", () => { + const markup = renderToStaticMarkup(); + expect(markup).toContain("lucide-rocket"); + expect(markup).not.toContain("lucide-folder"); + }); + + it("falls back to the folder icon for an unknown icon name", () => { + const markup = renderToStaticMarkup(); expect(markup).toContain("lucide-folder"); }); diff --git a/ui/src/components/ProjectTile.tsx b/ui/src/components/ProjectTile.tsx index 89ea411baa7..1ab113c827a 100644 --- a/ui/src/components/ProjectTile.tsx +++ b/ui/src/components/ProjectTile.tsx @@ -1,15 +1,15 @@ -import { Folder } from "lucide-react"; import { cn } from "../lib/utils"; +import { getProjectIcon } from "../lib/project-icons"; /** - * Reusable project tile (IA Phase 3 — PAP-58). + * Reusable project tile (IA Phase 3 — PAP-58; icon picker added PAP-68 part 3). * * Default render is a neutral gray rounded rectangle with a folder icon. - * An optional `color` tints the background; the folder icon is fixed for all - * tiles this phase (custom-icon picker is deferred to a later phase). + * An optional `color` tints the background; an optional `icon` selects which + * Lucide glyph to render (defaults to `folder`). * - * Used by the Projects list rows and the project detail header. The color - * lives on the project itself (`project.color`) — no new `project.icon` field. + * Used by the Projects list rows and the project detail header. Both `color` + * and `icon` live on the project itself (`project.color` / `project.icon`). */ export type ProjectTileSize = "xs" | "sm" | "md" | "lg"; @@ -24,13 +24,16 @@ const SIZE_STYLES: Record = { export interface ProjectTileProps { /** Optional project color. When unset, the tile stays neutral gray. */ color?: string | null; + /** Optional Lucide icon name. When unset, defaults to `folder`. */ + icon?: string | null; size?: ProjectTileSize; className?: string; } -export function ProjectTile({ color, size = "md", className }: ProjectTileProps) { +export function ProjectTile({ color, icon, size = "md", className }: ProjectTileProps) { const dims = SIZE_STYLES[size]; const tinted = Boolean(color); + const Icon = getProjectIcon(icon); return ( - + ); } diff --git a/ui/src/components/RoutineRunVariablesDialog.test.tsx b/ui/src/components/RoutineRunVariablesDialog.test.tsx index 301239e0c20..f4d5e1c2dc1 100644 --- a/ui/src/components/RoutineRunVariablesDialog.test.tsx +++ b/ui/src/components/RoutineRunVariablesDialog.test.tsx @@ -70,6 +70,7 @@ function createProject(): Project { leadAgentId: null, targetDate: null, color: "#22c55e", + icon: null, env: null, pauseReason: null, pausedAt: null, diff --git a/ui/src/components/SidebarProjects.test.tsx b/ui/src/components/SidebarProjects.test.tsx index c0bb075cf52..70ab120ca58 100644 --- a/ui/src/components/SidebarProjects.test.tsx +++ b/ui/src/components/SidebarProjects.test.tsx @@ -145,6 +145,7 @@ function makeProject(overrides: Partial): Project { leadAgentId: null, targetDate: null, color: "#ef4444", + icon: null, env: null, pauseReason: null, pausedAt: null, diff --git a/ui/src/components/SidebarProjects.tsx b/ui/src/components/SidebarProjects.tsx index 22e99c1d78b..eb189e5b10f 100644 --- a/ui/src/components/SidebarProjects.tsx +++ b/ui/src/components/SidebarProjects.tsx @@ -140,7 +140,7 @@ function ProjectItem({ : "text-foreground/80 hover:bg-accent/50 hover:text-foreground", )} > - + {project.name} {project.pauseReason === "budget" ? : null} diff --git a/ui/src/lib/company-portability-sidebar.test.ts b/ui/src/lib/company-portability-sidebar.test.ts index 2b648fcf899..3dec98ba1a7 100644 --- a/ui/src/lib/company-portability-sidebar.test.ts +++ b/ui/src/lib/company-portability-sidebar.test.ts @@ -46,6 +46,7 @@ function makeProject(id: string, name: string): Project { leadAgentId: null, targetDate: null, color: null, + icon: null, env: null, pauseReason: null, pausedAt: null, diff --git a/ui/src/lib/issueDetailBreadcrumb.test.ts b/ui/src/lib/issueDetailBreadcrumb.test.ts index 790fc4cd691..cc92b21c5e9 100644 --- a/ui/src/lib/issueDetailBreadcrumb.test.ts +++ b/ui/src/lib/issueDetailBreadcrumb.test.ts @@ -82,6 +82,7 @@ describe("issueDetailBreadcrumb", () => { leadAgentId: null, targetDate: null, color: null, + icon: null, env: null, pauseReason: null, pausedAt: null, diff --git a/ui/src/lib/optimistic-issue-comments.test.ts b/ui/src/lib/optimistic-issue-comments.test.ts index b8e8d300a1d..cd912379d07 100644 --- a/ui/src/lib/optimistic-issue-comments.test.ts +++ b/ui/src/lib/optimistic-issue-comments.test.ts @@ -594,6 +594,7 @@ describe("optimistic issue comments", () => { leadAgentId: null, targetDate: null, color: null, + icon: null, env: null, pauseReason: null, pausedAt: null, diff --git a/ui/src/lib/project-icons.ts b/ui/src/lib/project-icons.ts new file mode 100644 index 00000000000..a76207e2b02 --- /dev/null +++ b/ui/src/lib/project-icons.ts @@ -0,0 +1,94 @@ +import { + Atom, + Box, + Boxes, + Brain, + Briefcase, + Bug, + Code, + Cog, + Compass, + Cpu, + Crown, + Database, + FileCode, + Flame, + Folder, + Gem, + GitBranch, + Globe, + Hammer, + Heart, + Hexagon, + Layers, + Lightbulb, + Lock, + Mail, + MessageSquare, + Package, + Puzzle, + Radar, + Rocket, + Search, + Shield, + Sparkles, + Star, + Target, + Telescope, + Terminal, + Wrench, + Zap, + type LucideIcon, +} from "lucide-react"; +import { PROJECT_ICON_NAMES, type ProjectIconName } from "@paperclipai/shared"; + +export const PROJECT_ICONS: Record = { + folder: Folder, + rocket: Rocket, + code: Code, + terminal: Terminal, + database: Database, + globe: Globe, + package: Package, + boxes: Boxes, + box: Box, + layers: Layers, + briefcase: Briefcase, + compass: Compass, + target: Target, + flame: Flame, + zap: Zap, + star: Star, + bug: Bug, + wrench: Wrench, + hammer: Hammer, + lightbulb: Lightbulb, + sparkles: Sparkles, + shield: Shield, + lock: Lock, + search: Search, + cog: Cog, + brain: Brain, + cpu: Cpu, + "git-branch": GitBranch, + "file-code": FileCode, + puzzle: Puzzle, + gem: Gem, + atom: Atom, + heart: Heart, + mail: Mail, + "message-square": MessageSquare, + crown: Crown, + radar: Radar, + telescope: Telescope, + hexagon: Hexagon, +}; + +const DEFAULT_ICON: ProjectIconName = "folder"; + +export function getProjectIcon(iconName: string | null | undefined): LucideIcon { + if (iconName && PROJECT_ICON_NAMES.includes(iconName as ProjectIconName)) { + return PROJECT_ICONS[iconName as ProjectIconName]; + } + return PROJECT_ICONS[DEFAULT_ICON]; +} diff --git a/ui/src/pages/ExecutionWorkspaceDetail.test.tsx b/ui/src/pages/ExecutionWorkspaceDetail.test.tsx index a8a685da182..c96aad1120c 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.test.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.test.tsx @@ -155,6 +155,7 @@ function project(overrides: Partial = {}): Project { leadAgentId: null, targetDate: null, color: "#14b8a6", + icon: null, env: null, pauseReason: null, pausedAt: null, diff --git a/ui/src/pages/ProjectDetail.test.tsx b/ui/src/pages/ProjectDetail.test.tsx index b9dafe90eb1..9579bbf61ca 100644 --- a/ui/src/pages/ProjectDetail.test.tsx +++ b/ui/src/pages/ProjectDetail.test.tsx @@ -116,6 +116,7 @@ function project(overrides: Partial = {}): Project { leadAgentId: null, targetDate: null, color: "#14b8a6", + icon: null, env: null, pauseReason: null, pausedAt: null, diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index e5614ac409c..0a4972bba11 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -108,9 +108,11 @@ function OverviewContent({ function ColorPicker({ color, + icon, onSelect, }: { color: string | null; + icon?: string | null; onSelect: (color: string | null) => void; }) { const [open, setOpen] = useState(false); @@ -135,7 +137,7 @@ function ColorPicker({ className="shrink-0 rounded-lg cursor-pointer hover:ring-2 hover:ring-foreground/20 transition-[box-shadow]" aria-label="Change project color" > - + {open && (
@@ -722,6 +724,7 @@ export function ProjectDetail() {
updateProject.mutate({ color })} />
diff --git a/ui/src/pages/ProjectWorkspaceDetail.test.tsx b/ui/src/pages/ProjectWorkspaceDetail.test.tsx index 616fe863ceb..1b761a2eaf6 100644 --- a/ui/src/pages/ProjectWorkspaceDetail.test.tsx +++ b/ui/src/pages/ProjectWorkspaceDetail.test.tsx @@ -137,6 +137,7 @@ function project(overrides: Partial = {}): Project { leadAgentId: null, targetDate: null, color: "#14b8a6", + icon: null, env: null, pauseReason: null, pausedAt: null, diff --git a/ui/src/pages/Projects.test.tsx b/ui/src/pages/Projects.test.tsx index 54daec0a69a..53a417f19ac 100644 --- a/ui/src/pages/Projects.test.tsx +++ b/ui/src/pages/Projects.test.tsx @@ -76,6 +76,7 @@ function makeProject(overrides: Partial): Project { leadAgentId: null, targetDate: null, color: "#ef4444", + icon: null, env: null, pauseReason: null, pausedAt: null, diff --git a/ui/src/pages/Projects.tsx b/ui/src/pages/Projects.tsx index 16321a28b57..d6b201f3ac1 100644 --- a/ui/src/pages/Projects.tsx +++ b/ui/src/pages/Projects.tsx @@ -207,7 +207,7 @@ export function Projects() { return ( } + leading={} title={project.name} subtitle={project.description ?? undefined} reserveSubtitleSpace diff --git a/ui/storybook/fixtures/paperclipData.ts b/ui/storybook/fixtures/paperclipData.ts index d798c9ee039..3e430703ac5 100644 --- a/ui/storybook/fixtures/paperclipData.ts +++ b/ui/storybook/fixtures/paperclipData.ts @@ -590,6 +590,7 @@ function createProject(overrides: Partial = {}): Project { leadAgentId: "agent-codex", targetDate: "2026-04-30", color: "#0f766e", + icon: null, env: null, pauseReason: null, pausedAt: null, diff --git a/ui/storybook/stories/forms-editors.stories.tsx b/ui/storybook/stories/forms-editors.stories.tsx index bdf863459ab..8c267fea905 100644 --- a/ui/storybook/stories/forms-editors.stories.tsx +++ b/ui/storybook/stories/forms-editors.stories.tsx @@ -296,6 +296,7 @@ const storybookProject: Project = { leadAgentId: "agent-codex", targetDate: null, color: "#0f766e", + icon: null, env: null, pauseReason: null, pausedAt: null,