mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 02:40:39 +09:00
Merge pull request #1655 from paperclipai/pr/pap-795-company-portability
feat(portability): improve company import and export flow
This commit is contained in:
commit
eeb7e1a91a
36 changed files with 5238 additions and 271 deletions
|
|
@ -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<CompanyPortabilityExportResult>(`/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<CompanyPortabilityExportPreviewResult>(`/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<CompanyPortabilityExportResult>(`/companies/${companyId}/exports`, data),
|
||||
importPreview: (data: CompanyPortabilityPreviewRequest) =>
|
||||
|
|
|
|||
|
|
@ -160,6 +160,7 @@ export const FRONTMATTER_FIELD_LABELS: Record<string, string> = {
|
|||
priority: "Priority",
|
||||
assignee: "Assignee",
|
||||
project: "Project",
|
||||
recurring: "Recurring",
|
||||
targetDate: "Target date",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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() {
|
||||
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() {
|
|||
|
||||
<CollapsibleContent>
|
||||
<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;
|
||||
return (
|
||||
<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;
|
||||
}
|
||||
41
ui/src/lib/company-export-selection.test.ts
Normal file
41
ui/src/lib/company-export-selection.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildInitialExportCheckedFiles } from "./company-export-selection";
|
||||
|
||||
describe("buildInitialExportCheckedFiles", () => {
|
||||
it("checks non-task files and recurring task packages by default", () => {
|
||||
const checked = buildInitialExportCheckedFiles(
|
||||
[
|
||||
"README.md",
|
||||
".paperclip.yaml",
|
||||
"tasks/one-off/TASK.md",
|
||||
"tasks/recurring/TASK.md",
|
||||
"tasks/recurring/notes.md",
|
||||
],
|
||||
[
|
||||
{ path: "tasks/one-off/TASK.md", recurring: false },
|
||||
{ path: "tasks/recurring/TASK.md", recurring: true },
|
||||
],
|
||||
new Set<string>(),
|
||||
);
|
||||
|
||||
expect(Array.from(checked).sort()).toEqual([
|
||||
".paperclip.yaml",
|
||||
"README.md",
|
||||
"tasks/recurring/TASK.md",
|
||||
"tasks/recurring/notes.md",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves previous manual selections for one-time tasks", () => {
|
||||
const checked = buildInitialExportCheckedFiles(
|
||||
["README.md", "tasks/one-off/TASK.md"],
|
||||
[{ path: "tasks/one-off/TASK.md", recurring: false }],
|
||||
new Set(["tasks/one-off/TASK.md"]),
|
||||
);
|
||||
|
||||
expect(Array.from(checked).sort()).toEqual([
|
||||
"README.md",
|
||||
"tasks/one-off/TASK.md",
|
||||
]);
|
||||
});
|
||||
});
|
||||
56
ui/src/lib/company-export-selection.ts
Normal file
56
ui/src/lib/company-export-selection.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { CompanyPortabilityIssueManifestEntry } from "@paperclipai/shared";
|
||||
|
||||
function isTaskPath(filePath: string): boolean {
|
||||
return /(?:^|\/)tasks\//.test(filePath);
|
||||
}
|
||||
|
||||
function buildRecurringTaskPrefixes(
|
||||
issues: Array<Pick<CompanyPortabilityIssueManifestEntry, "path" | "recurring">>,
|
||||
): Set<string> {
|
||||
const prefixes = new Set<string>();
|
||||
|
||||
for (const issue of issues) {
|
||||
if (!issue.recurring) continue;
|
||||
|
||||
const filePath = issue.path.trim();
|
||||
if (!filePath) continue;
|
||||
|
||||
prefixes.add(filePath);
|
||||
|
||||
const lastSlash = filePath.lastIndexOf("/");
|
||||
if (lastSlash >= 0) {
|
||||
prefixes.add(`${filePath.slice(0, lastSlash + 1)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return prefixes;
|
||||
}
|
||||
|
||||
function isRecurringTaskFile(filePath: string, recurringTaskPrefixes: Set<string>): boolean {
|
||||
for (const prefix of recurringTaskPrefixes) {
|
||||
if (filePath === prefix || filePath.startsWith(prefix)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function buildInitialExportCheckedFiles(
|
||||
filePaths: string[],
|
||||
issues: Array<Pick<CompanyPortabilityIssueManifestEntry, "path" | "recurring">>,
|
||||
previousCheckedFiles: Set<string>,
|
||||
): Set<string> {
|
||||
const next = new Set<string>();
|
||||
const recurringTaskPrefixes = buildRecurringTaskPrefixes(issues);
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
if (previousCheckedFiles.has(filePath)) {
|
||||
next.add(filePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isTaskPath(filePath) || isRecurringTaskFile(filePath, recurringTaskPrefixes)) {
|
||||
next.add(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
|
@ -40,10 +40,10 @@ createRoot(document.getElementById("root")!).render(
|
|||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<CompanyProvider>
|
||||
<ToastProvider>
|
||||
<LiveUpdatesProvider>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter>
|
||||
<CompanyProvider>
|
||||
<ToastProvider>
|
||||
<LiveUpdatesProvider>
|
||||
<TooltipProvider>
|
||||
<BreadcrumbProvider>
|
||||
<SidebarProvider>
|
||||
|
|
@ -57,10 +57,10 @@ createRoot(document.getElementById("root")!).render(
|
|||
</SidebarProvider>
|
||||
</BreadcrumbProvider>
|
||||
</TooltipProvider>
|
||||
</BrowserRouter>
|
||||
</LiveUpdatesProvider>
|
||||
</ToastProvider>
|
||||
</CompanyProvider>
|
||||
</LiveUpdatesProvider>
|
||||
</ToastProvider>
|
||||
</CompanyProvider>
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
|
|
|
|||
|
|
@ -1,22 +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,
|
||||
|
|
@ -34,11 +44,6 @@ import {
|
|||
PackageFileTree,
|
||||
} from "../components/PackageFileTree";
|
||||
|
||||
/** Returns true if the path looks like a task file (e.g. tasks/slug/TASK.md or projects/x/tasks/slug/TASK.md) */
|
||||
function isTaskPath(filePath: string): boolean {
|
||||
return /(?:^|\/)tasks\//.test(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the set of agent/project/task slugs that are "checked" based on
|
||||
* which file paths are in the checked set.
|
||||
|
|
@ -50,6 +55,7 @@ function checkedSlugs(checkedFiles: Set<string>): {
|
|||
agents: Set<string>;
|
||||
projects: Set<string>;
|
||||
tasks: Set<string>;
|
||||
routines: Set<string>;
|
||||
} {
|
||||
const agents = new Set<string>();
|
||||
const projects = new Set<string>();
|
||||
|
|
@ -62,7 +68,7 @@ function checkedSlugs(checkedFiles: Set<string>): {
|
|||
const taskMatch = p.match(/^tasks\/([^/]+)\//);
|
||||
if (taskMatch) tasks.add(taskMatch[1]);
|
||||
}
|
||||
return { agents, projects, tasks };
|
||||
return { agents, projects, tasks, routines: new Set(tasks) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -77,16 +83,30 @@ function filterPaperclipYaml(yaml: string, checkedFiles: Set<string>): string {
|
|||
const out: string[] = [];
|
||||
|
||||
// Sections whose entries are slug-keyed and should be filtered
|
||||
const filterableSections = new Set(["agents", "projects", "tasks"]);
|
||||
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);
|
||||
|
|
@ -109,6 +129,11 @@ function filterPaperclipYaml(yaml: string, checkedFiles: Set<string>): string {
|
|||
currentSection = key;
|
||||
sectionHeaderLine = line;
|
||||
continue;
|
||||
} else if (key === "sidebar") {
|
||||
currentSection = key;
|
||||
currentSidebarList = null;
|
||||
sectionHeaderLine = line;
|
||||
continue;
|
||||
} else {
|
||||
currentSection = null;
|
||||
out.push(line);
|
||||
|
|
@ -116,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
|
||||
if (currentSection && filterableSections.has(currentSection)) {
|
||||
// 2-space indented key = entry slug (slugs may start with digits/hyphens)
|
||||
|
|
@ -532,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<CompanyPortabilityExportPreviewResult | null>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
|
|
@ -541,6 +606,38 @@ export function CompanyExport() {
|
|||
const [taskLimit, setTaskLimit] = useState(TASKS_PAGE_SIZE);
|
||||
const savedExpandedRef = useRef<Set<string> | 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).
|
||||
|
|
@ -584,17 +681,17 @@ export function CompanyExport() {
|
|||
mutationFn: () =>
|
||||
companiesApi.exportPreview(selectedCompanyId!, {
|
||||
include: { company: true, agents: true, projects: true, issues: true },
|
||||
sidebarOrder,
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
setExportData(result);
|
||||
setCheckedFiles((prev) => {
|
||||
const next = new Set<string>();
|
||||
for (const filePath of Object.keys(result.files)) {
|
||||
if (prev.has(filePath)) next.add(filePath);
|
||||
else if (!isTaskPath(filePath)) next.add(filePath);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setCheckedFiles((prev) =>
|
||||
buildInitialExportCheckedFiles(
|
||||
Object.keys(result.files),
|
||||
result.manifest.issues,
|
||||
prev,
|
||||
),
|
||||
);
|
||||
// Expand top-level dirs (except tasks — collapsed by default)
|
||||
const tree = buildFileTree(result.files);
|
||||
const topDirs = new Set<string>();
|
||||
|
|
@ -633,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));
|
||||
|
|
@ -654,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) : []),
|
||||
|
|
|
|||
|
|
@ -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,45 @@ 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;
|
||||
if (!userId?.trim()) 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 +653,11 @@ export function CompanyImport() {
|
|||
const { pushToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
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
|
||||
const [sourceMode, setSourceMode] = useState<"github" | "local">("github");
|
||||
|
|
@ -800,6 +847,18 @@ export function CompanyImport() {
|
|||
onSuccess: async (result) => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
const importedCompany = await companiesApi.get(result.company.id);
|
||||
const refreshedSession = currentUserId
|
||||
? null
|
||||
: await queryClient.fetchQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
const sidebarOrderUserId =
|
||||
currentUserId
|
||||
?? refreshedSession?.user?.id
|
||||
?? refreshedSession?.session?.userId
|
||||
?? null;
|
||||
applyImportedSidebarOrder(importPreview, result, sidebarOrderUserId);
|
||||
setSelectedCompanyId(importedCompany.id);
|
||||
pushToast({
|
||||
tone: "success",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue