Add kitchen sink plugin example

This commit is contained in:
Dotta
2026-03-13 23:03:51 -05:00
parent 12ccfc2c9a
commit 6fa1dd2197
18 changed files with 4117 additions and 7 deletions

View File

@@ -10,6 +10,7 @@ import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./Ma
import { StatusBadge } from "./StatusBadge";
import { AgentIcon } from "./AgentIconPicker";
import { formatDateTime } from "../lib/utils";
import { PluginSlotOutlet } from "@/plugins/slots";
interface CommentWithRunMeta extends IssueComment {
runId?: string | null;
@@ -32,6 +33,8 @@ interface CommentReassignment {
interface CommentThreadProps {
comments: CommentWithRunMeta[];
linkedRuns?: LinkedRunItem[];
companyId?: string | null;
projectId?: string | null;
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
issueStatus?: string;
agentMap?: Map<string, Agent>;
@@ -118,10 +121,14 @@ type TimelineItem =
const TimelineList = memo(function TimelineList({
timeline,
agentMap,
companyId,
projectId,
highlightCommentId,
}: {
timeline: TimelineItem[];
agentMap?: Map<string, Agent>;
companyId?: string | null;
projectId?: string | null;
highlightCommentId?: string | null;
}) {
if (timeline.length === 0) {
@@ -180,6 +187,22 @@ const TimelineList = memo(function TimelineList({
<Identity name="You" size="sm" />
)}
<span className="flex items-center gap-1.5">
{companyId ? (
<PluginSlotOutlet
slotTypes={["commentContextMenuItem"]}
entityType="comment"
context={{
companyId,
projectId: projectId ?? null,
entityId: comment.id,
entityType: "comment",
parentEntityId: comment.issueId,
}}
className="flex flex-wrap items-center gap-1.5"
itemClassName="inline-flex"
missingBehavior="placeholder"
/>
) : null}
<a
href={`#comment-${comment.id}`}
className="text-xs text-muted-foreground hover:text-foreground hover:underline transition-colors"
@@ -190,6 +213,24 @@ const TimelineList = memo(function TimelineList({
</span>
</div>
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
{companyId ? (
<div className="mt-2 space-y-2">
<PluginSlotOutlet
slotTypes={["commentAnnotation"]}
entityType="comment"
context={{
companyId,
projectId: projectId ?? null,
entityId: comment.id,
entityType: "comment",
parentEntityId: comment.issueId,
}}
className="space-y-2"
itemClassName="rounded-md"
missingBehavior="placeholder"
/>
</div>
) : null}
{comment.runId && (
<div className="mt-2 pt-2 border-t border-border/60">
{comment.runAgentId ? (
@@ -216,6 +257,8 @@ const TimelineList = memo(function TimelineList({
export function CommentThread({
comments,
linkedRuns = [],
companyId,
projectId,
onAdd,
issueStatus,
agentMap,
@@ -351,7 +394,13 @@ export function CommentThread({
<div className="space-y-4">
<h3 className="text-sm font-semibold">Comments &amp; Runs ({timeline.length})</h3>
<TimelineList timeline={timeline} agentMap={agentMap} highlightCommentId={highlightCommentId} />
<TimelineList
timeline={timeline}
agentMap={agentMap}
companyId={companyId}
projectId={projectId}
highlightCommentId={highlightCommentId}
/>
{liveRunSlot}

View File

@@ -1,7 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import { Clock3, Puzzle, Settings } from "lucide-react";
import { NavLink } from "@/lib/router";
import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
import { SidebarNavItem } from "./SidebarNavItem";
export function InstanceSidebar() {
const { data: plugins } = useQuery({
queryKey: queryKeys.plugins.all,
queryFn: () => pluginsApi.list(),
});
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-2 px-3 h-12 shrink-0">
@@ -15,6 +24,26 @@ export function InstanceSidebar() {
<div className="flex flex-col gap-0.5">
<SidebarNavItem to="/instance/settings/heartbeats" label="Heartbeats" icon={Clock3} end />
<SidebarNavItem to="/instance/settings/plugins" label="Plugins" icon={Puzzle} />
{(plugins ?? []).length > 0 ? (
<div className="ml-4 mt-1 flex flex-col gap-0.5 border-l border-border/70 pl-3">
{(plugins ?? []).map((plugin) => (
<NavLink
key={plugin.id}
to={`/instance/settings/plugins/${plugin.id}`}
className={({ isActive }) =>
[
"rounded-md px-2 py-1.5 text-xs transition-colors",
isActive
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
].join(" ")
}
>
{plugin.manifestJson.displayName ?? plugin.packageName}
</NavLink>
))}
</div>
) : null}
</div>
</nav>
</aside>

View File

@@ -21,6 +21,7 @@ import { heartbeatsApi } from "../api/heartbeats";
import { queryKeys } from "../lib/queryKeys";
import { useInboxBadge } from "../hooks/useInboxBadge";
import { Button } from "@/components/ui/button";
import { PluginSlotOutlet } from "@/plugins/slots";
export function Sidebar() {
const { openNewIssue } = useDialog();
@@ -38,6 +39,11 @@ export function Sidebar() {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true }));
}
const pluginContext = {
companyId: selectedCompanyId,
companyPrefix: selectedCompany?.issuePrefix ?? null,
};
return (
<aside className="w-60 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) */}
@@ -80,6 +86,13 @@ export function Sidebar() {
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
alert={inboxBadge.failedRuns > 0}
/>
<PluginSlotOutlet
slotTypes={["sidebar"]}
context={pluginContext}
className="flex flex-col gap-0.5"
itemClassName="text-[13px] font-medium"
missingBehavior="placeholder"
/>
</div>
<SidebarSection label="Work">
@@ -97,6 +110,14 @@ export function Sidebar() {
<SidebarNavItem to="/activity" label="Activity" icon={History} />
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
</SidebarSection>
<PluginSlotOutlet
slotTypes={["sidebarPanel"]}
context={pluginContext}
className="flex flex-col gap-3"
itemClassName="rounded-lg border border-border p-3"
missingBehavior="placeholder"
/>
</nav>
</aside>
);

View File

@@ -15,6 +15,7 @@ import { ToastProvider } from "./context/ToastContext";
import { ThemeProvider } from "./context/ThemeContext";
import { TooltipProvider } from "@/components/ui/tooltip";
import { initPluginBridge } from "./plugins/bridge-init";
import { PluginLauncherProvider } from "./plugins/launchers";
import "@mdxeditor/editor/style.css";
import "./index.css";
@@ -47,9 +48,11 @@ createRoot(document.getElementById("root")!).render(
<BreadcrumbProvider>
<SidebarProvider>
<PanelProvider>
<DialogProvider>
<App />
</DialogProvider>
<PluginLauncherProvider>
<DialogProvider>
<App />
</DialogProvider>
</PluginLauncherProvider>
</PanelProvider>
</SidebarProvider>
</BreadcrumbProvider>

View File

@@ -24,6 +24,8 @@ import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
import { StatusBadge } from "../components/StatusBadge";
import { Identity } from "../components/Identity";
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
import { PluginLauncherOutlet } from "@/plugins/launchers";
import { Separator } from "@/components/ui/separator";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
@@ -168,6 +170,7 @@ export function IssueDetail() {
queryFn: () => issuesApi.get(issueId!),
enabled: !!issueId,
});
const resolvedCompanyId = issue?.companyId ?? selectedCompanyId;
const { data: comments } = useQuery({
queryKey: queryKeys.issues.comments(issueId!),
@@ -257,6 +260,21 @@ export function IssueDetail() {
companyId: selectedCompanyId,
userId: currentUserId,
});
const { slots: issuePluginDetailSlots } = usePluginSlots({
slotTypes: ["detailTab"],
entityType: "issue",
companyId: resolvedCompanyId,
enabled: !!resolvedCompanyId,
});
const issuePluginTabItems = useMemo(
() => issuePluginDetailSlots.map((slot) => ({
value: `plugin:${slot.pluginKey}:${slot.id}`,
label: slot.displayName,
slot,
})),
[issuePluginDetailSlots],
);
const activePluginTab = issuePluginTabItems.find((item) => item.value === detailTab) ?? null;
const agentMap = useMemo(() => {
const map = new Map<string, Agent>();
@@ -678,6 +696,47 @@ export function IssueDetail() {
/>
</div>
<PluginSlotOutlet
slotTypes={["toolbarButton", "contextMenuItem"]}
entityType="issue"
context={{
companyId: issue.companyId,
projectId: issue.projectId ?? null,
entityId: issue.id,
entityType: "issue",
}}
className="flex flex-wrap gap-2"
itemClassName="inline-flex"
missingBehavior="placeholder"
/>
<PluginLauncherOutlet
placementZones={["toolbarButton"]}
entityType="issue"
context={{
companyId: issue.companyId,
projectId: issue.projectId ?? null,
entityId: issue.id,
entityType: "issue",
}}
className="flex flex-wrap gap-2"
itemClassName="inline-flex"
/>
<PluginSlotOutlet
slotTypes={["taskDetailView"]}
entityType="issue"
context={{
companyId: issue.companyId,
projectId: issue.projectId ?? null,
entityId: issue.id,
entityType: "issue",
}}
className="space-y-3"
itemClassName="rounded-lg border border-border p-3"
missingBehavior="placeholder"
/>
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium text-muted-foreground">Attachments</h3>
@@ -766,12 +825,19 @@ export function IssueDetail() {
<ActivityIcon className="h-3.5 w-3.5" />
Activity
</TabsTrigger>
{issuePluginTabItems.map((item) => (
<TabsTrigger key={item.value} value={item.value}>
{item.label}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="comments">
<CommentThread
comments={commentsWithRunMeta}
linkedRuns={timelineRuns}
companyId={issue.companyId}
projectId={issue.projectId}
issueStatus={issue.status}
agentMap={agentMap}
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
@@ -844,6 +910,21 @@ export function IssueDetail() {
</div>
)}
</TabsContent>
{activePluginTab && (
<TabsContent value={activePluginTab.value}>
<PluginSlotMount
slot={activePluginTab.slot}
context={{
companyId: issue.companyId,
projectId: issue.projectId ?? null,
entityId: issue.id,
entityType: "issue",
}}
missingBehavior="placeholder"
/>
</TabsContent>
)}
</Tabs>
{linkedApprovals && linkedApprovals.length > 0 && (

View File

@@ -19,7 +19,8 @@ import { PageSkeleton } from "../components/PageSkeleton";
import { PageTabBar } from "../components/PageTabBar";
import { projectRouteRef, cn } from "../lib/utils";
import { Tabs } from "@/components/ui/tabs";
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
import { PluginLauncherOutlet } from "@/plugins/launchers";
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
/* ── Top-level tab types ── */
@@ -405,6 +406,37 @@ export function ProjectDetail() {
/>
</div>
<PluginSlotOutlet
slotTypes={["toolbarButton", "contextMenuItem"]}
entityType="project"
context={{
companyId: resolvedCompanyId ?? null,
companyPrefix: companyPrefix ?? null,
projectId: project.id,
projectRef: canonicalProjectRef,
entityId: project.id,
entityType: "project",
}}
className="flex flex-wrap gap-2"
itemClassName="inline-flex"
missingBehavior="placeholder"
/>
<PluginLauncherOutlet
placementZones={["toolbarButton"]}
entityType="project"
context={{
companyId: resolvedCompanyId ?? null,
companyPrefix: companyPrefix ?? null,
projectId: project.id,
projectRef: canonicalProjectRef,
entityId: project.id,
entityType: "project",
}}
className="flex flex-wrap gap-2"
itemClassName="inline-flex"
/>
<Tabs value={activeTab ?? "list"} onValueChange={(value) => handleTabChange(value as ProjectTab)}>
<PageTabBar
items={[

View File

@@ -257,11 +257,11 @@ function getShimBlobUrl(specifier: "react" | "react-dom" | "react-dom/client" |
case "sdk-ui":
source = `
const SDK = globalThis.__paperclipPluginBridge__?.sdkUi ?? {};
const { usePluginData, usePluginAction, useHostContext,
const { usePluginData, usePluginAction, useHostContext, usePluginStream,
MetricCard, StatusBadge, DataTable, TimeseriesChart,
MarkdownBlock, KeyValueList, ActionBar, LogView, JsonTree,
Spinner, ErrorBoundary } = SDK;
export { usePluginData, usePluginAction, useHostContext,
export { usePluginData, usePluginAction, useHostContext, usePluginStream,
MetricCard, StatusBadge, DataTable, TimeseriesChart,
MarkdownBlock, KeyValueList, ActionBar, LogView, JsonTree,
Spinner, ErrorBoundary };