mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +09:00
Expand plugin host surface (#5205)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The plugin system is the extension boundary for optional product capabilities > - Rich plugins need more than a worker entrypoint: they need scoped database storage, local project folders, managed agents/routines, host navigation, and reusable UI components > - The LLM Wiki work exposed those missing host surfaces while keeping plugin code outside the core control plane > - This pull request expands the core plugin host, SDK, server APIs, and UI bridge so plugins can declare and use those surfaces > - The benefit is that future plugins can integrate with Paperclip through documented, validated contracts instead of bespoke server or UI imports ## What Changed - Added plugin-managed database namespaces and migration tracking, including Drizzle schema/migration files and SQL validation for namespace isolation. - Added server support for plugin local folders, managed agents, managed routines, scoped plugin APIs, and plugin operation visibility. - Expanded shared plugin manifest/types/validators and SDK host/testing/UI exports for richer plugin surfaces. - Added reusable UI pieces for file trees, managed routines, resizable sidebars, route sidebars, and plugin bridge initialization. - Updated plugin docs and example plugins to use the expanded host and SDK surface. ## Verification - `pnpm install --frozen-lockfile` - `pnpm run preflight:workspace-links && pnpm exec vitest run packages/shared/src/validators/plugin.test.ts server/src/__tests__/plugin-database.test.ts server/src/__tests__/plugin-local-folders.test.ts server/src/__tests__/plugin-managed-agents.test.ts server/src/__tests__/plugin-managed-routines.test.ts server/src/__tests__/plugin-orchestration-apis.test.ts ui/src/api/plugins.test.ts ui/src/components/FileTree.test.tsx ui/src/components/ResizableSidebarPane.test.tsx ui/src/pages/PluginPage.test.tsx ui/src/plugins/bridge.test.ts` passed: 11 files, 67 tests. - Confirmed this PR changes 89 files and does not include `pnpm-lock.yaml` or `.github/workflows/*`. ## Risks - Medium: this expands plugin host contracts across db/shared/server/ui and includes a new core migration (`0076_useful_elektra.sql`). - The plugin database namespace validator is intentionally restrictive; plugin authors may need follow-up affordances for SQL patterns that remain blocked. - Merge this before the LLM Wiki plugin PR so the plugin can resolve the new SDK and host APIs. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-enabled shell/git/GitHub workflow. Context window size was not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
d6bee62f02
commit
3c73ed26b5
89 changed files with 27516 additions and 914 deletions
|
|
@ -40,7 +40,7 @@ import { Identity } from "../components/Identity";
|
|||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { RunButton, PauseResumeButton } from "../components/AgentActionButtons";
|
||||
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
||||
import { PackageFileTree, buildFileTree } from "../components/PackageFileTree";
|
||||
import { FileTree, buildFileTree } from "../components/FileTree";
|
||||
import { ScrollToBottom } from "../components/ScrollToBottom";
|
||||
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||
import { cn } from "../lib/utils";
|
||||
|
|
@ -2276,7 +2276,7 @@ function PromptsTab({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<PackageFileTree
|
||||
<FileTree
|
||||
nodes={fileTree}
|
||||
selectedFile={selectedOrEntryFile}
|
||||
expandedDirs={expandedDirs}
|
||||
|
|
|
|||
|
|
@ -41,8 +41,8 @@ import {
|
|||
collectAllPaths,
|
||||
parseFrontmatter,
|
||||
FRONTMATTER_FIELD_LABELS,
|
||||
PackageFileTree,
|
||||
} from "../components/PackageFileTree";
|
||||
FileTree,
|
||||
} from "../components/FileTree";
|
||||
|
||||
/**
|
||||
* Extract the set of agent/project/task slugs that are "checked" based on
|
||||
|
|
@ -988,7 +988,7 @@ export function CompanyExport() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<PackageFileTree
|
||||
<FileTree
|
||||
nodes={displayTree}
|
||||
selectedFile={selectedFile}
|
||||
expandedDirs={expandedDirs}
|
||||
|
|
@ -996,6 +996,7 @@ export function CompanyExport() {
|
|||
onToggleDir={handleToggleDir}
|
||||
onSelectFile={selectFile}
|
||||
onToggleCheck={handleToggleCheck}
|
||||
wrapLabels={false}
|
||||
/>
|
||||
{totalTaskChildren > visibleTaskChildren && !treeSearch && (
|
||||
<div className="px-4 py-2">
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ import {
|
|||
collectAllPaths,
|
||||
parseFrontmatter,
|
||||
FRONTMATTER_FIELD_LABELS,
|
||||
PackageFileTree,
|
||||
} from "../components/PackageFileTree";
|
||||
FileTree,
|
||||
} from "../components/FileTree";
|
||||
import { readZipArchive } from "../lib/zip";
|
||||
import { getPortableFileDataUrl, getPortableFileText, isPortableImageFile } from "../lib/portable-files";
|
||||
|
||||
|
|
@ -1325,7 +1325,7 @@ export function CompanyImport() {
|
|||
<h2 className="text-base font-semibold">Package files</h2>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<PackageFileTree
|
||||
<FileTree
|
||||
nodes={tree}
|
||||
selectedFile={selectedFile}
|
||||
expandedDirs={expandedDirs}
|
||||
|
|
@ -1335,6 +1335,7 @@ export function CompanyImport() {
|
|||
onToggleCheck={handleToggleCheck}
|
||||
renderFileExtra={(node, checked) => renderImportFileExtra(node, checked, renameMap)}
|
||||
fileRowClassName={importFileRowClassName}
|
||||
wrapLabels={false}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
|
|
|||
207
ui/src/pages/PluginPage.test.tsx
Normal file
207
ui/src/pages/PluginPage.test.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PluginPage } from "./PluginPage";
|
||||
|
||||
const mockPluginsApi = vi.hoisted(() => ({
|
||||
listUiContributions: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||
const mockParams = vi.hoisted(() => ({
|
||||
companyPrefix: "PAP" as string | undefined,
|
||||
pluginId: undefined as string | undefined,
|
||||
pluginRoutePath: undefined as string | undefined,
|
||||
"*": undefined as string | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@/api/plugins", () => ({
|
||||
pluginsApi: mockPluginsApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({
|
||||
setBreadcrumbs: mockSetBreadcrumbs,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
companies: [{ id: "company-1", name: "Paperclip", issuePrefix: "PAP" }],
|
||||
selectedCompanyId: "company-1",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ to, children }: { to: string; children: React.ReactNode }) => <a href={to}>{children}</a>,
|
||||
Navigate: () => null,
|
||||
useParams: () => mockParams,
|
||||
}));
|
||||
|
||||
vi.mock("@/plugins/slots", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/plugins/slots")>("@/plugins/slots");
|
||||
return {
|
||||
resolveRouteSidebarSlot: actual.resolveRouteSidebarSlot,
|
||||
PluginSlotMount: ({ slot }: { slot: { displayName: string } }) => (
|
||||
<div data-testid="plugin-slot-mount">{slot.displayName}</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
function pageContribution(overrides: Partial<{ slots: unknown[] }> = {}) {
|
||||
return {
|
||||
pluginId: "plugin-wiki",
|
||||
pluginKey: "paperclipai.plugin-llm-wiki",
|
||||
displayName: "LLM Wiki",
|
||||
version: "0.1.0",
|
||||
uiEntryFile: "ui.js",
|
||||
slots: [
|
||||
{
|
||||
type: "page",
|
||||
id: "wiki-page",
|
||||
displayName: "Wiki",
|
||||
exportName: "WikiPage",
|
||||
routePath: "wiki",
|
||||
},
|
||||
],
|
||||
launchers: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function renderPage(container: HTMLDivElement) {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PluginPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("PluginPage", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockParams.companyPrefix = "PAP";
|
||||
mockParams.pluginId = undefined;
|
||||
mockParams.pluginRoutePath = undefined;
|
||||
mockParams["*"] = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the breadcrumb and Back button on a legacy plugin route (no routeSidebar)", async () => {
|
||||
mockParams.pluginRoutePath = "wiki";
|
||||
mockPluginsApi.listUiContributions.mockResolvedValue([pageContribution()]);
|
||||
|
||||
const root = await renderPage(container);
|
||||
|
||||
expect(mockSetBreadcrumbs).toHaveBeenCalledWith([
|
||||
{ label: "Plugins", href: "/instance/settings/plugins" },
|
||||
{ label: "LLM Wiki" },
|
||||
]);
|
||||
expect(container.textContent).toContain("Back");
|
||||
expect(container.querySelector('a[href="/PAP/dashboard"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a route title and hides the Back button when a routeSidebar matches the active route", async () => {
|
||||
mockParams.pluginRoutePath = "wiki";
|
||||
mockPluginsApi.listUiContributions.mockResolvedValue([
|
||||
pageContribution({
|
||||
slots: [
|
||||
{
|
||||
type: "page",
|
||||
id: "wiki-page",
|
||||
displayName: "Wiki",
|
||||
exportName: "WikiPage",
|
||||
routePath: "wiki",
|
||||
},
|
||||
{
|
||||
type: "routeSidebar",
|
||||
id: "wiki-sidebar",
|
||||
displayName: "Wiki Sidebar",
|
||||
exportName: "WikiRouteSidebar",
|
||||
routePath: "wiki",
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
const root = await renderPage(container);
|
||||
|
||||
expect(mockSetBreadcrumbs).toHaveBeenCalledWith([{ label: "Wiki" }]);
|
||||
expect(container.textContent).not.toContain("Back");
|
||||
expect(container.querySelector('a[href="/PAP/dashboard"]')).toBeNull();
|
||||
// Page slot itself still renders.
|
||||
expect(container.querySelector('[data-testid="plugin-slot-mount"]')?.textContent).toBe("Wiki");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the selected plugin page path as the route-sidebar title", async () => {
|
||||
mockParams.pluginRoutePath = "wiki";
|
||||
mockParams["*"] = "page/templates%3A%3Aindex.md";
|
||||
mockPluginsApi.listUiContributions.mockResolvedValue([
|
||||
pageContribution({
|
||||
slots: [
|
||||
{
|
||||
type: "page",
|
||||
id: "wiki-page",
|
||||
displayName: "Wiki",
|
||||
exportName: "WikiPage",
|
||||
routePath: "wiki",
|
||||
},
|
||||
{
|
||||
type: "routeSidebar",
|
||||
id: "wiki-sidebar",
|
||||
displayName: "Wiki Sidebar",
|
||||
exportName: "WikiRouteSidebar",
|
||||
routePath: "wiki",
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
const root = await renderPage(container);
|
||||
|
||||
expect(mockSetBreadcrumbs).toHaveBeenCalledWith([{ label: "index" }]);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -5,7 +5,11 @@ import { useCompany } from "@/context/CompanyContext";
|
|||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { pluginsApi } from "@/api/plugins";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { PluginSlotMount } from "@/plugins/slots";
|
||||
import {
|
||||
PluginSlotMount,
|
||||
resolveRouteSidebarSlot,
|
||||
type ResolvedPluginSlot,
|
||||
} from "@/plugins/slots";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { NotFoundPage } from "./NotFound";
|
||||
|
|
@ -19,11 +23,14 @@ import { NotFoundPage } from "./NotFound";
|
|||
* @see doc/plugins/PLUGIN_SPEC.md §24.4 — Company-Context Plugin Page
|
||||
*/
|
||||
export function PluginPage() {
|
||||
const { companyPrefix: routeCompanyPrefix, pluginId, pluginRoutePath } = useParams<{
|
||||
const params = useParams<{
|
||||
companyPrefix?: string;
|
||||
pluginId?: string;
|
||||
pluginRoutePath?: string;
|
||||
"*": string | undefined;
|
||||
}>();
|
||||
const { companyPrefix: routeCompanyPrefix, pluginId, pluginRoutePath } = params;
|
||||
const pluginRouteSplat = params["*"];
|
||||
const { companies, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const routeCompany = useMemo(() => {
|
||||
|
|
@ -89,14 +96,33 @@ export function PluginPage() {
|
|||
[resolvedCompanyId, companyPrefix],
|
||||
);
|
||||
|
||||
// When the active route has a routeSidebar slot, the sidebar provides the
|
||||
// back affordance, but the top bar still needs a route-specific title.
|
||||
const routeSidebarActive = useMemo(() => {
|
||||
if (!pluginRoutePath || !contributions) return false;
|
||||
const flattened: ResolvedPluginSlot[] = contributions.flatMap((contribution) =>
|
||||
contribution.slots.map((slot) => ({
|
||||
...slot,
|
||||
pluginId: contribution.pluginId,
|
||||
pluginKey: contribution.pluginKey,
|
||||
pluginDisplayName: contribution.displayName,
|
||||
pluginVersion: contribution.version,
|
||||
})),
|
||||
);
|
||||
return resolveRouteSidebarSlot(flattened, pluginRoutePath) !== null;
|
||||
}, [contributions, pluginRoutePath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pageSlot) {
|
||||
setBreadcrumbs([
|
||||
{ label: "Plugins", href: "/instance/settings/plugins" },
|
||||
{ label: pageSlot.pluginDisplayName },
|
||||
]);
|
||||
if (!pageSlot) return;
|
||||
if (routeSidebarActive) {
|
||||
setBreadcrumbs([{ label: resolveRouteSidebarPageTitle(pageSlot, pluginRouteSplat) }]);
|
||||
return;
|
||||
}
|
||||
}, [pageSlot, companyPrefix, setBreadcrumbs]);
|
||||
setBreadcrumbs([
|
||||
{ label: "Plugins", href: "/instance/settings/plugins" },
|
||||
{ label: pageSlot.pluginDisplayName },
|
||||
]);
|
||||
}, [pageSlot, pluginRouteSplat, setBreadcrumbs, routeSidebarActive]);
|
||||
|
||||
if (!resolvedCompanyId) {
|
||||
if (hasInvalidCompanyPrefix) {
|
||||
|
|
@ -137,14 +163,16 @@ export function PluginPage() {
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={companyPrefix ? `/${companyPrefix}/dashboard` : "/dashboard"}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{!routeSidebarActive && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={companyPrefix ? `/${companyPrefix}/dashboard` : "/dashboard"}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<PluginSlotMount
|
||||
slot={pageSlot}
|
||||
context={context}
|
||||
|
|
@ -154,3 +182,42 @@ export function PluginPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRouteSidebarPageTitle(pageSlot: ResolvedPluginSlot, routeSplat: string | undefined): string {
|
||||
const title = titleFromRouteSplat(routeSplat);
|
||||
return title ?? pageSlot.displayName ?? pageSlot.pluginDisplayName;
|
||||
}
|
||||
|
||||
function titleFromRouteSplat(routeSplat: string | undefined): string | null {
|
||||
const segments = (routeSplat ?? "")
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map(decodeRouteSegment);
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
if (segments[0] === "page" && segments.length > 1) {
|
||||
return titleFromPath(segments.slice(1).join("/"), { preserveCase: true });
|
||||
}
|
||||
|
||||
return titleFromPath(segments[0] ?? null);
|
||||
}
|
||||
|
||||
function titleFromPath(path: string | null | undefined, options: { preserveCase?: boolean } = {}): string | null {
|
||||
const trimmed = path?.trim();
|
||||
if (!trimmed) return null;
|
||||
const basename = trimmed.split("/").filter(Boolean).at(-1) ?? trimmed;
|
||||
const withoutNamespace = basename.split("::").at(-1) ?? basename;
|
||||
const withoutExtension = withoutNamespace.replace(/\.[^.]+$/, "");
|
||||
const normalized = withoutExtension.replace(/[-_]+/g, " ").trim();
|
||||
if (!normalized) return null;
|
||||
if (options.preserveCase) return normalized;
|
||||
return normalized.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function decodeRouteSegment(segment: string): string {
|
||||
try {
|
||||
return decodeURIComponent(segment);
|
||||
} catch {
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ const mockPluginsApi = vi.hoisted(() => ({
|
|||
dashboard: vi.fn(),
|
||||
logs: vi.fn(),
|
||||
getConfig: vi.fn(),
|
||||
listLocalFolders: vi.fn(),
|
||||
configureLocalFolder: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -58,6 +60,82 @@ async function flushReact() {
|
|||
});
|
||||
}
|
||||
|
||||
function basePlugin(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "plugin-1",
|
||||
pluginKey: "paperclip.e2b-sandbox-provider",
|
||||
packageName: "@paperclipai/plugin-e2b",
|
||||
version: "0.1.0",
|
||||
status: "error",
|
||||
categories: ["automation"],
|
||||
manifestJson: {
|
||||
displayName: "E2B Sandbox Provider",
|
||||
version: "0.1.0",
|
||||
description: "E2B environments for Paperclip.",
|
||||
author: "Paperclip",
|
||||
capabilities: ["environment.drivers.register"],
|
||||
environmentDrivers: [
|
||||
{
|
||||
driverKey: "e2b",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "E2B Cloud Sandbox",
|
||||
},
|
||||
],
|
||||
},
|
||||
lastError: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function wikiFolderDeclaration() {
|
||||
return {
|
||||
folderKey: "wiki-root",
|
||||
displayName: "Wiki root",
|
||||
description: "Company-scoped local folder that stores wiki files.",
|
||||
access: "readWrite" as const,
|
||||
requiredDirectories: ["raw", "wiki"],
|
||||
requiredFiles: ["WIKI.md", "index.md"],
|
||||
};
|
||||
}
|
||||
|
||||
function folderStatus(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
folderKey: "wiki-root",
|
||||
configured: false,
|
||||
path: null,
|
||||
realPath: null,
|
||||
access: "readWrite",
|
||||
readable: false,
|
||||
writable: false,
|
||||
requiredDirectories: ["raw", "wiki"],
|
||||
requiredFiles: ["WIKI.md", "index.md"],
|
||||
missingDirectories: ["raw", "wiki"],
|
||||
missingFiles: ["WIKI.md", "index.md"],
|
||||
healthy: false,
|
||||
problems: [{ code: "not_configured", message: "No local folder path is configured." }],
|
||||
checkedAt: "2026-05-02T16:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function renderSettings(container: HTMLDivElement) {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PluginSettings />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("PluginSettings", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
|
|
@ -65,30 +143,16 @@ describe("PluginSettings", () => {
|
|||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
|
||||
mockPluginsApi.get.mockResolvedValue({
|
||||
id: "plugin-1",
|
||||
pluginKey: "paperclip.e2b-sandbox-provider",
|
||||
packageName: "@paperclipai/plugin-e2b",
|
||||
version: "0.1.0",
|
||||
status: "error",
|
||||
categories: ["automation"],
|
||||
manifestJson: {
|
||||
displayName: "E2B Sandbox Provider",
|
||||
version: "0.1.0",
|
||||
description: "E2B environments for Paperclip.",
|
||||
author: "Paperclip",
|
||||
capabilities: ["environment.drivers.register"],
|
||||
environmentDrivers: [
|
||||
{
|
||||
driverKey: "e2b",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "E2B Cloud Sandbox",
|
||||
},
|
||||
],
|
||||
},
|
||||
lastError: null,
|
||||
});
|
||||
mockPluginsApi.get.mockResolvedValue(basePlugin());
|
||||
mockPluginsApi.dashboard.mockResolvedValue(null);
|
||||
mockPluginsApi.health.mockResolvedValue({ pluginId: "plugin-1", status: "ready", healthy: true, checks: [] });
|
||||
mockPluginsApi.logs.mockResolvedValue([]);
|
||||
mockPluginsApi.listLocalFolders.mockResolvedValue({
|
||||
pluginId: "plugin-1",
|
||||
companyId: "company-1",
|
||||
declarations: [],
|
||||
folders: [],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -98,20 +162,7 @@ describe("PluginSettings", () => {
|
|||
});
|
||||
|
||||
it("routes environment-provider plugins to company environments when they have no instance config", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PluginSettings />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
const root = await renderSettings(container);
|
||||
|
||||
expect(container.textContent).toContain("Configure this plugin from Company Environments.");
|
||||
expect(container.textContent).toContain("company-scoped instead of instance-global");
|
||||
|
|
@ -122,4 +173,165 @@ describe("PluginSettings", () => {
|
|||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders unconfigured manifest local folders with required paths", async () => {
|
||||
const declaration = wikiFolderDeclaration();
|
||||
mockPluginsApi.get.mockResolvedValue(basePlugin({
|
||||
pluginKey: "paperclipai.plugin-llm-wiki",
|
||||
packageName: "@paperclipai/plugin-llm-wiki",
|
||||
status: "ready",
|
||||
manifestJson: {
|
||||
displayName: "LLM Wiki",
|
||||
version: "0.1.0",
|
||||
description: "Local-file LLM Wiki plugin.",
|
||||
author: "Paperclip",
|
||||
capabilities: ["local.folders"],
|
||||
localFolders: [declaration],
|
||||
},
|
||||
}));
|
||||
mockPluginsApi.listLocalFolders.mockResolvedValue({
|
||||
pluginId: "plugin-1",
|
||||
companyId: "company-1",
|
||||
declarations: [declaration],
|
||||
folders: [folderStatus()],
|
||||
});
|
||||
|
||||
const root = await renderSettings(container);
|
||||
|
||||
expect(container.textContent).toContain("Local folders");
|
||||
expect(container.textContent).toContain("Wiki root");
|
||||
expect(container.textContent).toContain("Needs attention");
|
||||
expect(container.textContent).toContain("No local folder path is configured.");
|
||||
expect(container.textContent).toContain("Missing directories: raw, wiki");
|
||||
expect(container.textContent).toContain("Missing files: WIKI.md, index.md");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders invalid configured folders with validation problems", async () => {
|
||||
const declaration = wikiFolderDeclaration();
|
||||
mockPluginsApi.get.mockResolvedValue(basePlugin({
|
||||
manifestJson: {
|
||||
displayName: "LLM Wiki",
|
||||
version: "0.1.0",
|
||||
description: "Local-file LLM Wiki plugin.",
|
||||
author: "Paperclip",
|
||||
capabilities: ["local.folders"],
|
||||
localFolders: [declaration],
|
||||
},
|
||||
}));
|
||||
mockPluginsApi.listLocalFolders.mockResolvedValue({
|
||||
pluginId: "plugin-1",
|
||||
companyId: "company-1",
|
||||
declarations: [declaration],
|
||||
folders: [folderStatus({
|
||||
configured: true,
|
||||
path: "/tmp/wiki",
|
||||
realPath: "/tmp/wiki",
|
||||
readable: true,
|
||||
writable: true,
|
||||
missingDirectories: [],
|
||||
missingFiles: ["WIKI.md"],
|
||||
problems: [{ code: "missing_file", message: "Required file is missing.", path: "WIKI.md" }],
|
||||
})],
|
||||
});
|
||||
|
||||
const root = await renderSettings(container);
|
||||
|
||||
expect(container.textContent).toContain("/tmp/wiki");
|
||||
expect(container.textContent).toContain("ReadableYes");
|
||||
expect(container.textContent).toContain("WritableYes");
|
||||
expect(container.textContent).toContain("Validation problems");
|
||||
expect(container.textContent).toContain("Required file is missing.");
|
||||
expect(container.textContent).toContain("Missing files: WIKI.md");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render required paths as present when the configured root cannot be inspected", async () => {
|
||||
const declaration = wikiFolderDeclaration();
|
||||
mockPluginsApi.get.mockResolvedValue(basePlugin({
|
||||
manifestJson: {
|
||||
displayName: "LLM Wiki",
|
||||
version: "0.1.0",
|
||||
description: "Local-file LLM Wiki plugin.",
|
||||
author: "Paperclip",
|
||||
capabilities: ["local.folders"],
|
||||
localFolders: [declaration],
|
||||
},
|
||||
}));
|
||||
mockPluginsApi.listLocalFolders.mockResolvedValue({
|
||||
pluginId: "plugin-1",
|
||||
companyId: "company-1",
|
||||
declarations: [declaration],
|
||||
folders: [folderStatus({
|
||||
configured: true,
|
||||
path: "/tmp/wiki-missing",
|
||||
readable: false,
|
||||
writable: false,
|
||||
missingDirectories: [],
|
||||
missingFiles: [],
|
||||
problems: [{ code: "missing", message: "Configured local folder cannot be inspected.", path: "/tmp/wiki-missing" }],
|
||||
})],
|
||||
});
|
||||
|
||||
const root = await renderSettings(container);
|
||||
|
||||
expect(container.textContent).toContain("Configured local folder cannot be inspected.");
|
||||
expect(container.textContent).toContain("Not inspected");
|
||||
expect(container.textContent).toContain("Configured root was not inspected.");
|
||||
expect(container.textContent).not.toContain("Present");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders healthy folders without validation problems", async () => {
|
||||
const declaration = wikiFolderDeclaration();
|
||||
mockPluginsApi.get.mockResolvedValue(basePlugin({
|
||||
manifestJson: {
|
||||
displayName: "LLM Wiki",
|
||||
version: "0.1.0",
|
||||
description: "Local-file LLM Wiki plugin.",
|
||||
author: "Paperclip",
|
||||
capabilities: ["local.folders"],
|
||||
localFolders: [declaration],
|
||||
},
|
||||
}));
|
||||
mockPluginsApi.listLocalFolders.mockResolvedValue({
|
||||
pluginId: "plugin-1",
|
||||
companyId: "company-1",
|
||||
declarations: [declaration],
|
||||
folders: [folderStatus({
|
||||
configured: true,
|
||||
path: "/tmp/wiki",
|
||||
realPath: "/private/tmp/wiki",
|
||||
readable: true,
|
||||
writable: true,
|
||||
missingDirectories: [],
|
||||
missingFiles: [],
|
||||
healthy: true,
|
||||
problems: [],
|
||||
})],
|
||||
});
|
||||
|
||||
const root = await renderSettings(container);
|
||||
|
||||
expect(container.textContent).toContain("Healthy");
|
||||
expect(container.textContent).toContain("Configured path");
|
||||
expect(container.textContent).toContain("/tmp/wiki");
|
||||
expect(container.textContent).toContain("ReadableYes");
|
||||
expect(container.textContent).toContain("WritableYes");
|
||||
expect(container.textContent).toContain("Present");
|
||||
expect(container.textContent).not.toContain("Validation problems");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Puzzle, ArrowLeft, ShieldAlert, ActivitySquare, CheckCircle, XCircle, Loader2, Clock, Cpu, Webhook, CalendarClock, AlertTriangle } from "lucide-react";
|
||||
import { Puzzle, ArrowLeft, ShieldAlert, ActivitySquare, CheckCircle, XCircle, Loader2, Clock, Cpu, Webhook, CalendarClock, AlertTriangle, FolderOpen, Save } from "lucide-react";
|
||||
import type { PluginLocalFolderDeclaration } from "@paperclipai/shared";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { Link, Navigate, useParams } from "@/lib/router";
|
||||
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
|
||||
import { pluginsApi } from "@/api/plugins";
|
||||
import { pluginsApi, type PluginLocalFolderStatus } from "@/api/plugins";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChoosePathButton } from "@/components/PathInstructionsModal";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
|
@ -143,6 +145,8 @@ export function PluginSettings() {
|
|||
const pluginDescription = plugin.manifestJson.description || "No description provided.";
|
||||
const pluginCapabilities = plugin.manifestJson.capabilities ?? [];
|
||||
const environmentDrivers = plugin.manifestJson.environmentDrivers ?? [];
|
||||
const localFolderDeclarations = plugin.manifestJson.localFolders ?? [];
|
||||
const hasLocalFolders = localFolderDeclarations.length > 0;
|
||||
const environmentDriverNames = environmentDrivers
|
||||
.map((driver) => driver.displayName?.trim() || driver.driverKey)
|
||||
.filter((name, index, values) => values.indexOf(name) === index);
|
||||
|
|
@ -217,6 +221,13 @@ export function PluginSettings() {
|
|||
<div className="space-y-1">
|
||||
<h2 className="text-base font-semibold">Settings</h2>
|
||||
</div>
|
||||
{hasLocalFolders ? (
|
||||
<PluginLocalFoldersSettings
|
||||
pluginId={pluginId!}
|
||||
companyId={selectedCompanyId}
|
||||
declarations={localFolderDeclarations}
|
||||
/>
|
||||
) : null}
|
||||
{hasCustomSettingsPage ? (
|
||||
<div className="space-y-3">
|
||||
{pluginSlots.map((slot) => (
|
||||
|
|
@ -253,11 +264,11 @@ export function PluginSettings() {
|
|||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
) : !hasLocalFolders ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This plugin does not require any settings.
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
|
@ -558,6 +569,350 @@ export function PluginSettings() {
|
|||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PluginLocalFoldersSettings — host-managed company-scoped folders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PluginLocalFoldersSettingsProps {
|
||||
pluginId: string;
|
||||
companyId: string | null;
|
||||
declarations: PluginLocalFolderDeclaration[];
|
||||
}
|
||||
|
||||
function PluginLocalFoldersSettings({ pluginId, companyId, declarations }: PluginLocalFoldersSettingsProps) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: companyId
|
||||
? queryKeys.plugins.localFolders(pluginId, companyId)
|
||||
: ["plugins", pluginId, "companies", "none", "local-folders"],
|
||||
queryFn: () => pluginsApi.listLocalFolders(pluginId, companyId!),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
|
||||
const statusByKey = new Map((data?.folders ?? []).map((folder) => [folder.folderKey, folder]));
|
||||
|
||||
if (!companyId) {
|
||||
return (
|
||||
<div className="rounded-md border border-border/60 bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
|
||||
Select a company to configure this plugin's local folders.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">Local folders</h3>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{(error as Error).message || "Failed to load local folder settings."}
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 py-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading local folders...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{declarations.map((declaration) => (
|
||||
<PluginLocalFolderRow
|
||||
key={declaration.folderKey}
|
||||
pluginId={pluginId}
|
||||
companyId={companyId}
|
||||
declaration={declaration}
|
||||
status={statusByKey.get(declaration.folderKey)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PluginLocalFolderRowProps {
|
||||
pluginId: string;
|
||||
companyId: string;
|
||||
declaration: PluginLocalFolderDeclaration;
|
||||
status?: PluginLocalFolderStatus;
|
||||
}
|
||||
|
||||
function PluginLocalFolderRow({ pluginId, companyId, declaration, status }: PluginLocalFolderRowProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const serverPath = status?.path ?? "";
|
||||
const [pathValue, setPathValue] = useState(serverPath);
|
||||
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setPathValue(serverPath);
|
||||
setMessage(null);
|
||||
}, [serverPath, declaration.folderKey]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (path: string) =>
|
||||
pluginsApi.configureLocalFolder(pluginId, companyId, declaration.folderKey, {
|
||||
path,
|
||||
access: declaration.access,
|
||||
requiredDirectories: declaration.requiredDirectories,
|
||||
requiredFiles: declaration.requiredFiles,
|
||||
}),
|
||||
onSuccess: (nextStatus) => {
|
||||
setMessage({
|
||||
type: nextStatus.healthy ? "success" : "error",
|
||||
text: nextStatus.healthy
|
||||
? "Local folder saved."
|
||||
: "Local folder saved, but validation still needs attention.",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.localFolders(pluginId, companyId) });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setMessage({ type: "error", text: err.message || "Failed to save local folder." });
|
||||
},
|
||||
});
|
||||
|
||||
const trimmedPath = pathValue.trim();
|
||||
const isDirty = trimmedPath !== serverPath;
|
||||
const access = status?.access ?? declaration.access ?? "readWrite";
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!trimmedPath) {
|
||||
setMessage({ type: "error", text: "Local folder path is required." });
|
||||
return;
|
||||
}
|
||||
if (!isLikelyAbsolutePath(trimmedPath)) {
|
||||
setMessage({ type: "error", text: "Local folder must be a full absolute path." });
|
||||
return;
|
||||
}
|
||||
setMessage(null);
|
||||
saveMutation.mutate(trimmedPath);
|
||||
}, [saveMutation, trimmedPath]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-md border border-border/70 bg-background px-4 py-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h4 className="text-sm font-medium">{declaration.displayName}</h4>
|
||||
<Badge variant="outline" className="font-mono text-[10px]">
|
||||
{declaration.folderKey}
|
||||
</Badge>
|
||||
<Badge variant={status?.healthy ? "default" : "secondary"}>
|
||||
{status?.healthy ? "Healthy" : "Needs attention"}
|
||||
</Badge>
|
||||
</div>
|
||||
{declaration.description ? (
|
||||
<p className="max-w-3xl text-sm leading-5 text-muted-foreground">
|
||||
{declaration.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Badge variant={access === "readWrite" ? "default" : "outline"}>
|
||||
{access === "readWrite" ? "Read/write" : "Read only"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 text-sm sm:grid-cols-3">
|
||||
<FolderStatusMetric label="Configured" value={status?.configured ? "Yes" : "No"} ok={!!status?.configured} />
|
||||
<FolderStatusMetric label="Readable" value={status?.readable ? "Yes" : "No"} ok={!!status?.readable} />
|
||||
<FolderStatusMetric
|
||||
label="Writable"
|
||||
value={access === "read" ? "Not requested" : status?.writable ? "Yes" : "No"}
|
||||
ok={access === "read" || !!status?.writable}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status?.path ? (
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="text-xs font-medium text-muted-foreground">Configured path</div>
|
||||
<div className="break-all rounded-md bg-muted/60 px-2 py-1.5 font-mono text-xs text-foreground">
|
||||
{status.path}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor={`local-folder-${declaration.folderKey}`}>
|
||||
Local folder path
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={`local-folder-${declaration.folderKey}`}
|
||||
className="min-w-0 flex-1 rounded-md border border-border bg-background px-2.5 py-1.5 font-mono text-sm outline-none focus:border-foreground/40 focus:ring-2 focus:ring-ring/20"
|
||||
value={pathValue}
|
||||
onChange={(event) => {
|
||||
setPathValue(event.target.value);
|
||||
setMessage(null);
|
||||
}}
|
||||
placeholder="/absolute/path/to/folder"
|
||||
/>
|
||||
<ChoosePathButton className="h-8" />
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saveMutation.isPending || !isDirty}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FolderRequirements status={status} declaration={declaration} />
|
||||
|
||||
{status?.problems?.length ? (
|
||||
<div className="space-y-2 rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<div className="font-medium">Validation problems</div>
|
||||
<ul className="space-y-1">
|
||||
{status.problems.map((problem, index) => (
|
||||
<li key={`${problem.code}:${problem.path ?? ""}:${index}`}>
|
||||
{problem.message}
|
||||
{problem.path ? <span className="font-mono"> {problem.path}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{message ? (
|
||||
<div
|
||||
className={`rounded-md border px-3 py-2 text-sm ${
|
||||
message.type === "success"
|
||||
? "border-green-200 bg-green-50 text-green-700 dark:border-green-900 dark:bg-green-950/30 dark:text-green-400"
|
||||
: "border-destructive/20 bg-destructive/10 text-destructive"
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderStatusMetric({ label, value, ok }: { label: string; value: string; ok: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-md border border-border/60 px-2.5 py-2">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<Badge variant={ok ? "default" : "secondary"}>{value}</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderRequirements({
|
||||
status,
|
||||
declaration,
|
||||
}: {
|
||||
status?: PluginLocalFolderStatus;
|
||||
declaration: PluginLocalFolderDeclaration;
|
||||
}) {
|
||||
const requiredDirectories = status?.requiredDirectories ?? declaration.requiredDirectories ?? [];
|
||||
const requiredFiles = status?.requiredFiles ?? declaration.requiredFiles ?? [];
|
||||
const missingDirectories = status?.missingDirectories ?? requiredDirectories;
|
||||
const missingFiles = status?.missingFiles ?? requiredFiles;
|
||||
const rootNotInspected = isRootNotInspected(status);
|
||||
|
||||
if (requiredDirectories.length === 0 && requiredFiles.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 text-sm md:grid-cols-2">
|
||||
<RequirementList
|
||||
title="Required directories"
|
||||
items={requiredDirectories}
|
||||
missingItems={missingDirectories}
|
||||
missingLabel="Missing directories"
|
||||
inspectionUnavailable={rootNotInspected}
|
||||
/>
|
||||
<RequirementList
|
||||
title="Required files"
|
||||
items={requiredFiles}
|
||||
missingItems={missingFiles}
|
||||
missingLabel="Missing files"
|
||||
inspectionUnavailable={rootNotInspected}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isRootNotInspected(status?: PluginLocalFolderStatus) {
|
||||
if (!status?.configured || status.readable) return false;
|
||||
return status.problems.some((problem) =>
|
||||
problem.code === "missing" || problem.code === "not_readable" || problem.code === "not_directory"
|
||||
);
|
||||
}
|
||||
|
||||
function RequirementList({
|
||||
title,
|
||||
items,
|
||||
missingItems,
|
||||
missingLabel,
|
||||
inspectionUnavailable,
|
||||
}: {
|
||||
title: string;
|
||||
items: string[];
|
||||
missingItems: string[];
|
||||
missingLabel: string;
|
||||
inspectionUnavailable?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2 rounded-md border border-border/60 px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">{title}</span>
|
||||
{inspectionUnavailable ? (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Not inspected
|
||||
</Badge>
|
||||
) : missingItems.length > 0 ? (
|
||||
<Badge variant="destructive" className="text-[10px]">
|
||||
{missingItems.length} missing
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px]">Present</Badge>
|
||||
)}
|
||||
</div>
|
||||
{items.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{items.map((item) => {
|
||||
const missing = missingItems.includes(item);
|
||||
return (
|
||||
<span
|
||||
key={item}
|
||||
className={`rounded border px-1.5 py-0.5 font-mono text-[11px] ${
|
||||
inspectionUnavailable
|
||||
? "border-amber-300/60 bg-amber-50 text-amber-700 dark:border-amber-800/70 dark:bg-amber-950/30 dark:text-amber-300"
|
||||
: missing
|
||||
? "border-destructive/30 bg-destructive/10 text-destructive"
|
||||
: "border-border bg-muted/50 text-foreground/80"
|
||||
}`}
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">None declared.</p>
|
||||
)}
|
||||
{inspectionUnavailable ? (
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300">Configured root was not inspected.</p>
|
||||
) : missingItems.length > 0 ? (
|
||||
<p className="text-xs text-destructive">{missingLabel}: {missingItems.join(", ")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isLikelyAbsolutePath(pathValue: string) {
|
||||
return (
|
||||
pathValue.startsWith("/") ||
|
||||
/^[A-Za-z]:[\\/]/.test(pathValue) ||
|
||||
pathValue.startsWith("\\\\")
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PluginConfigForm — auto-generated form for instanceConfigSchema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
187
ui/src/pages/ProjectDetail.test.tsx
Normal file
187
ui/src/pages/ProjectDetail.test.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Project } from "@paperclipai/shared";
|
||||
import { act, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ProjectDetail } from "./ProjectDetail";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const mockProjectsApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
const mockIssuesApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
const mockAgentsApi = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
const mockHeartbeatsApi = vi.hoisted(() => ({ liveRunsForCompany: vi.fn() }));
|
||||
const mockBudgetsApi = vi.hoisted(() => ({ overview: vi.fn(), upsertPolicy: vi.fn() }));
|
||||
const mockExecutionWorkspacesApi = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({ getExperimental: vi.fn() }));
|
||||
const mockAssetsApi = vi.hoisted(() => ({ uploadImage: vi.fn() }));
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||
const mockIssuesList = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi }));
|
||||
vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi }));
|
||||
vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi }));
|
||||
vi.mock("../api/heartbeats", () => ({ heartbeatsApi: mockHeartbeatsApi }));
|
||||
vi.mock("../api/budgets", () => ({ budgetsApi: mockBudgetsApi }));
|
||||
vi.mock("../api/execution-workspaces", () => ({ executionWorkspacesApi: mockExecutionWorkspacesApi }));
|
||||
vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi }));
|
||||
vi.mock("../api/assets", () => ({ assetsApi: mockAssetsApi }));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to }: { children?: ReactNode; to: string }) => <a href={to}>{children}</a>,
|
||||
Navigate: ({ to }: { to: string }) => <div data-testid="navigate">{to}</div>,
|
||||
useLocation: () => ({ pathname: "/projects/project-1/plugin-operations", search: "", hash: "", state: null }),
|
||||
useNavigate: () => mockNavigate,
|
||||
useParams: () => ({ projectId: "project-1" }),
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
companies: [{ id: "company-1", issuePrefix: "PAP" }],
|
||||
selectedCompanyId: "company-1",
|
||||
setSelectedCompanyId: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("../context/PanelContext", () => ({ usePanel: () => ({ closePanel: vi.fn() }) }));
|
||||
vi.mock("../context/ToastContext", () => ({ useToastActions: () => ({ pushToast: vi.fn() }) }));
|
||||
vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }) }));
|
||||
vi.mock("@/plugins/slots", () => ({
|
||||
PluginSlotMount: () => null,
|
||||
PluginSlotOutlet: () => null,
|
||||
usePluginSlots: () => ({ slots: [], isLoading: false }),
|
||||
}));
|
||||
vi.mock("@/plugins/launchers", () => ({ PluginLauncherOutlet: () => null }));
|
||||
vi.mock("../components/ProjectProperties", () => ({
|
||||
ProjectProperties: () => <div data-testid="project-properties" />,
|
||||
}));
|
||||
vi.mock("../components/BudgetPolicyCard", () => ({
|
||||
BudgetPolicyCard: () => <div data-testid="budget-policy-card" />,
|
||||
}));
|
||||
vi.mock("../components/InlineEditor", () => ({
|
||||
InlineEditor: ({ value, placeholder }: { value?: string; placeholder?: string }) => (
|
||||
<span>{value || placeholder || null}</span>
|
||||
),
|
||||
}));
|
||||
vi.mock("../components/ProjectWorkspacesContent", () => ({
|
||||
ProjectWorkspacesContent: () => <div data-testid="project-workspaces" />,
|
||||
}));
|
||||
vi.mock("../components/PageTabBar", () => ({
|
||||
PageTabBar: ({ items }: { items: Array<{ value: string; label: string }> }) => (
|
||||
<div>{items.map((item) => <button key={item.value}>{item.label}</button>)}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("../components/IssuesList", () => ({
|
||||
IssuesList: (props: unknown) => {
|
||||
mockIssuesList(props);
|
||||
return <div data-testid="issues-list" />;
|
||||
},
|
||||
}));
|
||||
|
||||
function project(overrides: Partial<Project> = {}): Project {
|
||||
const now = new Date("2026-05-01T00:00:00Z");
|
||||
return {
|
||||
id: "project-1",
|
||||
companyId: "company-1",
|
||||
urlKey: "project-1",
|
||||
goalId: null,
|
||||
goalIds: [],
|
||||
goals: [],
|
||||
name: "Managed Project",
|
||||
description: null,
|
||||
status: "in_progress",
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: "#14b8a6",
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
executionWorkspacePolicy: null,
|
||||
codebase: {
|
||||
workspaceId: null,
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
defaultRef: null,
|
||||
repoName: null,
|
||||
localFolder: null,
|
||||
managedFolder: "/tmp/project-1",
|
||||
effectiveLocalFolder: "/tmp/project-1",
|
||||
origin: "managed_checkout",
|
||||
},
|
||||
workspaces: [],
|
||||
primaryWorkspace: null,
|
||||
managedByPlugin: {
|
||||
id: "managed-1",
|
||||
pluginId: "plugin-1",
|
||||
pluginKey: "paperclip.missions",
|
||||
pluginDisplayName: "Missions",
|
||||
resourceKind: "project",
|
||||
resourceKey: "operations",
|
||||
defaultsJson: {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
archivedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ProjectDetail", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockProjectsApi.get.mockResolvedValue(project());
|
||||
mockProjectsApi.list.mockResolvedValue([project()]);
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
mockAgentsApi.list.mockResolvedValue([]);
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
|
||||
mockBudgetsApi.overview.mockResolvedValue({ policies: [] });
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = null;
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows managed plugin affordances and filters the operations tab by plugin origin", async () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
await act(async () => {
|
||||
root = createRoot(container);
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ProjectDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Managed by Missions");
|
||||
expect(container.textContent).toContain("Plugin operations");
|
||||
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", {
|
||||
projectId: "project-1",
|
||||
originKindPrefix: "plugin:paperclip.missions",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -33,7 +33,7 @@ import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slo
|
|||
|
||||
/* ── Top-level tab types ── */
|
||||
|
||||
type ProjectBaseTab = "overview" | "list" | "workspaces" | "configuration" | "budget";
|
||||
type ProjectBaseTab = "overview" | "list" | "plugin-operations" | "workspaces" | "configuration" | "budget";
|
||||
type ProjectPluginTab = `plugin:${string}`;
|
||||
type ProjectTab = ProjectBaseTab | ProjectPluginTab;
|
||||
|
||||
|
|
@ -50,6 +50,7 @@ function resolveProjectTab(pathname: string, projectId: string): ProjectTab | nu
|
|||
if (tab === "configuration") return "configuration";
|
||||
if (tab === "budget") return "budget";
|
||||
if (tab === "issues") return "list";
|
||||
if (tab === "plugin-operations") return "plugin-operations";
|
||||
if (tab === "workspaces") return "workspaces";
|
||||
return null;
|
||||
}
|
||||
|
|
@ -208,6 +209,67 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan
|
|||
);
|
||||
}
|
||||
|
||||
function ProjectPluginOperationsList({
|
||||
projectId,
|
||||
companyId,
|
||||
pluginKey,
|
||||
}: {
|
||||
projectId: string;
|
||||
companyId: string;
|
||||
pluginKey: string;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const originKindPrefix = `plugin:${pluginKey}`;
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(companyId),
|
||||
queryFn: () => agentsApi.list(companyId),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: queryKeys.projects.list(companyId),
|
||||
queryFn: () => projectsApi.list(companyId),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
const { data: liveRuns } = useQuery({
|
||||
queryKey: queryKeys.liveRuns(companyId),
|
||||
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId),
|
||||
enabled: !!companyId,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
|
||||
|
||||
const { data: issues, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.issues.listPluginOperationsByProject(companyId, projectId, originKindPrefix),
|
||||
queryFn: () => issuesApi.list(companyId, { projectId, originKindPrefix }),
|
||||
enabled: !!companyId && !!projectId,
|
||||
});
|
||||
|
||||
const updateIssue = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
issuesApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listPluginOperationsByProject(companyId, projectId, originKindPrefix) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<IssuesList
|
||||
issues={issues ?? []}
|
||||
isLoading={isLoading}
|
||||
error={error as Error | null}
|
||||
agents={agents}
|
||||
projects={projects}
|
||||
liveIssueIds={liveIssueIds}
|
||||
projectId={projectId}
|
||||
viewStateKey={`paperclip:project-plugin-operations-view:${pluginKey}`}
|
||||
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main project page ── */
|
||||
|
||||
export function ProjectDetail() {
|
||||
|
|
@ -390,6 +452,10 @@ export function ProjectDetail() {
|
|||
navigate(`/projects/${canonicalProjectRef}/budget`, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (activeTab === "plugin-operations") {
|
||||
navigate(`/projects/${canonicalProjectRef}/plugin-operations`, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (activeTab === "workspaces") {
|
||||
navigate(`/projects/${canonicalProjectRef}/workspaces`, { replace: true });
|
||||
return;
|
||||
|
|
@ -523,6 +589,9 @@ export function ProjectDetail() {
|
|||
if (cachedTab === "budget") {
|
||||
return <Navigate to={`/projects/${canonicalProjectRef}/budget`} replace />;
|
||||
}
|
||||
if (cachedTab === "plugin-operations" && project?.managedByPlugin) {
|
||||
return <Navigate to={`/projects/${canonicalProjectRef}/plugin-operations`} replace />;
|
||||
}
|
||||
if (cachedTab === "workspaces" && workspaceTabDecisionLoaded && showWorkspacesTab) {
|
||||
return <Navigate to={`/projects/${canonicalProjectRef}/workspaces`} replace />;
|
||||
}
|
||||
|
|
@ -554,6 +623,8 @@ export function ProjectDetail() {
|
|||
navigate(`/projects/${canonicalProjectRef}/workspaces`);
|
||||
} else if (tab === "budget") {
|
||||
navigate(`/projects/${canonicalProjectRef}/budget`);
|
||||
} else if (tab === "plugin-operations") {
|
||||
navigate(`/projects/${canonicalProjectRef}/plugin-operations`);
|
||||
} else if (tab === "configuration") {
|
||||
navigate(`/projects/${canonicalProjectRef}/configuration`);
|
||||
} else {
|
||||
|
|
@ -583,6 +654,12 @@ export function ProjectDetail() {
|
|||
Paused by budget hard stop
|
||||
</div>
|
||||
) : null}
|
||||
{project.managedByPlugin ? (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-border bg-muted px-3 py-1 text-[11px] font-medium text-muted-foreground">
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: project.color ?? "#6366f1" }} />
|
||||
Managed by {project.managedByPlugin.pluginDisplayName}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -622,6 +699,7 @@ export function ProjectDetail() {
|
|||
items={[
|
||||
{ value: "list", label: "Issues" },
|
||||
{ value: "overview", label: "Overview" },
|
||||
...(project.managedByPlugin ? [{ value: "plugin-operations", label: "Plugin operations" }] : []),
|
||||
...(showWorkspacesTab ? [{ value: "workspaces", label: "Workspaces" }] : []),
|
||||
{ value: "configuration", label: "Configuration" },
|
||||
{ value: "budget", label: "Budget" },
|
||||
|
|
@ -651,6 +729,14 @@ export function ProjectDetail() {
|
|||
<ProjectIssuesList projectId={project.id} companyId={resolvedCompanyId} />
|
||||
)}
|
||||
|
||||
{activeTab === "plugin-operations" && project?.id && resolvedCompanyId && project.managedByPlugin && (
|
||||
<ProjectPluginOperationsList
|
||||
projectId={project.id}
|
||||
companyId={resolvedCompanyId}
|
||||
pluginKey={project.managedByPlugin.pluginKey}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "workspaces" ? (
|
||||
workspaceTabDecisionLoaded ? (
|
||||
workspaceTabError ? (
|
||||
|
|
|
|||
|
|
@ -702,36 +702,44 @@ export function RoutineDetail() {
|
|||
<div className="max-w-2xl space-y-6">
|
||||
{/* Header: editable title + actions */}
|
||||
<div className="flex items-start gap-4">
|
||||
<textarea
|
||||
ref={titleInputRef}
|
||||
className="flex-1 min-w-0 resize-none overflow-hidden bg-transparent text-xl font-bold outline-none placeholder:text-muted-foreground/50"
|
||||
placeholder="Routine title"
|
||||
rows={1}
|
||||
value={editDraft.title}
|
||||
onChange={(event) => {
|
||||
setEditDraft((current) => ({ ...current, title: event.target.value }));
|
||||
autoResizeTextarea(event.target);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
descriptionEditorRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Tab" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if (editDraft.assigneeAgentId) {
|
||||
if (editDraft.projectId) {
|
||||
descriptionEditorRef.current?.focus();
|
||||
} else {
|
||||
projectSelectorRef.current?.focus();
|
||||
}
|
||||
} else {
|
||||
assigneeSelectorRef.current?.focus();
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<textarea
|
||||
ref={titleInputRef}
|
||||
className="w-full resize-none overflow-hidden bg-transparent text-xl font-bold outline-none placeholder:text-muted-foreground/50"
|
||||
placeholder="Routine title"
|
||||
rows={1}
|
||||
value={editDraft.title}
|
||||
onChange={(event) => {
|
||||
setEditDraft((current) => ({ ...current, title: event.target.value }));
|
||||
autoResizeTextarea(event.target);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
descriptionEditorRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
if (event.key === "Tab" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if (editDraft.assigneeAgentId) {
|
||||
if (editDraft.projectId) {
|
||||
descriptionEditorRef.current?.focus();
|
||||
} else {
|
||||
projectSelectorRef.current?.focus();
|
||||
}
|
||||
} else {
|
||||
assigneeSelectorRef.current?.focus();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{routine.managedByPlugin ? (
|
||||
<Badge variant="outline" className="gap-1 text-xs text-muted-foreground">
|
||||
Managed by {routine.managedByPlugin.pluginDisplayName}
|
||||
<span className="font-mono text-[10px]">{routine.managedByPlugin.resourceKey}</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3 pt-1">
|
||||
<RunButton
|
||||
onClick={() => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useNavigate, useSearchParams } from "@/lib/router";
|
||||
import { ArrowUpDown, Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react";
|
||||
import { ArrowUpDown, Check, ChevronDown, ChevronRight, Layers, Plus, Repeat } from "lucide-react";
|
||||
import { routinesApi } from "../api/routines";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { projectsApi } from "../api/projects";
|
||||
|
|
@ -18,7 +18,6 @@ import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
|||
import { collectLiveIssueIds } from "../lib/liveIssueIds";
|
||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { IssuesList } from "../components/IssuesList";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
|
|
@ -26,6 +25,7 @@ import { PageTabBar } from "../components/PageTabBar";
|
|||
import { AgentIcon } from "../components/AgentIconPicker";
|
||||
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
|
||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "../components/MarkdownEditor";
|
||||
import { RoutineListRow, nextRoutineStatus } from "../components/RoutineList";
|
||||
import {
|
||||
RoutineRunVariablesDialog,
|
||||
type RoutineRunDialogSubmitData,
|
||||
|
|
@ -35,13 +35,6 @@ import { Button } from "@/components/ui/button";
|
|||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
|
|
@ -71,16 +64,6 @@ function autoResizeTextarea(element: HTMLTextAreaElement | null) {
|
|||
element.style.height = `${element.scrollHeight}px`;
|
||||
}
|
||||
|
||||
function formatLastRunTimestamp(value: Date | string | null | undefined) {
|
||||
if (!value) return "Never";
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function nextRoutineStatus(currentStatus: string, enabled: boolean) {
|
||||
if (currentStatus === "archived" && enabled) return "active";
|
||||
return enabled ? "active" : "paused";
|
||||
}
|
||||
|
||||
type RoutinesTab = "routines" | "runs";
|
||||
type RoutineGroupBy = "none" | "project" | "assignee";
|
||||
type RoutineSortField = "updated" | "created" | "title" | "lastRun";
|
||||
|
|
@ -130,11 +113,6 @@ function compareNullableText(left: string | null | undefined, right: string | nu
|
|||
return (left ?? "").localeCompare(right ?? "", undefined, { sensitivity: "base" });
|
||||
}
|
||||
|
||||
function formatRoutineRunStatus(value: string | null | undefined) {
|
||||
if (!value) return null;
|
||||
return value.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function buildRoutineMutationPayload(input: {
|
||||
title: string;
|
||||
description: string;
|
||||
|
|
@ -221,117 +199,6 @@ function buildRoutinesTabHref(tab: RoutinesTab) {
|
|||
return tab === "runs" ? "/routines?tab=runs" : "/routines";
|
||||
}
|
||||
|
||||
function RoutineListRow({
|
||||
routine,
|
||||
projectById,
|
||||
agentById,
|
||||
runningRoutineId,
|
||||
statusMutationRoutineId,
|
||||
href,
|
||||
onRunNow,
|
||||
onToggleEnabled,
|
||||
onToggleArchived,
|
||||
}: {
|
||||
routine: RoutineListItem;
|
||||
projectById: Map<string, { name: string; color?: string | null }>;
|
||||
agentById: Map<string, { name: string; icon?: string | null }>;
|
||||
runningRoutineId: string | null;
|
||||
statusMutationRoutineId: string | null;
|
||||
href: string;
|
||||
onRunNow: (routine: RoutineListItem) => void;
|
||||
onToggleEnabled: (routine: RoutineListItem, enabled: boolean) => void;
|
||||
onToggleArchived: (routine: RoutineListItem) => void;
|
||||
}) {
|
||||
const enabled = routine.status === "active";
|
||||
const isArchived = routine.status === "archived";
|
||||
const isStatusPending = statusMutationRoutineId === routine.id;
|
||||
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
|
||||
const agent = routine.assigneeAgentId ? agentById.get(routine.assigneeAgentId) ?? null : null;
|
||||
const isDraft = !isArchived && !routine.assigneeAgentId;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className="group flex flex-col gap-3 border-b border-border px-3 py-3 transition-colors hover:bg-accent/50 last:border-b-0 sm:flex-row sm:items-center no-underline text-inherit"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{routine.title}</span>
|
||||
{(isArchived || routine.status === "paused" || isDraft) ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isArchived ? "archived" : isDraft ? "draft" : "paused"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-sm"
|
||||
style={{ backgroundColor: project?.color ?? "#64748b" }}
|
||||
/>
|
||||
<span>{routine.projectId ? (project?.name ?? "Unknown project") : "No project"}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
{agent?.icon ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0" /> : null}
|
||||
<span>{routine.assigneeAgentId ? (agent?.name ?? "Unknown agent") : "No default agent"}</span>
|
||||
</span>
|
||||
<span>
|
||||
{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}
|
||||
{routine.lastRun ? ` · ${formatRoutineRunStatus(routine.lastRun.status)}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3" onClick={(event) => { event.preventDefault(); event.stopPropagation(); }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<ToggleSwitch
|
||||
size="lg"
|
||||
checked={enabled}
|
||||
onCheckedChange={() => onToggleEnabled(routine, enabled)}
|
||||
disabled={isStatusPending || isArchived}
|
||||
aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
|
||||
/>
|
||||
<span className="w-12 text-xs text-muted-foreground">
|
||||
{isArchived ? "Archived" : isDraft ? "Draft" : enabled ? "On" : "Off"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`More actions for ${routine.title}`}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={href}>Edit</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={runningRoutineId === routine.id || isArchived}
|
||||
onClick={() => onRunNow(routine)}
|
||||
>
|
||||
{runningRoutineId === routine.id ? "Running..." : "Run now"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onToggleEnabled(routine, enabled)}
|
||||
disabled={isStatusPending || isArchived}
|
||||
>
|
||||
{enabled ? "Pause" : "Enable"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onToggleArchived(routine)}
|
||||
disabled={isStatusPending}
|
||||
>
|
||||
{routine.status === "archived" ? "Restore" : "Archive"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function Routines() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue