Preserve sidebar order in company portability
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
b5fde733b0
commit
159c5b4360
15 changed files with 758 additions and 118 deletions
|
|
@ -253,6 +253,7 @@ export type {
|
||||||
CompanyPortabilityEnvInput,
|
CompanyPortabilityEnvInput,
|
||||||
CompanyPortabilityFileEntry,
|
CompanyPortabilityFileEntry,
|
||||||
CompanyPortabilityCompanyManifestEntry,
|
CompanyPortabilityCompanyManifestEntry,
|
||||||
|
CompanyPortabilitySidebarOrder,
|
||||||
CompanyPortabilityAgentManifestEntry,
|
CompanyPortabilityAgentManifestEntry,
|
||||||
CompanyPortabilitySkillManifestEntry,
|
CompanyPortabilitySkillManifestEntry,
|
||||||
CompanyPortabilityProjectManifestEntry,
|
CompanyPortabilityProjectManifestEntry,
|
||||||
|
|
@ -487,6 +488,7 @@ export {
|
||||||
portabilityIncludeSchema,
|
portabilityIncludeSchema,
|
||||||
portabilityEnvInputSchema,
|
portabilityEnvInputSchema,
|
||||||
portabilityCompanyManifestEntrySchema,
|
portabilityCompanyManifestEntrySchema,
|
||||||
|
portabilitySidebarOrderSchema,
|
||||||
portabilityAgentManifestEntrySchema,
|
portabilityAgentManifestEntrySchema,
|
||||||
portabilityManifestSchema,
|
portabilityManifestSchema,
|
||||||
portabilitySourceSchema,
|
portabilitySourceSchema,
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,11 @@ export interface CompanyPortabilityCompanyManifestEntry {
|
||||||
requireBoardApprovalForNewAgents: boolean;
|
requireBoardApprovalForNewAgents: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CompanyPortabilitySidebarOrder {
|
||||||
|
agents: string[];
|
||||||
|
projects: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface CompanyPortabilityProjectManifestEntry {
|
export interface CompanyPortabilityProjectManifestEntry {
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -144,6 +149,7 @@ export interface CompanyPortabilityManifest {
|
||||||
} | null;
|
} | null;
|
||||||
includes: CompanyPortabilityInclude;
|
includes: CompanyPortabilityInclude;
|
||||||
company: CompanyPortabilityCompanyManifestEntry | null;
|
company: CompanyPortabilityCompanyManifestEntry | null;
|
||||||
|
sidebar: CompanyPortabilitySidebarOrder | null;
|
||||||
agents: CompanyPortabilityAgentManifestEntry[];
|
agents: CompanyPortabilityAgentManifestEntry[];
|
||||||
skills: CompanyPortabilitySkillManifestEntry[];
|
skills: CompanyPortabilitySkillManifestEntry[];
|
||||||
projects: CompanyPortabilityProjectManifestEntry[];
|
projects: CompanyPortabilityProjectManifestEntry[];
|
||||||
|
|
@ -279,6 +285,13 @@ export interface CompanyPortabilityImportResult {
|
||||||
name: string;
|
name: string;
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
}[];
|
}[];
|
||||||
|
projects: {
|
||||||
|
slug: string;
|
||||||
|
id: string | null;
|
||||||
|
action: "created" | "updated" | "skipped";
|
||||||
|
name: string;
|
||||||
|
reason: string | null;
|
||||||
|
}[];
|
||||||
envInputs: CompanyPortabilityEnvInput[];
|
envInputs: CompanyPortabilityEnvInput[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
}
|
}
|
||||||
|
|
@ -292,4 +305,5 @@ export interface CompanyPortabilityExportRequest {
|
||||||
projectIssues?: string[];
|
projectIssues?: string[];
|
||||||
selectedFiles?: string[];
|
selectedFiles?: string[];
|
||||||
expandReferencedSkills?: boolean;
|
expandReferencedSkills?: boolean;
|
||||||
|
sidebarOrder?: Partial<CompanyPortabilitySidebarOrder>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,7 @@ export type {
|
||||||
CompanyPortabilityEnvInput,
|
CompanyPortabilityEnvInput,
|
||||||
CompanyPortabilityFileEntry,
|
CompanyPortabilityFileEntry,
|
||||||
CompanyPortabilityCompanyManifestEntry,
|
CompanyPortabilityCompanyManifestEntry,
|
||||||
|
CompanyPortabilitySidebarOrder,
|
||||||
CompanyPortabilityAgentManifestEntry,
|
CompanyPortabilityAgentManifestEntry,
|
||||||
CompanyPortabilitySkillManifestEntry,
|
CompanyPortabilitySkillManifestEntry,
|
||||||
CompanyPortabilityProjectManifestEntry,
|
CompanyPortabilityProjectManifestEntry,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,11 @@ export const portabilityCompanyManifestEntrySchema = z.object({
|
||||||
requireBoardApprovalForNewAgents: z.boolean(),
|
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({
|
export const portabilityAgentManifestEntrySchema = z.object({
|
||||||
slug: z.string().min(1),
|
slug: z.string().min(1),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
|
|
@ -155,6 +160,7 @@ export const portabilityManifestSchema = z.object({
|
||||||
skills: z.boolean(),
|
skills: z.boolean(),
|
||||||
}),
|
}),
|
||||||
company: portabilityCompanyManifestEntrySchema.nullable(),
|
company: portabilityCompanyManifestEntrySchema.nullable(),
|
||||||
|
sidebar: portabilitySidebarOrderSchema.nullable(),
|
||||||
agents: z.array(portabilityAgentManifestEntrySchema),
|
agents: z.array(portabilityAgentManifestEntrySchema),
|
||||||
skills: z.array(portabilitySkillManifestEntrySchema).default([]),
|
skills: z.array(portabilitySkillManifestEntrySchema).default([]),
|
||||||
projects: z.array(portabilityProjectManifestEntrySchema).default([]),
|
projects: z.array(portabilityProjectManifestEntrySchema).default([]),
|
||||||
|
|
@ -201,6 +207,7 @@ export const companyPortabilityExportSchema = z.object({
|
||||||
projectIssues: z.array(z.string().min(1)).optional(),
|
projectIssues: z.array(z.string().min(1)).optional(),
|
||||||
selectedFiles: z.array(z.string().min(1)).optional(),
|
selectedFiles: z.array(z.string().min(1)).optional(),
|
||||||
expandReferencedSkills: z.boolean().optional(),
|
expandReferencedSkills: z.boolean().optional(),
|
||||||
|
sidebarOrder: portabilitySidebarOrderSchema.partial().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CompanyPortabilityExport = z.infer<typeof companyPortabilityExportSchema>;
|
export type CompanyPortabilityExport = z.infer<typeof companyPortabilityExportSchema>;
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ export {
|
||||||
portabilityIncludeSchema,
|
portabilityIncludeSchema,
|
||||||
portabilityEnvInputSchema,
|
portabilityEnvInputSchema,
|
||||||
portabilityCompanyManifestEntrySchema,
|
portabilityCompanyManifestEntrySchema,
|
||||||
|
portabilitySidebarOrderSchema,
|
||||||
portabilityAgentManifestEntrySchema,
|
portabilityAgentManifestEntrySchema,
|
||||||
portabilitySkillManifestEntrySchema,
|
portabilitySkillManifestEntrySchema,
|
||||||
portabilityManifestSchema,
|
portabilityManifestSchema,
|
||||||
|
|
|
||||||
|
|
@ -440,6 +440,64 @@ describe("company portability", () => {
|
||||||
expect(exported.warnings).toContain("Agent claudecoder PATH override was omitted from export because it is system-dependent.");
|
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 () => {
|
it("expands referenced skills when requested", async () => {
|
||||||
const portability = companyPortabilityService({} as any);
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import type {
|
||||||
CompanyPortabilityIssueRoutineManifestEntry,
|
CompanyPortabilityIssueRoutineManifestEntry,
|
||||||
CompanyPortabilityIssueRoutineTriggerManifestEntry,
|
CompanyPortabilityIssueRoutineTriggerManifestEntry,
|
||||||
CompanyPortabilityIssueManifestEntry,
|
CompanyPortabilityIssueManifestEntry,
|
||||||
|
CompanyPortabilitySidebarOrder,
|
||||||
CompanyPortabilitySkillManifestEntry,
|
CompanyPortabilitySkillManifestEntry,
|
||||||
CompanySkill,
|
CompanySkill,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
|
|
@ -1321,76 +1322,100 @@ function collectSelectedExportSlugs(selectedFiles: Set<string>) {
|
||||||
return { agents, projects, tasks, routines: new Set(tasks) };
|
return { agents, projects, tasks, routines: new Set(tasks) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePortableSlugList(value: unknown) {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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<T extends { id: string; name: string; reportsTo: string | null }>(agents: T[]) {
|
||||||
|
if (agents.length === 0) return [];
|
||||||
|
|
||||||
|
const byId = new Map(agents.map((agent) => [agent.id, agent]));
|
||||||
|
const childrenOf = new Map<string | null, T[]>();
|
||||||
|
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<string>) {
|
function filterPortableExtensionYaml(yaml: string, selectedFiles: Set<string>) {
|
||||||
const selected = collectSelectedExportSlugs(selectedFiles);
|
const selected = collectSelectedExportSlugs(selectedFiles);
|
||||||
const lines = yaml.split("\n");
|
const parsed = parseYamlFile(yaml);
|
||||||
const out: string[] = [];
|
for (const section of ["agents", "projects", "tasks", "routines"] as const) {
|
||||||
const filterableSections = new Set(["agents", "projects", "tasks", "routines"]);
|
const sectionValue = parsed[section];
|
||||||
|
if (!isPlainRecord(sectionValue)) continue;
|
||||||
let currentSection: string | null = null;
|
const sectionSlugs = selected[section];
|
||||||
let currentEntry: string | null = null;
|
const filteredEntries = Object.fromEntries(
|
||||||
let includeEntry = true;
|
Object.entries(sectionValue).filter(([slug]) => sectionSlugs.has(slug)),
|
||||||
let sectionHeaderLine: string | null = null;
|
);
|
||||||
let sectionBuffer: string[] = [];
|
if (Object.keys(filteredEntries).length > 0) {
|
||||||
|
parsed[section] = filteredEntries;
|
||||||
const flushSection = () => {
|
} else {
|
||||||
if (sectionHeaderLine !== null && sectionBuffer.length > 0) {
|
delete parsed[section];
|
||||||
out.push(sectionHeaderLine);
|
|
||||||
out.push(...sectionBuffer);
|
|
||||||
}
|
}
|
||||||
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();
|
const companySection = parsed.company;
|
||||||
let filtered = out.join("\n");
|
if (isPlainRecord(companySection)) {
|
||||||
const logoPathMatch = filtered.match(/^\s{2}logoPath:\s*["']?([^"'\n]+)["']?\s*$/m);
|
const logoPath = asString(companySection.logoPath) ?? asString(companySection.logo);
|
||||||
if (logoPathMatch && !selectedFiles.has(logoPathMatch[1]!)) {
|
if (logoPath && !selectedFiles.has(logoPath)) {
|
||||||
filtered = filtered.replace(/^\s{2}logoPath:\s*["']?([^"'\n]+)["']?\s*\n?/m, "");
|
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(
|
function filterExportFiles(
|
||||||
|
|
@ -2218,6 +2243,7 @@ function buildManifestFromPackageFiles(
|
||||||
? parseYamlFile(readPortableTextFile(normalizedFiles, paperclipExtensionPath) ?? "")
|
? parseYamlFile(readPortableTextFile(normalizedFiles, paperclipExtensionPath) ?? "")
|
||||||
: {};
|
: {};
|
||||||
const paperclipCompany = isPlainRecord(paperclipExtension.company) ? paperclipExtension.company : {};
|
const paperclipCompany = isPlainRecord(paperclipExtension.company) ? paperclipExtension.company : {};
|
||||||
|
const paperclipSidebar = normalizePortableSidebarOrder(paperclipExtension.sidebar);
|
||||||
const paperclipAgents = isPlainRecord(paperclipExtension.agents) ? paperclipExtension.agents : {};
|
const paperclipAgents = isPlainRecord(paperclipExtension.agents) ? paperclipExtension.agents : {};
|
||||||
const paperclipProjects = isPlainRecord(paperclipExtension.projects) ? paperclipExtension.projects : {};
|
const paperclipProjects = isPlainRecord(paperclipExtension.projects) ? paperclipExtension.projects : {};
|
||||||
const paperclipTasks = isPlainRecord(paperclipExtension.tasks) ? paperclipExtension.tasks : {};
|
const paperclipTasks = isPlainRecord(paperclipExtension.tasks) ? paperclipExtension.tasks : {};
|
||||||
|
|
@ -2283,6 +2309,7 @@ function buildManifestFromPackageFiles(
|
||||||
? paperclipCompany.requireBoardApprovalForNewAgents
|
? paperclipCompany.requireBoardApprovalForNewAgents
|
||||||
: readCompanyApprovalDefault(companyFrontmatter),
|
: readCompanyApprovalDefault(companyFrontmatter),
|
||||||
},
|
},
|
||||||
|
sidebar: paperclipSidebar,
|
||||||
agents: [],
|
agents: [],
|
||||||
skills: [],
|
skills: [],
|
||||||
projects: [],
|
projects: [],
|
||||||
|
|
@ -2711,6 +2738,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||||
const files: Record<string, CompanyPortabilityFileEntry> = {};
|
const files: Record<string, CompanyPortabilityFileEntry> = {};
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const envInputs: CompanyPortabilityManifest["envInputs"] = [];
|
const envInputs: CompanyPortabilityManifest["envInputs"] = [];
|
||||||
|
const requestedSidebarOrder = normalizePortableSidebarOrder(input.sidebarOrder);
|
||||||
const rootPath = normalizeAgentUrlKey(company.name) ?? "company-package";
|
const rootPath = normalizeAgentUrlKey(company.name) ?? "company-package";
|
||||||
let companyLogoPath: string | null = null;
|
let companyLogoPath: string | null = null;
|
||||||
|
|
||||||
|
|
@ -2892,6 +2920,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||||
const baseSlug = deriveProjectUrlKey(project.name, project.name);
|
const baseSlug = deriveProjectUrlKey(project.name, project.name);
|
||||||
projectSlugById.set(project.id, uniqueSlug(baseSlug, usedProjectSlugs));
|
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";
|
const companyPath = "COMPANY.md";
|
||||||
files[companyPath] = buildMarkdown(
|
files[companyPath] = buildMarkdown(
|
||||||
|
|
@ -3190,6 +3226,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||||
logoPath: companyLogoPath,
|
logoPath: companyLogoPath,
|
||||||
requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents ? undefined : false,
|
requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents ? undefined : false,
|
||||||
}),
|
}),
|
||||||
|
sidebar: stripEmptyValues(sidebarOrder),
|
||||||
agents: Object.keys(paperclipAgents).length > 0 ? paperclipAgents : undefined,
|
agents: Object.keys(paperclipAgents).length > 0 ? paperclipAgents : undefined,
|
||||||
projects: Object.keys(paperclipProjects).length > 0 ? paperclipProjects : undefined,
|
projects: Object.keys(paperclipProjects).length > 0 ? paperclipProjects : undefined,
|
||||||
tasks: Object.keys(paperclipTasks).length > 0 ? paperclipTasks : 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 resultAgents: CompanyPortabilityImportResult["agents"] = [];
|
||||||
|
const resultProjects: CompanyPortabilityImportResult["projects"] = [];
|
||||||
const importedSlugToAgentId = new Map<string, string>();
|
const importedSlugToAgentId = new Map<string, string>();
|
||||||
const existingSlugToAgentId = new Map<string, string>();
|
const existingSlugToAgentId = new Map<string, string>();
|
||||||
const existingAgents = await agents.list(targetCompany.id);
|
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) {
|
for (const planProject of plan.preview.plan.projectPlans) {
|
||||||
const manifestProject = sourceManifest.projects.find((project) => project.slug === planProject.slug);
|
const manifestProject = sourceManifest.projects.find((project) => project.slug === planProject.slug);
|
||||||
if (!manifestProject) continue;
|
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
|
const projectLeadAgentId = manifestProject.leadAgentSlug
|
||||||
? importedSlugToAgentId.get(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);
|
const updated = await projects.update(planProject.existingProjectId, projectPatch);
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
warnings.push(`Skipped update for missing project ${planProject.existingProjectId}.`);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
projectId = updated.id;
|
projectId = updated.id;
|
||||||
importedSlugToProjectId.set(planProject.slug, updated.id);
|
importedSlugToProjectId.set(planProject.slug, updated.id);
|
||||||
existingProjectSlugToId.set(updated.urlKey, 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 {
|
} else {
|
||||||
const created = await projects.create(targetCompany.id, projectPatch);
|
const created = await projects.create(targetCompany.id, projectPatch);
|
||||||
projectId = created.id;
|
projectId = created.id;
|
||||||
importedSlugToProjectId.set(planProject.slug, created.id);
|
importedSlugToProjectId.set(planProject.slug, created.id);
|
||||||
existingProjectSlugToId.set(created.urlKey, 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;
|
if (!projectId) continue;
|
||||||
|
|
@ -4154,6 +4222,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||||
action: companyAction,
|
action: companyAction,
|
||||||
},
|
},
|
||||||
agents: resultAgents,
|
agents: resultAgents,
|
||||||
|
projects: resultProjects,
|
||||||
envInputs: sourceManifest.envInputs ?? [],
|
envInputs: sourceManifest.envInputs ?? [],
|
||||||
warnings,
|
warnings,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type {
|
import type {
|
||||||
Company,
|
Company,
|
||||||
|
CompanyPortabilityExportRequest,
|
||||||
CompanyPortabilityExportPreviewResult,
|
CompanyPortabilityExportPreviewResult,
|
||||||
CompanyPortabilityExportResult,
|
CompanyPortabilityExportResult,
|
||||||
CompanyPortabilityImportRequest,
|
CompanyPortabilityImportRequest,
|
||||||
|
|
@ -37,41 +38,17 @@ export const companiesApi = {
|
||||||
remove: (companyId: string) => api.delete<{ ok: true }>(`/companies/${companyId}`),
|
remove: (companyId: string) => api.delete<{ ok: true }>(`/companies/${companyId}`),
|
||||||
exportBundle: (
|
exportBundle: (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
data: {
|
data: CompanyPortabilityExportRequest,
|
||||||
include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean };
|
|
||||||
agents?: string[];
|
|
||||||
skills?: string[];
|
|
||||||
projects?: string[];
|
|
||||||
issues?: string[];
|
|
||||||
projectIssues?: string[];
|
|
||||||
selectedFiles?: string[];
|
|
||||||
},
|
|
||||||
) =>
|
) =>
|
||||||
api.post<CompanyPortabilityExportResult>(`/companies/${companyId}/export`, data),
|
api.post<CompanyPortabilityExportResult>(`/companies/${companyId}/export`, data),
|
||||||
exportPreview: (
|
exportPreview: (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
data: {
|
data: CompanyPortabilityExportRequest,
|
||||||
include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean };
|
|
||||||
agents?: string[];
|
|
||||||
skills?: string[];
|
|
||||||
projects?: string[];
|
|
||||||
issues?: string[];
|
|
||||||
projectIssues?: string[];
|
|
||||||
selectedFiles?: string[];
|
|
||||||
},
|
|
||||||
) =>
|
) =>
|
||||||
api.post<CompanyPortabilityExportPreviewResult>(`/companies/${companyId}/exports/preview`, data),
|
api.post<CompanyPortabilityExportPreviewResult>(`/companies/${companyId}/exports/preview`, data),
|
||||||
exportPackage: (
|
exportPackage: (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
data: {
|
data: CompanyPortabilityExportRequest,
|
||||||
include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean };
|
|
||||||
agents?: string[];
|
|
||||||
skills?: string[];
|
|
||||||
projects?: string[];
|
|
||||||
issues?: string[];
|
|
||||||
projectIssues?: string[];
|
|
||||||
selectedFiles?: string[];
|
|
||||||
},
|
|
||||||
) =>
|
) =>
|
||||||
api.post<CompanyPortabilityExportResult>(`/companies/${companyId}/exports`, data),
|
api.post<CompanyPortabilityExportResult>(`/companies/${companyId}/exports`, data),
|
||||||
importPreview: (data: CompanyPortabilityPreviewRequest) =>
|
importPreview: (data: CompanyPortabilityPreviewRequest) =>
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,11 @@ import { useCompany } from "../context/CompanyContext";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useSidebar } from "../context/SidebarContext";
|
import { useSidebar } from "../context/SidebarContext";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
|
import { authApi } from "../api/auth";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { cn, agentRouteRef, agentUrl } from "../lib/utils";
|
import { cn, agentRouteRef, agentUrl } from "../lib/utils";
|
||||||
|
import { useAgentOrder } from "../hooks/useAgentOrder";
|
||||||
import { AgentIcon } from "./AgentIconPicker";
|
import { AgentIcon } from "./AgentIconPicker";
|
||||||
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
|
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
|
||||||
import {
|
import {
|
||||||
|
|
@ -17,28 +19,6 @@ import {
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
import type { Agent } from "@paperclipai/shared";
|
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<string | null, Agent[]>();
|
|
||||||
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() {
|
export function SidebarAgents() {
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
|
|
@ -51,6 +31,10 @@ export function SidebarAgents() {
|
||||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
const { data: session } = useQuery({
|
||||||
|
queryKey: queryKeys.auth.session,
|
||||||
|
queryFn: () => authApi.getSession(),
|
||||||
|
});
|
||||||
|
|
||||||
const { data: liveRuns } = useQuery({
|
const { data: liveRuns } = useQuery({
|
||||||
queryKey: queryKeys.liveRuns(selectedCompanyId!),
|
queryKey: queryKeys.liveRuns(selectedCompanyId!),
|
||||||
|
|
@ -71,8 +55,14 @@ export function SidebarAgents() {
|
||||||
const filtered = (agents ?? []).filter(
|
const filtered = (agents ?? []).filter(
|
||||||
(a: Agent) => a.status !== "terminated"
|
(a: Agent) => a.status !== "terminated"
|
||||||
);
|
);
|
||||||
return sortByHierarchy(filtered);
|
return filtered;
|
||||||
}, [agents]);
|
}, [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 agentMatch = location.pathname.match(/^\/(?:[^/]+\/)?agents\/([^/]+)(?:\/([^/]+))?/);
|
||||||
const activeAgentId = agentMatch?.[1] ?? null;
|
const activeAgentId = agentMatch?.[1] ?? null;
|
||||||
|
|
@ -109,7 +99,7 @@ export function SidebarAgents() {
|
||||||
|
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="flex flex-col gap-0.5 mt-0.5">
|
<div className="flex flex-col gap-0.5 mt-0.5">
|
||||||
{visibleAgents.map((agent: Agent) => {
|
{orderedAgents.map((agent: Agent) => {
|
||||||
const runCount = liveCountByAgent.get(agent.id) ?? 0;
|
const runCount = liveCountByAgent.get(agent.id) ?? 0;
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
|
|
|
||||||
104
ui/src/hooks/useAgentOrder.ts
Normal file
104
ui/src/hooks/useAgentOrder.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import type { Agent } from "@paperclipai/shared";
|
||||||
|
import {
|
||||||
|
AGENT_ORDER_UPDATED_EVENT,
|
||||||
|
getAgentOrderStorageKey,
|
||||||
|
readAgentOrder,
|
||||||
|
sortAgentsByStoredOrder,
|
||||||
|
writeAgentOrder,
|
||||||
|
} from "../lib/agent-order";
|
||||||
|
|
||||||
|
type UseAgentOrderParams = {
|
||||||
|
agents: Agent[];
|
||||||
|
companyId: string | null | undefined;
|
||||||
|
userId: string | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AgentOrderUpdatedDetail = {
|
||||||
|
storageKey: string;
|
||||||
|
orderedIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function areEqual(a: string[], b: string[]) {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
for (let i = 0; i < a.length; i += 1) {
|
||||||
|
if (a[i] !== b[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOrderIds(agents: Agent[], orderedIds: string[]) {
|
||||||
|
return sortAgentsByStoredOrder(agents, orderedIds).map((agent) => 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<string[]>(() => {
|
||||||
|
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<AgentOrderUpdatedDetail>).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,
|
||||||
|
};
|
||||||
|
}
|
||||||
106
ui/src/lib/agent-order.ts
Normal file
106
ui/src/lib/agent-order.ts
Normal file
|
|
@ -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<AgentOrderUpdatedDetail>(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<string | null, Agent[]>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
100
ui/src/lib/company-portability-sidebar.test.ts
Normal file
100
ui/src/lib/company-portability-sidebar.test.ts
Normal file
|
|
@ -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"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
61
ui/src/lib/company-portability-sidebar.ts
Normal file
61
ui/src/lib/company-portability-sidebar.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import type { Agent, CompanyPortabilitySidebarOrder, Project } from "@paperclipai/shared";
|
||||||
|
import { deriveProjectUrlKey, normalizeAgentUrlKey } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
function uniqueSlug(base: string, used: Set<string>) {
|
||||||
|
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<string, string> {
|
||||||
|
const usedSlugs = new Set<string>();
|
||||||
|
const byId = new Map<string, string>();
|
||||||
|
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<string, string> {
|
||||||
|
const usedSlugs = new Set<string>();
|
||||||
|
const byId = new Map<string, string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -1,23 +1,32 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import type {
|
import type {
|
||||||
|
Agent,
|
||||||
CompanyPortabilityFileEntry,
|
CompanyPortabilityFileEntry,
|
||||||
CompanyPortabilityExportPreviewResult,
|
CompanyPortabilityExportPreviewResult,
|
||||||
CompanyPortabilityExportResult,
|
CompanyPortabilityExportResult,
|
||||||
CompanyPortabilityManifest,
|
CompanyPortabilityManifest,
|
||||||
|
Project,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { useNavigate, useLocation } from "@/lib/router";
|
import { useNavigate, useLocation } from "@/lib/router";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
|
import { agentsApi } from "../api/agents";
|
||||||
|
import { authApi } from "../api/auth";
|
||||||
import { companiesApi } from "../api/companies";
|
import { companiesApi } from "../api/companies";
|
||||||
|
import { projectsApi } from "../api/projects";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
import { MarkdownBody } from "../components/MarkdownBody";
|
import { MarkdownBody } from "../components/MarkdownBody";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { createZipArchive } from "../lib/zip";
|
import { createZipArchive } from "../lib/zip";
|
||||||
import { buildInitialExportCheckedFiles } from "../lib/company-export-selection";
|
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 { getPortableFileDataUrl, getPortableFileText, isPortableImageFile } from "../lib/portable-files";
|
||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
|
|
@ -75,15 +84,29 @@ function filterPaperclipYaml(yaml: string, checkedFiles: Set<string>): string {
|
||||||
|
|
||||||
// Sections whose entries are slug-keyed and should be filtered
|
// Sections whose entries are slug-keyed and should be filtered
|
||||||
const filterableSections = new Set(["agents", "projects", "tasks", "routines"]);
|
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 currentSection: string | null = null; // top-level key (e.g. "agents")
|
||||||
let currentEntry: string | null = null; // slug under that section
|
let currentEntry: string | null = null; // slug under that section
|
||||||
let includeEntry = true;
|
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
|
// Collect entries per section so we can omit empty section headers
|
||||||
let sectionHeaderLine: string | null = null;
|
let sectionHeaderLine: string | null = null;
|
||||||
let sectionBuffer: string[] = [];
|
let sectionBuffer: string[] = [];
|
||||||
|
|
||||||
|
function flushSidebarSection() {
|
||||||
|
if (currentSidebarHeaderLine !== null && currentSidebarBuffer.length > 0) {
|
||||||
|
sectionBuffer.push(currentSidebarHeaderLine);
|
||||||
|
sectionBuffer.push(...currentSidebarBuffer);
|
||||||
|
}
|
||||||
|
currentSidebarHeaderLine = null;
|
||||||
|
currentSidebarBuffer = [];
|
||||||
|
}
|
||||||
|
|
||||||
function flushSection() {
|
function flushSection() {
|
||||||
|
flushSidebarSection();
|
||||||
if (sectionHeaderLine !== null && sectionBuffer.length > 0) {
|
if (sectionHeaderLine !== null && sectionBuffer.length > 0) {
|
||||||
out.push(sectionHeaderLine);
|
out.push(sectionHeaderLine);
|
||||||
out.push(...sectionBuffer);
|
out.push(...sectionBuffer);
|
||||||
|
|
@ -106,6 +129,11 @@ function filterPaperclipYaml(yaml: string, checkedFiles: Set<string>): string {
|
||||||
currentSection = key;
|
currentSection = key;
|
||||||
sectionHeaderLine = line;
|
sectionHeaderLine = line;
|
||||||
continue;
|
continue;
|
||||||
|
} else if (key === "sidebar") {
|
||||||
|
currentSection = key;
|
||||||
|
currentSidebarList = null;
|
||||||
|
sectionHeaderLine = line;
|
||||||
|
continue;
|
||||||
} else {
|
} else {
|
||||||
currentSection = null;
|
currentSection = null;
|
||||||
out.push(line);
|
out.push(line);
|
||||||
|
|
@ -113,6 +141,32 @@ function filterPaperclipYaml(yaml: string, checkedFiles: Set<string>): 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
|
// Inside a filterable section
|
||||||
if (currentSection && filterableSections.has(currentSection)) {
|
if (currentSection && filterableSections.has(currentSection)) {
|
||||||
// 2-space indented key = entry slug (slugs may start with digits/hyphens)
|
// 2-space indented key = entry slug (slugs may start with digits/hyphens)
|
||||||
|
|
@ -529,6 +583,20 @@ export function CompanyExport() {
|
||||||
const { pushToast } = useToast();
|
const { pushToast } = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
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<CompanyPortabilityExportPreviewResult | null>(null);
|
const [exportData, setExportData] = useState<CompanyPortabilityExportPreviewResult | null>(null);
|
||||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||||
|
|
@ -538,6 +606,38 @@ export function CompanyExport() {
|
||||||
const [taskLimit, setTaskLimit] = useState(TASKS_PAGE_SIZE);
|
const [taskLimit, setTaskLimit] = useState(TASKS_PAGE_SIZE);
|
||||||
const savedExpandedRef = useRef<Set<string> | null>(null);
|
const savedExpandedRef = useRef<Set<string> | null>(null);
|
||||||
const initialFileFromUrl = useRef(filePathFromLocation(location.pathname));
|
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.
|
// Navigate-aware file selection: updates state + URL without page reload.
|
||||||
// `replace` = true skips history entry (used for initial load); false = pushes (used for clicks).
|
// `replace` = true skips history entry (used for initial load); false = pushes (used for clicks).
|
||||||
|
|
@ -581,6 +681,7 @@ export function CompanyExport() {
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
companiesApi.exportPreview(selectedCompanyId!, {
|
companiesApi.exportPreview(selectedCompanyId!, {
|
||||||
include: { company: true, agents: true, projects: true, issues: true },
|
include: { company: true, agents: true, projects: true, issues: true },
|
||||||
|
sidebarOrder,
|
||||||
}),
|
}),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
setExportData(result);
|
setExportData(result);
|
||||||
|
|
@ -629,6 +730,7 @@ export function CompanyExport() {
|
||||||
companiesApi.exportPackage(selectedCompanyId!, {
|
companiesApi.exportPackage(selectedCompanyId!, {
|
||||||
include: { company: true, agents: true, projects: true, issues: true },
|
include: { company: true, agents: true, projects: true, issues: true },
|
||||||
selectedFiles: Array.from(checkedFiles).sort(),
|
selectedFiles: Array.from(checkedFiles).sort(),
|
||||||
|
sidebarOrder,
|
||||||
}),
|
}),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
const resultCheckedFiles = new Set(Object.keys(result.files));
|
const resultCheckedFiles = new Set(Object.keys(result.files));
|
||||||
|
|
@ -650,10 +752,11 @@ export function CompanyExport() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedCompanyId || exportPreviewMutation.isPending) return;
|
if (!selectedCompanyId || exportPreviewMutation.isPending) return;
|
||||||
|
if (!isSessionFetched || !areAgentsFetched || !areProjectsFetched) return;
|
||||||
setExportData(null);
|
setExportData(null);
|
||||||
exportPreviewMutation.mutate();
|
exportPreviewMutation.mutate();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedCompanyId]);
|
}, [selectedCompanyId, isSessionFetched, areAgentsFetched, areProjectsFetched, sidebarOrderKey]);
|
||||||
|
|
||||||
const tree = useMemo(
|
const tree = useMemo(
|
||||||
() => (exportData ? buildFileTree(exportData.files) : []),
|
() => (exportData ? buildFileTree(exportData.files) : []),
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,12 @@ import type {
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
|
import { authApi } from "../api/auth";
|
||||||
import { companiesApi } from "../api/companies";
|
import { companiesApi } from "../api/companies";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
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 { MarkdownBody } from "../components/MarkdownBody";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
|
|
@ -342,6 +345,44 @@ function prefixedName(prefix: string | null, originalName: string): string {
|
||||||
return `${prefix}-${originalName}`;
|
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 ───────────────────────────────────────────
|
// ── Conflict resolution UI ───────────────────────────────────────────
|
||||||
|
|
||||||
function ConflictResolutionList({
|
function ConflictResolutionList({
|
||||||
|
|
@ -611,6 +652,11 @@ export function CompanyImport() {
|
||||||
const { pushToast } = useToast();
|
const { pushToast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const packageInputRef = useRef<HTMLInputElement | null>(null);
|
const packageInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const { data: session } = useQuery({
|
||||||
|
queryKey: queryKeys.auth.session,
|
||||||
|
queryFn: () => authApi.getSession(),
|
||||||
|
});
|
||||||
|
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||||
|
|
||||||
// Source state
|
// Source state
|
||||||
const [sourceMode, setSourceMode] = useState<"github" | "local">("github");
|
const [sourceMode, setSourceMode] = useState<"github" | "local">("github");
|
||||||
|
|
@ -800,6 +846,7 @@ export function CompanyImport() {
|
||||||
onSuccess: async (result) => {
|
onSuccess: async (result) => {
|
||||||
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||||
const importedCompany = await companiesApi.get(result.company.id);
|
const importedCompany = await companiesApi.get(result.company.id);
|
||||||
|
applyImportedSidebarOrder(importPreview, result, currentUserId);
|
||||||
setSelectedCompanyId(importedCompany.id);
|
setSelectedCompanyId(importedCompany.id);
|
||||||
pushToast({
|
pushToast({
|
||||||
tone: "success",
|
tone: "success",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue