mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +09:00
Merge branch 'master' into feature/change-reports-to
This commit is contained in:
commit
dfb83295de
191 changed files with 46471 additions and 1103 deletions
File diff suppressed because it is too large
Load diff
919
ui/src/pages/CompanyExport.tsx
Normal file
919
ui/src/pages/CompanyExport.tsx
Normal 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("");
|
||||
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>
|
||||
);
|
||||
}
|
||||
1295
ui/src/pages/CompanyImport.tsx
Normal file
1295
ui/src/pages/CompanyImport.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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">
|
||||
|
|
|
|||
1170
ui/src/pages/CompanySkills.tsx
Normal file
1170
ui/src/pages/CompanySkills.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
|
|
|
|||
103
ui/src/pages/InstanceGeneralSettings.tsx
Normal file
103
ui/src/pages/InstanceGeneralSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue