Add plugin framework and settings UI

This commit is contained in:
Dotta
2026-03-13 16:22:34 -05:00
parent 7e288d20fc
commit 80cdbdbd47
103 changed files with 31760 additions and 35 deletions

View File

@@ -1,4 +1,4 @@
import { Clock3, Settings } from "lucide-react";
import { Clock3, Puzzle, Settings } from "lucide-react";
import { SidebarNavItem } from "./SidebarNavItem";
export function InstanceSidebar() {
@@ -13,7 +13,8 @@ export function InstanceSidebar() {
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
<div className="flex flex-col gap-0.5">
<SidebarNavItem to="/instance/settings" label="Heartbeats" icon={Clock3} />
<SidebarNavItem to="/instance/settings/heartbeats" label="Heartbeats" icon={Clock3} end />
<SidebarNavItem to="/instance/settings/plugins" label="Plugins" icon={Puzzle} />
</div>
</nav>
</aside>

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,37 @@ import { cn } from "../lib/utils";
import { NotFoundPage } from "../pages/NotFound";
import { Button } from "@/components/ui/button";
const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath";
const DEFAULT_INSTANCE_SETTINGS_PATH = "/instance/settings/heartbeats";
function normalizeRememberedInstanceSettingsPath(rawPath: string | null): string {
if (!rawPath) return DEFAULT_INSTANCE_SETTINGS_PATH;
const match = rawPath.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/);
const pathname = match?.[1] ?? rawPath;
const search = match?.[2] ?? "";
const hash = match?.[3] ?? "";
if (pathname === "/instance/settings/heartbeats" || pathname === "/instance/settings/plugins") {
return `${pathname}${search}${hash}`;
}
if (/^\/instance\/settings\/plugins\/[^/?#]+$/.test(pathname)) {
return `${pathname}${search}${hash}`;
}
return DEFAULT_INSTANCE_SETTINGS_PATH;
}
function readRememberedInstanceSettingsPath(): string {
if (typeof window === "undefined") return DEFAULT_INSTANCE_SETTINGS_PATH;
try {
return normalizeRememberedInstanceSettingsPath(window.localStorage.getItem(INSTANCE_SETTINGS_MEMORY_KEY));
} catch {
return DEFAULT_INSTANCE_SETTINGS_PATH;
}
}
export function Layout() {
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
const { openNewIssue, openOnboarding } = useDialog();
@@ -49,6 +80,7 @@ export function Layout() {
const onboardingTriggered = useRef(false);
const lastMainScrollTop = useRef(0);
const [mobileNavVisible, setMobileNavVisible] = useState(true);
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
const nextTheme = theme === "dark" ? "light" : "dark";
const matchedCompany = useMemo(() => {
if (!companyPrefix) return null;
@@ -220,6 +252,21 @@ export function Layout() {
};
}, [isMobile]);
useEffect(() => {
if (!location.pathname.startsWith("/instance/settings/")) return;
const nextPath = normalizeRememberedInstanceSettingsPath(
`${location.pathname}${location.search}${location.hash}`,
);
setInstanceSettingsTarget(nextPath);
try {
window.localStorage.setItem(INSTANCE_SETTINGS_MEMORY_KEY, nextPath);
} catch {
// Ignore storage failures in restricted environments.
}
}, [location.hash, location.pathname, location.search]);
return (
<div
className={cn(
@@ -235,7 +282,6 @@ export function Layout() {
</a>
<WorktreeBanner />
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-hidden")}>
{/* Mobile backdrop */}
{isMobile && sidebarOpen && (
<button
type="button"
@@ -245,7 +291,6 @@ export function Layout() {
/>
)}
{/* Combined sidebar area: company rail + inner sidebar + docs bar */}
{isMobile ? (
<div
className={cn(
@@ -270,7 +315,7 @@ export function Layout() {
</a>
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
<Link
to="/instance/settings"
to={instanceSettingsTarget}
aria-label="Instance settings"
title="Instance settings"
onClick={() => {
@@ -320,7 +365,7 @@ export function Layout() {
</a>
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
<Link
to="/instance/settings"
to={instanceSettingsTarget}
aria-label="Instance settings"
title="Instance settings"
onClick={() => {
@@ -346,7 +391,6 @@ export function Layout() {
</div>
)}
{/* Main content */}
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
<div
className={cn(

View File

@@ -25,17 +25,26 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
import type { Project } from "@paperclipai/shared";
type ProjectSidebarSlot = ReturnType<typeof usePluginSlots>["slots"][number];
function SortableProjectItem({
activeProjectRef,
companyId,
companyPrefix,
isMobile,
project,
projectSidebarSlots,
setSidebarOpen,
}: {
activeProjectRef: string | null;
companyId: string | null;
companyPrefix: string | null;
isMobile: boolean;
project: Project;
projectSidebarSlots: ProjectSidebarSlot[];
setSidebarOpen: (open: boolean) => void;
}) {
const {
@@ -61,31 +70,52 @@ function SortableProjectItem({
{...attributes}
{...listeners}
>
<NavLink
to={`/projects/${routeRef}/issues`}
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
className={cn(
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
activeProjectRef === routeRef || activeProjectRef === project.id
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
<div className="flex flex-col gap-0.5">
<NavLink
to={`/projects/${routeRef}/issues`}
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
className={cn(
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
activeProjectRef === routeRef || activeProjectRef === project.id
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
)}
>
<span
className="shrink-0 h-3.5 w-3.5 rounded-sm"
style={{ backgroundColor: project.color ?? "#6366f1" }}
/>
<span className="flex-1 truncate">{project.name}</span>
</NavLink>
{projectSidebarSlots.length > 0 && (
<div className="ml-5 flex flex-col gap-0.5">
{projectSidebarSlots.map((slot) => (
<PluginSlotMount
key={`${project.id}:${slot.pluginKey}:${slot.id}`}
slot={slot}
context={{
companyId,
companyPrefix,
projectId: project.id,
projectRef: routeRef,
entityId: project.id,
entityType: "project",
}}
missingBehavior="placeholder"
/>
))}
</div>
)}
>
<span
className="shrink-0 h-3.5 w-3.5 rounded-sm"
style={{ backgroundColor: project.color ?? "#6366f1" }}
/>
<span className="flex-1 truncate">{project.name}</span>
</NavLink>
</div>
</div>
);
}
export function SidebarProjects() {
const [open, setOpen] = useState(true);
const { selectedCompanyId } = useCompany();
const { selectedCompany, selectedCompanyId } = useCompany();
const { openNewProject } = useDialog();
const { isMobile, setSidebarOpen } = useSidebar();
const location = useLocation();
@@ -99,6 +129,12 @@ export function SidebarProjects() {
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const { slots: projectSidebarSlots } = usePluginSlots({
slotTypes: ["projectSidebarItem"],
entityType: "project",
companyId: selectedCompanyId,
enabled: !!selectedCompanyId,
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
@@ -178,8 +214,11 @@ export function SidebarProjects() {
<SortableProjectItem
key={project.id}
activeProjectRef={activeProjectRef}
companyId={selectedCompanyId}
companyPrefix={selectedCompany?.issuePrefix ?? null}
isMobile={isMobile}
project={project}
projectSidebarSlots={projectSidebarSlots}
setSidebarOpen={setSidebarOpen}
/>
))}