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
|
|
@ -129,7 +129,7 @@ function boardRoutes() {
|
|||
<Route path="u/:userSlug" element={<UserProfile />} />
|
||||
<Route path="design-guide" element={<DesignGuide />} />
|
||||
<Route path="instance/settings/adapters" element={<AdapterManager />} />
|
||||
<Route path=":pluginRoutePath" element={<PluginPage />} />
|
||||
<Route path=":pluginRoutePath/*" element={<PluginPage />} />
|
||||
<Route path="*" element={<NotFoundPage scope="board" />} />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export const issuesApi = {
|
|||
workspaceId?: string;
|
||||
executionWorkspaceId?: string;
|
||||
originKind?: string;
|
||||
originKindPrefix?: string;
|
||||
originId?: string;
|
||||
descendantOf?: string;
|
||||
includeRoutineExecutions?: boolean;
|
||||
|
|
@ -66,6 +67,7 @@ export const issuesApi = {
|
|||
if (filters?.workspaceId) params.set("workspaceId", filters.workspaceId);
|
||||
if (filters?.executionWorkspaceId) params.set("executionWorkspaceId", filters.executionWorkspaceId);
|
||||
if (filters?.originKind) params.set("originKind", filters.originKind);
|
||||
if (filters?.originKindPrefix) params.set("originKindPrefix", filters.originKindPrefix);
|
||||
if (filters?.originId) params.set("originId", filters.originId);
|
||||
if (filters?.descendantOf) params.set("descendantOf", filters.descendantOf);
|
||||
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
|
||||
|
|
|
|||
64
ui/src/api/plugins.test.ts
Normal file
64
ui/src/api/plugins.test.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./client", () => ({
|
||||
api: mockApi,
|
||||
}));
|
||||
|
||||
import { pluginsApi } from "./plugins";
|
||||
|
||||
describe("pluginsApi local folders", () => {
|
||||
beforeEach(() => {
|
||||
mockApi.get.mockReset();
|
||||
mockApi.post.mockReset();
|
||||
mockApi.put.mockReset();
|
||||
mockApi.get.mockResolvedValue({});
|
||||
mockApi.post.mockResolvedValue({});
|
||||
mockApi.put.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("lists company-scoped local folders for a plugin", async () => {
|
||||
await pluginsApi.listLocalFolders("plugin-1", "company-1");
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith(
|
||||
"/plugins/plugin-1/companies/company-1/local-folders",
|
||||
);
|
||||
});
|
||||
|
||||
it("validates a candidate folder path without saving", async () => {
|
||||
await pluginsApi.validateLocalFolder("plugin-1", "company-1", "wiki-root", {
|
||||
path: "/tmp/wiki",
|
||||
access: "readWrite",
|
||||
requiredFiles: ["WIKI.md"],
|
||||
});
|
||||
|
||||
expect(mockApi.post).toHaveBeenCalledWith(
|
||||
"/plugins/plugin-1/companies/company-1/local-folders/wiki-root/validate",
|
||||
{
|
||||
path: "/tmp/wiki",
|
||||
access: "readWrite",
|
||||
requiredFiles: ["WIKI.md"],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("saves through the local-folder PUT endpoint", async () => {
|
||||
await pluginsApi.configureLocalFolder("plugin-1", "company-1", "wiki-root", {
|
||||
path: "/tmp/wiki",
|
||||
requiredDirectories: ["wiki"],
|
||||
});
|
||||
|
||||
expect(mockApi.put).toHaveBeenCalledWith(
|
||||
"/plugins/plugin-1/companies/company-1/local-folders/wiki-root",
|
||||
{
|
||||
path: "/tmp/wiki",
|
||||
requiredDirectories: ["wiki"],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -14,6 +14,7 @@ import type {
|
|||
PluginLauncherDeclaration,
|
||||
PluginLauncherRenderContextSnapshot,
|
||||
PluginUiSlotDeclaration,
|
||||
PluginLocalFolderDeclaration,
|
||||
PluginRecord,
|
||||
PluginConfig,
|
||||
PluginStatus,
|
||||
|
|
@ -140,6 +141,54 @@ export interface AvailablePluginExample {
|
|||
tag: "example";
|
||||
}
|
||||
|
||||
export interface PluginLocalFolderProblem {
|
||||
code:
|
||||
| "not_configured"
|
||||
| "not_absolute"
|
||||
| "missing"
|
||||
| "not_directory"
|
||||
| "not_readable"
|
||||
| "not_writable"
|
||||
| "missing_directory"
|
||||
| "missing_file"
|
||||
| "path_traversal"
|
||||
| "symlink_escape"
|
||||
| "atomic_write_failed";
|
||||
message: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface PluginLocalFolderStatus {
|
||||
folderKey: string;
|
||||
configured: boolean;
|
||||
path: string | null;
|
||||
realPath: string | null;
|
||||
access: "read" | "readWrite";
|
||||
readable: boolean;
|
||||
writable: boolean;
|
||||
requiredDirectories: string[];
|
||||
requiredFiles: string[];
|
||||
missingDirectories: string[];
|
||||
missingFiles: string[];
|
||||
healthy: boolean;
|
||||
problems: PluginLocalFolderProblem[];
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export interface PluginLocalFoldersResponse {
|
||||
pluginId: string;
|
||||
companyId: string;
|
||||
declarations: PluginLocalFolderDeclaration[];
|
||||
folders: PluginLocalFolderStatus[];
|
||||
}
|
||||
|
||||
export interface PluginLocalFolderSaveInput {
|
||||
path: string;
|
||||
access?: "read" | "readWrite";
|
||||
requiredDirectories?: string[];
|
||||
requiredFiles?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin management API client.
|
||||
*
|
||||
|
|
@ -337,6 +386,48 @@ export const pluginsApi = {
|
|||
testConfig: (pluginId: string, configJson: Record<string, unknown>) =>
|
||||
api.post<{ valid: boolean; message?: string }>(`/plugins/${pluginId}/config/test`, { configJson }),
|
||||
|
||||
/**
|
||||
* List manifest-declared and stored company-scoped local folders for a plugin.
|
||||
*/
|
||||
listLocalFolders: (pluginId: string, companyId: string) =>
|
||||
api.get<PluginLocalFoldersResponse>(`/plugins/${pluginId}/companies/${companyId}/local-folders`),
|
||||
|
||||
/**
|
||||
* Inspect a configured local folder without changing persisted settings.
|
||||
*/
|
||||
localFolderStatus: (pluginId: string, companyId: string, folderKey: string) =>
|
||||
api.get<PluginLocalFolderStatus>(
|
||||
`/plugins/${pluginId}/companies/${companyId}/local-folders/${encodeURIComponent(folderKey)}/status`,
|
||||
),
|
||||
|
||||
/**
|
||||
* Validate a candidate local folder path without saving it.
|
||||
*/
|
||||
validateLocalFolder: (
|
||||
pluginId: string,
|
||||
companyId: string,
|
||||
folderKey: string,
|
||||
input: PluginLocalFolderSaveInput,
|
||||
) =>
|
||||
api.post<PluginLocalFolderStatus>(
|
||||
`/plugins/${pluginId}/companies/${companyId}/local-folders/${encodeURIComponent(folderKey)}/validate`,
|
||||
input,
|
||||
),
|
||||
|
||||
/**
|
||||
* Persist a company-scoped local folder path and return its inspected status.
|
||||
*/
|
||||
configureLocalFolder: (
|
||||
pluginId: string,
|
||||
companyId: string,
|
||||
folderKey: string,
|
||||
input: PluginLocalFolderSaveInput,
|
||||
) =>
|
||||
api.put<PluginLocalFolderStatus>(
|
||||
`/plugins/${pluginId}/companies/${companyId}/local-folders/${encodeURIComponent(folderKey)}`,
|
||||
input,
|
||||
),
|
||||
|
||||
// ===========================================================================
|
||||
// Bridge proxy endpoints — used by the plugin UI bridge runtime
|
||||
// ===========================================================================
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { queryKeys } from "@/lib/queryKeys";
|
|||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useSidebar } from "@/context/SidebarContext";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
||||
|
||||
export function CompanySettingsSidebar() {
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
|
|
@ -32,11 +31,8 @@ export function CompanySettingsSidebar() {
|
|||
});
|
||||
|
||||
return (
|
||||
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||
<div className="flex items-center gap-1 px-3 h-12 shrink-0">
|
||||
<SidebarCompanyMenu />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 px-3 pb-3 shrink-0">
|
||||
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||
<div className="flex flex-col gap-1 px-3 py-3 shrink-0">
|
||||
<Link
|
||||
to="/dashboard"
|
||||
onClick={() => {
|
||||
|
|
|
|||
190
ui/src/components/FileTree.test.tsx
Normal file
190
ui/src/components/FileTree.test.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { FileTree, buildFileTree } from "./FileTree";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("FileTree", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
function row(path: string) {
|
||||
return container.querySelector(`[data-file-tree-path="${path}"]`) as HTMLDivElement | null;
|
||||
}
|
||||
|
||||
it("selects file rows and expands directory rows", () => {
|
||||
const onSelectFile = vi.fn();
|
||||
const onToggleDir = vi.fn();
|
||||
const nodes = buildFileTree({
|
||||
"README.md": "",
|
||||
"docs/guide.md": "",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<FileTree
|
||||
nodes={nodes}
|
||||
selectedFile="README.md"
|
||||
expandedDirs={new Set(["docs"])}
|
||||
onSelectFile={onSelectFile}
|
||||
onToggleDir={onToggleDir}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(row("README.md")?.getAttribute("aria-selected")).toBe("true");
|
||||
|
||||
act(() => {
|
||||
row("docs/guide.md")?.click();
|
||||
});
|
||||
expect(onSelectFile).toHaveBeenCalledWith("docs/guide.md");
|
||||
|
||||
act(() => {
|
||||
row("docs")?.click();
|
||||
});
|
||||
expect(onToggleDir).toHaveBeenCalledWith("docs");
|
||||
});
|
||||
|
||||
it("marks partially selected directories as indeterminate", () => {
|
||||
const nodes = buildFileTree({
|
||||
"docs/a.md": "",
|
||||
"docs/b.md": "",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<FileTree
|
||||
nodes={nodes}
|
||||
selectedFile={null}
|
||||
expandedDirs={new Set(["docs"])}
|
||||
checkedFiles={new Set(["docs/a.md"])}
|
||||
onSelectFile={() => {}}
|
||||
onToggleDir={() => {}}
|
||||
onToggleCheck={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const input = row("docs")?.querySelector("input[type='checkbox']") as HTMLInputElement | null;
|
||||
expect(input?.checked).toBe(false);
|
||||
expect(input?.indeterminate).toBe(true);
|
||||
expect(row("docs")?.getAttribute("aria-checked")).toBe("mixed");
|
||||
});
|
||||
|
||||
it("renders file badges and host-only file extras", () => {
|
||||
const nodes = buildFileTree({
|
||||
"wiki/very-long-page-slug.md": "",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<FileTree
|
||||
nodes={nodes}
|
||||
selectedFile={null}
|
||||
expandedDirs={new Set(["wiki"])}
|
||||
onSelectFile={() => {}}
|
||||
onToggleDir={() => {}}
|
||||
fileBadges={{
|
||||
"wiki/very-long-page-slug.md": {
|
||||
label: "fresh",
|
||||
status: "ok",
|
||||
tooltip: "Synced",
|
||||
},
|
||||
}}
|
||||
renderFileExtra={(node) => (
|
||||
node.kind === "file" ? <span data-testid="file-extra">{node.name.length} chars</span> : null
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("fresh");
|
||||
expect(container.querySelector("[title='Synced']")).not.toBeNull();
|
||||
expect(container.querySelector("[data-testid='file-extra']")?.textContent).toBe("22 chars");
|
||||
});
|
||||
|
||||
it("wraps long labels by default and can opt back into truncation", () => {
|
||||
const nodes = buildFileTree({
|
||||
"wiki/extremely-long-page-slug-that-wraps-on-mobile.md": "",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<FileTree
|
||||
nodes={nodes}
|
||||
selectedFile={null}
|
||||
expandedDirs={new Set(["wiki"])}
|
||||
onSelectFile={() => {}}
|
||||
onToggleDir={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(row("wiki/extremely-long-page-slug-that-wraps-on-mobile.md")?.innerHTML).toContain("break-all");
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<FileTree
|
||||
nodes={nodes}
|
||||
selectedFile={null}
|
||||
expandedDirs={new Set(["wiki"])}
|
||||
onSelectFile={() => {}}
|
||||
onToggleDir={() => {}}
|
||||
wrapLabels={false}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(row("wiki/extremely-long-page-slug-that-wraps-on-mobile.md")?.innerHTML).toContain("truncate");
|
||||
});
|
||||
|
||||
it("supports tree keyboard expansion and checkbox toggling", () => {
|
||||
const onToggleDir = vi.fn();
|
||||
const onToggleCheck = vi.fn();
|
||||
const nodes = buildFileTree({
|
||||
"docs/a.md": "",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<FileTree
|
||||
nodes={nodes}
|
||||
selectedFile={null}
|
||||
expandedDirs={new Set()}
|
||||
onSelectFile={() => {}}
|
||||
onToggleDir={onToggleDir}
|
||||
onToggleCheck={onToggleCheck}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const docsRow = row("docs");
|
||||
act(() => {
|
||||
docsRow?.focus();
|
||||
docsRow?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
|
||||
});
|
||||
expect(onToggleDir).toHaveBeenCalledWith("docs");
|
||||
|
||||
act(() => {
|
||||
docsRow?.dispatchEvent(new KeyboardEvent("keydown", { key: " ", bubbles: true }));
|
||||
});
|
||||
expect(onToggleCheck).toHaveBeenCalledWith("docs", "dir");
|
||||
});
|
||||
});
|
||||
500
ui/src/components/FileTree.tsx
Normal file
500
ui/src/components/FileTree.tsx
Normal file
|
|
@ -0,0 +1,500 @@
|
|||
import type { KeyboardEvent, ReactNode } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileCode2,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
import { statusBadge, statusBadgeDefault } from "../lib/status-colors";
|
||||
import { Button } from "./ui/button";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
|
||||
// -- Tree types --------------------------------------------------------------
|
||||
|
||||
export type FileTreeNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
kind: "dir" | "file";
|
||||
children: FileTreeNode[];
|
||||
/** Optional per-node metadata (e.g. import action) */
|
||||
action?: string | null;
|
||||
};
|
||||
|
||||
export type FileTreeBadgeVariant = "ok" | "warning" | "error" | "info" | "pending";
|
||||
|
||||
export type FileTreeBadge = {
|
||||
label: string;
|
||||
status: FileTreeBadgeVariant;
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
export type FileTreeTone = "default" | "warning" | "error" | "muted";
|
||||
|
||||
export type FileTreeEmptyState = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type FileTreeErrorState = {
|
||||
message: string;
|
||||
retry?: () => void;
|
||||
};
|
||||
|
||||
type VisibleFileTreeNode = {
|
||||
node: FileTreeNode;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
const TREE_BASE_INDENT = 16;
|
||||
const TREE_STEP_INDENT = 24;
|
||||
const TREE_ROW_HEIGHT_CLASS = "min-h-9";
|
||||
|
||||
const fileTreeToneClass: Record<FileTreeTone, string | undefined> = {
|
||||
default: undefined,
|
||||
warning: "bg-amber-500/5 text-amber-700 dark:text-amber-300",
|
||||
error: "bg-destructive/5 text-destructive",
|
||||
muted: "opacity-50",
|
||||
};
|
||||
|
||||
// -- Helpers -----------------------------------------------------------------
|
||||
|
||||
export function buildFileTree(
|
||||
files: Record<string, unknown>,
|
||||
actionMap?: Map<string, string>,
|
||||
): FileTreeNode[] {
|
||||
const root: FileTreeNode = { name: "", path: "", kind: "dir", children: [] };
|
||||
|
||||
for (const filePath of Object.keys(files)) {
|
||||
const segments = filePath.split("/").filter(Boolean);
|
||||
let current = root;
|
||||
let currentPath = "";
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i];
|
||||
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
||||
const isLeaf = i === segments.length - 1;
|
||||
let next = current.children.find((c) => c.name === segment);
|
||||
if (!next) {
|
||||
next = {
|
||||
name: segment,
|
||||
path: currentPath,
|
||||
kind: isLeaf ? "file" : "dir",
|
||||
children: [],
|
||||
action: isLeaf ? (actionMap?.get(filePath) ?? null) : null,
|
||||
};
|
||||
current.children.push(next);
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
|
||||
function sortNode(node: FileTreeNode) {
|
||||
node.children.sort((a, b) => {
|
||||
// Files before directories so PROJECT.md appears above tasks/
|
||||
if (a.kind !== b.kind) return a.kind === "file" ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
node.children.forEach(sortNode);
|
||||
}
|
||||
|
||||
sortNode(root);
|
||||
return root.children;
|
||||
}
|
||||
|
||||
export function countFiles(nodes: FileTreeNode[]): number {
|
||||
let count = 0;
|
||||
for (const node of nodes) {
|
||||
if (node.kind === "file") count++;
|
||||
else count += countFiles(node.children);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function collectAllPaths(
|
||||
nodes: FileTreeNode[],
|
||||
type: "file" | "dir" | "all" = "all",
|
||||
): Set<string> {
|
||||
const paths = new Set<string>();
|
||||
for (const node of nodes) {
|
||||
if (type === "all" || node.kind === type) paths.add(node.path);
|
||||
for (const p of collectAllPaths(node.children, type)) paths.add(p);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function fileIcon(name: string) {
|
||||
if (name.endsWith(".yaml") || name.endsWith(".yml")) return FileCode2;
|
||||
return FileText;
|
||||
}
|
||||
|
||||
function flattenVisibleNodes(
|
||||
nodes: FileTreeNode[],
|
||||
expandedDirs: Set<string>,
|
||||
depth = 0,
|
||||
): VisibleFileTreeNode[] {
|
||||
const flattened: VisibleFileTreeNode[] = [];
|
||||
for (const node of nodes) {
|
||||
flattened.push({ node, depth });
|
||||
if (node.kind === "dir" && expandedDirs.has(node.path)) {
|
||||
flattened.push(...flattenVisibleNodes(node.children, expandedDirs, depth + 1));
|
||||
}
|
||||
}
|
||||
return flattened;
|
||||
}
|
||||
|
||||
function checkboxState(node: FileTreeNode, checkedFiles: Set<string>) {
|
||||
if (node.kind === "file") {
|
||||
return {
|
||||
allChecked: checkedFiles.has(node.path),
|
||||
someChecked: false,
|
||||
};
|
||||
}
|
||||
|
||||
const childFiles = collectAllPaths(node.children, "file");
|
||||
const childFilePaths = [...childFiles];
|
||||
const allChecked = childFilePaths.length > 0 && childFilePaths.every((p) => checkedFiles.has(p));
|
||||
const someChecked = childFilePaths.some((p) => checkedFiles.has(p));
|
||||
return { allChecked, someChecked: someChecked && !allChecked };
|
||||
}
|
||||
|
||||
// -- Frontmatter helpers -----------------------------------------------------
|
||||
|
||||
export type FrontmatterData = Record<string, string | string[]>;
|
||||
|
||||
export function parseFrontmatter(content: string): { data: FrontmatterData; body: string } | null {
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
||||
if (!match) return null;
|
||||
|
||||
const data: FrontmatterData = {};
|
||||
const rawYaml = match[1];
|
||||
const body = match[2];
|
||||
|
||||
let currentKey: string | null = null;
|
||||
let currentList: string[] | null = null;
|
||||
|
||||
for (const line of rawYaml.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
|
||||
if (trimmed.startsWith("- ") && currentKey) {
|
||||
if (!currentList) currentList = [];
|
||||
currentList.push(trimmed.slice(2).trim().replace(/^["']|["']$/g, ""));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentKey && currentList) {
|
||||
data[currentKey] = currentList;
|
||||
currentList = null;
|
||||
currentKey = null;
|
||||
}
|
||||
|
||||
const kvMatch = trimmed.match(/^([a-zA-Z_][\w-]*)\s*:\s*(.*)$/);
|
||||
if (kvMatch) {
|
||||
const key = kvMatch[1];
|
||||
const val = kvMatch[2].trim().replace(/^["']|["']$/g, "");
|
||||
if (val === "null") {
|
||||
currentKey = null;
|
||||
continue;
|
||||
}
|
||||
if (val) {
|
||||
data[key] = val;
|
||||
currentKey = null;
|
||||
} else {
|
||||
currentKey = key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentKey && currentList) {
|
||||
data[currentKey] = currentList;
|
||||
}
|
||||
|
||||
return Object.keys(data).length > 0 ? { data, body } : null;
|
||||
}
|
||||
|
||||
export const FRONTMATTER_FIELD_LABELS: Record<string, string> = {
|
||||
name: "Name",
|
||||
title: "Title",
|
||||
kind: "Kind",
|
||||
reportsTo: "Reports to",
|
||||
skills: "Skills",
|
||||
status: "Status",
|
||||
description: "Description",
|
||||
priority: "Priority",
|
||||
assignee: "Assignee",
|
||||
project: "Project",
|
||||
recurring: "Recurring",
|
||||
targetDate: "Target date",
|
||||
};
|
||||
|
||||
// -- File tree component -----------------------------------------------------
|
||||
|
||||
export type FileTreeProps = {
|
||||
nodes: FileTreeNode[];
|
||||
selectedFile: string | null;
|
||||
expandedDirs: Set<string>;
|
||||
checkedFiles?: Set<string>;
|
||||
onToggleDir: (path: string) => void;
|
||||
onSelectFile: (path: string) => void;
|
||||
onToggleCheck?: (path: string, kind: "file" | "dir") => void;
|
||||
/** Serializable badge metadata keyed by path. This is safe to expose through plugin UI contracts. */
|
||||
fileBadges?: Record<string, FileTreeBadge | undefined>;
|
||||
/** Closed row tone metadata keyed by path. This avoids raw host class names in public contracts. */
|
||||
fileTones?: Record<string, FileTreeTone | undefined>;
|
||||
/** Internal-only escape hatch for current host call sites that need richer row content. */
|
||||
renderFileExtra?: (node: FileTreeNode, checked: boolean) => ReactNode;
|
||||
/** @deprecated Use fileTones for public surfaces. Kept for compatibility with host-only callers. */
|
||||
fileRowClassName?: (node: FileTreeNode, checked: boolean) => string | undefined;
|
||||
showCheckboxes?: boolean;
|
||||
/** Allow long file and directory names to wrap instead of forcing horizontal overflow. */
|
||||
wrapLabels?: boolean;
|
||||
loading?: boolean;
|
||||
error?: FileTreeErrorState | null;
|
||||
empty?: FileTreeEmptyState;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
export function FileTree({
|
||||
nodes,
|
||||
selectedFile,
|
||||
expandedDirs,
|
||||
checkedFiles,
|
||||
onToggleDir,
|
||||
onSelectFile,
|
||||
onToggleCheck,
|
||||
fileBadges,
|
||||
fileTones,
|
||||
renderFileExtra,
|
||||
fileRowClassName,
|
||||
showCheckboxes = true,
|
||||
wrapLabels = true,
|
||||
loading = false,
|
||||
error,
|
||||
empty,
|
||||
ariaLabel = "Files",
|
||||
}: FileTreeProps) {
|
||||
const effectiveCheckedFiles = checkedFiles ?? new Set<string>();
|
||||
const visibleNodes = useMemo(
|
||||
() => flattenVisibleNodes(nodes, expandedDirs),
|
||||
[expandedDirs, nodes],
|
||||
);
|
||||
const [focusedPath, setFocusedPath] = useState<string | null>(null);
|
||||
const rowRefs = useRef(new Map<string, HTMLDivElement>());
|
||||
|
||||
function focusPath(path: string) {
|
||||
setFocusedPath(path);
|
||||
window.requestAnimationFrame(() => {
|
||||
rowRefs.current.get(path)?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function toggleNode(node: FileTreeNode) {
|
||||
if (node.kind === "dir") onToggleDir(node.path);
|
||||
else onSelectFile(node.path);
|
||||
}
|
||||
|
||||
function handleRowKeyDown(event: KeyboardEvent<HTMLDivElement>, index: number, node: FileTreeNode) {
|
||||
switch (event.key) {
|
||||
case "ArrowDown": {
|
||||
event.preventDefault();
|
||||
const next = visibleNodes[Math.min(index + 1, visibleNodes.length - 1)];
|
||||
if (next) focusPath(next.node.path);
|
||||
break;
|
||||
}
|
||||
case "ArrowUp": {
|
||||
event.preventDefault();
|
||||
const previous = visibleNodes[Math.max(index - 1, 0)];
|
||||
if (previous) focusPath(previous.node.path);
|
||||
break;
|
||||
}
|
||||
case "ArrowRight":
|
||||
if (node.kind === "dir" && !expandedDirs.has(node.path)) {
|
||||
event.preventDefault();
|
||||
onToggleDir(node.path);
|
||||
}
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
if (node.kind === "dir" && expandedDirs.has(node.path)) {
|
||||
event.preventDefault();
|
||||
onToggleDir(node.path);
|
||||
}
|
||||
break;
|
||||
case "Enter":
|
||||
event.preventDefault();
|
||||
toggleNode(node);
|
||||
break;
|
||||
case " ":
|
||||
if (showCheckboxes && onToggleCheck) {
|
||||
event.preventDefault();
|
||||
onToggleCheck(node.path, node.kind);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div aria-busy="true" aria-label={ariaLabel} role="tree" className="py-1">
|
||||
{[0, 1, 2, 3].map((row) => (
|
||||
<div key={row} className={cn("flex items-center gap-2 px-4", TREE_ROW_HEIGHT_CLASS)}>
|
||||
<Skeleton className="h-4 w-4 shrink-0 rounded-sm" />
|
||||
<Skeleton className={cn("h-3.5", row === 1 ? "w-3/5" : "w-4/5")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div aria-label={ariaLabel} role="tree" className="p-3">
|
||||
<div
|
||||
role="treeitem"
|
||||
aria-level={1}
|
||||
className="flex min-h-9 items-center justify-between gap-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center rounded-full px-2.5 py-0.5 text-xs font-medium whitespace-nowrap",
|
||||
statusBadge.error ?? statusBadgeDefault,
|
||||
)}
|
||||
>
|
||||
error
|
||||
</span>
|
||||
<span className="min-w-0 text-destructive">{error.message}</span>
|
||||
</div>
|
||||
{error.retry && (
|
||||
<Button type="button" size="xs" variant="outline" onClick={error.retry}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<div aria-label={ariaLabel} role="tree" className="p-3">
|
||||
<div className="rounded-md border border-dashed border-border px-4 py-8 text-center">
|
||||
<div className="text-sm font-medium">{empty?.title ?? "No files"}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{empty?.description ?? "Files will appear here when they are available."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div aria-label={ariaLabel} role="tree">
|
||||
{visibleNodes.map(({ node, depth }, index) => {
|
||||
const expanded = node.kind === "dir" && expandedDirs.has(node.path);
|
||||
const { allChecked, someChecked } = checkboxState(node, effectiveCheckedFiles);
|
||||
const badge = fileBadges?.[node.path];
|
||||
const tone = fileTones?.[node.path] ?? "default";
|
||||
const extraClassName = node.kind === "file" ? fileRowClassName?.(node, allChecked) : undefined;
|
||||
const FileIcon = node.kind === "file" ? fileIcon(node.name) : null;
|
||||
const isSelected = node.kind === "file" && node.path === selectedFile;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={node.path}
|
||||
ref={(element) => {
|
||||
if (element) rowRefs.current.set(node.path, element);
|
||||
else rowRefs.current.delete(node.path);
|
||||
}}
|
||||
role="treeitem"
|
||||
aria-level={depth + 1}
|
||||
aria-expanded={node.kind === "dir" ? expanded : undefined}
|
||||
aria-selected={node.kind === "file" ? isSelected : undefined}
|
||||
aria-checked={showCheckboxes ? (someChecked ? "mixed" : allChecked) : undefined}
|
||||
tabIndex={(focusedPath ?? visibleNodes[0]?.node.path) === node.path ? 0 : -1}
|
||||
className={cn(
|
||||
node.kind === "dir"
|
||||
? showCheckboxes
|
||||
? "group grid w-full grid-cols-[auto_minmax(0,1fr)_2.25rem] items-center gap-x-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground"
|
||||
: "group grid w-full grid-cols-[minmax(0,1fr)_2.25rem] items-center gap-x-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground max-[480px]:grid-cols-[minmax(0,1fr)]"
|
||||
: "group flex w-full items-center gap-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground cursor-pointer",
|
||||
TREE_ROW_HEIGHT_CLASS,
|
||||
isSelected && "text-foreground bg-accent/20",
|
||||
fileTreeToneClass[tone],
|
||||
extraClassName,
|
||||
"outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset",
|
||||
)}
|
||||
style={{
|
||||
paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`,
|
||||
}}
|
||||
onFocus={() => setFocusedPath(node.path)}
|
||||
onClick={() => toggleNode(node)}
|
||||
onKeyDown={(event) => handleRowKeyDown(event, index, node)}
|
||||
data-file-tree-path={node.path}
|
||||
>
|
||||
{showCheckboxes && (
|
||||
<label className="flex items-center pl-2" onClick={(event) => event.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allChecked}
|
||||
ref={(element) => {
|
||||
if (element) element.indeterminate = someChecked;
|
||||
}}
|
||||
onChange={() => onToggleCheck?.(node.path, node.kind)}
|
||||
className="mr-2 accent-foreground"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2 py-1 text-left">
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
{node.kind === "dir" ? (
|
||||
expanded ? (
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Folder className="h-3.5 w-3.5" />
|
||||
)
|
||||
) : FileIcon ? (
|
||||
<FileIcon className="h-3.5 w-3.5" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className={cn("min-w-0", wrapLabels ? "break-all leading-4" : "truncate")}>
|
||||
{node.name}
|
||||
</span>
|
||||
</span>
|
||||
{badge && (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-3 shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide",
|
||||
statusBadge[badge.status] ?? statusBadgeDefault,
|
||||
)}
|
||||
title={badge.tooltip}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
)}
|
||||
{node.kind === "file" && renderFileExtra?.(node, allChecked)}
|
||||
{node.kind === "dir" && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-9 items-center justify-center self-center rounded-sm text-muted-foreground opacity-70 transition-[background-color,color,opacity] hover:bg-accent hover:text-foreground group-hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring/50 max-[480px]:hidden"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleDir(node.path);
|
||||
}}
|
||||
aria-label={expanded ? `Collapse ${node.name}` : `Expand ${node.name}`}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ import { pluginsApi } from "@/api/plugins";
|
|||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { SIDEBAR_SCROLL_RESET_STATE } from "@/lib/navigation-scroll";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
||||
|
||||
export function InstanceSidebar() {
|
||||
const { data: plugins } = useQuery({
|
||||
|
|
@ -14,13 +13,12 @@ export function InstanceSidebar() {
|
|||
});
|
||||
|
||||
return (
|
||||
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||
<div className="flex items-center gap-1 px-3 h-12 shrink-0">
|
||||
<SidebarCompanyMenu />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-5 pb-3 shrink-0">
|
||||
<Settings className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="flex-1 truncate text-sm font-bold text-foreground">Instance Settings</span>
|
||||
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||
<div className="flex items-center gap-2 px-3 h-12 shrink-0">
|
||||
<Settings className="h-4 w-4 text-muted-foreground shrink-0 ml-1" />
|
||||
<span className="flex-1 text-sm font-bold text-foreground truncate">
|
||||
Instance Settings
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
|
||||
|
|
|
|||
|
|
@ -17,6 +17,16 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({
|
|||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
|
||||
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
||||
const mockCompanyState = vi.hoisted(() => ({
|
||||
companies: [{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" }],
|
||||
selectedCompany: { id: "company-1", issuePrefix: "PAP", name: "Paperclip" },
|
||||
selectedCompanyId: "company-1",
|
||||
}));
|
||||
const mockPluginSlots = vi.hoisted(() => ({
|
||||
slots: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
|
||||
const mockPluginSlotContexts = vi.hoisted(() => [] as Array<Record<string, unknown>>);
|
||||
let currentPathname = "/PAP/dashboard";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
|
|
@ -24,7 +34,10 @@ vi.mock("@/lib/router", () => ({
|
|||
useLocation: () => ({ pathname: currentPathname, search: "", hash: "", state: null }),
|
||||
useNavigate: () => mockNavigate,
|
||||
useNavigationType: () => "PUSH",
|
||||
useParams: () => ({ companyPrefix: "PAP" }),
|
||||
useParams: () => {
|
||||
const firstSegment = currentPathname.split("/").filter(Boolean)[0];
|
||||
return { companyPrefix: firstSegment === "instance" ? undefined : firstSegment ?? "PAP" };
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./CompanyRail", () => ({
|
||||
|
|
@ -95,6 +108,33 @@ vi.mock("./SidebarAccountMenu", () => ({
|
|||
SidebarAccountMenu: () => <div>Account menu</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/slots", async () => {
|
||||
const actual = await vi.importActual<typeof import("../plugins/slots")>("../plugins/slots");
|
||||
return {
|
||||
resolveRouteSidebarSlot: actual.resolveRouteSidebarSlot,
|
||||
usePluginSlots: (params: Record<string, unknown>) => {
|
||||
mockUsePluginSlots(params);
|
||||
return {
|
||||
slots: mockPluginSlots.slots,
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
};
|
||||
},
|
||||
PluginSlotMount: ({
|
||||
slot,
|
||||
context,
|
||||
className,
|
||||
}: {
|
||||
slot: { displayName: string };
|
||||
context: Record<string, unknown>;
|
||||
className?: string;
|
||||
}) => {
|
||||
mockPluginSlotContexts.push(context);
|
||||
return <div data-plugin-slot-class={className}>Plugin route sidebar: {slot.displayName}</div>;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../context/DialogContext", () => ({
|
||||
useDialog: () => ({
|
||||
openNewIssue: vi.fn(),
|
||||
|
|
@ -114,10 +154,10 @@ vi.mock("../context/PanelContext", () => ({
|
|||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
companies: [{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" }],
|
||||
companies: mockCompanyState.companies,
|
||||
loading: false,
|
||||
selectedCompany: { id: "company-1", issuePrefix: "PAP", name: "Paperclip" },
|
||||
selectedCompanyId: "company-1",
|
||||
selectedCompany: mockCompanyState.selectedCompany,
|
||||
selectedCompanyId: mockCompanyState.selectedCompanyId,
|
||||
selectionSource: "manual",
|
||||
setSelectedCompanyId: mockSetSelectedCompanyId,
|
||||
}),
|
||||
|
|
@ -179,6 +219,9 @@ describe("Layout", () => {
|
|||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
currentPathname = "/PAP/dashboard";
|
||||
mockCompanyState.companies = [{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" }];
|
||||
mockCompanyState.selectedCompany = { id: "company-1", issuePrefix: "PAP", name: "Paperclip" };
|
||||
mockCompanyState.selectedCompanyId = "company-1";
|
||||
mockHealthApi.get.mockResolvedValue({
|
||||
status: "ok",
|
||||
deploymentMode: "authenticated",
|
||||
|
|
@ -188,6 +231,8 @@ describe("Layout", () => {
|
|||
mockInstanceSettingsApi.getGeneral.mockResolvedValue({
|
||||
keyboardShortcuts: false,
|
||||
});
|
||||
mockPluginSlots.slots = [];
|
||||
mockPluginSlotContexts.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -227,6 +272,30 @@ describe("Layout", () => {
|
|||
|
||||
it("renders the company settings sidebar on company settings routes", async () => {
|
||||
currentPathname = "/PAP/company/settings/access";
|
||||
mockPluginSlots.slots = [
|
||||
{
|
||||
type: "page",
|
||||
id: "company-page",
|
||||
displayName: "Company Page",
|
||||
exportName: "CompanyPage",
|
||||
routePath: "company",
|
||||
pluginId: "plugin-1",
|
||||
pluginKey: "fake-plugin",
|
||||
pluginDisplayName: "Fake Plugin",
|
||||
pluginVersion: "1.0.0",
|
||||
},
|
||||
{
|
||||
type: "routeSidebar",
|
||||
id: "company-sidebar",
|
||||
displayName: "Company Route Sidebar",
|
||||
exportName: "CompanySidebar",
|
||||
routePath: "company",
|
||||
pluginId: "plugin-1",
|
||||
pluginKey: "fake-plugin",
|
||||
pluginDisplayName: "Fake Plugin",
|
||||
pluginVersion: "1.0.0",
|
||||
},
|
||||
];
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
|
|
@ -245,6 +314,213 @@ describe("Layout", () => {
|
|||
expect(container.textContent).toContain("Company settings sidebar");
|
||||
expect(container.textContent).not.toContain("Instance sidebar");
|
||||
expect(container.textContent).not.toContain("Main company nav");
|
||||
expect(container.textContent).not.toContain("Plugin route sidebar");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the instance settings sidebar on instance settings routes", async () => {
|
||||
currentPathname = "/instance/settings/general";
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Instance sidebar");
|
||||
expect(container.textContent).not.toContain("Company settings sidebar");
|
||||
expect(container.textContent).not.toContain("Main company nav");
|
||||
expect(container.textContent).not.toContain("Plugin route sidebar");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a route-scoped plugin sidebar for a matching plugin page route", async () => {
|
||||
currentPathname = "/PAP/wiki";
|
||||
mockPluginSlots.slots = [
|
||||
{
|
||||
type: "page",
|
||||
id: "wiki-page",
|
||||
displayName: "Wiki Page",
|
||||
exportName: "WikiPage",
|
||||
routePath: "wiki",
|
||||
pluginId: "plugin-1",
|
||||
pluginKey: "wiki-plugin",
|
||||
pluginDisplayName: "Wiki Plugin",
|
||||
pluginVersion: "1.0.0",
|
||||
},
|
||||
{
|
||||
type: "routeSidebar",
|
||||
id: "wiki-route-sidebar",
|
||||
displayName: "Wiki Sidebar",
|
||||
exportName: "WikiSidebar",
|
||||
routePath: "wiki",
|
||||
pluginId: "plugin-1",
|
||||
pluginKey: "wiki-plugin",
|
||||
pluginDisplayName: "Wiki Plugin",
|
||||
pluginVersion: "1.0.0",
|
||||
},
|
||||
];
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Plugin route sidebar: Wiki Sidebar");
|
||||
expect(container.querySelector("[data-plugin-slot-class='h-full w-full']")).not.toBeNull();
|
||||
expect(container.textContent).not.toContain("Main company nav");
|
||||
expect(container.textContent).not.toContain("Company settings sidebar");
|
||||
expect(container.textContent).not.toContain("Instance sidebar");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the route company context for plugin route sidebars on the first render", async () => {
|
||||
currentPathname = "/ALT/wiki";
|
||||
mockCompanyState.companies = [
|
||||
{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" },
|
||||
{ id: "company-2", issuePrefix: "ALT", name: "Alternate" },
|
||||
];
|
||||
mockCompanyState.selectedCompany = { id: "company-1", issuePrefix: "PAP", name: "Paperclip" };
|
||||
mockCompanyState.selectedCompanyId = "company-1";
|
||||
mockPluginSlots.slots = [
|
||||
{
|
||||
type: "page",
|
||||
id: "wiki-page",
|
||||
displayName: "Wiki Page",
|
||||
exportName: "WikiPage",
|
||||
routePath: "wiki",
|
||||
pluginId: "plugin-1",
|
||||
pluginKey: "wiki-plugin",
|
||||
pluginDisplayName: "Wiki Plugin",
|
||||
pluginVersion: "1.0.0",
|
||||
},
|
||||
{
|
||||
type: "routeSidebar",
|
||||
id: "wiki-route-sidebar",
|
||||
displayName: "Wiki Sidebar",
|
||||
exportName: "WikiSidebar",
|
||||
routePath: "wiki",
|
||||
pluginId: "plugin-1",
|
||||
pluginKey: "wiki-plugin",
|
||||
pluginDisplayName: "Wiki Plugin",
|
||||
pluginVersion: "1.0.0",
|
||||
},
|
||||
];
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockUsePluginSlots).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
companyId: "company-2",
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
expect(mockPluginSlotContexts).toContainEqual({
|
||||
companyId: "company-2",
|
||||
companyPrefix: "ALT",
|
||||
});
|
||||
expect(mockPluginSlotContexts).not.toContainEqual({
|
||||
companyId: "company-1",
|
||||
companyPrefix: "PAP",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the normal company sidebar when a plugin page route is ambiguous", async () => {
|
||||
currentPathname = "/PAP/wiki";
|
||||
mockPluginSlots.slots = [
|
||||
{
|
||||
type: "page",
|
||||
id: "wiki-page-a",
|
||||
displayName: "Wiki Page A",
|
||||
exportName: "WikiPageA",
|
||||
routePath: "wiki",
|
||||
pluginId: "plugin-1",
|
||||
pluginKey: "wiki-plugin-a",
|
||||
pluginDisplayName: "Wiki Plugin A",
|
||||
pluginVersion: "1.0.0",
|
||||
},
|
||||
{
|
||||
type: "page",
|
||||
id: "wiki-page-b",
|
||||
displayName: "Wiki Page B",
|
||||
exportName: "WikiPageB",
|
||||
routePath: "wiki",
|
||||
pluginId: "plugin-2",
|
||||
pluginKey: "wiki-plugin-b",
|
||||
pluginDisplayName: "Wiki Plugin B",
|
||||
pluginVersion: "1.0.0",
|
||||
},
|
||||
{
|
||||
type: "routeSidebar",
|
||||
id: "wiki-route-sidebar",
|
||||
displayName: "Wiki Sidebar",
|
||||
exportName: "WikiSidebar",
|
||||
routePath: "wiki",
|
||||
pluginId: "plugin-1",
|
||||
pluginKey: "wiki-plugin-a",
|
||||
pluginDisplayName: "Wiki Plugin A",
|
||||
pluginVersion: "1.0.0",
|
||||
},
|
||||
];
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Main company nav");
|
||||
expect(container.textContent).not.toContain("Plugin route sidebar");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
|
||||
import { CompanyRail } from "./CompanyRail";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { InstanceSidebar } from "./InstanceSidebar";
|
||||
import { CompanySettingsSidebar } from "./CompanySettingsSidebar";
|
||||
|
|
@ -16,6 +17,7 @@ import { ToastViewport } from "./ToastViewport";
|
|||
import { MobileBottomNav } from "./MobileBottomNav";
|
||||
import { WorktreeBanner } from "./WorktreeBanner";
|
||||
import { DevRestartBanner } from "./DevRestartBanner";
|
||||
import { ResizableSidebarPane } from "./ResizableSidebarPane";
|
||||
import { SidebarAccountMenu } from "./SidebarAccountMenu";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
|
||||
|
|
@ -39,9 +41,18 @@ import { queryKeys } from "../lib/queryKeys";
|
|||
import { scheduleMainContentFocus } from "../lib/main-content-focus";
|
||||
import { cn } from "../lib/utils";
|
||||
import { NotFoundPage } from "../pages/NotFound";
|
||||
import { PluginSlotMount, resolveRouteSidebarSlot, usePluginSlots } from "../plugins/slots";
|
||||
|
||||
const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath";
|
||||
|
||||
function getCompanyRouteSegment(pathname: string, companyPrefix: string | undefined): string | null {
|
||||
if (!companyPrefix) return null;
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
if (segments.length < 2) return null;
|
||||
if (segments[0]?.toUpperCase() !== companyPrefix.toUpperCase()) return null;
|
||||
return segments[1]?.toLowerCase() ?? null;
|
||||
}
|
||||
|
||||
function readRememberedInstanceSettingsPath(): string {
|
||||
if (typeof window === "undefined") return DEFAULT_INSTANCE_SETTINGS_PATH;
|
||||
try {
|
||||
|
|
@ -83,6 +94,38 @@ export function Layout() {
|
|||
}, [companies, companyPrefix]);
|
||||
const hasUnknownCompanyPrefix =
|
||||
Boolean(companyPrefix) && !companiesLoading && companies.length > 0 && !matchedCompany;
|
||||
const pluginRoutePath = useMemo(
|
||||
() => getCompanyRouteSegment(location.pathname, companyPrefix),
|
||||
[companyPrefix, location.pathname],
|
||||
);
|
||||
const routeSidebarCompanyId = matchedCompany?.id ?? null;
|
||||
const routeSidebarCompanyPrefix = matchedCompany?.issuePrefix ?? null;
|
||||
const { slots: routeSidebarSlots } = usePluginSlots({
|
||||
slotTypes: ["page", "routeSidebar"],
|
||||
companyId: routeSidebarCompanyId,
|
||||
enabled: Boolean(routeSidebarCompanyId && pluginRoutePath),
|
||||
});
|
||||
const routeSidebarSlot = useMemo(
|
||||
() => resolveRouteSidebarSlot(routeSidebarSlots, pluginRoutePath),
|
||||
[pluginRoutePath, routeSidebarSlots],
|
||||
);
|
||||
const sidebarContext = useMemo(
|
||||
() => ({
|
||||
companyId: routeSidebarCompanyId,
|
||||
companyPrefix: routeSidebarCompanyPrefix,
|
||||
}),
|
||||
[routeSidebarCompanyId, routeSidebarCompanyPrefix],
|
||||
);
|
||||
const companySidebar = routeSidebarSlot ? (
|
||||
<PluginSlotMount
|
||||
slot={routeSidebarSlot}
|
||||
context={sidebarContext}
|
||||
className="h-full w-full"
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
) : (
|
||||
<Sidebar />
|
||||
);
|
||||
const { data: health } = useQuery({
|
||||
queryKey: queryKeys.health,
|
||||
queryFn: () => healthApi.get(),
|
||||
|
|
@ -335,13 +378,16 @@ export function Layout() {
|
|||
)}
|
||||
>
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
{isInstanceSettingsRoute ? (
|
||||
<InstanceSidebar />
|
||||
) : isCompanySettingsRoute ? (
|
||||
<CompanySettingsSidebar />
|
||||
) : (
|
||||
<Sidebar />
|
||||
)}
|
||||
<CompanyRail />
|
||||
<div className="w-60 shrink-0 overflow-hidden">
|
||||
{isInstanceSettingsRoute ? (
|
||||
<InstanceSidebar />
|
||||
) : isCompanySettingsRoute ? (
|
||||
<CompanySettingsSidebar />
|
||||
) : (
|
||||
companySidebar
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SidebarAccountMenu
|
||||
deploymentMode={health?.deploymentMode}
|
||||
|
|
@ -352,20 +398,16 @@ export function Layout() {
|
|||
) : (
|
||||
<div className="flex h-full flex-col shrink-0">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden transition-[width] duration-100 ease-out",
|
||||
sidebarOpen ? "w-60" : "w-0"
|
||||
)}
|
||||
>
|
||||
<CompanyRail />
|
||||
<ResizableSidebarPane open={sidebarOpen} resizable className="h-full shrink-0">
|
||||
{isInstanceSettingsRoute ? (
|
||||
<InstanceSidebar />
|
||||
) : isCompanySettingsRoute ? (
|
||||
<CompanySettingsSidebar />
|
||||
) : (
|
||||
<Sidebar />
|
||||
companySidebar
|
||||
)}
|
||||
</div>
|
||||
</ResizableSidebarPane>
|
||||
</div>
|
||||
<SidebarAccountMenu
|
||||
deploymentMode={health?.deploymentMode}
|
||||
|
|
|
|||
180
ui/src/components/ManagedRoutinesList.tsx
Normal file
180
ui/src/components/ManagedRoutinesList.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
RoutineListRow,
|
||||
type RoutineListAgentSummary,
|
||||
type RoutineListProjectSummary,
|
||||
type RoutineListRowItem,
|
||||
} from "@/components/RoutineList";
|
||||
|
||||
export type ManagedRoutinesListAgent = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string | null;
|
||||
};
|
||||
|
||||
export type ManagedRoutinesListProject = {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string | null;
|
||||
};
|
||||
|
||||
export type ManagedRoutineMissingRef = {
|
||||
resourceKind: string;
|
||||
resourceKey: string;
|
||||
};
|
||||
|
||||
export type ManagedRoutinesListItem = {
|
||||
key: string;
|
||||
title: string;
|
||||
status: string;
|
||||
routineId?: string | null;
|
||||
href?: string | null;
|
||||
resourceKey?: string | null;
|
||||
projectId?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
cronExpression?: string | null;
|
||||
lastRunAt?: Date | string | null;
|
||||
lastRunStatus?: string | null;
|
||||
managedByPluginDisplayName?: string | null;
|
||||
missingRefs?: ManagedRoutineMissingRef[];
|
||||
};
|
||||
|
||||
export type ManagedRoutinesListProps = {
|
||||
routines: ManagedRoutinesListItem[];
|
||||
agents?: ManagedRoutinesListAgent[];
|
||||
projects?: ManagedRoutinesListProject[];
|
||||
pluginDisplayName?: string | null;
|
||||
emptyMessage?: string;
|
||||
runningRoutineKey?: string | null;
|
||||
statusMutationRoutineKey?: string | null;
|
||||
reconcilingRoutineKey?: string | null;
|
||||
resettingRoutineKey?: string | null;
|
||||
onRunNow?: (routine: ManagedRoutinesListItem) => void;
|
||||
onToggleEnabled?: (routine: ManagedRoutinesListItem, enabled: boolean) => void;
|
||||
onReconcile?: (routine: ManagedRoutinesListItem) => void;
|
||||
onReset?: (routine: ManagedRoutinesListItem) => void;
|
||||
};
|
||||
|
||||
function managedRoutineToRow(routine: ManagedRoutinesListItem): RoutineListRowItem {
|
||||
return {
|
||||
id: routine.key,
|
||||
title: routine.title,
|
||||
status: routine.status,
|
||||
projectId: routine.projectId ?? null,
|
||||
assigneeAgentId: routine.assigneeAgentId ?? null,
|
||||
lastRun: routine.lastRunAt || routine.lastRunStatus
|
||||
? {
|
||||
triggeredAt: routine.lastRunAt ?? null,
|
||||
status: routine.lastRunStatus ?? null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function ManagedRoutinesList({
|
||||
routines,
|
||||
agents = [],
|
||||
projects = [],
|
||||
pluginDisplayName = null,
|
||||
emptyMessage = "No managed routines.",
|
||||
runningRoutineKey = null,
|
||||
statusMutationRoutineKey = null,
|
||||
reconcilingRoutineKey = null,
|
||||
resettingRoutineKey = null,
|
||||
onRunNow,
|
||||
onToggleEnabled,
|
||||
onReconcile,
|
||||
onReset,
|
||||
}: ManagedRoutinesListProps) {
|
||||
const agentById = new Map<string, RoutineListAgentSummary>(
|
||||
agents.map((agent) => [agent.id, { name: agent.name, icon: agent.icon }]),
|
||||
);
|
||||
const projectById = new Map<string, RoutineListProjectSummary>(
|
||||
projects.map((project) => [project.id, { name: project.name, color: project.color }]),
|
||||
);
|
||||
|
||||
if (routines.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border px-3 py-8 text-center text-sm text-muted-foreground">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border">
|
||||
{routines.map((routine) => {
|
||||
const row = managedRoutineToRow(routine);
|
||||
const href = routine.href ?? (routine.routineId ? `/routines/${routine.routineId}` : "/routines");
|
||||
const missingRefs = routine.missingRefs ?? [];
|
||||
const canUseRoutine = Boolean(routine.routineId && routine.resourceKey && missingRefs.length === 0);
|
||||
const managedBy = routine.managedByPluginDisplayName ?? pluginDisplayName;
|
||||
const hasRepairActions = Boolean(onReconcile || onReset);
|
||||
|
||||
return (
|
||||
<div key={routine.key} className="last:[&_a]:border-b-0">
|
||||
<RoutineListRow
|
||||
routine={row}
|
||||
projectById={projectById}
|
||||
agentById={agentById}
|
||||
runningRoutineId={runningRoutineKey}
|
||||
statusMutationRoutineId={statusMutationRoutineKey}
|
||||
href={href}
|
||||
configureLabel="Configure"
|
||||
managedByLabel={managedBy ? `Managed by ${managedBy}` : null}
|
||||
runNowButton
|
||||
hideArchiveAction
|
||||
disableRunNow={!canUseRoutine}
|
||||
disableToggle={!canUseRoutine}
|
||||
secondaryDetails={
|
||||
<span className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
{routine.resourceKey ? <span>{routine.resourceKey}</span> : null}
|
||||
{routine.cronExpression ? <span>Schedule {routine.cronExpression}</span> : null}
|
||||
</span>
|
||||
}
|
||||
onRunNow={() => onRunNow?.(routine)}
|
||||
onToggleEnabled={() => onToggleEnabled?.(routine, row.status === "active")}
|
||||
/>
|
||||
{hasRepairActions ? (
|
||||
<div
|
||||
className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-3 pb-3 text-xs text-muted-foreground last:border-b-0"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{missingRefs.length
|
||||
? `Missing ${missingRefs.map((ref) => `${ref.resourceKind}:${ref.resourceKey}`).join(", ")}`
|
||||
: "Routine defaults can be repaired."}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
{onReconcile ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={reconcilingRoutineKey === routine.key}
|
||||
onClick={() => onReconcile(routine)}
|
||||
>
|
||||
{reconcilingRoutineKey === routine.key ? "Reconciling..." : "Reconcile"}
|
||||
</Button>
|
||||
) : null}
|
||||
{onReset ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={resettingRoutineKey === routine.key}
|
||||
onClick={() => onReset(routine)}
|
||||
>
|
||||
{resettingRoutineKey === routine.key ? "Resetting..." : "Reset"}
|
||||
</Button>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
|
@ -33,7 +33,11 @@ vi.mock("../api/issues", () => ({
|
|||
issuesApi: mockIssuesApi,
|
||||
}));
|
||||
|
||||
function renderMarkdown(children: string, seededIssues: Array<{ identifier: string; status: string; title?: string }> = []) {
|
||||
function renderMarkdown(
|
||||
children: string,
|
||||
seededIssues: Array<{ identifier: string; status: string; title?: string }> = [],
|
||||
props: Partial<ComponentProps<typeof MarkdownBody>> = {},
|
||||
) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
|
|
@ -54,7 +58,7 @@ function renderMarkdown(children: string, seededIssues: Array<{ identifier: stri
|
|||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<MarkdownBody>{children}</MarkdownBody>
|
||||
<MarkdownBody {...props}>{children}</MarkdownBody>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
|
@ -279,6 +283,64 @@ describe("MarkdownBody", () => {
|
|||
expect(html).toContain('href="PAP-1271"');
|
||||
});
|
||||
|
||||
it("leaves wiki links as text unless explicitly enabled", () => {
|
||||
const html = renderMarkdown("See [[wiki/entities/paperclip]].");
|
||||
|
||||
expect(html).toContain("[[wiki/entities/paperclip]]");
|
||||
expect(html).not.toContain('href="/wiki/page/wiki/entities/paperclip.md"');
|
||||
});
|
||||
|
||||
it("renders wiki links with a custom resolver when enabled", () => {
|
||||
const html = renderMarkdown(
|
||||
"See [[wiki/entities/paperclip|Paperclip]] and [[wiki/entities/dotta-b]].",
|
||||
[],
|
||||
{
|
||||
enableWikiLinks: true,
|
||||
resolveWikiLinkHref: (target) => `/wiki/page/${target.endsWith(".md") ? target : `${target}.md`}`,
|
||||
},
|
||||
);
|
||||
|
||||
expect(html).toContain('href="/wiki/page/wiki/entities/paperclip.md"');
|
||||
expect(html).toContain('data-paperclip-wiki-link="true"');
|
||||
expect(html).toContain('data-paperclip-wiki-target="wiki/entities/paperclip"');
|
||||
expect(html).toContain(">Paperclip</a>");
|
||||
expect(html).toContain('href="/wiki/page/wiki/entities/dotta-b.md"');
|
||||
expect(html).toContain(">wiki/entities/dotta-b</a>");
|
||||
expect(html).not.toContain("[[wiki/entities/paperclip");
|
||||
});
|
||||
|
||||
it("keeps wiki links as text when the custom resolver rejects them", () => {
|
||||
const html = renderMarkdown(
|
||||
"See [[wiki/entities/paperclip]].",
|
||||
[],
|
||||
{
|
||||
enableWikiLinks: true,
|
||||
wikiLinkRoot: "/wiki/page",
|
||||
resolveWikiLinkHref: () => null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(html).toContain("[[wiki/entities/paperclip]]");
|
||||
expect(html).not.toContain('data-paperclip-wiki-link="true"');
|
||||
expect(html).not.toContain('href="/wiki/page/wiki/entities/paperclip"');
|
||||
});
|
||||
|
||||
it("does not render wiki links inside code spans or code blocks", () => {
|
||||
const html = renderMarkdown(
|
||||
"Inline `[[wiki/entities/paperclip]]`.\n\n```md\n[[wiki/entities/dotta-b]]\n```",
|
||||
[],
|
||||
{
|
||||
enableWikiLinks: true,
|
||||
wikiLinkRoot: "/wiki/page",
|
||||
},
|
||||
);
|
||||
|
||||
expect(html).toContain("[[wiki/entities/paperclip]]");
|
||||
expect(html).toContain("[[wiki/entities/dotta-b]]");
|
||||
expect(html).not.toContain('href="/wiki/page/wiki/entities/paperclip"');
|
||||
expect(html).not.toContain('href="/wiki/page/wiki/entities/dotta-b"');
|
||||
});
|
||||
|
||||
it("applies wrap-friendly styles to long inline content", () => {
|
||||
const html = renderMarkdown("averyveryveryveryveryveryveryveryveryverylongtoken");
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ interface MarkdownBodyProps {
|
|||
style?: React.CSSProperties;
|
||||
softBreaks?: boolean;
|
||||
linkIssueReferences?: boolean;
|
||||
/** Opt into Obsidian-style [[target]] / [[target|label]] wikilinks. */
|
||||
enableWikiLinks?: boolean;
|
||||
/** Base href used for wikilinks when no resolver is supplied. */
|
||||
wikiLinkRoot?: string;
|
||||
/** Optional href resolver for wikilinks. Return null to leave a token as plain text. */
|
||||
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
|
||||
/** Optional resolver for relative image paths (e.g. within export packages) */
|
||||
resolveImageSrc?: (src: string) => string | null;
|
||||
/** Called when a user clicks an inline image */
|
||||
|
|
@ -111,6 +117,160 @@ function safeMarkdownUrlTransform(url: string): string {
|
|||
return parseMentionChipHref(url) ? url : defaultUrlTransform(url);
|
||||
}
|
||||
|
||||
type MarkdownAstNode = {
|
||||
type?: string;
|
||||
value?: string;
|
||||
children?: MarkdownAstNode[];
|
||||
url?: string;
|
||||
title?: string | null;
|
||||
data?: {
|
||||
hProperties?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
type ParsedWikiLink = {
|
||||
target: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const WIKI_LINK_PATTERN = /\[\[([^\]\r\n]+)\]\]/g;
|
||||
const WIKI_LINK_SKIP_PARENT_TYPES = new Set([
|
||||
"definition",
|
||||
"image",
|
||||
"imageReference",
|
||||
"link",
|
||||
"linkReference",
|
||||
]);
|
||||
|
||||
function parseWikiLinkBody(body: string): ParsedWikiLink | null {
|
||||
const [rawTarget, ...rawLabelParts] = body.split("|");
|
||||
const target = rawTarget?.trim() ?? "";
|
||||
const label = rawLabelParts.length > 0 ? rawLabelParts.join("|").trim() : target;
|
||||
if (!target || target.includes("[") || target.includes("]")) return null;
|
||||
return {
|
||||
target,
|
||||
label: label || target,
|
||||
};
|
||||
}
|
||||
|
||||
function encodeWikiLinkTarget(target: string): string | null {
|
||||
const trimmed = target.trim();
|
||||
if (!trimmed || /^[a-z][a-z\d+.-]*:/i.test(trimmed) || trimmed.startsWith("//")) return null;
|
||||
|
||||
const hashIndex = trimmed.indexOf("#");
|
||||
const rawPath = (hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed)
|
||||
.trim()
|
||||
.replace(/^\/+/, "");
|
||||
if (
|
||||
!rawPath ||
|
||||
rawPath.includes("\\") ||
|
||||
rawPath.split("/").some((segment) => !segment || segment === "." || segment === "..")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const encodedPath = rawPath.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
||||
const rawHash = hashIndex >= 0 ? trimmed.slice(hashIndex + 1).trim() : "";
|
||||
return rawHash ? `${encodedPath}#${encodeURIComponent(rawHash)}` : encodedPath;
|
||||
}
|
||||
|
||||
function defaultWikiLinkHref(target: string, wikiLinkRoot?: string): string | null {
|
||||
const encodedTarget = encodeWikiLinkTarget(target);
|
||||
if (!encodedTarget) return null;
|
||||
const root = wikiLinkRoot?.trim().replace(/\/+$/, "") ?? "";
|
||||
return root ? `${root}/${encodedTarget}` : encodedTarget;
|
||||
}
|
||||
|
||||
function createWikiLinkNode(href: string, wikiLink: ParsedWikiLink): MarkdownAstNode {
|
||||
return {
|
||||
type: "link",
|
||||
url: href,
|
||||
title: null,
|
||||
data: {
|
||||
hProperties: {
|
||||
"data-paperclip-wiki-link": "true",
|
||||
"data-paperclip-wiki-target": wikiLink.target,
|
||||
},
|
||||
},
|
||||
children: [{ type: "text", value: wikiLink.label }],
|
||||
};
|
||||
}
|
||||
|
||||
function splitTextByWikiLinks(
|
||||
value: string,
|
||||
options: {
|
||||
wikiLinkRoot?: string;
|
||||
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
|
||||
},
|
||||
): MarkdownAstNode[] {
|
||||
const nodes: MarkdownAstNode[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
for (const match of value.matchAll(WIKI_LINK_PATTERN)) {
|
||||
const raw = match[0] ?? "";
|
||||
const body = match[1] ?? "";
|
||||
const start = match.index ?? 0;
|
||||
if (start > lastIndex) {
|
||||
nodes.push({ type: "text", value: value.slice(lastIndex, start) });
|
||||
}
|
||||
|
||||
const wikiLink = parseWikiLinkBody(body);
|
||||
let resolvedHref: string | null = null;
|
||||
if (wikiLink) {
|
||||
if (options.resolveWikiLinkHref) {
|
||||
const customHref = options.resolveWikiLinkHref(wikiLink.target, wikiLink.label);
|
||||
resolvedHref = customHref === undefined
|
||||
? defaultWikiLinkHref(wikiLink.target, options.wikiLinkRoot)
|
||||
: customHref;
|
||||
} else {
|
||||
resolvedHref = defaultWikiLinkHref(wikiLink.target, options.wikiLinkRoot);
|
||||
}
|
||||
}
|
||||
|
||||
if (wikiLink && resolvedHref) {
|
||||
nodes.push(createWikiLinkNode(resolvedHref, wikiLink));
|
||||
} else {
|
||||
nodes.push({ type: "text", value: raw });
|
||||
}
|
||||
lastIndex = start + raw.length;
|
||||
}
|
||||
|
||||
if (lastIndex < value.length) {
|
||||
nodes.push({ type: "text", value: value.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function transformWikiLinkChildren(
|
||||
node: MarkdownAstNode,
|
||||
options: {
|
||||
wikiLinkRoot?: string;
|
||||
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
|
||||
},
|
||||
) {
|
||||
if (!node.children || WIKI_LINK_SKIP_PARENT_TYPES.has(node.type ?? "")) return;
|
||||
|
||||
node.children = node.children.flatMap((child) => {
|
||||
if (child.type === "text" && typeof child.value === "string" && child.value.includes("[[")) {
|
||||
return splitTextByWikiLinks(child.value, options);
|
||||
}
|
||||
transformWikiLinkChildren(child, options);
|
||||
return child;
|
||||
});
|
||||
}
|
||||
|
||||
function createRemarkWikiLinks(options: {
|
||||
wikiLinkRoot?: string;
|
||||
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
|
||||
}) {
|
||||
return function remarkWikiLinks() {
|
||||
return (tree: MarkdownAstNode) => {
|
||||
transformWikiLinkChildren(tree, options);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function isGitHubUrl(href: string | null | undefined): boolean {
|
||||
if (!href) return false;
|
||||
try {
|
||||
|
|
@ -321,11 +481,17 @@ export function MarkdownBody({
|
|||
style,
|
||||
softBreaks = true,
|
||||
linkIssueReferences = true,
|
||||
enableWikiLinks = false,
|
||||
wikiLinkRoot,
|
||||
resolveWikiLinkHref,
|
||||
resolveImageSrc,
|
||||
onImageClick,
|
||||
}: MarkdownBodyProps) {
|
||||
const { theme } = useTheme();
|
||||
const remarkPlugins: NonNullable<Options["remarkPlugins"]> = [remarkGfm];
|
||||
if (enableWikiLinks) {
|
||||
remarkPlugins.push(createRemarkWikiLinks({ wikiLinkRoot, resolveWikiLinkHref }));
|
||||
}
|
||||
if (linkIssueReferences) {
|
||||
remarkPlugins.push(remarkLinkIssueReferences);
|
||||
}
|
||||
|
|
@ -370,7 +536,22 @@ export function MarkdownBody({
|
|||
{codeChildren}
|
||||
</code>
|
||||
),
|
||||
a: ({ href, style: linkStyle, children: linkChildren }) => {
|
||||
a: ({ node: _node, href, style: linkStyle, children: linkChildren, ...anchorProps }) => {
|
||||
const dataProps = anchorProps as Record<string, unknown>;
|
||||
const isWikiLink = dataProps["data-paperclip-wiki-link"] === "true";
|
||||
if (isWikiLink && href && !/^[a-z][a-z\d+.-]*:/i.test(href) && !href.startsWith("//")) {
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
{...anchorProps}
|
||||
rel="noreferrer"
|
||||
style={mergeWrapStyle(linkStyle as React.CSSProperties | undefined)}
|
||||
>
|
||||
{linkChildren}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const issueRef = linkIssueReferences ? parseIssueReferenceFromHref(href) : null;
|
||||
if (issueRef) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,326 +1,24 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileCode2,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
import { FileTree } from "./FileTree";
|
||||
import type { FileTreeProps } from "./FileTree";
|
||||
|
||||
// ── Tree types ────────────────────────────────────────────────────────
|
||||
|
||||
export type FileTreeNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
kind: "dir" | "file";
|
||||
children: FileTreeNode[];
|
||||
/** Optional per-node metadata (e.g. import action) */
|
||||
action?: string | null;
|
||||
};
|
||||
|
||||
const TREE_BASE_INDENT = 16;
|
||||
const TREE_STEP_INDENT = 24;
|
||||
const TREE_ROW_HEIGHT_CLASS = "min-h-9";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
export function buildFileTree(
|
||||
files: Record<string, unknown>,
|
||||
actionMap?: Map<string, string>,
|
||||
): FileTreeNode[] {
|
||||
const root: FileTreeNode = { name: "", path: "", kind: "dir", children: [] };
|
||||
|
||||
for (const filePath of Object.keys(files)) {
|
||||
const segments = filePath.split("/").filter(Boolean);
|
||||
let current = root;
|
||||
let currentPath = "";
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i];
|
||||
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
||||
const isLeaf = i === segments.length - 1;
|
||||
let next = current.children.find((c) => c.name === segment);
|
||||
if (!next) {
|
||||
next = {
|
||||
name: segment,
|
||||
path: currentPath,
|
||||
kind: isLeaf ? "file" : "dir",
|
||||
children: [],
|
||||
action: isLeaf ? (actionMap?.get(filePath) ?? null) : null,
|
||||
};
|
||||
current.children.push(next);
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
|
||||
function sortNode(node: FileTreeNode) {
|
||||
node.children.sort((a, b) => {
|
||||
// Files before directories so PROJECT.md appears above tasks/
|
||||
if (a.kind !== b.kind) return a.kind === "file" ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
node.children.forEach(sortNode);
|
||||
}
|
||||
|
||||
sortNode(root);
|
||||
return root.children;
|
||||
export function PackageFileTree({ wrapLabels = false, ...props }: FileTreeProps) {
|
||||
return <FileTree {...props} wrapLabels={wrapLabels} />;
|
||||
}
|
||||
|
||||
export function countFiles(nodes: FileTreeNode[]): number {
|
||||
let count = 0;
|
||||
for (const node of nodes) {
|
||||
if (node.kind === "file") count++;
|
||||
else count += countFiles(node.children);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function collectAllPaths(
|
||||
nodes: FileTreeNode[],
|
||||
type: "file" | "dir" | "all" = "all",
|
||||
): Set<string> {
|
||||
const paths = new Set<string>();
|
||||
for (const node of nodes) {
|
||||
if (type === "all" || node.kind === type) paths.add(node.path);
|
||||
for (const p of collectAllPaths(node.children, type)) paths.add(p);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function fileIcon(name: string) {
|
||||
if (name.endsWith(".yaml") || name.endsWith(".yml")) return FileCode2;
|
||||
return FileText;
|
||||
}
|
||||
|
||||
// ── Frontmatter helpers ───────────────────────────────────────────────
|
||||
|
||||
export type FrontmatterData = Record<string, string | string[]>;
|
||||
|
||||
export function parseFrontmatter(content: string): { data: FrontmatterData; body: string } | null {
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
||||
if (!match) return null;
|
||||
|
||||
const data: FrontmatterData = {};
|
||||
const rawYaml = match[1];
|
||||
const body = match[2];
|
||||
|
||||
let currentKey: string | null = null;
|
||||
let currentList: string[] | null = null;
|
||||
|
||||
for (const line of rawYaml.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
|
||||
if (trimmed.startsWith("- ") && currentKey) {
|
||||
if (!currentList) currentList = [];
|
||||
currentList.push(trimmed.slice(2).trim().replace(/^["']|["']$/g, ""));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentKey && currentList) {
|
||||
data[currentKey] = currentList;
|
||||
currentList = null;
|
||||
currentKey = null;
|
||||
}
|
||||
|
||||
const kvMatch = trimmed.match(/^([a-zA-Z_][\w-]*)\s*:\s*(.*)$/);
|
||||
if (kvMatch) {
|
||||
const key = kvMatch[1];
|
||||
const val = kvMatch[2].trim().replace(/^["']|["']$/g, "");
|
||||
if (val === "null") {
|
||||
currentKey = null;
|
||||
continue;
|
||||
}
|
||||
if (val) {
|
||||
data[key] = val;
|
||||
currentKey = null;
|
||||
} else {
|
||||
currentKey = key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentKey && currentList) {
|
||||
data[currentKey] = currentList;
|
||||
}
|
||||
|
||||
return Object.keys(data).length > 0 ? { data, body } : null;
|
||||
}
|
||||
|
||||
export const FRONTMATTER_FIELD_LABELS: Record<string, string> = {
|
||||
name: "Name",
|
||||
title: "Title",
|
||||
kind: "Kind",
|
||||
reportsTo: "Reports to",
|
||||
skills: "Skills",
|
||||
status: "Status",
|
||||
description: "Description",
|
||||
priority: "Priority",
|
||||
assignee: "Assignee",
|
||||
project: "Project",
|
||||
recurring: "Recurring",
|
||||
targetDate: "Target date",
|
||||
};
|
||||
|
||||
// ── File tree component ───────────────────────────────────────────────
|
||||
|
||||
export function PackageFileTree({
|
||||
nodes,
|
||||
selectedFile,
|
||||
expandedDirs,
|
||||
checkedFiles,
|
||||
onToggleDir,
|
||||
onSelectFile,
|
||||
onToggleCheck,
|
||||
renderFileExtra,
|
||||
fileRowClassName,
|
||||
showCheckboxes = true,
|
||||
wrapLabels = false,
|
||||
depth = 0,
|
||||
}: {
|
||||
nodes: FileTreeNode[];
|
||||
selectedFile: string | null;
|
||||
expandedDirs: Set<string>;
|
||||
checkedFiles?: Set<string>;
|
||||
onToggleDir: (path: string) => void;
|
||||
onSelectFile: (path: string) => void;
|
||||
onToggleCheck?: (path: string, kind: "file" | "dir") => void;
|
||||
/** Optional extra content rendered at the end of each file row (e.g. action badge) */
|
||||
renderFileExtra?: (node: FileTreeNode, checked: boolean) => ReactNode;
|
||||
/** Optional additional className for file rows */
|
||||
fileRowClassName?: (node: FileTreeNode, checked: boolean) => string | undefined;
|
||||
showCheckboxes?: boolean;
|
||||
/** Allow long file and directory names to wrap instead of forcing horizontal overflow. */
|
||||
wrapLabels?: boolean;
|
||||
depth?: number;
|
||||
}) {
|
||||
const effectiveCheckedFiles = checkedFiles ?? new Set<string>();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{nodes.map((node) => {
|
||||
const expanded = node.kind === "dir" && expandedDirs.has(node.path);
|
||||
if (node.kind === "dir") {
|
||||
const childFiles = collectAllPaths(node.children, "file");
|
||||
const allChecked = [...childFiles].every((p) => effectiveCheckedFiles.has(p));
|
||||
const someChecked = [...childFiles].some((p) => effectiveCheckedFiles.has(p));
|
||||
return (
|
||||
<div key={node.path}>
|
||||
<div
|
||||
className={cn(
|
||||
showCheckboxes
|
||||
? "group grid w-full grid-cols-[auto_minmax(0,1fr)_2.25rem] items-center gap-x-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground"
|
||||
: "group grid w-full grid-cols-[minmax(0,1fr)_2.25rem] items-center gap-x-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground",
|
||||
TREE_ROW_HEIGHT_CLASS,
|
||||
)}
|
||||
style={{
|
||||
paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`,
|
||||
}}
|
||||
>
|
||||
{showCheckboxes && (
|
||||
<label className="flex items-center pl-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allChecked}
|
||||
ref={(el) => { if (el) el.indeterminate = someChecked && !allChecked; }}
|
||||
onChange={() => onToggleCheck?.(node.path, "dir")}
|
||||
className="mr-2 accent-foreground"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 items-center gap-2 py-1 text-left"
|
||||
onClick={() => onToggleDir(node.path)}
|
||||
>
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
{expanded ? (
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Folder className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</span>
|
||||
<span className={cn("min-w-0", wrapLabels ? "break-all leading-4" : "truncate")}>
|
||||
{node.name}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-9 items-center justify-center self-center rounded-sm text-muted-foreground opacity-70 transition-[background-color,color,opacity] hover:bg-accent hover:text-foreground group-hover:opacity-100"
|
||||
onClick={() => onToggleDir(node.path)}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<PackageFileTree
|
||||
nodes={node.children}
|
||||
selectedFile={selectedFile}
|
||||
expandedDirs={expandedDirs}
|
||||
checkedFiles={effectiveCheckedFiles}
|
||||
onToggleDir={onToggleDir}
|
||||
onSelectFile={onSelectFile}
|
||||
onToggleCheck={onToggleCheck}
|
||||
renderFileExtra={renderFileExtra}
|
||||
fileRowClassName={fileRowClassName}
|
||||
showCheckboxes={showCheckboxes}
|
||||
wrapLabels={wrapLabels}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FileIcon = fileIcon(node.name);
|
||||
const checked = effectiveCheckedFiles.has(node.path);
|
||||
const extraClassName = fileRowClassName?.(node, checked);
|
||||
return (
|
||||
<div
|
||||
key={node.path}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground cursor-pointer",
|
||||
TREE_ROW_HEIGHT_CLASS,
|
||||
node.path === selectedFile && "text-foreground bg-accent/20",
|
||||
extraClassName,
|
||||
)}
|
||||
style={{
|
||||
paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`,
|
||||
}}
|
||||
onClick={() => onSelectFile(node.path)}
|
||||
>
|
||||
{showCheckboxes && (
|
||||
<label className="flex items-center pl-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggleCheck?.(node.path, "file")}
|
||||
className="mr-2 accent-foreground"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 py-1 text-left"
|
||||
onClick={() => onSelectFile(node.path)}
|
||||
>
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<FileIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className={cn("min-w-0", wrapLabels ? "break-all leading-4" : "truncate")}>
|
||||
{node.name}
|
||||
</span>
|
||||
</button>
|
||||
{renderFileExtra?.(node, checked)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export {
|
||||
FRONTMATTER_FIELD_LABELS,
|
||||
buildFileTree,
|
||||
collectAllPaths,
|
||||
countFiles,
|
||||
parseFrontmatter,
|
||||
} from "./FileTree";
|
||||
export type {
|
||||
FileTreeBadge,
|
||||
FileTreeBadgeVariant,
|
||||
FileTreeEmptyState,
|
||||
FileTreeErrorState,
|
||||
FileTreeNode,
|
||||
FileTreeProps,
|
||||
FileTreeTone,
|
||||
FrontmatterData,
|
||||
} from "./FileTree";
|
||||
|
|
|
|||
121
ui/src/components/ResizableSidebarPane.test.tsx
Normal file
121
ui/src/components/ResizableSidebarPane.test.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ResizableSidebarPane } from "./ResizableSidebarPane";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function pointerEvent(type: string, clientX: number) {
|
||||
const event = new MouseEvent(type, { bubbles: true, clientX });
|
||||
Object.defineProperty(event, "pointerId", { value: 1 });
|
||||
return event;
|
||||
}
|
||||
|
||||
describe("ResizableSidebarPane", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
function pane() {
|
||||
return container.firstElementChild as HTMLDivElement;
|
||||
}
|
||||
|
||||
function handle() {
|
||||
return container.querySelector('[role="separator"]') as HTMLDivElement | null;
|
||||
}
|
||||
|
||||
it("uses a persisted width when open", () => {
|
||||
window.localStorage.setItem("test.sidebar.width", "320");
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ResizableSidebarPane open resizable storageKey="test.sidebar.width">
|
||||
<div>Sidebar</div>
|
||||
</ResizableSidebarPane>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(pane().style.width).toBe("320px");
|
||||
expect(handle()?.getAttribute("aria-valuenow")).toBe("320");
|
||||
});
|
||||
|
||||
it("resizes by dragging and persists the new width", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ResizableSidebarPane open resizable storageKey="test.sidebar.width">
|
||||
<div>Sidebar</div>
|
||||
</ResizableSidebarPane>,
|
||||
);
|
||||
});
|
||||
|
||||
const separator = handle();
|
||||
expect(separator).not.toBeNull();
|
||||
separator!.setPointerCapture = vi.fn();
|
||||
|
||||
act(() => {
|
||||
separator!.dispatchEvent(pointerEvent("pointerdown", 240));
|
||||
separator!.dispatchEvent(pointerEvent("pointermove", 320));
|
||||
separator!.dispatchEvent(pointerEvent("pointerup", 320));
|
||||
});
|
||||
|
||||
expect(pane().style.width).toBe("320px");
|
||||
expect(window.localStorage.getItem("test.sidebar.width")).toBe("320");
|
||||
});
|
||||
|
||||
it("supports keyboard resizing and clamps to the configured bounds", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ResizableSidebarPane open resizable storageKey="test.sidebar.width">
|
||||
<div>Sidebar</div>
|
||||
</ResizableSidebarPane>,
|
||||
);
|
||||
});
|
||||
|
||||
const separator = handle();
|
||||
act(() => {
|
||||
separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
|
||||
});
|
||||
expect(pane().style.width).toBe("256px");
|
||||
expect(window.localStorage.getItem("test.sidebar.width")).toBe("256");
|
||||
|
||||
act(() => {
|
||||
separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
|
||||
});
|
||||
expect(pane().style.width).toBe("208px");
|
||||
|
||||
act(() => {
|
||||
separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true }));
|
||||
});
|
||||
expect(pane().style.width).toBe("420px");
|
||||
});
|
||||
|
||||
it("can render without a resize handle", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ResizableSidebarPane open resizable={false}>
|
||||
<div>Sidebar</div>
|
||||
</ResizableSidebarPane>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(handle()).toBeNull();
|
||||
expect(pane().style.width).toBe("240px");
|
||||
});
|
||||
});
|
||||
176
ui/src/components/ResizableSidebarPane.tsx
Normal file
176
ui/src/components/ResizableSidebarPane.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type PointerEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DEFAULT_SIDEBAR_WIDTH = 240;
|
||||
const MIN_SIDEBAR_WIDTH = 208;
|
||||
const MAX_SIDEBAR_WIDTH = 420;
|
||||
const SIDEBAR_WIDTH_STEP = 16;
|
||||
|
||||
function clampSidebarWidth(width: number) {
|
||||
return Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, width));
|
||||
}
|
||||
|
||||
function readStoredSidebarWidth(storageKey: string) {
|
||||
if (typeof window === "undefined") return DEFAULT_SIDEBAR_WIDTH;
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(storageKey);
|
||||
if (!stored) return DEFAULT_SIDEBAR_WIDTH;
|
||||
const parsed = Number.parseInt(stored, 10);
|
||||
if (!Number.isFinite(parsed)) return DEFAULT_SIDEBAR_WIDTH;
|
||||
return clampSidebarWidth(parsed);
|
||||
} catch {
|
||||
return DEFAULT_SIDEBAR_WIDTH;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredSidebarWidth(storageKey: string, width: number) {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, String(clampSidebarWidth(width)));
|
||||
} catch {
|
||||
// Storage can be unavailable in private contexts; resizing should still work.
|
||||
}
|
||||
}
|
||||
|
||||
type ResizableSidebarPaneProps = {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
resizable?: boolean;
|
||||
storageKey?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ResizableSidebarPane({
|
||||
children,
|
||||
open,
|
||||
resizable = false,
|
||||
storageKey = "paperclip.sidebar.width",
|
||||
className,
|
||||
}: ResizableSidebarPaneProps) {
|
||||
const [width, setWidth] = useState(() => readStoredSidebarWidth(storageKey));
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const widthRef = useRef(width);
|
||||
const dragState = useRef<{ startX: number; startWidth: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const storedWidth = readStoredSidebarWidth(storageKey);
|
||||
widthRef.current = storedWidth;
|
||||
setWidth(storedWidth);
|
||||
}, [storageKey]);
|
||||
|
||||
const visibleWidth = open ? width : 0;
|
||||
const paneStyle = useMemo(
|
||||
() => ({ width: `${visibleWidth}px` }),
|
||||
[visibleWidth],
|
||||
);
|
||||
|
||||
const commitWidth = useCallback(
|
||||
(nextWidth: number) => {
|
||||
const clamped = clampSidebarWidth(nextWidth);
|
||||
widthRef.current = clamped;
|
||||
setWidth(clamped);
|
||||
writeStoredSidebarWidth(storageKey, clamped);
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!open || !resizable) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
dragState.current = { startX: event.clientX, startWidth: widthRef.current };
|
||||
setIsResizing(true);
|
||||
},
|
||||
[open, resizable],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragState.current) return;
|
||||
|
||||
const nextWidth = dragState.current.startWidth + event.clientX - dragState.current.startX;
|
||||
const clamped = clampSidebarWidth(nextWidth);
|
||||
widthRef.current = clamped;
|
||||
setWidth(clamped);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const endResize = useCallback(() => {
|
||||
if (!dragState.current) return;
|
||||
|
||||
dragState.current = null;
|
||||
setIsResizing(false);
|
||||
writeStoredSidebarWidth(storageKey, widthRef.current);
|
||||
}, [storageKey]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!open || !resizable) return;
|
||||
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
commitWidth(width - SIDEBAR_WIDTH_STEP);
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
commitWidth(width + SIDEBAR_WIDTH_STEP);
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
commitWidth(MIN_SIDEBAR_WIDTH);
|
||||
} else if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
commitWidth(MAX_SIDEBAR_WIDTH);
|
||||
}
|
||||
},
|
||||
[commitWidth, open, resizable, width],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden",
|
||||
!isResizing && "transition-[width] duration-100 ease-out",
|
||||
className,
|
||||
)}
|
||||
style={paneStyle}
|
||||
>
|
||||
{children}
|
||||
{resizable && open ? (
|
||||
<div
|
||||
role="separator"
|
||||
aria-label="Resize sidebar"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin={MIN_SIDEBAR_WIDTH}
|
||||
aria-valuemax={MAX_SIDEBAR_WIDTH}
|
||||
aria-valuenow={width}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"absolute inset-y-0 right-0 z-20 w-3 cursor-col-resize touch-none outline-none",
|
||||
"before:absolute before:inset-y-0 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-transparent before:transition-colors",
|
||||
"hover:before:bg-border focus-visible:before:bg-ring",
|
||||
isResizing && "before:bg-ring",
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={endResize}
|
||||
onPointerCancel={endResize}
|
||||
onLostPointerCapture={endResize}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
196
ui/src/components/RoutineList.tsx
Normal file
196
ui/src/components/RoutineList.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { MoreHorizontal, Play } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { AgentIcon } from "@/components/AgentIconPicker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
|
||||
export type RoutineListProjectSummary = {
|
||||
name: string;
|
||||
color?: string | null;
|
||||
};
|
||||
|
||||
export type RoutineListAgentSummary = {
|
||||
name: string;
|
||||
icon?: string | null;
|
||||
};
|
||||
|
||||
export type RoutineListRowItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
projectId: string | null;
|
||||
assigneeAgentId: string | null;
|
||||
lastRun?: {
|
||||
triggeredAt?: Date | string | null;
|
||||
status?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export function formatLastRunTimestamp(value: Date | string | null | undefined) {
|
||||
if (!value) return "Never";
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
export function formatRoutineRunStatus(value: string | null | undefined) {
|
||||
if (!value) return null;
|
||||
return value.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
export function nextRoutineStatus(currentStatus: string, enabled: boolean) {
|
||||
if (currentStatus === "archived" && enabled) return "active";
|
||||
return enabled ? "active" : "paused";
|
||||
}
|
||||
|
||||
export function RoutineListRow<TRoutine extends RoutineListRowItem>({
|
||||
routine,
|
||||
projectById,
|
||||
agentById,
|
||||
runningRoutineId,
|
||||
statusMutationRoutineId,
|
||||
href,
|
||||
configureLabel = "Edit",
|
||||
managedByLabel,
|
||||
secondaryDetails,
|
||||
runNowButton = false,
|
||||
disableRunNow = false,
|
||||
disableToggle = false,
|
||||
hideArchiveAction = false,
|
||||
onRunNow,
|
||||
onToggleEnabled,
|
||||
onToggleArchived,
|
||||
}: {
|
||||
routine: TRoutine;
|
||||
projectById: Map<string, RoutineListProjectSummary>;
|
||||
agentById: Map<string, RoutineListAgentSummary>;
|
||||
runningRoutineId: string | null;
|
||||
statusMutationRoutineId: string | null;
|
||||
href: string;
|
||||
configureLabel?: string;
|
||||
managedByLabel?: string | null;
|
||||
secondaryDetails?: ReactNode;
|
||||
runNowButton?: boolean;
|
||||
disableRunNow?: boolean;
|
||||
disableToggle?: boolean;
|
||||
hideArchiveAction?: boolean;
|
||||
onRunNow: (routine: TRoutine) => void;
|
||||
onToggleEnabled: (routine: TRoutine, enabled: boolean) => void;
|
||||
onToggleArchived?: (routine: TRoutine) => 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;
|
||||
const runDisabled = runningRoutineId === routine.id || isArchived || disableRunNow;
|
||||
|
||||
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}
|
||||
{managedByLabel ? (
|
||||
<span className="text-xs text-muted-foreground">{managedByLabel}</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>
|
||||
{secondaryDetails ? (
|
||||
<div className="text-xs text-muted-foreground">{secondaryDetails}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3" onClick={(event) => { event.preventDefault(); event.stopPropagation(); }}>
|
||||
{runNowButton ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={runDisabled}
|
||||
onClick={() => onRunNow(routine)}
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
{runningRoutineId === routine.id ? "Running..." : "Run now"}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ToggleSwitch
|
||||
size="lg"
|
||||
checked={enabled}
|
||||
onCheckedChange={() => onToggleEnabled(routine, enabled)}
|
||||
disabled={isStatusPending || isArchived || disableToggle}
|
||||
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}>{configureLabel}</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={runDisabled}
|
||||
onClick={() => onRunNow(routine)}
|
||||
>
|
||||
{runningRoutineId === routine.id ? "Running..." : "Run now"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onToggleEnabled(routine, enabled)}
|
||||
disabled={isStatusPending || isArchived || disableToggle}
|
||||
>
|
||||
{enabled ? "Pause" : "Enable"}
|
||||
</DropdownMenuItem>
|
||||
{!hideArchiveAction && onToggleArchived ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onToggleArchived(routine)}
|
||||
disabled={isStatusPending}
|
||||
>
|
||||
{routine.status === "archived" ? "Restore" : "Archive"}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -55,7 +55,7 @@ export function Sidebar() {
|
|||
};
|
||||
|
||||
return (
|
||||
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||
{/* Top bar: Company name (bold) + Search — aligned with top sections (no visible border) */}
|
||||
<div className="flex items-center gap-1 px-3 h-12 shrink-0">
|
||||
<SidebarCompanyMenu />
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ export const queryKeys = {
|
|||
labels: (companyId: string) => ["issues", companyId, "labels"] as const,
|
||||
listByProject: (companyId: string, projectId: string) =>
|
||||
["issues", companyId, "project", projectId] as const,
|
||||
listPluginOperationsByProject: (companyId: string, projectId: string, originKindPrefix: string) =>
|
||||
["issues", companyId, "project", projectId, "plugin-operations", originKindPrefix] as const,
|
||||
listByParent: (companyId: string, parentId: string) =>
|
||||
["issues", companyId, "parent", parentId] as const,
|
||||
listByDescendantRoot: (companyId: string, rootIssueId: string) =>
|
||||
|
|
@ -171,6 +173,8 @@ export const queryKeys = {
|
|||
health: (pluginId: string) => ["plugins", pluginId, "health"] as const,
|
||||
uiContributions: ["plugins", "ui-contributions"] as const,
|
||||
config: (pluginId: string) => ["plugins", pluginId, "config"] as const,
|
||||
localFolders: (pluginId: string, companyId: string) =>
|
||||
["plugins", pluginId, "companies", companyId, "local-folders"] as const,
|
||||
dashboard: (pluginId: string) => ["plugins", pluginId, "dashboard"] as const,
|
||||
logs: (pluginId: string) => ["plugins", pluginId, "logs"] as const,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -57,7 +57,10 @@ export const statusBadge: Record<string, string> = {
|
|||
failed: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
|
||||
timed_out: "bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300",
|
||||
succeeded: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300",
|
||||
ok: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300",
|
||||
warning: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300",
|
||||
error: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
|
||||
info: "bg-sky-100 text-sky-700 dark:bg-sky-900/50 dark:text-sky-300",
|
||||
terminated: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
|
||||
pending: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300",
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -16,9 +16,47 @@ import {
|
|||
usePluginData,
|
||||
usePluginAction,
|
||||
useHostContext,
|
||||
useHostLocation,
|
||||
useHostNavigation,
|
||||
usePluginStream,
|
||||
usePluginToast,
|
||||
} from "./bridge.js";
|
||||
import { createElement, useEffect, useMemo, useState, type ComponentType, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { User } from "lucide-react";
|
||||
import {
|
||||
FileTree,
|
||||
type FileTreeProps as HostFileTreeProps,
|
||||
} from "@/components/FileTree";
|
||||
import { AgentIcon } from "@/components/AgentIconPicker";
|
||||
import { InlineEntitySelector, type InlineEntityOption } from "@/components/InlineEntitySelector";
|
||||
import { IssuesList as HostIssuesList } from "@/components/IssuesList";
|
||||
import { ManagedRoutinesList as HostManagedRoutinesList } from "@/components/ManagedRoutinesList";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import { accessApi } from "@/api/access";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { heartbeatsApi } from "@/api/heartbeats";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { projectsApi } from "@/api/projects";
|
||||
import {
|
||||
buildCompanyUserInlineOptions,
|
||||
} from "@/lib/company-members";
|
||||
import { collectLiveIssueIds } from "@/lib/liveIssueIds";
|
||||
import { useProjectOrder } from "@/hooks/useProjectOrder";
|
||||
import {
|
||||
assigneeValueFromSelection,
|
||||
currentUserAssigneeOption,
|
||||
parseAssigneeValue,
|
||||
} from "@/lib/assignees";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import {
|
||||
getRecentAssigneeSelectionIds,
|
||||
sortAgentsByRecency,
|
||||
trackRecentAssignee,
|
||||
trackRecentAssigneeUser,
|
||||
} from "@/lib/recent-assignees";
|
||||
import { getRecentProjectIds, trackRecentProject } from "@/lib/recent-projects";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global bridge registry
|
||||
|
|
@ -41,6 +79,451 @@ declare global {
|
|||
var __paperclipPluginBridge__: PluginBridgeRegistry | undefined;
|
||||
}
|
||||
|
||||
type PluginFileTreePathCollection = ReadonlySet<string> | readonly string[];
|
||||
|
||||
type PluginFileTreeProps = Omit<
|
||||
HostFileTreeProps,
|
||||
| "expandedDirs"
|
||||
| "checkedFiles"
|
||||
| "renderFileExtra"
|
||||
| "fileRowClassName"
|
||||
| "selectedFile"
|
||||
| "showCheckboxes"
|
||||
| "onToggleDir"
|
||||
| "onSelectFile"
|
||||
> & {
|
||||
selectedFile?: string | null;
|
||||
expandedPaths?: PluginFileTreePathCollection;
|
||||
checkedPaths?: PluginFileTreePathCollection;
|
||||
showCheckboxes?: boolean;
|
||||
onToggleDir?: (path: string) => void;
|
||||
onSelectFile?: (path: string) => void;
|
||||
};
|
||||
|
||||
function toPathSet(paths?: PluginFileTreePathCollection | null): Set<string> {
|
||||
return new Set(paths ?? []);
|
||||
}
|
||||
|
||||
function PluginSdkFileTree({
|
||||
expandedPaths,
|
||||
checkedPaths,
|
||||
selectedFile = null,
|
||||
showCheckboxes = false,
|
||||
onToggleDir,
|
||||
onSelectFile,
|
||||
...props
|
||||
}: PluginFileTreeProps) {
|
||||
return createElement(FileTree, {
|
||||
...props,
|
||||
selectedFile,
|
||||
expandedDirs: toPathSet(expandedPaths),
|
||||
checkedFiles: checkedPaths ? toPathSet(checkedPaths) : undefined,
|
||||
showCheckboxes,
|
||||
onToggleDir: onToggleDir ?? (() => undefined),
|
||||
onSelectFile: onSelectFile ?? (() => undefined),
|
||||
});
|
||||
}
|
||||
|
||||
type PluginMarkdownBlockProps = {
|
||||
content: string;
|
||||
className?: string;
|
||||
enableWikiLinks?: boolean;
|
||||
wikiLinkRoot?: string;
|
||||
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
|
||||
};
|
||||
|
||||
type PluginMarkdownEditorProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
onBlur?: () => void;
|
||||
bordered?: boolean;
|
||||
readOnly?: boolean;
|
||||
onSubmit?: () => void;
|
||||
};
|
||||
|
||||
type PluginIssuesListFilters = {
|
||||
status?: string;
|
||||
projectId?: string;
|
||||
parentId?: string;
|
||||
assigneeAgentId?: string;
|
||||
participantAgentId?: string;
|
||||
assigneeUserId?: string;
|
||||
labelId?: string;
|
||||
workspaceId?: string;
|
||||
executionWorkspaceId?: string;
|
||||
originKind?: string;
|
||||
originKindPrefix?: string;
|
||||
originId?: string;
|
||||
descendantOf?: string;
|
||||
includeRoutineExecutions?: boolean;
|
||||
};
|
||||
|
||||
type PluginIssuesListProps = {
|
||||
companyId: string | null;
|
||||
projectId?: string | null;
|
||||
filters?: PluginIssuesListFilters;
|
||||
viewStateKey?: string;
|
||||
initialSearch?: string;
|
||||
createIssueLabel?: string;
|
||||
searchWithinLoadedIssues?: boolean;
|
||||
};
|
||||
|
||||
type PluginAssigneePickerSelection = {
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
};
|
||||
|
||||
type PluginAssigneePickerProps = {
|
||||
companyId?: string | null;
|
||||
value: string;
|
||||
onChange: (value: string, selection: PluginAssigneePickerSelection) => void;
|
||||
placeholder?: string;
|
||||
noneLabel?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyMessage?: string;
|
||||
includeUsers?: boolean;
|
||||
includeTerminatedAgents?: boolean;
|
||||
className?: string;
|
||||
onConfirm?: () => void;
|
||||
};
|
||||
|
||||
type PluginProjectPickerProps = {
|
||||
companyId?: string | null;
|
||||
value: string;
|
||||
onChange: (projectId: string) => void;
|
||||
placeholder?: string;
|
||||
noneLabel?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyMessage?: string;
|
||||
includeArchived?: boolean;
|
||||
className?: string;
|
||||
onConfirm?: () => void;
|
||||
};
|
||||
|
||||
function PluginSdkMarkdownEditor(props: PluginMarkdownEditorProps) {
|
||||
const [Editor, setEditor] = useState<ComponentType<PluginMarkdownEditorProps> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
import("@/components/MarkdownEditor").then((module) => {
|
||||
if (!cancelled) setEditor(() => module.MarkdownEditor as ComponentType<PluginMarkdownEditorProps>);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (Editor) return createElement(Editor, props);
|
||||
|
||||
return createElement("textarea", {
|
||||
className: props.className,
|
||||
value: props.value,
|
||||
placeholder: props.placeholder,
|
||||
readOnly: props.readOnly,
|
||||
onBlur: props.onBlur,
|
||||
onChange: (event) => props.onChange((event.currentTarget as HTMLTextAreaElement).value),
|
||||
});
|
||||
}
|
||||
|
||||
function compactIssueFilters(filters: PluginIssuesListFilters): PluginIssuesListFilters {
|
||||
return Object.fromEntries(
|
||||
Object.entries(filters).filter(([, value]) =>
|
||||
value !== undefined && value !== null && value !== "" && value !== false,
|
||||
),
|
||||
) as PluginIssuesListFilters;
|
||||
}
|
||||
|
||||
function PluginSdkIssuesList({
|
||||
companyId,
|
||||
projectId = null,
|
||||
filters,
|
||||
viewStateKey = "paperclip:plugin-issues-view",
|
||||
initialSearch,
|
||||
createIssueLabel,
|
||||
searchWithinLoadedIssues = true,
|
||||
}: PluginIssuesListProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const issueFilters = useMemo(
|
||||
() => compactIssueFilters({
|
||||
...(filters ?? {}),
|
||||
projectId: filters?.projectId ?? projectId ?? undefined,
|
||||
}),
|
||||
[filters, projectId],
|
||||
);
|
||||
const originKindPrefix = issueFilters.originKindPrefix ?? null;
|
||||
const resolvedProjectId = issueFilters.projectId ?? projectId ?? null;
|
||||
const issuesQueryKey = useMemo(
|
||||
() => ["plugins", "sdk-ui", "issues-list", companyId ?? "__no-company__", issueFilters] as const,
|
||||
[companyId, issueFilters],
|
||||
);
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(companyId ?? "__no-company__"),
|
||||
queryFn: () => agentsApi.list(companyId!),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: queryKeys.projects.list(companyId ?? "__no-company__"),
|
||||
queryFn: () => projectsApi.list(companyId!),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
const { data: liveRuns } = useQuery({
|
||||
queryKey: queryKeys.liveRuns(companyId ?? "__no-company__"),
|
||||
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId!),
|
||||
enabled: !!companyId,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
|
||||
|
||||
const { data: issues, isLoading, error } = useQuery({
|
||||
queryKey: issuesQueryKey,
|
||||
queryFn: () => issuesApi.list(companyId!, issueFilters),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
|
||||
const updateIssue = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
issuesApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
if (!companyId) return;
|
||||
queryClient.invalidateQueries({ queryKey: ["plugins", "sdk-ui", "issues-list", companyId] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||
if (resolvedProjectId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, resolvedProjectId) });
|
||||
if (originKindPrefix) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.listPluginOperationsByProject(companyId, resolvedProjectId, originKindPrefix),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!companyId) {
|
||||
return createElement("div", { className: "text-sm text-muted-foreground" }, "Select a company to view issues.");
|
||||
}
|
||||
|
||||
return createElement(HostIssuesList, {
|
||||
issues: issues ?? [],
|
||||
isLoading,
|
||||
error: error as Error | null,
|
||||
agents,
|
||||
projects,
|
||||
liveIssueIds,
|
||||
projectId: resolvedProjectId ?? undefined,
|
||||
viewStateKey,
|
||||
initialSearch,
|
||||
createIssueLabel,
|
||||
searchWithinLoadedIssues,
|
||||
onUpdateIssue: (id: string, data: Record<string, unknown>) => updateIssue.mutate({ id, data }),
|
||||
});
|
||||
}
|
||||
|
||||
function PluginSdkAssigneePicker({
|
||||
companyId,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Assignee",
|
||||
noneLabel = "No assignee",
|
||||
searchPlaceholder = "Search assignees...",
|
||||
emptyMessage = "No assignees found.",
|
||||
includeUsers = true,
|
||||
includeTerminatedAgents = false,
|
||||
className,
|
||||
onConfirm,
|
||||
}: PluginAssigneePickerProps) {
|
||||
const hostContext = useHostContext();
|
||||
const resolvedCompanyId = companyId ?? hostContext.companyId ?? null;
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
enabled: includeUsers,
|
||||
});
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(resolvedCompanyId ?? "__no-company__"),
|
||||
queryFn: () => agentsApi.list(resolvedCompanyId!),
|
||||
enabled: !!resolvedCompanyId,
|
||||
});
|
||||
const { data: companyMembers } = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(resolvedCompanyId ?? "__no-company__"),
|
||||
queryFn: () => accessApi.listUserDirectory(resolvedCompanyId!),
|
||||
enabled: !!resolvedCompanyId && includeUsers,
|
||||
});
|
||||
const recentAssigneeSelectionIds = useMemo(() => getRecentAssigneeSelectionIds(), []);
|
||||
const recentAssigneeIds = useMemo(
|
||||
() => recentAssigneeSelectionIds
|
||||
.map((id) => id.startsWith("agent:") ? id.slice("agent:".length) : null)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
[recentAssigneeSelectionIds],
|
||||
);
|
||||
const sortedAgents = useMemo(
|
||||
() => sortAgentsByRecency(
|
||||
(agents ?? []).filter((agent) => includeTerminatedAgents || agent.status !== "terminated"),
|
||||
recentAssigneeIds,
|
||||
),
|
||||
[agents, includeTerminatedAgents, recentAssigneeIds],
|
||||
);
|
||||
const options = useMemo<InlineEntityOption[]>(
|
||||
() => [
|
||||
...(includeUsers ? currentUserAssigneeOption(currentUserId) : []),
|
||||
...(includeUsers
|
||||
? buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId] })
|
||||
: []),
|
||||
...sortedAgents.map((agent) => ({
|
||||
id: assigneeValueFromSelection({ assigneeAgentId: agent.id }),
|
||||
label: agent.name,
|
||||
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
|
||||
})),
|
||||
],
|
||||
[companyMembers?.users, currentUserId, includeUsers, sortedAgents],
|
||||
);
|
||||
const selectedAssignee = parseAssigneeValue(value);
|
||||
const selectedAgent = selectedAssignee.assigneeAgentId
|
||||
? sortedAgents.find((agent) => agent.id === selectedAssignee.assigneeAgentId)
|
||||
: null;
|
||||
|
||||
return createElement(InlineEntitySelector, {
|
||||
value,
|
||||
options,
|
||||
recentOptionIds: recentAssigneeSelectionIds,
|
||||
placeholder,
|
||||
noneLabel,
|
||||
searchPlaceholder,
|
||||
emptyMessage,
|
||||
className,
|
||||
onConfirm,
|
||||
onChange: (nextValue: string) => {
|
||||
const selection = parseAssigneeValue(nextValue);
|
||||
if (selection.assigneeAgentId) trackRecentAssignee(selection.assigneeAgentId);
|
||||
if (selection.assigneeUserId) trackRecentAssigneeUser(selection.assigneeUserId);
|
||||
onChange(nextValue, selection);
|
||||
},
|
||||
renderTriggerValue: (option: InlineEntityOption | null) => {
|
||||
if (!option) return createElement("span", { className: "text-muted-foreground" }, placeholder);
|
||||
if (selectedAgent) {
|
||||
return createElement(
|
||||
FragmentSafe,
|
||||
null,
|
||||
createElement(AgentIcon, { icon: selectedAgent.icon, className: "h-3.5 w-3.5 shrink-0 text-muted-foreground" }),
|
||||
createElement("span", { className: "truncate" }, option.label),
|
||||
);
|
||||
}
|
||||
return createElement("span", { className: "truncate" }, option.label);
|
||||
},
|
||||
renderOption: (option: InlineEntityOption) => {
|
||||
if (!option.id) return createElement("span", { className: "truncate" }, option.label);
|
||||
const selection = parseAssigneeValue(option.id);
|
||||
const agent = selection.assigneeAgentId
|
||||
? sortedAgents.find((entry) => entry.id === selection.assigneeAgentId)
|
||||
: null;
|
||||
return createElement(
|
||||
FragmentSafe,
|
||||
null,
|
||||
agent
|
||||
? createElement(AgentIcon, { icon: agent.icon, className: "h-3.5 w-3.5 shrink-0 text-muted-foreground" })
|
||||
: createElement(User, { className: "h-3.5 w-3.5 shrink-0 text-muted-foreground" }),
|
||||
createElement("span", { className: "truncate" }, option.label),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function PluginSdkProjectPicker({
|
||||
companyId,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Project",
|
||||
noneLabel = "No project",
|
||||
searchPlaceholder = "Search projects...",
|
||||
emptyMessage = "No projects found.",
|
||||
includeArchived = false,
|
||||
className,
|
||||
onConfirm,
|
||||
}: PluginProjectPickerProps) {
|
||||
const hostContext = useHostContext();
|
||||
const resolvedCompanyId = companyId ?? hostContext.companyId ?? null;
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: queryKeys.projects.list(resolvedCompanyId ?? "__no-company__"),
|
||||
queryFn: () => projectsApi.list(resolvedCompanyId!),
|
||||
enabled: !!resolvedCompanyId,
|
||||
});
|
||||
const visibleProjects = useMemo(
|
||||
() => (projects ?? []).filter((project) => includeArchived || !project.archivedAt),
|
||||
[includeArchived, projects],
|
||||
);
|
||||
const { orderedProjects } = useProjectOrder({
|
||||
projects: visibleProjects,
|
||||
companyId: resolvedCompanyId,
|
||||
userId: currentUserId,
|
||||
});
|
||||
const recentProjectIds = useMemo(() => getRecentProjectIds(), []);
|
||||
const options = useMemo<InlineEntityOption[]>(
|
||||
() => orderedProjects.map((project) => ({
|
||||
id: project.id,
|
||||
label: project.name,
|
||||
searchText: project.description ?? "",
|
||||
})),
|
||||
[orderedProjects],
|
||||
);
|
||||
const selectedProject = orderedProjects.find((project) => project.id === value) ?? null;
|
||||
|
||||
return createElement(InlineEntitySelector, {
|
||||
value,
|
||||
options,
|
||||
recentOptionIds: recentProjectIds,
|
||||
placeholder,
|
||||
noneLabel,
|
||||
searchPlaceholder,
|
||||
emptyMessage,
|
||||
className,
|
||||
onConfirm,
|
||||
onChange: (nextProjectId: string) => {
|
||||
if (nextProjectId) trackRecentProject(nextProjectId);
|
||||
onChange(nextProjectId);
|
||||
},
|
||||
renderTriggerValue: (option: InlineEntityOption | null) => {
|
||||
if (!option || !selectedProject) {
|
||||
return createElement("span", { className: "text-muted-foreground" }, placeholder);
|
||||
}
|
||||
return createElement(
|
||||
FragmentSafe,
|
||||
null,
|
||||
createElement("span", {
|
||||
className: "h-3.5 w-3.5 shrink-0 rounded-sm",
|
||||
style: { backgroundColor: selectedProject.color ?? "#6366f1" },
|
||||
}),
|
||||
createElement("span", { className: "truncate" }, option.label),
|
||||
);
|
||||
},
|
||||
renderOption: (option: InlineEntityOption) => {
|
||||
if (!option.id) return createElement("span", { className: "truncate" }, option.label);
|
||||
const project = orderedProjects.find((entry) => entry.id === option.id);
|
||||
return createElement(
|
||||
FragmentSafe,
|
||||
null,
|
||||
createElement("span", {
|
||||
className: "h-3.5 w-3.5 shrink-0 rounded-sm",
|
||||
style: { backgroundColor: project?.color ?? "#6366f1" },
|
||||
}),
|
||||
createElement("span", { className: "truncate" }, option.label),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function FragmentSafe({ children }: { children?: ReactNode }) {
|
||||
return createElement("span", { className: "contents" }, children);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the plugin bridge global registry.
|
||||
*
|
||||
|
|
@ -62,8 +545,31 @@ export function initPluginBridge(
|
|||
usePluginData,
|
||||
usePluginAction,
|
||||
useHostContext,
|
||||
useHostLocation,
|
||||
useHostNavigation,
|
||||
usePluginStream,
|
||||
usePluginToast,
|
||||
MarkdownBlock: ({
|
||||
content,
|
||||
className,
|
||||
enableWikiLinks,
|
||||
wikiLinkRoot,
|
||||
resolveWikiLinkHref,
|
||||
}: PluginMarkdownBlockProps) =>
|
||||
createElement(MarkdownBody, {
|
||||
className,
|
||||
softBreaks: false,
|
||||
enableWikiLinks,
|
||||
wikiLinkRoot,
|
||||
resolveWikiLinkHref,
|
||||
children: content,
|
||||
}),
|
||||
MarkdownEditor: PluginSdkMarkdownEditor,
|
||||
FileTree: PluginSdkFileTree,
|
||||
IssuesList: PluginSdkIssuesList,
|
||||
AssigneePicker: PluginSdkAssigneePicker,
|
||||
ProjectPicker: PluginSdkProjectPicker,
|
||||
ManagedRoutinesList: HostManagedRoutinesList,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
306
ui/src/plugins/bridge.test.ts
Normal file
306
ui/src/plugins/bridge.test.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
// @vitest-environment jsdom
|
||||
import * as React from "react";
|
||||
import * as ReactDOM from "react-dom";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
FileTree as SdkFileTree,
|
||||
ManagedRoutinesList as SdkManagedRoutinesList,
|
||||
MarkdownBlock as SdkMarkdownBlock,
|
||||
MarkdownEditor as SdkMarkdownEditor,
|
||||
type FileTreeNode as SdkFileTreeNode,
|
||||
} from "../../../packages/plugins/sdk/src/ui/components";
|
||||
import { SidebarProvider, useSidebar } from "@/context/SidebarContext";
|
||||
import {
|
||||
PluginBridgeContext,
|
||||
resolveHostNavigationHref,
|
||||
shouldHandleHostNavigationClick,
|
||||
useHostNavigation,
|
||||
type PluginBridgeContextValue,
|
||||
} from "./bridge";
|
||||
import { initPluginBridge } from "./bridge-init";
|
||||
|
||||
function clickEvent(
|
||||
overrides: Partial<ReactMouseEvent<HTMLAnchorElement>> = {},
|
||||
): ReactMouseEvent<HTMLAnchorElement> {
|
||||
return {
|
||||
defaultPrevented: false,
|
||||
button: 0,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
currentTarget: {
|
||||
hasAttribute: () => false,
|
||||
},
|
||||
...overrides,
|
||||
} as ReactMouseEvent<HTMLAnchorElement>;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete globalThis.__paperclipPluginBridge__;
|
||||
});
|
||||
|
||||
describe("plugin host navigation", () => {
|
||||
it("resolves plugin page routes into the active company prefix", () => {
|
||||
expect(resolveHostNavigationHref("/wiki", "PAP")).toBe("/PAP/wiki");
|
||||
expect(resolveHostNavigationHref("/wiki?tab=browse#page", "pap")).toBe(
|
||||
"/PAP/wiki?tab=browse#page",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not double-prefix active company paths or global host paths", () => {
|
||||
expect(resolveHostNavigationHref("/PAP/wiki", "PAP")).toBe("/PAP/wiki");
|
||||
expect(resolveHostNavigationHref("/pap/wiki", "PAP")).toBe("/pap/wiki");
|
||||
expect(resolveHostNavigationHref("/instance/settings/plugins", "PAP")).toBe(
|
||||
"/instance/settings/plugins",
|
||||
);
|
||||
});
|
||||
|
||||
it("intercepts only same-origin plain left-click navigation", () => {
|
||||
expect(shouldHandleHostNavigationClick(clickEvent(), "/PAP/wiki")).toBe(true);
|
||||
expect(
|
||||
shouldHandleHostNavigationClick(clickEvent({ ctrlKey: true }), "/PAP/wiki"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldHandleHostNavigationClick(clickEvent(), "/PAP/wiki", "_blank"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldHandleHostNavigationClick(clickEvent(), "https://example.com/wiki"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useHostNavigation mobile drawer behavior", () => {
|
||||
// React 19's `act` requires the env flag and React DOM client.
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function makeBridgeValue(): PluginBridgeContextValue {
|
||||
return {
|
||||
pluginId: "test-plugin",
|
||||
hostContext: {
|
||||
companyId: "co",
|
||||
companyPrefix: "PAP",
|
||||
projectId: null,
|
||||
entityId: null,
|
||||
entityType: null,
|
||||
userId: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setViewport(width: number) {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: width,
|
||||
});
|
||||
if (typeof window.matchMedia !== "function") {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: /max-width:\s*767px/.test(query) ? width < 768 : false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it("closes the sidebar drawer on mobile after a same-origin navigate()", () => {
|
||||
setViewport(390);
|
||||
|
||||
let nav: ReturnType<typeof useHostNavigation> | null = null;
|
||||
let sidebar: ReturnType<typeof useSidebar> | null = null;
|
||||
function Probe() {
|
||||
nav = useHostNavigation();
|
||||
sidebar = useSidebar();
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(
|
||||
MemoryRouter,
|
||||
{ initialEntries: ["/PAP/wiki"] },
|
||||
React.createElement(
|
||||
SidebarProvider,
|
||||
null,
|
||||
React.createElement(
|
||||
PluginBridgeContext.Provider,
|
||||
{ value: makeBridgeValue() },
|
||||
React.createElement(Probe),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(sidebar!.isMobile).toBe(true);
|
||||
act(() => sidebar!.setSidebarOpen(true));
|
||||
expect(sidebar!.sidebarOpen).toBe(true);
|
||||
|
||||
act(() => nav!.navigate("/wiki?section=ingest"));
|
||||
expect(sidebar!.sidebarOpen).toBe(false);
|
||||
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("leaves the sidebar open on desktop after navigate()", () => {
|
||||
setViewport(1280);
|
||||
|
||||
let nav: ReturnType<typeof useHostNavigation> | null = null;
|
||||
let sidebar: ReturnType<typeof useSidebar> | null = null;
|
||||
function Probe() {
|
||||
nav = useHostNavigation();
|
||||
sidebar = useSidebar();
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(
|
||||
MemoryRouter,
|
||||
{ initialEntries: ["/PAP/wiki"] },
|
||||
React.createElement(
|
||||
SidebarProvider,
|
||||
null,
|
||||
React.createElement(
|
||||
PluginBridgeContext.Provider,
|
||||
{ value: makeBridgeValue() },
|
||||
React.createElement(Probe),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(sidebar!.isMobile).toBe(false);
|
||||
expect(sidebar!.sidebarOpen).toBe(true);
|
||||
|
||||
act(() => nav!.navigate("/wiki?section=ingest"));
|
||||
expect(sidebar!.sidebarOpen).toBe(true);
|
||||
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin SDK FileTree bridge", () => {
|
||||
const nodes: SdkFileTreeNode[] = [
|
||||
{
|
||||
name: "wiki",
|
||||
path: "wiki",
|
||||
kind: "dir",
|
||||
children: [
|
||||
{
|
||||
name: "index.md",
|
||||
path: "wiki/index.md",
|
||||
kind: "file",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
it("injects the host FileTree implementation through the bridge runtime", () => {
|
||||
initPluginBridge(React, ReactDOM);
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(SdkFileTree, {
|
||||
nodes,
|
||||
expandedPaths: ["wiki"],
|
||||
selectedFile: "wiki/index.md",
|
||||
onToggleDir: () => undefined,
|
||||
onSelectFile: () => undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain('role="tree"');
|
||||
expect(html).toContain("wiki");
|
||||
expect(html).toContain("index.md");
|
||||
});
|
||||
|
||||
it("throws a clear error when the host FileTree implementation is missing", () => {
|
||||
globalThis.__paperclipPluginBridge__ = {
|
||||
react: React,
|
||||
reactDom: ReactDOM,
|
||||
sdkUi: {},
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(SdkFileTree, {
|
||||
nodes,
|
||||
expandedPaths: ["wiki"],
|
||||
onToggleDir: () => undefined,
|
||||
onSelectFile: () => undefined,
|
||||
}),
|
||||
),
|
||||
).toThrow('Paperclip plugin UI runtime is not initialized for "FileTree"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin SDK markdown component bridge", () => {
|
||||
it("injects markdown display and editor components through the bridge runtime", () => {
|
||||
initPluginBridge(React, ReactDOM);
|
||||
|
||||
const registry = globalThis.__paperclipPluginBridge__?.sdkUi ?? {};
|
||||
expect(registry.MarkdownBlock).toBeTypeOf("function");
|
||||
expect(registry.MarkdownEditor).toBeTypeOf("function");
|
||||
expect(registry.IssuesList).toBeTypeOf("function");
|
||||
expect(registry.AssigneePicker).toBeTypeOf("function");
|
||||
expect(registry.ProjectPicker).toBeTypeOf("function");
|
||||
expect(registry.ManagedRoutinesList).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("renders plugin-provided markdown components when registered by the host", () => {
|
||||
globalThis.__paperclipPluginBridge__ = {
|
||||
react: React,
|
||||
reactDom: ReactDOM,
|
||||
sdkUi: {
|
||||
MarkdownBlock: ({ content, enableWikiLinks, wikiLinkRoot }: { content: string; enableWikiLinks?: boolean; wikiLinkRoot?: string }) =>
|
||||
React.createElement("article", {
|
||||
"data-wiki-links": enableWikiLinks ? "true" : "false",
|
||||
"data-wiki-root": wikiLinkRoot,
|
||||
}, content),
|
||||
MarkdownEditor: ({ value }: { value: string }) =>
|
||||
React.createElement("textarea", { value, readOnly: true }),
|
||||
ManagedRoutinesList: ({ routines }: { routines: Array<{ title: string }> }) =>
|
||||
React.createElement("section", null, routines.map((routine) => routine.title).join(", ")),
|
||||
},
|
||||
};
|
||||
|
||||
const markdownHtml = renderToStaticMarkup(React.createElement(SdkMarkdownBlock, {
|
||||
content: "# Wiki",
|
||||
enableWikiLinks: true,
|
||||
wikiLinkRoot: "/wiki/page",
|
||||
}));
|
||||
expect(markdownHtml).toContain("# Wiki");
|
||||
expect(markdownHtml).toContain('data-wiki-links="true"');
|
||||
expect(markdownHtml).toContain('data-wiki-root="/wiki/page"');
|
||||
expect(renderToStaticMarkup(React.createElement(SdkMarkdownEditor, { value: "# Wiki", onChange: () => undefined }))).toContain("# Wiki");
|
||||
expect(renderToStaticMarkup(React.createElement(SdkManagedRoutinesList, {
|
||||
routines: [{ key: "lint", title: "Run lint", status: "active" }],
|
||||
}))).toContain("Run lint");
|
||||
});
|
||||
});
|
||||
|
|
@ -25,7 +25,8 @@
|
|||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*/
|
||||
|
||||
import { createContext, useCallback, useContext, useRef, useState, useEffect } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
|
||||
import { useLocation as useRouterLocation, useNavigate as useRouterNavigate, type NavigateOptions } from "react-router-dom";
|
||||
import type {
|
||||
PluginBridgeErrorCode,
|
||||
PluginLauncherBounds,
|
||||
|
|
@ -35,6 +36,8 @@ import type {
|
|||
import { pluginsApi } from "@/api/plugins";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { useToastActions, type ToastInput } from "@/context/ToastContext";
|
||||
import { useSidebar } from "@/context/SidebarContext";
|
||||
import { isGlobalPath, normalizeCompanyPrefix } from "@/lib/company-routes";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bridge error type (mirrors the SDK's PluginBridgeError)
|
||||
|
|
@ -63,6 +66,36 @@ export interface PluginDataResult<T = unknown> {
|
|||
export type PluginToastInput = ToastInput;
|
||||
export type PluginToastFn = (input: PluginToastInput) => string | null;
|
||||
|
||||
export interface HostNavigationOptions {
|
||||
replace?: boolean;
|
||||
state?: unknown;
|
||||
}
|
||||
|
||||
export interface HostNavigationLinkOptions extends HostNavigationOptions {
|
||||
target?: string;
|
||||
rel?: string;
|
||||
}
|
||||
|
||||
export interface HostNavigationLinkProps {
|
||||
href: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
onClick(event: ReactMouseEvent<HTMLAnchorElement>): void;
|
||||
}
|
||||
|
||||
export interface HostNavigation {
|
||||
resolveHref(to: string): string;
|
||||
navigate(to: string, options?: HostNavigationOptions): void;
|
||||
linkProps(to: string, options?: HostNavigationLinkOptions): HostNavigationLinkProps;
|
||||
}
|
||||
|
||||
export interface HostLocation {
|
||||
pathname: string;
|
||||
search: string;
|
||||
hash: string;
|
||||
state?: unknown;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host context type (mirrors the SDK's PluginHostContext)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -220,6 +253,81 @@ function serializeRenderEnvironmentSnapshot(
|
|||
return snapshot ? JSON.stringify(snapshot) : "";
|
||||
}
|
||||
|
||||
function splitPath(path: string): { pathname: string; search: string; hash: string } {
|
||||
const match = path.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/);
|
||||
return {
|
||||
pathname: match?.[1] ?? path,
|
||||
search: match?.[2] ?? "",
|
||||
hash: match?.[3] ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function sameOriginPathFromHref(href: string): string | null {
|
||||
if (!/^[a-z][a-z\d+.-]*:/i.test(href) && !href.startsWith("//")) {
|
||||
return href;
|
||||
}
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const url = new URL(href, window.location.origin);
|
||||
if (url.origin !== window.location.origin) return null;
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hasCompanyPrefix(pathname: string, companyPrefix: string): boolean {
|
||||
const [firstSegment] = pathname.split("/").filter(Boolean);
|
||||
return firstSegment?.toUpperCase() === normalizeCompanyPrefix(companyPrefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a plugin-provided Paperclip path to the active company scope.
|
||||
*
|
||||
* This intentionally handles plugin page roots such as `/wiki`, which cannot
|
||||
* be listed in the host router's static board-route table ahead of time.
|
||||
*/
|
||||
export function resolveHostNavigationHref(
|
||||
to: string,
|
||||
companyPrefix: string | null | undefined,
|
||||
): string {
|
||||
const sameOriginPath = sameOriginPathFromHref(to);
|
||||
if (sameOriginPath === null) return to;
|
||||
|
||||
const { pathname, search, hash } = splitPath(sameOriginPath);
|
||||
if (!pathname.startsWith("/") || isGlobalPath(pathname) || !companyPrefix) {
|
||||
return sameOriginPath;
|
||||
}
|
||||
|
||||
if (hasCompanyPrefix(pathname, companyPrefix)) {
|
||||
return sameOriginPath;
|
||||
}
|
||||
|
||||
return `/${normalizeCompanyPrefix(companyPrefix)}${pathname}${search}${hash}`;
|
||||
}
|
||||
|
||||
function isPlainLeftClick(event: ReactMouseEvent<HTMLAnchorElement>): boolean {
|
||||
return (
|
||||
!event.defaultPrevented &&
|
||||
event.button === 0 &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.shiftKey
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldHandleHostNavigationClick(
|
||||
event: ReactMouseEvent<HTMLAnchorElement>,
|
||||
href: string,
|
||||
target?: string,
|
||||
): boolean {
|
||||
if (!isPlainLeftClick(event)) return false;
|
||||
if (target && target !== "_self") return false;
|
||||
if (event.currentTarget.hasAttribute("download")) return false;
|
||||
return sameOriginPathFromHref(href) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concrete implementation of `usePluginData<T>(key, params)`.
|
||||
*
|
||||
|
|
@ -364,6 +472,81 @@ export function useHostContext(): PluginHostContext {
|
|||
return hostContext;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useHostNavigation — concrete implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useHostNavigation(): HostNavigation {
|
||||
const { hostContext } = usePluginBridgeContext();
|
||||
const routerNavigate = useRouterNavigate();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const companyPrefix = hostContext.companyPrefix;
|
||||
|
||||
const resolveHref = useCallback(
|
||||
(to: string) => resolveHostNavigationHref(to, companyPrefix),
|
||||
[companyPrefix],
|
||||
);
|
||||
|
||||
const navigate = useCallback(
|
||||
(to: string, options?: HostNavigationOptions) => {
|
||||
const href = resolveHref(to);
|
||||
const sameOriginPath = sameOriginPathFromHref(href);
|
||||
if (sameOriginPath === null) {
|
||||
window.location.assign(href);
|
||||
return;
|
||||
}
|
||||
routerNavigate(sameOriginPath, options as NavigateOptions | undefined);
|
||||
// Mirror host sidebar behavior: tapping a link inside the mobile drawer
|
||||
// dismisses the drawer so the user can see the destination page.
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
},
|
||||
[isMobile, resolveHref, routerNavigate, setSidebarOpen],
|
||||
);
|
||||
|
||||
const linkProps = useCallback(
|
||||
(to: string, options?: HostNavigationLinkOptions): HostNavigationLinkProps => {
|
||||
const href = resolveHref(to);
|
||||
return {
|
||||
href,
|
||||
target: options?.target,
|
||||
rel: options?.rel,
|
||||
onClick: (event) => {
|
||||
if (!shouldHandleHostNavigationClick(event, href, options?.target)) return;
|
||||
event.preventDefault();
|
||||
navigate(href, options);
|
||||
},
|
||||
};
|
||||
},
|
||||
[navigate, resolveHref],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
resolveHref,
|
||||
navigate,
|
||||
linkProps,
|
||||
}),
|
||||
[linkProps, navigate, resolveHref],
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useHostLocation — concrete implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useHostLocation(): HostLocation {
|
||||
const location = useRouterLocation();
|
||||
return useMemo(
|
||||
() => ({
|
||||
pathname: location.pathname,
|
||||
search: location.search,
|
||||
hash: location.hash,
|
||||
state: location.state,
|
||||
}),
|
||||
[location.hash, location.pathname, location.search, location.state],
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginToast — concrete implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -63,6 +63,33 @@ export type ResolvedPluginSlot = PluginUiSlotDeclaration & {
|
|||
pluginVersion: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the unique `routeSidebar` slot that pairs with a single `page` slot
|
||||
* for the given route, or `null` if no unambiguous pairing exists.
|
||||
*
|
||||
* Used to detect when a route is taken over by a plugin's full-page sidebar so
|
||||
* host chrome (breadcrumb, in-page Back) can be suppressed.
|
||||
*/
|
||||
export function resolveRouteSidebarSlot(
|
||||
slots: ResolvedPluginSlot[],
|
||||
routePath: string | null,
|
||||
): ResolvedPluginSlot | null {
|
||||
if (!routePath) return null;
|
||||
|
||||
const pageMatches = slots.filter((slot) => slot.type === "page" && slot.routePath === routePath);
|
||||
if (pageMatches.length !== 1) return null;
|
||||
|
||||
const [pageSlot] = pageMatches;
|
||||
const sidebarMatches = slots.filter((slot) =>
|
||||
slot.type === "routeSidebar"
|
||||
&& slot.routePath === routePath
|
||||
&& slot.pluginId === pageSlot.pluginId,
|
||||
);
|
||||
|
||||
if (sidebarMatches.length !== 1) return null;
|
||||
return sidebarMatches[0] ?? null;
|
||||
}
|
||||
|
||||
type PluginSlotComponentProps = {
|
||||
slot: ResolvedPluginSlot;
|
||||
context: PluginSlotContext;
|
||||
|
|
@ -257,8 +284,30 @@ function getShimBlobUrl(specifier: "react" | "react-dom" | "react-dom/client" |
|
|||
case "sdk-ui":
|
||||
source = `
|
||||
const SDK = globalThis.__paperclipPluginBridge__?.sdkUi ?? {};
|
||||
const { usePluginData, usePluginAction, useHostContext, usePluginStream, usePluginToast } = SDK;
|
||||
export { usePluginData, usePluginAction, useHostContext, usePluginStream, usePluginToast };
|
||||
function missing(name) {
|
||||
return function MissingPaperclipSdkUiComponent() {
|
||||
throw new Error('Paperclip plugin UI runtime is not initialized for "' + name + '". Ensure the host loaded the plugin bridge before rendering this UI module.');
|
||||
};
|
||||
}
|
||||
const { usePluginData, usePluginAction, useHostContext, useHostLocation, useHostNavigation, usePluginStream, usePluginToast } = SDK;
|
||||
const MetricCard = SDK.MetricCard ?? missing("MetricCard");
|
||||
const StatusBadge = SDK.StatusBadge ?? missing("StatusBadge");
|
||||
const DataTable = SDK.DataTable ?? missing("DataTable");
|
||||
const TimeseriesChart = SDK.TimeseriesChart ?? missing("TimeseriesChart");
|
||||
const MarkdownBlock = SDK.MarkdownBlock ?? missing("MarkdownBlock");
|
||||
const MarkdownEditor = SDK.MarkdownEditor ?? missing("MarkdownEditor");
|
||||
const KeyValueList = SDK.KeyValueList ?? missing("KeyValueList");
|
||||
const ActionBar = SDK.ActionBar ?? missing("ActionBar");
|
||||
const LogView = SDK.LogView ?? missing("LogView");
|
||||
const JsonTree = SDK.JsonTree ?? missing("JsonTree");
|
||||
const Spinner = SDK.Spinner ?? missing("Spinner");
|
||||
const ErrorBoundary = SDK.ErrorBoundary ?? missing("ErrorBoundary");
|
||||
const FileTree = SDK.FileTree ?? missing("FileTree");
|
||||
const IssuesList = SDK.IssuesList ?? missing("IssuesList");
|
||||
const AssigneePicker = SDK.AssigneePicker ?? missing("AssigneePicker");
|
||||
const ProjectPicker = SDK.ProjectPicker ?? missing("ProjectPicker");
|
||||
const ManagedRoutinesList = SDK.ManagedRoutinesList ?? missing("ManagedRoutinesList");
|
||||
export { usePluginData, usePluginAction, useHostContext, useHostLocation, useHostNavigation, usePluginStream, usePluginToast, MetricCard, StatusBadge, DataTable, TimeseriesChart, MarkdownBlock, MarkdownEditor, KeyValueList, ActionBar, LogView, JsonTree, Spinner, ErrorBoundary, FileTree, IssuesList, AssigneePicker, ProjectPicker, ManagedRoutinesList };
|
||||
`;
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue