Merge branch 'master' into feature/change-reports-to

This commit is contained in:
Daniel Sousa 2026-03-20 20:13:19 +00:00
commit dfb83295de
No known key found for this signature in database
191 changed files with 46471 additions and 1103 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,919 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import type {
CompanyPortabilityFileEntry,
CompanyPortabilityExportPreviewResult,
CompanyPortabilityExportResult,
CompanyPortabilityManifest,
} 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 { companiesApi } from "../api/companies";
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 { createZipArchive } from "../lib/zip";
import { getPortableFileDataUrl, getPortableFileText, isPortableImageFile } from "../lib/portable-files";
import {
Download,
Package,
Search,
} from "lucide-react";
import {
type FileTreeNode,
type FrontmatterData,
buildFileTree,
countFiles,
collectAllPaths,
parseFrontmatter,
FRONTMATTER_FIELD_LABELS,
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.
* agents/{slug}/AGENT.md agents slug
* projects/{slug}/PROJECT.md projects slug
* tasks/{slug}/TASK.md tasks slug
*/
function checkedSlugs(checkedFiles: Set<string>): {
agents: Set<string>;
projects: Set<string>;
tasks: Set<string>;
} {
const agents = new Set<string>();
const projects = new Set<string>();
const tasks = new Set<string>();
for (const p of checkedFiles) {
const agentMatch = p.match(/^agents\/([^/]+)\//);
if (agentMatch) agents.add(agentMatch[1]);
const projectMatch = p.match(/^projects\/([^/]+)\//);
if (projectMatch) projects.add(projectMatch[1]);
const taskMatch = p.match(/^tasks\/([^/]+)\//);
if (taskMatch) tasks.add(taskMatch[1]);
}
return { agents, projects, tasks };
}
/**
* Filter .paperclip.yaml content so it only includes entries whose
* corresponding files are checked. Works by line-level YAML parsing
* since the file has a known, simple structure produced by our own
* renderYamlBlock.
*/
function filterPaperclipYaml(yaml: string, checkedFiles: Set<string>): string {
const slugs = checkedSlugs(checkedFiles);
const lines = yaml.split("\n");
const out: string[] = [];
// Sections whose entries are slug-keyed and should be filtered
const filterableSections = new Set(["agents", "projects", "tasks"]);
let currentSection: string | null = null; // top-level key (e.g. "agents")
let currentEntry: string | null = null; // slug under that section
let includeEntry = true;
// Collect entries per section so we can omit empty section headers
let sectionHeaderLine: string | null = null;
let sectionBuffer: string[] = [];
function flushSection() {
if (sectionHeaderLine !== null && sectionBuffer.length > 0) {
out.push(sectionHeaderLine);
out.push(...sectionBuffer);
}
sectionHeaderLine = null;
sectionBuffer = [];
}
for (const line of lines) {
// Detect top-level key (no indentation)
const topMatch = line.match(/^([a-zA-Z_][\w-]*):\s*(.*)$/);
if (topMatch && !line.startsWith(" ")) {
// Flush previous section
flushSection();
currentEntry = null;
includeEntry = true;
const key = topMatch[0].split(":")[0];
if (filterableSections.has(key)) {
currentSection = key;
sectionHeaderLine = line;
continue;
} else {
currentSection = null;
out.push(line);
continue;
}
}
// Inside a filterable section
if (currentSection && filterableSections.has(currentSection)) {
// 2-space indented key = entry slug (slugs may start with digits/hyphens)
const entryMatch = line.match(/^ ([\w][\w-]*):\s*(.*)$/);
if (entryMatch && !line.startsWith(" ")) {
const slug = entryMatch[1];
currentEntry = slug;
const sectionSlugs = slugs[currentSection as keyof typeof slugs];
includeEntry = sectionSlugs.has(slug);
if (includeEntry) sectionBuffer.push(line);
continue;
}
// Deeper indented line belongs to current entry
if (currentEntry !== null) {
if (includeEntry) sectionBuffer.push(line);
continue;
}
// Shouldn't happen in well-formed output, but pass through
sectionBuffer.push(line);
continue;
}
// Outside filterable sections — pass through
out.push(line);
}
// Flush last section
flushSection();
let filtered = out.join("\n");
const logoPathMatch = filtered.match(/^\s{2}logoPath:\s*["']?([^"'\n]+)["']?\s*$/m);
if (logoPathMatch && !checkedFiles.has(logoPathMatch[1]!)) {
filtered = filtered.replace(/^\s{2}logoPath:\s*["']?([^"'\n]+)["']?\s*\n?/m, "");
}
return filtered;
}
/** Filter tree nodes whose path (or descendant paths) match a search string */
function filterTree(nodes: FileTreeNode[], query: string): FileTreeNode[] {
if (!query) return nodes;
const lower = query.toLowerCase();
return nodes
.map((node) => {
if (node.kind === "file") {
return node.name.toLowerCase().includes(lower) || node.path.toLowerCase().includes(lower)
? node
: null;
}
const filteredChildren = filterTree(node.children, query);
return filteredChildren.length > 0
? { ...node, children: filteredChildren }
: null;
})
.filter((n): n is FileTreeNode => n !== null);
}
/** Collect all ancestor dir paths for files that match a filter */
function collectMatchedParentDirs(nodes: FileTreeNode[], query: string): Set<string> {
const dirs = new Set<string>();
const lower = query.toLowerCase();
function walk(node: FileTreeNode, ancestors: string[]) {
if (node.kind === "file") {
if (node.name.toLowerCase().includes(lower) || node.path.toLowerCase().includes(lower)) {
for (const a of ancestors) dirs.add(a);
}
} else {
for (const child of node.children) {
walk(child, [...ancestors, node.path]);
}
}
}
for (const node of nodes) walk(node, []);
return dirs;
}
/** Sort tree: checked files first, then unchecked */
function sortByChecked(nodes: FileTreeNode[], checkedFiles: Set<string>): FileTreeNode[] {
return nodes.map((node) => {
if (node.kind === "dir") {
return { ...node, children: sortByChecked(node.children, checkedFiles) };
}
return node;
}).sort((a, b) => {
if (a.kind !== b.kind) return a.kind === "file" ? -1 : 1;
if (a.kind === "file" && b.kind === "file") {
const aChecked = checkedFiles.has(a.path);
const bChecked = checkedFiles.has(b.path);
if (aChecked !== bChecked) return aChecked ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
}
const TASKS_PAGE_SIZE = 10;
/**
* Paginate children of `tasks/` directories: show up to `limit` entries,
* but always include children that are checked or match the search query.
* Returns the paginated tree and the total count of task children.
*/
function paginateTaskNodes(
nodes: FileTreeNode[],
limit: number,
checkedFiles: Set<string>,
searchQuery: string,
): { nodes: FileTreeNode[]; totalTaskChildren: number; visibleTaskChildren: number } {
let totalTaskChildren = 0;
let visibleTaskChildren = 0;
const result = nodes.map((node) => {
// Only paginate direct children of "tasks" directories
if (node.kind === "dir" && node.name === "tasks") {
totalTaskChildren = node.children.length;
// Partition children: pinned (checked or search-matched) vs rest
const pinned: FileTreeNode[] = [];
const rest: FileTreeNode[] = [];
const lower = searchQuery.toLowerCase();
for (const child of node.children) {
const childFiles = collectAllPaths([child], "file");
const isChecked = [...childFiles].some((p) => checkedFiles.has(p));
const isSearchMatch = searchQuery && (
child.name.toLowerCase().includes(lower) ||
child.path.toLowerCase().includes(lower) ||
[...childFiles].some((p) => p.toLowerCase().includes(lower))
);
if (isChecked || isSearchMatch) {
pinned.push(child);
} else {
rest.push(child);
}
}
// Show pinned + up to `limit` from rest
const remaining = Math.max(0, limit - pinned.length);
const visible = [...pinned, ...rest.slice(0, remaining)];
visibleTaskChildren = visible.length;
return { ...node, children: visible };
}
return node;
});
return { nodes: result, totalTaskChildren, visibleTaskChildren };
}
function downloadZip(
exported: CompanyPortabilityExportResult,
selectedFiles: Set<string>,
effectiveFiles: Record<string, CompanyPortabilityFileEntry>,
) {
const filteredFiles: Record<string, CompanyPortabilityFileEntry> = {};
for (const [path] of Object.entries(exported.files)) {
if (selectedFiles.has(path)) filteredFiles[path] = effectiveFiles[path] ?? exported.files[path];
}
const zipBytes = createZipArchive(filteredFiles, exported.rootPath);
const zipBuffer = new ArrayBuffer(zipBytes.byteLength);
new Uint8Array(zipBuffer).set(zipBytes);
const blob = new Blob([zipBuffer], { type: "application/zip" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${exported.rootPath}.zip`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// ── Frontmatter card (export-specific: skill click support) ──────────
function FrontmatterCard({
data,
onSkillClick,
}: {
data: FrontmatterData;
onSkillClick?: (skill: string) => void;
}) {
return (
<div className="rounded-md border border-border bg-accent/20 px-4 py-3 mb-4">
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1.5 text-sm">
{Object.entries(data).map(([key, value]) => (
<div key={key} className="contents">
<dt className="text-muted-foreground whitespace-nowrap py-0.5">
{FRONTMATTER_FIELD_LABELS[key] ?? key}
</dt>
<dd className="py-0.5">
{Array.isArray(value) ? (
<div className="flex flex-wrap gap-1.5">
{value.map((item) => (
<button
key={item}
type="button"
className={cn(
"inline-flex items-center rounded-md border border-border bg-background px-2 py-0.5 text-xs",
key === "skills" && onSkillClick && "cursor-pointer hover:bg-accent/50 hover:border-foreground/30 transition-colors",
)}
onClick={() => key === "skills" && onSkillClick?.(item)}
>
{item}
</button>
))}
</div>
) : (
<span>{value}</span>
)}
</dd>
</div>
))}
</dl>
</div>
);
}
// ── Client-side README generation ────────────────────────────────────
const ROLE_LABELS: Record<string, string> = {
ceo: "CEO", cto: "CTO", cmo: "CMO", cfo: "CFO", coo: "COO",
vp: "VP", manager: "Manager", engineer: "Engineer", agent: "Agent",
};
/**
* Regenerate README.md content based on the currently checked files.
* Only counts/lists entities whose files are in the checked set.
*/
function generateReadmeFromSelection(
manifest: CompanyPortabilityManifest,
checkedFiles: Set<string>,
companyName: string,
companyDescription: string | null,
): string {
const slugs = checkedSlugs(checkedFiles);
const agents = manifest.agents.filter((a) => slugs.agents.has(a.slug));
const projects = manifest.projects.filter((p) => slugs.projects.has(p.slug));
const tasks = manifest.issues.filter((t) => slugs.tasks.has(t.slug));
const skills = manifest.skills.filter((s) => {
// Skill files live under skills/{key}/...
return [...checkedFiles].some((f) => f.startsWith(`skills/${s.key}/`) || f.startsWith(`skills/`) && f.includes(`/${s.slug}/`));
});
const lines: string[] = [];
lines.push(`# ${companyName}`);
lines.push("");
if (companyDescription) {
lines.push(`> ${companyDescription}`);
lines.push("");
}
// Org chart image (generated during export as images/org-chart.png)
if (agents.length > 0) {
lines.push("![Org Chart](images/org-chart.png)");
lines.push("");
}
lines.push("## What's Inside");
lines.push("");
lines.push("This is an [Agent Company](https://paperclip.ing) package.");
lines.push("");
const counts: Array<[string, number]> = [];
if (agents.length > 0) counts.push(["Agents", agents.length]);
if (projects.length > 0) counts.push(["Projects", projects.length]);
if (skills.length > 0) counts.push(["Skills", skills.length]);
if (tasks.length > 0) counts.push(["Tasks", tasks.length]);
if (counts.length > 0) {
lines.push("| Content | Count |");
lines.push("|---------|-------|");
for (const [label, count] of counts) {
lines.push(`| ${label} | ${count} |`);
}
lines.push("");
}
if (agents.length > 0) {
lines.push("### Agents");
lines.push("");
lines.push("| Agent | Role | Reports To |");
lines.push("|-------|------|------------|");
for (const agent of agents) {
const roleLabel = ROLE_LABELS[agent.role] ?? agent.role;
const reportsTo = agent.reportsToSlug ?? "\u2014";
lines.push(`| ${agent.name} | ${roleLabel} | ${reportsTo} |`);
}
lines.push("");
}
if (projects.length > 0) {
lines.push("### Projects");
lines.push("");
for (const project of projects) {
const desc = project.description ? ` \u2014 ${project.description}` : "";
lines.push(`- **${project.name}**${desc}`);
}
lines.push("");
}
lines.push("## Getting Started");
lines.push("");
lines.push("```bash");
lines.push("pnpm paperclipai company import this-github-url-or-folder");
lines.push("```");
lines.push("");
lines.push("See [Paperclip](https://paperclip.ing) for more information.");
lines.push("");
lines.push("---");
lines.push(`Exported from [Paperclip](https://paperclip.ing) on ${new Date().toISOString().split("T")[0]}`);
lines.push("");
return lines.join("\n");
}
// ── Preview pane ──────────────────────────────────────────────────────
function ExportPreviewPane({
selectedFile,
content,
allFiles,
onSkillClick,
}: {
selectedFile: string | null;
content: CompanyPortabilityFileEntry | null;
allFiles: Record<string, CompanyPortabilityFileEntry>;
onSkillClick?: (skill: string) => void;
}) {
if (!selectedFile || content === null) {
return (
<EmptyState icon={Package} message="Select a file to preview its contents." />
);
}
const textContent = getPortableFileText(content);
const isMarkdown = selectedFile.endsWith(".md") && textContent !== null;
const parsed = isMarkdown && textContent ? parseFrontmatter(textContent) : null;
const imageSrc = isPortableImageFile(selectedFile, content) ? getPortableFileDataUrl(selectedFile, content) : null;
// Resolve relative image paths within the export package (e.g. images/org-chart.png)
const resolveImageSrc = isMarkdown
? (src: string) => {
// Skip absolute URLs and data URIs
if (/^(?:https?:|data:)/i.test(src)) return null;
// Resolve relative to the directory of the current markdown file
const dir = selectedFile.includes("/") ? selectedFile.slice(0, selectedFile.lastIndexOf("/") + 1) : "";
const resolved = dir + src;
const entry = allFiles[resolved] ?? allFiles[src];
if (!entry) return null;
return getPortableFileDataUrl(resolved in allFiles ? resolved : src, entry);
}
: undefined;
return (
<div className="min-w-0">
<div className="border-b border-border px-5 py-3">
<div className="truncate font-mono text-sm">{selectedFile}</div>
</div>
<div className="min-h-[560px] px-5 py-5">
{parsed ? (
<>
<FrontmatterCard data={parsed.data} onSkillClick={onSkillClick} />
{parsed.body.trim() && <MarkdownBody resolveImageSrc={resolveImageSrc}>{parsed.body}</MarkdownBody>}
</>
) : isMarkdown ? (
<MarkdownBody resolveImageSrc={resolveImageSrc}>{textContent ?? ""}</MarkdownBody>
) : imageSrc ? (
<div className="flex min-h-[520px] items-center justify-center rounded-lg border border-border bg-accent/10 p-6">
<img src={imageSrc} alt={selectedFile} className="max-h-[480px] max-w-full object-contain" />
</div>
) : textContent !== null ? (
<pre className="overflow-x-auto whitespace-pre-wrap break-words border-0 bg-transparent p-0 font-mono text-sm text-foreground">
<code>{textContent}</code>
</pre>
) : (
<div className="rounded-lg border border-border bg-accent/10 px-4 py-3 text-sm text-muted-foreground">
Binary asset preview is not available for this file type.
</div>
)}
</div>
</div>
);
}
// ── Main page ─────────────────────────────────────────────────────────
/** Extract the file path from the current URL pathname (after /company/export/files/) */
function filePathFromLocation(pathname: string): string | null {
const marker = "/company/export/files/";
const idx = pathname.indexOf(marker);
if (idx === -1) return null;
const filePath = decodeURIComponent(pathname.slice(idx + marker.length));
return filePath || null;
}
/** Expand all ancestor directories for a given file path */
function expandAncestors(filePath: string): string[] {
const parts = filePath.split("/").slice(0, -1);
const dirs: string[] = [];
let current = "";
for (const part of parts) {
current = current ? `${current}/${part}` : part;
dirs.push(current);
}
return dirs;
}
export function CompanyExport() {
const { selectedCompanyId, selectedCompany } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const navigate = useNavigate();
const location = useLocation();
const [exportData, setExportData] = useState<CompanyPortabilityExportPreviewResult | null>(null);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [checkedFiles, setCheckedFiles] = useState<Set<string>>(new Set());
const [treeSearch, setTreeSearch] = useState("");
const [taskLimit, setTaskLimit] = useState(TASKS_PAGE_SIZE);
const savedExpandedRef = useRef<Set<string> | null>(null);
const initialFileFromUrl = useRef(filePathFromLocation(location.pathname));
// Navigate-aware file selection: updates state + URL without page reload.
// `replace` = true skips history entry (used for initial load); false = pushes (used for clicks).
const selectFile = useCallback(
(filePath: string | null, replace = false) => {
setSelectedFile(filePath);
if (filePath) {
navigate(`/company/export/files/${encodeURI(filePath)}`, { replace });
} else {
navigate("/company/export", { replace });
}
},
[navigate],
);
// Sync selectedFile from URL on browser back/forward
useEffect(() => {
if (!exportData) return;
const urlFile = filePathFromLocation(location.pathname);
if (urlFile && urlFile in exportData.files && urlFile !== selectedFile) {
setSelectedFile(urlFile);
// Expand ancestors so the file is visible in the tree
setExpandedDirs((prev) => {
const next = new Set(prev);
for (const dir of expandAncestors(urlFile)) next.add(dir);
return next;
});
} else if (!urlFile && selectedFile) {
setSelectedFile(null);
}
}, [location.pathname, exportData]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
setBreadcrumbs([
{ label: "Org Chart", href: "/org" },
{ label: "Export" },
]);
}, [setBreadcrumbs]);
const exportPreviewMutation = useMutation({
mutationFn: () =>
companiesApi.exportPreview(selectedCompanyId!, {
include: { company: true, agents: true, projects: true, issues: true },
}),
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;
});
// Expand top-level dirs (except tasks — collapsed by default)
const tree = buildFileTree(result.files);
const topDirs = new Set<string>();
for (const node of tree) {
if (node.kind === "dir" && node.name !== "tasks") topDirs.add(node.path);
}
// If URL contains a deep-linked file path, select it and expand ancestors
const urlFile = initialFileFromUrl.current;
if (urlFile && urlFile in result.files) {
setSelectedFile(urlFile);
const ancestors = expandAncestors(urlFile);
setExpandedDirs(new Set([...topDirs, ...ancestors]));
} else {
// Default to README.md if present, otherwise fall back to first file
const defaultFile = "README.md" in result.files
? "README.md"
: Object.keys(result.files)[0];
if (defaultFile) {
selectFile(defaultFile, true);
}
setExpandedDirs(topDirs);
}
},
onError: (err) => {
pushToast({
tone: "error",
title: "Export failed",
body: err instanceof Error ? err.message : "Failed to load export data.",
});
},
});
const downloadMutation = useMutation({
mutationFn: () =>
companiesApi.exportPackage(selectedCompanyId!, {
include: { company: true, agents: true, projects: true, issues: true },
selectedFiles: Array.from(checkedFiles).sort(),
}),
onSuccess: (result) => {
const resultCheckedFiles = new Set(Object.keys(result.files));
downloadZip(result, resultCheckedFiles, result.files);
pushToast({
tone: "success",
title: "Export downloaded",
body: `${resultCheckedFiles.size} file${resultCheckedFiles.size === 1 ? "" : "s"} exported as ${result.rootPath}.zip`,
});
},
onError: (err) => {
pushToast({
tone: "error",
title: "Export failed",
body: err instanceof Error ? err.message : "Failed to build export package.",
});
},
});
useEffect(() => {
if (!selectedCompanyId || exportPreviewMutation.isPending) return;
setExportData(null);
exportPreviewMutation.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedCompanyId]);
const tree = useMemo(
() => (exportData ? buildFileTree(exportData.files) : []),
[exportData],
);
const { displayTree, totalTaskChildren, visibleTaskChildren } = useMemo(() => {
let result = tree;
if (treeSearch) result = filterTree(result, treeSearch);
result = sortByChecked(result, checkedFiles);
const paginated = paginateTaskNodes(result, taskLimit, checkedFiles, treeSearch);
return {
displayTree: paginated.nodes,
totalTaskChildren: paginated.totalTaskChildren,
visibleTaskChildren: paginated.visibleTaskChildren,
};
}, [tree, treeSearch, checkedFiles, taskLimit]);
// Recompute .paperclip.yaml and README.md content whenever checked files
// change so the preview & download always reflect the current selection.
const effectiveFiles = useMemo(() => {
if (!exportData) return {} as Record<string, CompanyPortabilityFileEntry>;
const filtered = { ...exportData.files };
// Filter .paperclip.yaml
const yamlPath = exportData.paperclipExtensionPath;
if (yamlPath && typeof exportData.files[yamlPath] === "string") {
filtered[yamlPath] = filterPaperclipYaml(exportData.files[yamlPath], checkedFiles);
}
// Regenerate README.md based on checked selection
if (typeof exportData.files["README.md"] === "string") {
const companyName = exportData.manifest.company?.name ?? selectedCompany?.name ?? "Company";
const companyDescription = exportData.manifest.company?.description ?? null;
filtered["README.md"] = generateReadmeFromSelection(
exportData.manifest,
checkedFiles,
companyName,
companyDescription,
);
}
return filtered;
}, [exportData, checkedFiles, selectedCompany?.name]);
const totalFiles = useMemo(() => countFiles(tree), [tree]);
const selectedCount = checkedFiles.size;
// Filter out terminated agent messages — they don't need to be shown
const warnings = useMemo(() => {
if (!exportData) return [] as string[];
return exportData.warnings.filter((w) => !/terminated agent/i.test(w));
}, [exportData]);
function handleToggleDir(path: string) {
setExpandedDirs((prev) => {
const next = new Set(prev);
if (next.has(path)) next.delete(path);
else next.add(path);
return next;
});
}
function handleToggleCheck(path: string, kind: "file" | "dir") {
if (!exportData) return;
setCheckedFiles((prev) => {
const next = new Set(prev);
if (kind === "file") {
if (next.has(path)) next.delete(path);
else next.add(path);
} else {
// Find all child file paths under this dir
const dirTree = buildFileTree(exportData.files);
const findNode = (nodes: FileTreeNode[], target: string): FileTreeNode | null => {
for (const n of nodes) {
if (n.path === target) return n;
const found = findNode(n.children, target);
if (found) return found;
}
return null;
};
const dirNode = findNode(dirTree, path);
if (dirNode) {
const childFiles = collectAllPaths(dirNode.children, "file");
// Add the dir's own file children
for (const child of dirNode.children) {
if (child.kind === "file") childFiles.add(child.path);
}
const allChecked = [...childFiles].every((p) => next.has(p));
for (const f of childFiles) {
if (allChecked) next.delete(f);
else next.add(f);
}
}
}
return next;
});
}
function handleSearchChange(query: string) {
const wasSearching = treeSearch.length > 0;
const isSearching = query.length > 0;
if (isSearching && !wasSearching) {
// Save current expansion state before search
savedExpandedRef.current = new Set(expandedDirs);
}
setTreeSearch(query);
if (isSearching) {
// Expand all parent dirs of matched files
const matchedParents = collectMatchedParentDirs(tree, query);
setExpandedDirs((prev) => {
const next = new Set(prev);
for (const d of matchedParents) next.add(d);
return next;
});
} else if (wasSearching) {
// Restore pre-search expansion state
if (savedExpandedRef.current) {
setExpandedDirs(savedExpandedRef.current);
savedExpandedRef.current = null;
}
}
}
function handleSkillClick(skillKey: string) {
if (!exportData) return;
const manifestSkill = exportData.manifest.skills.find(
(skill) => skill.key === skillKey || skill.slug === skillKey,
);
const skillPath = manifestSkill?.path ?? `skills/${skillKey}/SKILL.md`;
if (!(skillPath in exportData.files)) return;
selectFile(skillPath);
setExpandedDirs((prev) => {
const next = new Set(prev);
next.add("skills");
const parts = skillPath.split("/").slice(0, -1);
let current = "";
for (const part of parts) {
current = current ? `${current}/${part}` : part;
next.add(current);
}
return next;
});
}
function handleDownload() {
if (!exportData || checkedFiles.size === 0 || downloadMutation.isPending) return;
downloadMutation.mutate();
}
if (!selectedCompanyId) {
return <EmptyState icon={Package} message="Select a company to export." />;
}
if (exportPreviewMutation.isPending && !exportData) {
return <PageSkeleton variant="detail" />;
}
if (!exportData) {
return <EmptyState icon={Package} message="Loading export data..." />;
}
const previewContent = selectedFile
? (() => {
return effectiveFiles[selectedFile] ?? null;
})()
: null;
return (
<div>
{/* Sticky top action bar */}
<div className="sticky top-0 z-10 border-b border-border bg-background px-5 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-4 text-sm">
<span className="font-medium">
{selectedCompany?.name ?? "Company"} export
</span>
<span className="text-muted-foreground">
{selectedCount} / {totalFiles} file{totalFiles === 1 ? "" : "s"} selected
</span>
{warnings.length > 0 && (
<span className="text-amber-500">
{warnings.length} warning{warnings.length === 1 ? "" : "s"}
</span>
)}
</div>
<Button
size="sm"
onClick={handleDownload}
disabled={selectedCount === 0 || downloadMutation.isPending}
>
<Download className="mr-1.5 h-3.5 w-3.5" />
{downloadMutation.isPending
? "Building export..."
: `Export ${selectedCount} file${selectedCount === 1 ? "" : "s"}`}
</Button>
</div>
</div>
{/* Warnings */}
{warnings.length > 0 && (
<div className="mx-5 mt-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
{warnings.map((w) => (
<div key={w} className="text-xs text-amber-500">{w}</div>
))}
</div>
)}
{/* Two-column layout */}
<div className="grid h-[calc(100vh-12rem)] gap-0 xl:grid-cols-[19rem_minmax(0,1fr)]">
<aside className="flex flex-col border-r border-border overflow-hidden">
<div className="border-b border-border px-4 py-3 shrink-0">
<h2 className="text-base font-semibold">Package files</h2>
</div>
<div className="border-b border-border px-3 py-2 shrink-0">
<div className="flex items-center gap-2 rounded-md border border-border px-2 py-1">
<Search className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<input
type="text"
value={treeSearch}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Search files..."
className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<PackageFileTree
nodes={displayTree}
selectedFile={selectedFile}
expandedDirs={expandedDirs}
checkedFiles={checkedFiles}
onToggleDir={handleToggleDir}
onSelectFile={selectFile}
onToggleCheck={handleToggleCheck}
/>
{totalTaskChildren > visibleTaskChildren && !treeSearch && (
<div className="px-4 py-2">
<button
type="button"
onClick={() => setTaskLimit((prev) => prev + TASKS_PAGE_SIZE)}
className="w-full rounded-md border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent/30 hover:text-foreground transition-colors"
>
Show more issues ({visibleTaskChildren} of {totalTaskChildren})
</button>
</div>
)}
</div>
</aside>
<div className="min-w-0 overflow-y-auto pl-6">
<ExportPreviewPane selectedFile={selectedFile} content={previewContent} allFiles={effectiveFiles} onSkillClick={handleSkillClick} />
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -2,12 +2,13 @@ import { ChangeEvent, useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
import { companiesApi } from "../api/companies";
import { accessApi } from "../api/access";
import { assetsApi } from "../api/assets";
import { queryKeys } from "../lib/queryKeys";
import { Button } from "@/components/ui/button";
import { Settings, Check } from "lucide-react";
import { Settings, Check, Download, Upload } from "lucide-react";
import { CompanyPatternIcon } from "../components/CompanyPatternIcon";
import {
Field,
@ -29,8 +30,8 @@ export function CompanySettings() {
setSelectedCompanyId
} = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const queryClient = useQueryClient();
// General settings local state
const [companyName, setCompanyName] = useState("");
const [description, setDescription] = useState("");
@ -174,6 +175,7 @@ export function CompanySettings() {
setSnippetCopied(false);
setSnippetCopyDelightId(0);
}, [selectedCompanyId]);
const archiveMutation = useMutation({
mutationFn: ({
companyId,
@ -461,6 +463,33 @@ export function CompanySettings() {
</div>
</div>
{/* Import / Export */}
<div className="space-y-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Company Packages
</div>
<div className="rounded-md border border-border px-4 py-4">
<p className="text-sm text-muted-foreground">
Import and export have moved to dedicated pages accessible from the{" "}
<a href="/org" className="underline hover:text-foreground">Org Chart</a> header.
</p>
<div className="mt-3 flex items-center gap-2">
<Button size="sm" variant="outline" asChild>
<a href="/company/export">
<Download className="mr-1.5 h-3.5 w-3.5" />
Export
</a>
</Button>
<Button size="sm" variant="outline" asChild>
<a href="/company/import">
<Upload className="mr-1.5 h-3.5 w-3.5" />
Import
</a>
</Button>
</div>
</div>
</div>
{/* Danger Zone */}
<div className="space-y-4">
<div className="text-xs font-medium text-destructive uppercase tracking-wide">

File diff suppressed because it is too large Load diff

View file

@ -24,11 +24,14 @@ export function InstanceExperimentalSettings() {
});
const toggleMutation = useMutation({
mutationFn: async (enabled: boolean) =>
instanceSettingsApi.updateExperimental({ enableIsolatedWorkspaces: enabled }),
mutationFn: async (patch: { enableIsolatedWorkspaces?: boolean; autoRestartDevServerWhenIdle?: boolean }) =>
instanceSettingsApi.updateExperimental(patch),
onSuccess: async () => {
setActionError(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings });
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings }),
queryClient.invalidateQueries({ queryKey: queryKeys.health }),
]);
},
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to update experimental settings.");
@ -50,6 +53,7 @@ export function InstanceExperimentalSettings() {
}
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
return (
<div className="max-w-4xl space-y-6">
@ -72,7 +76,7 @@ export function InstanceExperimentalSettings() {
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Enabled Isolated Workspaces</h2>
<h2 className="text-sm font-semibold">Enable Isolated Workspaces</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Show execution workspace controls in project configuration and allow isolated workspace behavior for new
and existing issue runs.
@ -83,15 +87,46 @@ export function InstanceExperimentalSettings() {
aria-label="Toggle isolated workspaces experimental setting"
disabled={toggleMutation.isPending}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
enableIsolatedWorkspaces ? "bg-green-600" : "bg-muted",
)}
onClick={() => toggleMutation.mutate(!enableIsolatedWorkspaces)}
onClick={() => toggleMutation.mutate({ enableIsolatedWorkspaces: !enableIsolatedWorkspaces })}
>
<span
className={cn(
"inline-block h-4.5 w-4.5 rounded-full bg-white transition-transform",
enableIsolatedWorkspaces ? "translate-x-6" : "translate-x-0.5",
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
enableIsolatedWorkspaces ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Auto-Restart Dev Server When Idle</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
In `pnpm dev:once`, wait for all queued and running local agent runs to finish, then restart the server
automatically when backend changes or migrations make the current boot stale.
</p>
</div>
<button
type="button"
aria-label="Toggle guarded dev-server auto-restart"
disabled={toggleMutation.isPending}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
autoRestartDevServerWhenIdle ? "bg-green-600" : "bg-muted",
)}
onClick={() =>
toggleMutation.mutate({ autoRestartDevServerWhenIdle: !autoRestartDevServerWhenIdle })
}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
autoRestartDevServerWhenIdle ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>

View file

@ -0,0 +1,103 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { SlidersHorizontal } from "lucide-react";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
export function InstanceGeneralSettings() {
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const [actionError, setActionError] = useState<string | null>(null);
useEffect(() => {
setBreadcrumbs([
{ label: "Instance Settings" },
{ label: "General" },
]);
}, [setBreadcrumbs]);
const generalQuery = useQuery({
queryKey: queryKeys.instance.generalSettings,
queryFn: () => instanceSettingsApi.getGeneral(),
});
const toggleMutation = useMutation({
mutationFn: async (enabled: boolean) =>
instanceSettingsApi.updateGeneral({ censorUsernameInLogs: enabled }),
onSuccess: async () => {
setActionError(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.instance.generalSettings });
},
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to update general settings.");
},
});
if (generalQuery.isLoading) {
return <div className="text-sm text-muted-foreground">Loading general settings...</div>;
}
if (generalQuery.error) {
return (
<div className="text-sm text-destructive">
{generalQuery.error instanceof Error
? generalQuery.error.message
: "Failed to load general settings."}
</div>
);
}
const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true;
return (
<div className="max-w-4xl space-y-6">
<div className="space-y-2">
<div className="flex items-center gap-2">
<SlidersHorizontal className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">General</h1>
</div>
<p className="text-sm text-muted-foreground">
Configure instance-wide defaults that affect how operator-visible logs are displayed.
</p>
</div>
{actionError && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{actionError}
</div>
)}
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Censor username in logs</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Hide the username segment in home-directory paths and similar operator-visible log output. Standalone
username mentions outside of paths are not yet masked in the live transcript view. This is off by
default.
</p>
</div>
<button
type="button"
aria-label="Toggle username log censoring"
disabled={toggleMutation.isPending}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
censorUsernameInLogs ? "bg-green-600" : "bg-muted",
)}
onClick={() => toggleMutation.mutate(!censorUsernameInLogs)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
censorUsernameInLogs ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div>
</section>
</div>
);
}

View file

@ -4,9 +4,11 @@ import { useNavigate, useSearchParams } from "@/lib/router";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { agentsApi } from "../api/agents";
import { companySkillsApi } from "../api/companySkills";
import { queryKeys } from "../lib/queryKeys";
import { AGENT_ROLES } from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Popover,
PopoverContent,
@ -68,6 +70,7 @@ export function NewAgent() {
const [role, setRole] = useState("general");
const [reportsTo, setReportsTo] = useState<string | null>(null);
const [configValues, setConfigValues] = useState<CreateConfigValues>(defaultCreateValues);
const [selectedSkillKeys, setSelectedSkillKeys] = useState<string[]>([]);
const [roleOpen, setRoleOpen] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
@ -90,6 +93,12 @@ export function NewAgent() {
enabled: Boolean(selectedCompanyId),
});
const { data: companySkills } = useQuery({
queryKey: queryKeys.companySkills.list(selectedCompanyId ?? ""),
queryFn: () => companySkillsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
const isFirstAgent = !agents || agents.length === 0;
const effectiveRole = isFirstAgent ? "ceo" : role;
@ -172,7 +181,8 @@ export function NewAgent() {
name: name.trim(),
role: effectiveRole,
...(title.trim() ? { title: title.trim() } : {}),
...(reportsTo != null ? { reportsTo } : {}),
...(reportsTo ? { reportsTo } : {}),
...(selectedSkillKeys.length > 0 ? { desiredSkills: selectedSkillKeys } : {}),
adapterType: configValues.adapterType,
adapterConfig: buildAdapterConfig(),
runtimeConfig: {
@ -188,6 +198,17 @@ export function NewAgent() {
});
}
const availableSkills = (companySkills ?? []).filter((skill) => !skill.key.startsWith("paperclipai/paperclip/"));
function toggleSkill(key: string, checked: boolean) {
setSelectedSkillKeys((prev) => {
if (checked) {
return prev.includes(key) ? prev : [...prev, key];
}
return prev.filter((value) => value !== key);
});
}
return (
<div className="mx-auto max-w-2xl space-y-6">
<div>
@ -266,6 +287,44 @@ export function NewAgent() {
adapterModels={adapterModels}
/>
<div className="border-t border-border px-4 py-4">
<div className="space-y-3">
<div>
<h2 className="text-sm font-medium">Company skills</h2>
<p className="mt-1 text-xs text-muted-foreground">
Optional skills from the company library. Built-in Paperclip runtime skills are added automatically.
</p>
</div>
{availableSkills.length === 0 ? (
<p className="text-xs text-muted-foreground">
No optional company skills installed yet.
</p>
) : (
<div className="space-y-3">
{availableSkills.map((skill) => {
const inputId = `skill-${skill.id}`;
const checked = selectedSkillKeys.includes(skill.key);
return (
<div key={skill.id} className="flex items-start gap-3">
<Checkbox
id={inputId}
checked={checked}
onCheckedChange={(next) => toggleSkill(skill.key, next === true)}
/>
<label htmlFor={inputId} className="grid gap-1 leading-none">
<span className="text-sm font-medium">{skill.name}</span>
<span className="text-xs text-muted-foreground">
{skill.description ?? skill.key}
</span>
</label>
</div>
);
})}
</div>
)}
</div>
</div>
{/* Footer */}
<div className="border-t border-border px-4 py-3">
{isFirstAgent && (

View file

@ -1,15 +1,16 @@
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
import { useNavigate } from "@/lib/router";
import { Link, useNavigate } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { agentsApi, type OrgNode } from "../api/agents";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { agentUrl } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { AgentIcon } from "../components/AgentIconPicker";
import { Network } from "lucide-react";
import { Download, Network, Upload } from "lucide-react";
import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
// Layout constants
@ -267,9 +268,24 @@ export function OrgChart() {
}
return (
<div className="flex flex-col h-full">
<div className="mb-2 flex items-center justify-start gap-2 shrink-0">
<Link to="/company/import">
<Button variant="outline" size="sm">
<Upload className="mr-1.5 h-3.5 w-3.5" />
Import company
</Button>
</Link>
<Link to="/company/export">
<Button variant="outline" size="sm">
<Download className="mr-1.5 h-3.5 w-3.5" />
Export company
</Button>
</Link>
</div>
<div
ref={containerRef}
className="w-full h-[calc(100dvh-6rem)] overflow-hidden relative bg-muted/20 border border-border rounded-lg"
className="w-full flex-1 min-h-0 overflow-hidden relative bg-muted/20 border border-border rounded-lg"
style={{ cursor: dragging ? "grabbing" : "grab" }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
@ -419,10 +435,11 @@ export function OrgChart() {
})}
</div>
</div>
</div>
);
}
const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
const roleLabels: Record<string, string> = AGENT_ROLE_LABELS;
function roleLabel(role: string): string {
return roleLabels[role] ?? role;