From 159c5b43605ec523b5f63dfa6b42873f42dae857 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 16:49:46 -0500 Subject: [PATCH] Preserve sidebar order in company portability Co-Authored-By: Paperclip --- packages/shared/src/index.ts | 2 + .../shared/src/types/company-portability.ts | 14 ++ packages/shared/src/types/index.ts | 1 + .../src/validators/company-portability.ts | 7 + packages/shared/src/validators/index.ts | 1 + .../src/__tests__/company-portability.test.ts | 58 +++++ server/src/services/company-portability.ts | 199 ++++++++++++------ ui/src/api/companies.ts | 31 +-- ui/src/components/SidebarAgents.tsx | 38 ++-- ui/src/hooks/useAgentOrder.ts | 104 +++++++++ ui/src/lib/agent-order.ts | 106 ++++++++++ .../lib/company-portability-sidebar.test.ts | 100 +++++++++ ui/src/lib/company-portability-sidebar.ts | 61 ++++++ ui/src/pages/CompanyExport.tsx | 107 +++++++++- ui/src/pages/CompanyImport.tsx | 47 +++++ 15 files changed, 758 insertions(+), 118 deletions(-) create mode 100644 ui/src/hooks/useAgentOrder.ts create mode 100644 ui/src/lib/agent-order.ts create mode 100644 ui/src/lib/company-portability-sidebar.test.ts create mode 100644 ui/src/lib/company-portability-sidebar.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 85494986..47f85d82 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -253,6 +253,7 @@ export type { CompanyPortabilityEnvInput, CompanyPortabilityFileEntry, CompanyPortabilityCompanyManifestEntry, + CompanyPortabilitySidebarOrder, CompanyPortabilityAgentManifestEntry, CompanyPortabilitySkillManifestEntry, CompanyPortabilityProjectManifestEntry, @@ -487,6 +488,7 @@ export { portabilityIncludeSchema, portabilityEnvInputSchema, portabilityCompanyManifestEntrySchema, + portabilitySidebarOrderSchema, portabilityAgentManifestEntrySchema, portabilityManifestSchema, portabilitySourceSchema, diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts index 26400c57..63016e93 100644 --- a/packages/shared/src/types/company-portability.ts +++ b/packages/shared/src/types/company-portability.ts @@ -33,6 +33,11 @@ export interface CompanyPortabilityCompanyManifestEntry { requireBoardApprovalForNewAgents: boolean; } +export interface CompanyPortabilitySidebarOrder { + agents: string[]; + projects: string[]; +} + export interface CompanyPortabilityProjectManifestEntry { slug: string; name: string; @@ -144,6 +149,7 @@ export interface CompanyPortabilityManifest { } | null; includes: CompanyPortabilityInclude; company: CompanyPortabilityCompanyManifestEntry | null; + sidebar: CompanyPortabilitySidebarOrder | null; agents: CompanyPortabilityAgentManifestEntry[]; skills: CompanyPortabilitySkillManifestEntry[]; projects: CompanyPortabilityProjectManifestEntry[]; @@ -279,6 +285,13 @@ export interface CompanyPortabilityImportResult { name: string; reason: string | null; }[]; + projects: { + slug: string; + id: string | null; + action: "created" | "updated" | "skipped"; + name: string; + reason: string | null; + }[]; envInputs: CompanyPortabilityEnvInput[]; warnings: string[]; } @@ -292,4 +305,5 @@ export interface CompanyPortabilityExportRequest { projectIssues?: string[]; selectedFiles?: string[]; expandReferencedSkills?: boolean; + sidebarOrder?: Partial; } diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 028ae787..dd615c4c 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -144,6 +144,7 @@ export type { CompanyPortabilityEnvInput, CompanyPortabilityFileEntry, CompanyPortabilityCompanyManifestEntry, + CompanyPortabilitySidebarOrder, CompanyPortabilityAgentManifestEntry, CompanyPortabilitySkillManifestEntry, CompanyPortabilityProjectManifestEntry, diff --git a/packages/shared/src/validators/company-portability.ts b/packages/shared/src/validators/company-portability.ts index 72359bb8..7cbd4884 100644 --- a/packages/shared/src/validators/company-portability.ts +++ b/packages/shared/src/validators/company-portability.ts @@ -38,6 +38,11 @@ export const portabilityCompanyManifestEntrySchema = z.object({ requireBoardApprovalForNewAgents: z.boolean(), }); +export const portabilitySidebarOrderSchema = z.object({ + agents: z.array(z.string().min(1)).default([]), + projects: z.array(z.string().min(1)).default([]), +}); + export const portabilityAgentManifestEntrySchema = z.object({ slug: z.string().min(1), name: z.string().min(1), @@ -155,6 +160,7 @@ export const portabilityManifestSchema = z.object({ skills: z.boolean(), }), company: portabilityCompanyManifestEntrySchema.nullable(), + sidebar: portabilitySidebarOrderSchema.nullable(), agents: z.array(portabilityAgentManifestEntrySchema), skills: z.array(portabilitySkillManifestEntrySchema).default([]), projects: z.array(portabilityProjectManifestEntrySchema).default([]), @@ -201,6 +207,7 @@ export const companyPortabilityExportSchema = z.object({ projectIssues: z.array(z.string().min(1)).optional(), selectedFiles: z.array(z.string().min(1)).optional(), expandReferencedSkills: z.boolean().optional(), + sidebarOrder: portabilitySidebarOrderSchema.partial().optional(), }); export type CompanyPortabilityExport = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 20a2ce9b..3f33bceb 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -60,6 +60,7 @@ export { portabilityIncludeSchema, portabilityEnvInputSchema, portabilityCompanyManifestEntrySchema, + portabilitySidebarOrderSchema, portabilityAgentManifestEntrySchema, portabilitySkillManifestEntrySchema, portabilityManifestSchema, diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index fdf0f9b9..bf6cfabe 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -440,6 +440,64 @@ describe("company portability", () => { expect(exported.warnings).toContain("Agent claudecoder PATH override was omitted from export because it is system-dependent."); }); + it("exports default sidebar order into the Paperclip extension and manifest", async () => { + const portability = companyPortabilityService({} as any); + + projectSvc.list.mockResolvedValue([ + { + id: "project-2", + companyId: "company-1", + name: "Zulu", + urlKey: "zulu", + description: null, + leadAgentId: null, + targetDate: null, + color: null, + status: "planned", + executionWorkspacePolicy: null, + archivedAt: null, + workspaces: [], + }, + { + id: "project-1", + companyId: "company-1", + name: "Alpha", + urlKey: "alpha", + description: null, + leadAgentId: null, + targetDate: null, + color: null, + status: "planned", + executionWorkspacePolicy: null, + archivedAt: null, + workspaces: [], + }, + ]); + + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: true, + issues: false, + }, + }); + + expect(asTextFile(exported.files[".paperclip.yaml"])).toContain([ + "sidebar:", + " agents:", + ' - "claudecoder"', + ' - "cmo"', + " projects:", + ' - "alpha"', + ' - "zulu"', + ].join("\n")); + expect(exported.manifest.sidebar).toEqual({ + agents: ["claudecoder", "cmo"], + projects: ["alpha", "zulu"], + }); + }); + it("expands referenced skills when requested", async () => { const portability = companyPortabilityService({} as any); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index fb8f2b2e..a1f6a7c8 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -24,6 +24,7 @@ import type { CompanyPortabilityIssueRoutineManifestEntry, CompanyPortabilityIssueRoutineTriggerManifestEntry, CompanyPortabilityIssueManifestEntry, + CompanyPortabilitySidebarOrder, CompanyPortabilitySkillManifestEntry, CompanySkill, } from "@paperclipai/shared"; @@ -1321,76 +1322,100 @@ function collectSelectedExportSlugs(selectedFiles: Set) { return { agents, projects, tasks, routines: new Set(tasks) }; } +function normalizePortableSlugList(value: unknown) { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const normalized: string[] = []; + for (const entry of value) { + if (typeof entry !== "string") continue; + const trimmed = entry.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + normalized.push(trimmed); + } + return normalized; +} + +function normalizePortableSidebarOrder(value: unknown): CompanyPortabilitySidebarOrder | null { + if (!isPlainRecord(value)) return null; + const sidebar = { + agents: normalizePortableSlugList(value.agents), + projects: normalizePortableSlugList(value.projects), + }; + return sidebar.agents.length > 0 || sidebar.projects.length > 0 ? sidebar : null; +} + +function sortAgentsBySidebarOrder(agents: T[]) { + if (agents.length === 0) return []; + + const byId = new Map(agents.map((agent) => [agent.id, agent])); + const childrenOf = new Map(); + for (const agent of agents) { + const parentId = agent.reportsTo && byId.has(agent.reportsTo) ? agent.reportsTo : null; + const siblings = childrenOf.get(parentId) ?? []; + siblings.push(agent); + childrenOf.set(parentId, siblings); + } + + for (const siblings of childrenOf.values()) { + siblings.sort((left, right) => left.name.localeCompare(right.name)); + } + + const sorted: T[] = []; + const queue = [...(childrenOf.get(null) ?? [])]; + while (queue.length > 0) { + const agent = queue.shift(); + if (!agent) continue; + sorted.push(agent); + const children = childrenOf.get(agent.id); + if (children) queue.push(...children); + } + + return sorted; +} + function filterPortableExtensionYaml(yaml: string, selectedFiles: Set) { const selected = collectSelectedExportSlugs(selectedFiles); - const lines = yaml.split("\n"); - const out: string[] = []; - const filterableSections = new Set(["agents", "projects", "tasks", "routines"]); - - let currentSection: string | null = null; - let currentEntry: string | null = null; - let includeEntry = true; - let sectionHeaderLine: string | null = null; - let sectionBuffer: string[] = []; - - const flushSection = () => { - if (sectionHeaderLine !== null && sectionBuffer.length > 0) { - out.push(sectionHeaderLine); - out.push(...sectionBuffer); + const parsed = parseYamlFile(yaml); + for (const section of ["agents", "projects", "tasks", "routines"] as const) { + const sectionValue = parsed[section]; + if (!isPlainRecord(sectionValue)) continue; + const sectionSlugs = selected[section]; + const filteredEntries = Object.fromEntries( + Object.entries(sectionValue).filter(([slug]) => sectionSlugs.has(slug)), + ); + if (Object.keys(filteredEntries).length > 0) { + parsed[section] = filteredEntries; + } else { + delete parsed[section]; } - sectionHeaderLine = null; - sectionBuffer = []; - }; - - for (const line of lines) { - const topMatch = line.match(/^([a-zA-Z_][\w-]*):\s*(.*)$/); - if (topMatch && !line.startsWith(" ")) { - flushSection(); - currentEntry = null; - includeEntry = true; - - const key = topMatch[1]!; - if (filterableSections.has(key)) { - currentSection = key; - sectionHeaderLine = line; - continue; - } - - currentSection = null; - out.push(line); - continue; - } - - if (currentSection && filterableSections.has(currentSection)) { - const entryMatch = line.match(/^ ([\w][\w-]*):\s*(.*)$/); - if (entryMatch && !line.startsWith(" ")) { - const slug = entryMatch[1]!; - currentEntry = slug; - const sectionSlugs = selected[currentSection as keyof typeof selected]; - includeEntry = sectionSlugs.has(slug); - if (includeEntry) sectionBuffer.push(line); - continue; - } - - if (currentEntry !== null) { - if (includeEntry) sectionBuffer.push(line); - continue; - } - - sectionBuffer.push(line); - continue; - } - - out.push(line); } - flushSection(); - let filtered = out.join("\n"); - const logoPathMatch = filtered.match(/^\s{2}logoPath:\s*["']?([^"'\n]+)["']?\s*$/m); - if (logoPathMatch && !selectedFiles.has(logoPathMatch[1]!)) { - filtered = filtered.replace(/^\s{2}logoPath:\s*["']?([^"'\n]+)["']?\s*\n?/m, ""); + const companySection = parsed.company; + if (isPlainRecord(companySection)) { + const logoPath = asString(companySection.logoPath) ?? asString(companySection.logo); + if (logoPath && !selectedFiles.has(logoPath)) { + delete companySection.logoPath; + delete companySection.logo; + } } - return filtered; + + const sidebarOrder = normalizePortableSidebarOrder(parsed.sidebar); + if (sidebarOrder) { + const filteredSidebar = stripEmptyValues({ + agents: sidebarOrder.agents.filter((slug) => selected.agents.has(slug)), + projects: sidebarOrder.projects.filter((slug) => selected.projects.has(slug)), + }); + if (isPlainRecord(filteredSidebar)) { + parsed.sidebar = filteredSidebar; + } else { + delete parsed.sidebar; + } + } else { + delete parsed.sidebar; + } + + return buildYamlFile(parsed, { preserveEmptyStrings: true }); } function filterExportFiles( @@ -2218,6 +2243,7 @@ function buildManifestFromPackageFiles( ? parseYamlFile(readPortableTextFile(normalizedFiles, paperclipExtensionPath) ?? "") : {}; const paperclipCompany = isPlainRecord(paperclipExtension.company) ? paperclipExtension.company : {}; + const paperclipSidebar = normalizePortableSidebarOrder(paperclipExtension.sidebar); const paperclipAgents = isPlainRecord(paperclipExtension.agents) ? paperclipExtension.agents : {}; const paperclipProjects = isPlainRecord(paperclipExtension.projects) ? paperclipExtension.projects : {}; const paperclipTasks = isPlainRecord(paperclipExtension.tasks) ? paperclipExtension.tasks : {}; @@ -2283,6 +2309,7 @@ function buildManifestFromPackageFiles( ? paperclipCompany.requireBoardApprovalForNewAgents : readCompanyApprovalDefault(companyFrontmatter), }, + sidebar: paperclipSidebar, agents: [], skills: [], projects: [], @@ -2711,6 +2738,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const files: Record = {}; const warnings: string[] = []; const envInputs: CompanyPortabilityManifest["envInputs"] = []; + const requestedSidebarOrder = normalizePortableSidebarOrder(input.sidebarOrder); const rootPath = normalizeAgentUrlKey(company.name) ?? "company-package"; let companyLogoPath: string | null = null; @@ -2892,6 +2920,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const baseSlug = deriveProjectUrlKey(project.name, project.name); projectSlugById.set(project.id, uniqueSlug(baseSlug, usedProjectSlugs)); } + const sidebarOrder = requestedSidebarOrder ?? stripEmptyValues({ + agents: sortAgentsBySidebarOrder(Array.from(selectedAgents.values())) + .map((agent) => idToSlug.get(agent.id)) + .filter((slug): slug is string => Boolean(slug)), + projects: selectedProjectRows + .map((project) => projectSlugById.get(project.id)) + .filter((slug): slug is string => Boolean(slug)), + }); const companyPath = "COMPANY.md"; files[companyPath] = buildMarkdown( @@ -3190,6 +3226,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { logoPath: companyLogoPath, requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents ? undefined : false, }), + sidebar: stripEmptyValues(sidebarOrder), agents: Object.keys(paperclipAgents).length > 0 ? paperclipAgents : undefined, projects: Object.keys(paperclipProjects).length > 0 ? paperclipProjects : undefined, tasks: Object.keys(paperclipTasks).length > 0 ? paperclipTasks : undefined, @@ -3772,6 +3809,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } const resultAgents: CompanyPortabilityImportResult["agents"] = []; + const resultProjects: CompanyPortabilityImportResult["projects"] = []; const importedSlugToAgentId = new Map(); const existingSlugToAgentId = new Map(); const existingAgents = await agents.list(targetCompany.id); @@ -3951,7 +3989,16 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { for (const planProject of plan.preview.plan.projectPlans) { const manifestProject = sourceManifest.projects.find((project) => project.slug === planProject.slug); if (!manifestProject) continue; - if (planProject.action === "skip") continue; + if (planProject.action === "skip") { + resultProjects.push({ + slug: planProject.slug, + id: planProject.existingProjectId, + action: "skipped", + name: planProject.plannedName, + reason: planProject.reason, + }); + continue; + } const projectLeadAgentId = manifestProject.leadAgentSlug ? importedSlugToAgentId.get(manifestProject.leadAgentSlug) @@ -3976,16 +4023,37 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const updated = await projects.update(planProject.existingProjectId, projectPatch); if (!updated) { warnings.push(`Skipped update for missing project ${planProject.existingProjectId}.`); + resultProjects.push({ + slug: planProject.slug, + id: null, + action: "skipped", + name: planProject.plannedName, + reason: "Existing target project not found.", + }); continue; } projectId = updated.id; importedSlugToProjectId.set(planProject.slug, updated.id); existingProjectSlugToId.set(updated.urlKey, updated.id); + resultProjects.push({ + slug: planProject.slug, + id: updated.id, + action: "updated", + name: updated.name, + reason: planProject.reason, + }); } else { const created = await projects.create(targetCompany.id, projectPatch); projectId = created.id; importedSlugToProjectId.set(planProject.slug, created.id); existingProjectSlugToId.set(created.urlKey, created.id); + resultProjects.push({ + slug: planProject.slug, + id: created.id, + action: "created", + name: created.name, + reason: planProject.reason, + }); } if (!projectId) continue; @@ -4154,6 +4222,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { action: companyAction, }, agents: resultAgents, + projects: resultProjects, envInputs: sourceManifest.envInputs ?? [], warnings, }; diff --git a/ui/src/api/companies.ts b/ui/src/api/companies.ts index 60a41742..82d2e54e 100644 --- a/ui/src/api/companies.ts +++ b/ui/src/api/companies.ts @@ -1,5 +1,6 @@ import type { Company, + CompanyPortabilityExportRequest, CompanyPortabilityExportPreviewResult, CompanyPortabilityExportResult, CompanyPortabilityImportRequest, @@ -37,41 +38,17 @@ export const companiesApi = { remove: (companyId: string) => api.delete<{ ok: true }>(`/companies/${companyId}`), exportBundle: ( companyId: string, - data: { - include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean }; - agents?: string[]; - skills?: string[]; - projects?: string[]; - issues?: string[]; - projectIssues?: string[]; - selectedFiles?: string[]; - }, + data: CompanyPortabilityExportRequest, ) => api.post(`/companies/${companyId}/export`, data), exportPreview: ( companyId: string, - data: { - include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean }; - agents?: string[]; - skills?: string[]; - projects?: string[]; - issues?: string[]; - projectIssues?: string[]; - selectedFiles?: string[]; - }, + data: CompanyPortabilityExportRequest, ) => api.post(`/companies/${companyId}/exports/preview`, data), exportPackage: ( companyId: string, - data: { - include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean }; - agents?: string[]; - skills?: string[]; - projects?: string[]; - issues?: string[]; - projectIssues?: string[]; - selectedFiles?: string[]; - }, + data: CompanyPortabilityExportRequest, ) => api.post(`/companies/${companyId}/exports`, data), importPreview: (data: CompanyPortabilityPreviewRequest) => diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx index 0a7e8c18..0b7c7d95 100644 --- a/ui/src/components/SidebarAgents.tsx +++ b/ui/src/components/SidebarAgents.tsx @@ -6,9 +6,11 @@ import { useCompany } from "../context/CompanyContext"; import { useDialog } from "../context/DialogContext"; import { useSidebar } from "../context/SidebarContext"; import { agentsApi } from "../api/agents"; +import { authApi } from "../api/auth"; import { heartbeatsApi } from "../api/heartbeats"; import { queryKeys } from "../lib/queryKeys"; import { cn, agentRouteRef, agentUrl } from "../lib/utils"; +import { useAgentOrder } from "../hooks/useAgentOrder"; import { AgentIcon } from "./AgentIconPicker"; import { BudgetSidebarMarker } from "./BudgetSidebarMarker"; import { @@ -17,28 +19,6 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible"; import type { Agent } from "@paperclipai/shared"; - -/** BFS sort: roots first (no reportsTo), then their direct reports, etc. */ -function sortByHierarchy(agents: Agent[]): Agent[] { - const byId = new Map(agents.map((a) => [a.id, a])); - const childrenOf = new Map(); - for (const a of agents) { - const parent = a.reportsTo && byId.has(a.reportsTo) ? a.reportsTo : null; - const list = childrenOf.get(parent) ?? []; - list.push(a); - childrenOf.set(parent, list); - } - const sorted: Agent[] = []; - const queue = childrenOf.get(null) ?? []; - while (queue.length > 0) { - const agent = queue.shift()!; - sorted.push(agent); - const children = childrenOf.get(agent.id); - if (children) queue.push(...children); - } - return sorted; -} - export function SidebarAgents() { const [open, setOpen] = useState(true); const { selectedCompanyId } = useCompany(); @@ -51,6 +31,10 @@ export function SidebarAgents() { queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); + const { data: session } = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + }); const { data: liveRuns } = useQuery({ queryKey: queryKeys.liveRuns(selectedCompanyId!), @@ -71,8 +55,14 @@ export function SidebarAgents() { const filtered = (agents ?? []).filter( (a: Agent) => a.status !== "terminated" ); - return sortByHierarchy(filtered); + return filtered; }, [agents]); + const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; + const { orderedAgents } = useAgentOrder({ + agents: visibleAgents, + companyId: selectedCompanyId, + userId: currentUserId, + }); const agentMatch = location.pathname.match(/^\/(?:[^/]+\/)?agents\/([^/]+)(?:\/([^/]+))?/); const activeAgentId = agentMatch?.[1] ?? null; @@ -109,7 +99,7 @@ export function SidebarAgents() {
- {visibleAgents.map((agent: Agent) => { + {orderedAgents.map((agent: Agent) => { const runCount = liveCountByAgent.get(agent.id) ?? 0; return ( agent.id); +} + +export function useAgentOrder({ agents, companyId, userId }: UseAgentOrderParams) { + const storageKey = useMemo(() => { + if (!companyId) return null; + return getAgentOrderStorageKey(companyId, userId); + }, [companyId, userId]); + + const [orderedIds, setOrderedIds] = useState(() => { + if (!storageKey) return agents.map((agent) => agent.id); + return buildOrderIds(agents, readAgentOrder(storageKey)); + }); + + useEffect(() => { + const nextIds = storageKey + ? buildOrderIds(agents, readAgentOrder(storageKey)) + : agents.map((agent) => agent.id); + setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds)); + }, [agents, storageKey]); + + useEffect(() => { + if (!storageKey) return; + + const syncFromIds = (ids: string[]) => { + const nextIds = buildOrderIds(agents, ids); + setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds)); + }; + + const onStorage = (event: StorageEvent) => { + if (event.key !== storageKey) return; + syncFromIds(readAgentOrder(storageKey)); + }; + const onCustomEvent = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!detail || detail.storageKey !== storageKey) return; + syncFromIds(detail.orderedIds); + }; + + window.addEventListener("storage", onStorage); + window.addEventListener(AGENT_ORDER_UPDATED_EVENT, onCustomEvent); + return () => { + window.removeEventListener("storage", onStorage); + window.removeEventListener(AGENT_ORDER_UPDATED_EVENT, onCustomEvent); + }; + }, [agents, storageKey]); + + const orderedAgents = useMemo( + () => sortAgentsByStoredOrder(agents, orderedIds), + [agents, orderedIds], + ); + + const persistOrder = useCallback( + (ids: string[]) => { + const idSet = new Set(agents.map((agent) => agent.id)); + const filtered = ids.filter((id) => idSet.has(id)); + for (const agent of sortAgentsByStoredOrder(agents, [])) { + if (!filtered.includes(agent.id)) filtered.push(agent.id); + } + + setOrderedIds((current) => (areEqual(current, filtered) ? current : filtered)); + if (storageKey) { + writeAgentOrder(storageKey, filtered); + } + }, + [agents, storageKey], + ); + + return { + orderedAgents, + orderedIds, + persistOrder, + }; +} diff --git a/ui/src/lib/agent-order.ts b/ui/src/lib/agent-order.ts new file mode 100644 index 00000000..831624df --- /dev/null +++ b/ui/src/lib/agent-order.ts @@ -0,0 +1,106 @@ +import type { Agent } from "@paperclipai/shared"; + +export const AGENT_ORDER_UPDATED_EVENT = "paperclip:agent-order-updated"; +const AGENT_ORDER_STORAGE_PREFIX = "paperclip.agentOrder"; +const ANONYMOUS_USER_ID = "anonymous"; + +type AgentOrderUpdatedDetail = { + storageKey: string; + orderedIds: string[]; +}; + +function normalizeIdList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string" && item.length > 0); +} + +function resolveUserId(userId: string | null | undefined): string { + if (!userId) return ANONYMOUS_USER_ID; + const trimmed = userId.trim(); + return trimmed.length > 0 ? trimmed : ANONYMOUS_USER_ID; +} + +export function getAgentOrderStorageKey(companyId: string, userId: string | null | undefined): string { + return `${AGENT_ORDER_STORAGE_PREFIX}:${companyId}:${resolveUserId(userId)}`; +} + +export function readAgentOrder(storageKey: string): string[] { + try { + const raw = localStorage.getItem(storageKey); + if (!raw) return []; + return normalizeIdList(JSON.parse(raw)); + } catch { + return []; + } +} + +export function writeAgentOrder(storageKey: string, orderedIds: string[]) { + const normalized = normalizeIdList(orderedIds); + try { + localStorage.setItem(storageKey, JSON.stringify(normalized)); + } catch { + // Ignore storage write failures in restricted browser contexts. + } + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent(AGENT_ORDER_UPDATED_EVENT, { + detail: { storageKey, orderedIds: normalized }, + }), + ); + } +} + +export function sortAgentsByDefaultSidebarOrder(agents: Agent[]): Agent[] { + if (agents.length === 0) return []; + + const byId = new Map(agents.map((agent) => [agent.id, agent])); + const childrenOf = new Map(); + for (const agent of agents) { + const parentId = agent.reportsTo && byId.has(agent.reportsTo) ? agent.reportsTo : null; + const siblings = childrenOf.get(parentId) ?? []; + siblings.push(agent); + childrenOf.set(parentId, siblings); + } + + for (const siblings of childrenOf.values()) { + siblings.sort((left, right) => left.name.localeCompare(right.name)); + } + + const sorted: Agent[] = []; + const queue = [...(childrenOf.get(null) ?? [])]; + while (queue.length > 0) { + const agent = queue.shift(); + if (!agent) continue; + sorted.push(agent); + const children = childrenOf.get(agent.id); + if (children) queue.push(...children); + } + + return sorted; +} + +export function sortAgentsByStoredOrder(agents: Agent[], orderedIds: string[]): Agent[] { + if (agents.length === 0) return []; + + const defaultSorted = sortAgentsByDefaultSidebarOrder(agents); + if (orderedIds.length === 0) return defaultSorted; + + const byId = new Map(defaultSorted.map((agent) => [agent.id, agent])); + const sorted: Agent[] = []; + + for (const id of orderedIds) { + const agent = byId.get(id); + if (!agent) continue; + sorted.push(agent); + byId.delete(id); + } + + for (const agent of defaultSorted) { + if (byId.has(agent.id)) { + sorted.push(agent); + byId.delete(agent.id); + } + } + + return sorted; +} diff --git a/ui/src/lib/company-portability-sidebar.test.ts b/ui/src/lib/company-portability-sidebar.test.ts new file mode 100644 index 00000000..1bc7b06d --- /dev/null +++ b/ui/src/lib/company-portability-sidebar.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import type { Agent, Project } from "@paperclipai/shared"; +import { + buildPortableAgentSlugMap, + buildPortableProjectSlugMap, + buildPortableSidebarOrder, +} from "./company-portability-sidebar"; + +function makeAgent(id: string, name: string): Agent { + return { + id, + companyId: "company-1", + name, + role: "engineer", + title: null, + icon: null, + status: "idle", + reportsTo: null, + capabilities: null, + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + pauseReason: null, + pausedAt: null, + permissions: { canCreateAgents: false }, + lastHeartbeatAt: null, + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + urlKey: name.toLowerCase(), + }; +} + +function makeProject(id: string, name: string): Project { + return { + id, + companyId: "company-1", + goalId: null, + urlKey: name.toLowerCase(), + name, + description: null, + status: "planned", + leadAgentId: null, + targetDate: null, + color: null, + pauseReason: null, + pausedAt: null, + executionWorkspacePolicy: null, + archivedAt: null, + goalIds: [], + goals: [], + primaryWorkspace: null, + workspaces: [], + codebase: { + workspaceId: null, + repoUrl: null, + repoRef: null, + defaultRef: null, + repoName: null, + localFolder: null, + managedFolder: "/tmp/managed", + effectiveLocalFolder: "/tmp/managed", + origin: "managed_checkout", + }, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +describe("company portability sidebar order", () => { + it("uses the same unique slug allocation as export and preserves the requested order", () => { + const alphaOne = makeAgent("agent-1", "Alpha"); + const alphaTwo = makeAgent("agent-2", "Alpha"); + const beta = makeAgent("agent-3", "Beta"); + const launch = makeProject("project-1", "Launch"); + const launchTwo = makeProject("project-2", "Launch"); + + expect(Array.from(buildPortableAgentSlugMap([alphaOne, alphaTwo, beta]).entries())).toEqual([ + ["agent-1", "alpha"], + ["agent-2", "alpha-2"], + ["agent-3", "beta"], + ]); + expect(Array.from(buildPortableProjectSlugMap([launch, launchTwo]).entries())).toEqual([ + ["project-1", "launch"], + ["project-2", "launch-2"], + ]); + + expect(buildPortableSidebarOrder({ + agents: [alphaOne, alphaTwo, beta], + orderedAgents: [beta, alphaTwo, alphaOne], + projects: [launch, launchTwo], + orderedProjects: [launchTwo, launch], + })).toEqual({ + agents: ["beta", "alpha-2", "alpha"], + projects: ["launch-2", "launch"], + }); + }); +}); diff --git a/ui/src/lib/company-portability-sidebar.ts b/ui/src/lib/company-portability-sidebar.ts new file mode 100644 index 00000000..7790086d --- /dev/null +++ b/ui/src/lib/company-portability-sidebar.ts @@ -0,0 +1,61 @@ +import type { Agent, CompanyPortabilitySidebarOrder, Project } from "@paperclipai/shared"; +import { deriveProjectUrlKey, normalizeAgentUrlKey } from "@paperclipai/shared"; + +function uniqueSlug(base: string, used: Set) { + if (!used.has(base)) { + used.add(base); + return base; + } + + let index = 2; + while (true) { + const candidate = `${base}-${index}`; + if (!used.has(candidate)) { + used.add(candidate); + return candidate; + } + index += 1; + } +} + +export function buildPortableAgentSlugMap(agents: Agent[]): Map { + const usedSlugs = new Set(); + const byId = new Map(); + const sortedAgents = [...agents].sort((left, right) => left.name.localeCompare(right.name)); + + for (const agent of sortedAgents) { + const baseSlug = normalizeAgentUrlKey(agent.name) ?? "agent"; + byId.set(agent.id, uniqueSlug(baseSlug, usedSlugs)); + } + + return byId; +} + +export function buildPortableProjectSlugMap(projects: Project[]): Map { + const usedSlugs = new Set(); + const byId = new Map(); + const sortedProjects = [...projects].sort((left, right) => left.name.localeCompare(right.name)); + + for (const project of sortedProjects) { + const baseSlug = deriveProjectUrlKey(project.name, project.name); + byId.set(project.id, uniqueSlug(baseSlug, usedSlugs)); + } + + return byId; +} + +export function buildPortableSidebarOrder(input: { + agents: Agent[]; + orderedAgents: Agent[]; + projects: Project[]; + orderedProjects: Project[]; +}): CompanyPortabilitySidebarOrder | undefined { + const agentSlugById = buildPortableAgentSlugMap(input.agents); + const projectSlugById = buildPortableProjectSlugMap(input.projects); + const sidebar = { + agents: input.orderedAgents.map((agent) => agentSlugById.get(agent.id)).filter((slug): slug is string => Boolean(slug)), + projects: input.orderedProjects.map((project) => projectSlugById.get(project.id)).filter((slug): slug is string => Boolean(slug)), + }; + + return sidebar.agents.length > 0 || sidebar.projects.length > 0 ? sidebar : undefined; +} diff --git a/ui/src/pages/CompanyExport.tsx b/ui/src/pages/CompanyExport.tsx index 298785d9..e82aeafb 100644 --- a/ui/src/pages/CompanyExport.tsx +++ b/ui/src/pages/CompanyExport.tsx @@ -1,23 +1,32 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import type { + Agent, CompanyPortabilityFileEntry, CompanyPortabilityExportPreviewResult, CompanyPortabilityExportResult, CompanyPortabilityManifest, + Project, } from "@paperclipai/shared"; import { useNavigate, useLocation } from "@/lib/router"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToast } from "../context/ToastContext"; +import { agentsApi } from "../api/agents"; +import { authApi } from "../api/auth"; import { companiesApi } from "../api/companies"; +import { projectsApi } from "../api/projects"; import { Button } from "@/components/ui/button"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { MarkdownBody } from "../components/MarkdownBody"; import { cn } from "../lib/utils"; +import { queryKeys } from "../lib/queryKeys"; import { createZipArchive } from "../lib/zip"; import { buildInitialExportCheckedFiles } from "../lib/company-export-selection"; +import { useAgentOrder } from "../hooks/useAgentOrder"; +import { useProjectOrder } from "../hooks/useProjectOrder"; +import { buildPortableSidebarOrder } from "../lib/company-portability-sidebar"; import { getPortableFileDataUrl, getPortableFileText, isPortableImageFile } from "../lib/portable-files"; import { Download, @@ -75,15 +84,29 @@ function filterPaperclipYaml(yaml: string, checkedFiles: Set): string { // Sections whose entries are slug-keyed and should be filtered const filterableSections = new Set(["agents", "projects", "tasks", "routines"]); + const sidebarSections = new Set(["agents", "projects"]); let currentSection: string | null = null; // top-level key (e.g. "agents") let currentEntry: string | null = null; // slug under that section let includeEntry = true; + let currentSidebarList: string | null = null; + let currentSidebarHeaderLine: string | null = null; + let currentSidebarBuffer: string[] = []; // Collect entries per section so we can omit empty section headers let sectionHeaderLine: string | null = null; let sectionBuffer: string[] = []; + function flushSidebarSection() { + if (currentSidebarHeaderLine !== null && currentSidebarBuffer.length > 0) { + sectionBuffer.push(currentSidebarHeaderLine); + sectionBuffer.push(...currentSidebarBuffer); + } + currentSidebarHeaderLine = null; + currentSidebarBuffer = []; + } + function flushSection() { + flushSidebarSection(); if (sectionHeaderLine !== null && sectionBuffer.length > 0) { out.push(sectionHeaderLine); out.push(...sectionBuffer); @@ -106,6 +129,11 @@ function filterPaperclipYaml(yaml: string, checkedFiles: Set): string { currentSection = key; sectionHeaderLine = line; continue; + } else if (key === "sidebar") { + currentSection = key; + currentSidebarList = null; + sectionHeaderLine = line; + continue; } else { currentSection = null; out.push(line); @@ -113,6 +141,32 @@ function filterPaperclipYaml(yaml: string, checkedFiles: Set): string { } } + if (currentSection === "sidebar") { + const sidebarMatch = line.match(/^ ([\w-]+):\s*$/); + if (sidebarMatch && !line.startsWith(" ")) { + flushSidebarSection(); + const sidebarKey = sidebarMatch[1]; + currentSidebarList = sidebarKey && sidebarSections.has(sidebarKey) ? sidebarKey : null; + currentSidebarHeaderLine = currentSidebarList ? line : null; + continue; + } + + const sidebarEntryMatch = line.match(/^ - ["']?([^"'\n]+)["']?\s*$/); + if (sidebarEntryMatch && currentSidebarList) { + const slug = sidebarEntryMatch[1]; + const sectionSlugs = slugs[currentSidebarList as keyof typeof slugs]; + if (slug && sectionSlugs.has(slug)) { + currentSidebarBuffer.push(line); + } + continue; + } + + if (currentSidebarList) { + currentSidebarBuffer.push(line); + continue; + } + } + // Inside a filterable section if (currentSection && filterableSections.has(currentSection)) { // 2-space indented key = entry slug (slugs may start with digits/hyphens) @@ -529,6 +583,20 @@ export function CompanyExport() { const { pushToast } = useToast(); const navigate = useNavigate(); const location = useLocation(); + const { data: session, isFetched: isSessionFetched } = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + }); + const { data: agents = [], isFetched: areAgentsFetched } = useQuery({ + queryKey: queryKeys.agents.list(selectedCompanyId!), + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + const { data: projects = [], isFetched: areProjectsFetched } = useQuery({ + queryKey: queryKeys.projects.list(selectedCompanyId!), + queryFn: () => projectsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); const [exportData, setExportData] = useState(null); const [selectedFile, setSelectedFile] = useState(null); @@ -538,6 +606,38 @@ export function CompanyExport() { const [taskLimit, setTaskLimit] = useState(TASKS_PAGE_SIZE); const savedExpandedRef = useRef | null>(null); const initialFileFromUrl = useRef(filePathFromLocation(location.pathname)); + const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; + const visibleAgents = useMemo( + () => agents.filter((agent: Agent) => agent.status !== "terminated"), + [agents], + ); + const visibleProjects = useMemo( + () => projects.filter((project: Project) => !project.archivedAt), + [projects], + ); + const { orderedAgents } = useAgentOrder({ + agents: visibleAgents, + companyId: selectedCompanyId, + userId: currentUserId, + }); + const { orderedProjects } = useProjectOrder({ + projects: visibleProjects, + companyId: selectedCompanyId, + userId: currentUserId, + }); + const sidebarOrder = useMemo( + () => buildPortableSidebarOrder({ + agents: visibleAgents, + orderedAgents, + projects: visibleProjects, + orderedProjects, + }), + [orderedAgents, orderedProjects, visibleAgents, visibleProjects], + ); + const sidebarOrderKey = useMemo( + () => JSON.stringify(sidebarOrder ?? null), + [sidebarOrder], + ); // Navigate-aware file selection: updates state + URL without page reload. // `replace` = true skips history entry (used for initial load); false = pushes (used for clicks). @@ -581,6 +681,7 @@ export function CompanyExport() { mutationFn: () => companiesApi.exportPreview(selectedCompanyId!, { include: { company: true, agents: true, projects: true, issues: true }, + sidebarOrder, }), onSuccess: (result) => { setExportData(result); @@ -629,6 +730,7 @@ export function CompanyExport() { companiesApi.exportPackage(selectedCompanyId!, { include: { company: true, agents: true, projects: true, issues: true }, selectedFiles: Array.from(checkedFiles).sort(), + sidebarOrder, }), onSuccess: (result) => { const resultCheckedFiles = new Set(Object.keys(result.files)); @@ -650,10 +752,11 @@ export function CompanyExport() { useEffect(() => { if (!selectedCompanyId || exportPreviewMutation.isPending) return; + if (!isSessionFetched || !areAgentsFetched || !areProjectsFetched) return; setExportData(null); exportPreviewMutation.mutate(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedCompanyId]); + }, [selectedCompanyId, isSessionFetched, areAgentsFetched, areProjectsFetched, sidebarOrderKey]); const tree = useMemo( () => (exportData ? buildFileTree(exportData.files) : []), diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index c185615d..d8108f61 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -10,9 +10,12 @@ import type { import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToast } from "../context/ToastContext"; +import { authApi } from "../api/auth"; import { companiesApi } from "../api/companies"; import { agentsApi } from "../api/agents"; import { queryKeys } from "../lib/queryKeys"; +import { getAgentOrderStorageKey, writeAgentOrder } from "../lib/agent-order"; +import { getProjectOrderStorageKey, writeProjectOrder } from "../lib/project-order"; import { MarkdownBody } from "../components/MarkdownBody"; import { Button } from "@/components/ui/button"; import { EmptyState } from "../components/EmptyState"; @@ -342,6 +345,44 @@ function prefixedName(prefix: string | null, originalName: string): string { return `${prefix}-${originalName}`; } +function applyImportedSidebarOrder( + preview: CompanyPortabilityPreviewResult | null, + result: { + company: { id: string }; + agents: Array<{ slug: string; id: string | null }>; + projects: Array<{ slug: string; id: string | null }>; + }, + userId: string | null | undefined, +) { + const sidebar = preview?.manifest.sidebar; + if (!sidebar) return; + + const agentIdBySlug = new Map( + result.agents + .filter((agent): agent is { slug: string; id: string } => typeof agent.id === "string" && agent.id.length > 0) + .map((agent) => [agent.slug, agent.id]), + ); + const projectIdBySlug = new Map( + result.projects + .filter((project): project is { slug: string; id: string } => typeof project.id === "string" && project.id.length > 0) + .map((project) => [project.slug, project.id]), + ); + + const orderedAgentIds = sidebar.agents + .map((slug) => agentIdBySlug.get(slug)) + .filter((id): id is string => Boolean(id)); + const orderedProjectIds = sidebar.projects + .map((slug) => projectIdBySlug.get(slug)) + .filter((id): id is string => Boolean(id)); + + if (orderedAgentIds.length > 0) { + writeAgentOrder(getAgentOrderStorageKey(result.company.id, userId), orderedAgentIds); + } + if (orderedProjectIds.length > 0) { + writeProjectOrder(getProjectOrderStorageKey(result.company.id, userId), orderedProjectIds); + } +} + // ── Conflict resolution UI ─────────────────────────────────────────── function ConflictResolutionList({ @@ -611,6 +652,11 @@ export function CompanyImport() { const { pushToast } = useToast(); const queryClient = useQueryClient(); const packageInputRef = useRef(null); + const { data: session } = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + }); + const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; // Source state const [sourceMode, setSourceMode] = useState<"github" | "local">("github"); @@ -800,6 +846,7 @@ export function CompanyImport() { onSuccess: async (result) => { await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); const importedCompany = await companiesApi.get(result.company.id); + applyImportedSidebarOrder(importPreview, result, currentUserId); setSelectedCompanyId(importedCompany.id); pushToast({ tone: "success",