From ebfeb8714d154825b25243481baa09d63bfb9f23 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 6 Jun 2026 13:52:58 +0000 Subject: [PATCH] PAP-10451: address Greptile PR feedback Co-Authored-By: Paperclip --- .../db/src/migrations/0098_project_icon.sql | 6 +- .../src/__tests__/company-portability.test.ts | 51 ++++++++++++ server/src/services/company-portability.ts | 7 +- ui/src/components/ProjectIconPicker.tsx | 77 ------------------- ui/src/components/SidebarAgents.test.tsx | 32 ++++++++ ui/src/components/SidebarAgents.tsx | 2 +- 6 files changed, 91 insertions(+), 84 deletions(-) delete mode 100644 ui/src/components/ProjectIconPicker.tsx diff --git a/packages/db/src/migrations/0098_project_icon.sql b/packages/db/src/migrations/0098_project_icon.sql index d94c0ebae2a..95ea4f09c10 100644 --- a/packages/db/src/migrations/0098_project_icon.sql +++ b/packages/db/src/migrations/0098_project_icon.sql @@ -1,5 +1 @@ -ALTER TABLE "projects" ADD COLUMN "icon" text;--> statement-breakpoint --- One-time bulk reset of all existing projects to neutral gray (PAP-68 board --- decision #1). Intentionally discards current per-project colors so the new --- icon-led project identity starts from a clean, neutral baseline. -UPDATE "projects" SET "color" = NULL; +ALTER TABLE "projects" ADD COLUMN "icon" text; diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index d1bcee10706..8d58ea264f4 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -1156,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-")); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index da0fd4c2b60..e696063445c 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -35,6 +35,7 @@ import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, ISSUE_PRIORITIES, ISSUE_STATUSES, + PROJECT_ICON_NAMES, PROJECT_STATUSES, ROUTINE_CATCH_UP_POLICIES, ROUTINE_CONCURRENCY_POLICIES, @@ -471,6 +472,10 @@ function normalizePortableProjectEnv(value: unknown): AgentEnvConfig | null { return parsed.success ? parsed.data : null; } +function normalizeProjectIconName(value: string | null | undefined): string | null { + return value && PROJECT_ICON_NAMES.includes(value as typeof PROJECT_ICON_NAMES[number]) ? value : null; +} + function extractPortableScopedEnvInputs( scope: { label: string; @@ -4711,7 +4716,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { leadAgentId: projectLeadAgentId, targetDate: manifestProject.targetDate, color: manifestProject.color, - icon: manifestProject.icon, + icon: normalizeProjectIconName(manifestProject.icon), status: manifestProject.status && PROJECT_STATUSES.includes(manifestProject.status as any) ? manifestProject.status as typeof PROJECT_STATUSES[number] : "backlog", diff --git a/ui/src/components/ProjectIconPicker.tsx b/ui/src/components/ProjectIconPicker.tsx deleted file mode 100644 index 094319ade3d..00000000000 --- a/ui/src/components/ProjectIconPicker.tsx +++ /dev/null @@ -1,77 +0,0 @@ -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/SidebarAgents.test.tsx b/ui/src/components/SidebarAgents.test.tsx index 481eb5b0908..96926b86a8b 100644 --- a/ui/src/components/SidebarAgents.test.tsx +++ b/ui/src/components/SidebarAgents.test.tsx @@ -272,6 +272,20 @@ describe("SidebarAgents", () => { await flushReact(); } + async function renderSidebarAgentsWithDefaultProps() { + const currentRoot = createRoot(container); + root = currentRoot; + + await act(async () => { + currentRoot.render( + + + , + ); + }); + await flushReact(); + } + it("keeps top mode in stored org-aware order", async () => { localStorage.setItem("paperclip.agentOrder:company-1:user-1", JSON.stringify(["agent-b", "agent-a", "agent-c"])); mockAgentsApi.list.mockResolvedValue([ @@ -555,6 +569,24 @@ describe("SidebarAgents", () => { expect(seeAllAgentsLink(container)).toBeNull(); }); + it("defaults to classic mode when rendered outside the Sidebar flag path", async () => { + mockAgentsApi.list.mockResolvedValue( + Array.from({ length: 7 }, (_, index) => + makeAgent({ + id: `agent-${index}`, + name: `Agent ${index}`, + urlKey: `agent-${index}`, + }), + ), + ); + mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]); + + await renderSidebarAgentsWithDefaultProps(); + + expect(agentLinkLabels(container)).toHaveLength(7); + expect(seeAllAgentsLink(container)).toBeNull(); + }); + it("does not offer sidebar resume for budget-paused agents", async () => { mockAgentsApi.list.mockResolvedValue([ makeAgent({ diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx index 85e8c59989e..70fe74c2b21 100644 --- a/ui/src/components/SidebarAgents.tsx +++ b/ui/src/components/SidebarAgents.tsx @@ -216,7 +216,7 @@ function SidebarAgentItem({ ); } -export function SidebarAgents({ streamlined = true }: { streamlined?: boolean } = {}) { +export function SidebarAgents({ streamlined = false }: { streamlined?: boolean } = {}) { const [open, setOpen] = useState(true); const [pendingAgentIds, setPendingAgentIds] = useState>(() => new Set()); const queryClient = useQueryClient();