PAP-10451: address Greptile PR feedback

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-06 13:52:58 +00:00
1 parent 75e34e5c90
commit ebfeb8714d
6 files changed
+91 -84

No files matched your search

@@ -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;
@@ -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-"));
+6 -1
View File
@@ -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",
-77
View File
@@ -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 <Icon className={className} />;
}
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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent className="w-72 p-3" align="start">
<Input
placeholder="Search icons..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="mb-2 h-8 text-sm"
autoFocus
/>
<div className="grid grid-cols-7 gap-1 max-h-48 overflow-y-auto">
{filtered.map(([name, Icon]) => (
<button
key={name}
onClick={() => {
onChange(name);
setOpen(false);
setSearch("");
}}
className={cn(
"flex items-center justify-center h-8 w-8 rounded hover:bg-accent transition-colors",
(value ?? DEFAULT_ICON) === name && "bg-accent ring-1 ring-primary"
)}
title={name}
>
<Icon className="h-4 w-4" />
</button>
))}
{filtered.length === 0 && (
<p className="col-span-7 text-xs text-muted-foreground text-center py-2">No icons match</p>
)}
</div>
</PopoverContent>
</Popover>
);
}
+32
View File
@@ -272,6 +272,20 @@ describe("SidebarAgents", () => {
await flushReact();
}
async function renderSidebarAgentsWithDefaultProps() {
const currentRoot = createRoot(container);
root = currentRoot;
await act(async () => {
currentRoot.render(
<QueryClientProvider client={queryClient}>
<SidebarAgents />
</QueryClientProvider>,
);
});
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({
+1 -1
View File
@@ -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<Set<string>>(() => new Set());
const queryClient = useQueryClient();