mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
1626 lines
61 KiB
TypeScript
1626 lines
61 KiB
TypeScript
import { useEffect, useMemo, useState, type CSSProperties, type FormEvent, type ReactNode } from "react";
|
|
import {
|
|
useHostContext,
|
|
usePluginAction,
|
|
usePluginData,
|
|
usePluginStream,
|
|
type PluginCommentAnnotationProps,
|
|
type PluginCommentContextMenuItemProps,
|
|
type PluginDetailTabProps,
|
|
type PluginPageProps,
|
|
type PluginProjectSidebarItemProps,
|
|
type PluginSettingsPageProps,
|
|
type PluginSidebarProps,
|
|
type PluginWidgetProps,
|
|
} from "@paperclipai/plugin-sdk/ui";
|
|
import {
|
|
DEFAULT_CONFIG,
|
|
JOB_KEYS,
|
|
PLUGIN_ID,
|
|
SAFE_COMMANDS,
|
|
SLOT_IDS,
|
|
STREAM_CHANNELS,
|
|
TOOL_NAMES,
|
|
WEBHOOK_KEYS,
|
|
} from "../constants.js";
|
|
|
|
type CompanyRecord = { id: string; name: string; issuePrefix?: string | null };
|
|
type ProjectRecord = { id: string; name: string; status?: string; path?: string | null };
|
|
type IssueRecord = { id: string; title: string; status: string; projectId?: string | null };
|
|
type GoalRecord = { id: string; title: string; status: string };
|
|
type AgentRecord = { id: string; name: string; status: string };
|
|
|
|
type OverviewData = {
|
|
pluginId: string;
|
|
version: string;
|
|
capabilities: string[];
|
|
config: Record<string, unknown>;
|
|
runtimeLaunchers: Array<{ id: string; displayName: string; placementZone: string }>;
|
|
recentRecords: Array<{ id: string; source: string; message: string; createdAt: string; level: string; data?: unknown }>;
|
|
counts: {
|
|
companies: number;
|
|
projects: number;
|
|
issues: number;
|
|
goals: number;
|
|
agents: number;
|
|
entities: number;
|
|
};
|
|
lastJob: unknown;
|
|
lastWebhook: unknown;
|
|
lastAsset: unknown;
|
|
lastProcessResult: unknown;
|
|
streamChannels: Record<string, string>;
|
|
safeCommands: Array<{ key: string; label: string; description: string }>;
|
|
manifest: {
|
|
jobs: Array<{ jobKey: string; displayName: string; schedule?: string }>;
|
|
webhooks: Array<{ endpointKey: string; displayName: string }>;
|
|
tools: Array<{ name: string; displayName: string; description: string }>;
|
|
};
|
|
};
|
|
|
|
type EntityRecord = {
|
|
id: string;
|
|
entityType: string;
|
|
title: string | null;
|
|
status: string | null;
|
|
scopeKind: string;
|
|
scopeId: string | null;
|
|
externalId: string | null;
|
|
data: unknown;
|
|
};
|
|
|
|
type StateValueData = {
|
|
scope: {
|
|
scopeKind: string;
|
|
scopeId?: string;
|
|
namespace?: string;
|
|
stateKey: string;
|
|
};
|
|
value: unknown;
|
|
};
|
|
|
|
type PluginConfigData = {
|
|
showSidebarEntry?: boolean;
|
|
showSidebarPanel?: boolean;
|
|
showProjectSidebarItem?: boolean;
|
|
showCommentAnnotation?: boolean;
|
|
showCommentContextMenuItem?: boolean;
|
|
enableWorkspaceDemos?: boolean;
|
|
enableProcessDemos?: boolean;
|
|
};
|
|
|
|
type CommentContextData = {
|
|
commentId: string;
|
|
issueId: string;
|
|
preview: string;
|
|
length: number;
|
|
copiedCount: number;
|
|
} | null;
|
|
|
|
type ProcessResult = {
|
|
commandKey: string;
|
|
cwd: string;
|
|
code: number | null;
|
|
stdout: string;
|
|
stderr: string;
|
|
startedAt: string;
|
|
finishedAt: string;
|
|
};
|
|
|
|
const layoutStack: CSSProperties = {
|
|
display: "grid",
|
|
gap: "12px",
|
|
};
|
|
|
|
const cardStyle: CSSProperties = {
|
|
border: "1px solid var(--border)",
|
|
borderRadius: "12px",
|
|
padding: "14px",
|
|
background: "var(--card, transparent)",
|
|
};
|
|
|
|
const subtleCardStyle: CSSProperties = {
|
|
border: "1px solid color-mix(in srgb, var(--border) 75%, transparent)",
|
|
borderRadius: "10px",
|
|
padding: "12px",
|
|
};
|
|
|
|
const rowStyle: CSSProperties = {
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
alignItems: "center",
|
|
gap: "8px",
|
|
};
|
|
|
|
const sectionHeaderStyle: CSSProperties = {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: "8px",
|
|
marginBottom: "10px",
|
|
};
|
|
|
|
const buttonStyle: CSSProperties = {
|
|
appearance: "none",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: "999px",
|
|
background: "transparent",
|
|
color: "inherit",
|
|
padding: "6px 12px",
|
|
fontSize: "12px",
|
|
cursor: "pointer",
|
|
};
|
|
|
|
const primaryButtonStyle: CSSProperties = {
|
|
...buttonStyle,
|
|
background: "var(--foreground)",
|
|
color: "var(--background)",
|
|
borderColor: "var(--foreground)",
|
|
};
|
|
|
|
const inputStyle: CSSProperties = {
|
|
width: "100%",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: "8px",
|
|
padding: "8px 10px",
|
|
background: "transparent",
|
|
color: "inherit",
|
|
fontSize: "12px",
|
|
};
|
|
|
|
const codeStyle: CSSProperties = {
|
|
margin: 0,
|
|
padding: "10px",
|
|
borderRadius: "8px",
|
|
border: "1px solid var(--border)",
|
|
background: "color-mix(in srgb, var(--muted, #888) 16%, transparent)",
|
|
overflowX: "auto",
|
|
fontSize: "11px",
|
|
lineHeight: 1.45,
|
|
};
|
|
|
|
const widgetGridStyle: CSSProperties = {
|
|
display: "grid",
|
|
gap: "12px",
|
|
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
|
};
|
|
|
|
const widgetStyle: CSSProperties = {
|
|
border: "1px solid var(--border)",
|
|
borderRadius: "14px",
|
|
padding: "14px",
|
|
display: "grid",
|
|
gap: "8px",
|
|
background: "color-mix(in srgb, var(--card, transparent) 72%, transparent)",
|
|
};
|
|
|
|
const mutedTextStyle: CSSProperties = {
|
|
fontSize: "12px",
|
|
opacity: 0.72,
|
|
lineHeight: 1.45,
|
|
};
|
|
|
|
function hostPath(companyPrefix: string | null | undefined, suffix: string): string {
|
|
return companyPrefix ? `/${companyPrefix}${suffix}` : suffix;
|
|
}
|
|
|
|
function JsonBlock({ value }: { value: unknown }) {
|
|
return <pre style={codeStyle}>{JSON.stringify(value, null, 2)}</pre>;
|
|
}
|
|
|
|
function Section({
|
|
title,
|
|
action,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
action?: ReactNode;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<section style={cardStyle}>
|
|
<div style={sectionHeaderStyle}>
|
|
<strong>{title}</strong>
|
|
{action}
|
|
</div>
|
|
<div style={layoutStack}>{children}</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Pill({ label }: { label: string }) {
|
|
return (
|
|
<span
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: "6px",
|
|
borderRadius: "999px",
|
|
border: "1px solid var(--border)",
|
|
padding: "2px 8px",
|
|
fontSize: "11px",
|
|
}}
|
|
>
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function MiniWidget({
|
|
title,
|
|
eyebrow,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
eyebrow?: string;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<section style={widgetStyle}>
|
|
{eyebrow ? <div style={{ fontSize: "11px", opacity: 0.65, textTransform: "uppercase", letterSpacing: "0.06em" }}>{eyebrow}</div> : null}
|
|
<strong>{title}</strong>
|
|
<div style={layoutStack}>{children}</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function MiniList({
|
|
items,
|
|
render,
|
|
empty,
|
|
}: {
|
|
items: unknown[];
|
|
render: (item: unknown, index: number) => ReactNode;
|
|
empty: string;
|
|
}) {
|
|
if (items.length === 0) return <div style={{ fontSize: "12px", opacity: 0.7 }}>{empty}</div>;
|
|
return (
|
|
<div style={{ display: "grid", gap: "8px" }}>
|
|
{items.map((item, index) => (
|
|
<div key={index} style={subtleCardStyle}>
|
|
{render(item, index)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatusLine({ label, value }: { label: string; value: ReactNode }) {
|
|
return (
|
|
<div style={{ display: "grid", gap: "4px" }}>
|
|
<span style={{ fontSize: "11px", opacity: 0.65, textTransform: "uppercase", letterSpacing: "0.06em" }}>{label}</span>
|
|
<div style={{ fontSize: "12px" }}>{value}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function usePluginOverview(companyId: string | null) {
|
|
return usePluginData<OverviewData>("overview", companyId ? { companyId } : {});
|
|
}
|
|
|
|
function usePluginConfigData() {
|
|
return usePluginData<PluginConfigData>("plugin-config");
|
|
}
|
|
|
|
function hostFetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
|
return fetch(path, {
|
|
credentials: "include",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
...(init?.headers ?? {}),
|
|
},
|
|
...init,
|
|
}).then(async (response) => {
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(text || `Request failed: ${response.status}`);
|
|
}
|
|
return await response.json() as T;
|
|
});
|
|
}
|
|
|
|
function useSettingsConfig() {
|
|
const [configJson, setConfigJson] = useState<Record<string, unknown>>({ ...DEFAULT_CONFIG });
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
hostFetchJson<{ configJson?: Record<string, unknown> | null } | null>(`/api/plugins/${PLUGIN_ID}/config`)
|
|
.then((result) => {
|
|
if (cancelled) return;
|
|
setConfigJson({ ...DEFAULT_CONFIG, ...(result?.configJson ?? {}) });
|
|
setError(null);
|
|
})
|
|
.catch((nextError) => {
|
|
if (cancelled) return;
|
|
setError(nextError instanceof Error ? nextError.message : String(nextError));
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
async function save(nextConfig: Record<string, unknown>) {
|
|
setSaving(true);
|
|
try {
|
|
await hostFetchJson(`/api/plugins/${PLUGIN_ID}/config`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ configJson: nextConfig }),
|
|
});
|
|
setConfigJson(nextConfig);
|
|
setError(null);
|
|
} catch (nextError) {
|
|
setError(nextError instanceof Error ? nextError.message : String(nextError));
|
|
throw nextError;
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return {
|
|
configJson,
|
|
setConfigJson,
|
|
loading,
|
|
saving,
|
|
error,
|
|
save,
|
|
};
|
|
}
|
|
|
|
function CompactSurfaceSummary({ label, entityType }: { label: string; entityType?: string | null }) {
|
|
const context = useHostContext();
|
|
const companyId = context.companyId;
|
|
const entityId = context.entityId;
|
|
const resolvedEntityType = entityType ?? context.entityType ?? null;
|
|
const entityQuery = usePluginData(
|
|
"entity-context",
|
|
companyId && entityId && resolvedEntityType
|
|
? { companyId, entityId, entityType: resolvedEntityType }
|
|
: {},
|
|
);
|
|
const writeMetric = usePluginAction("write-metric");
|
|
|
|
return (
|
|
<div style={layoutStack}>
|
|
<div style={rowStyle}>
|
|
<strong>{label}</strong>
|
|
{resolvedEntityType ? <Pill label={resolvedEntityType} /> : null}
|
|
</div>
|
|
<JsonBlock value={context} />
|
|
<button
|
|
type="button"
|
|
style={buttonStyle}
|
|
onClick={() => {
|
|
if (!companyId) return;
|
|
void writeMetric({ name: "surface_click", value: 1, companyId }).catch(console.error);
|
|
}}
|
|
>
|
|
Write surface metric
|
|
</button>
|
|
{entityQuery.data ? <JsonBlock value={entityQuery.data} /> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function KitchenSinkPageWidgets({ context }: { context: PluginPageProps["context"] }) {
|
|
const overview = usePluginOverview(context.companyId);
|
|
const emitDemoEvent = usePluginAction("emit-demo-event");
|
|
const startProgressStream = usePluginAction("start-progress-stream");
|
|
const writeMetric = usePluginAction("write-metric");
|
|
const progressStream = usePluginStream<{ step?: number; message?: string }>(
|
|
STREAM_CHANNELS.progress,
|
|
{ companyId: context.companyId ?? undefined },
|
|
);
|
|
|
|
const companyPath = hostPath(context.companyPrefix, `/plugins/${PLUGIN_ID}`);
|
|
|
|
return (
|
|
<div style={widgetGridStyle}>
|
|
<MiniWidget title="Runtime Summary" eyebrow="Overview">
|
|
<div style={{ display: "grid", gap: "4px", fontSize: "12px" }}>
|
|
<div>Companies: {overview.data?.counts.companies ?? 0}</div>
|
|
<div>Projects: {overview.data?.counts.projects ?? 0}</div>
|
|
<div>Issues: {overview.data?.counts.issues ?? 0}</div>
|
|
<div>Agents: {overview.data?.counts.agents ?? 0}</div>
|
|
</div>
|
|
</MiniWidget>
|
|
|
|
<MiniWidget title="Quick Actions" eyebrow="Try It">
|
|
<div style={rowStyle}>
|
|
<button
|
|
type="button"
|
|
style={buttonStyle}
|
|
onClick={() => {
|
|
if (!context.companyId) return;
|
|
void emitDemoEvent({ companyId: context.companyId, message: "Triggered from Kitchen Sink page" }).catch(console.error);
|
|
}}
|
|
>
|
|
Emit event
|
|
</button>
|
|
<button
|
|
type="button"
|
|
style={buttonStyle}
|
|
onClick={() => {
|
|
if (!context.companyId) return;
|
|
void startProgressStream({ companyId: context.companyId, steps: 4 }).catch(console.error);
|
|
}}
|
|
>
|
|
Start stream
|
|
</button>
|
|
<button
|
|
type="button"
|
|
style={buttonStyle}
|
|
onClick={() => {
|
|
if (!context.companyId) return;
|
|
void writeMetric({ companyId: context.companyId, name: "page_quick_action", value: 1 }).catch(console.error);
|
|
}}
|
|
>
|
|
Write metric
|
|
</button>
|
|
</div>
|
|
<div style={mutedTextStyle}>
|
|
Recent progress events: {progressStream.events.length}
|
|
</div>
|
|
</MiniWidget>
|
|
|
|
<MiniWidget title="Surface Map" eyebrow="UI">
|
|
<div style={{ display: "grid", gap: "4px", fontSize: "12px" }}>
|
|
<div>Sidebar link and panel</div>
|
|
<div>Dashboard widget</div>
|
|
<div>Project link, tab, toolbar button, launcher</div>
|
|
<div>Issue tab, task view, toolbar button, launcher</div>
|
|
<div>Comment annotation and comment action</div>
|
|
</div>
|
|
</MiniWidget>
|
|
|
|
<MiniWidget title="Manifest Coverage" eyebrow="Worker">
|
|
<div style={{ display: "grid", gap: "4px", fontSize: "12px" }}>
|
|
<div>Jobs: {overview.data?.manifest.jobs.length ?? 0}</div>
|
|
<div>Webhooks: {overview.data?.manifest.webhooks.length ?? 0}</div>
|
|
<div>Tools: {overview.data?.manifest.tools.length ?? 0}</div>
|
|
<div>Launchers: {overview.data?.runtimeLaunchers.length ?? 0}</div>
|
|
</div>
|
|
</MiniWidget>
|
|
|
|
<MiniWidget title="Latest Runtime State" eyebrow="Diagnostics">
|
|
<div style={mutedTextStyle}>
|
|
This updates as you use the worker demos below.
|
|
</div>
|
|
<JsonBlock
|
|
value={{
|
|
lastJob: overview.data?.lastJob ?? null,
|
|
lastWebhook: overview.data?.lastWebhook ?? null,
|
|
lastAsset: overview.data?.lastAsset ?? null,
|
|
lastProcessResult: overview.data?.lastProcessResult ?? null,
|
|
}}
|
|
/>
|
|
</MiniWidget>
|
|
|
|
<MiniWidget title="Plugin Page Route" eyebrow="Navigation">
|
|
<div style={mutedTextStyle}>
|
|
The sidebar entry opens this page directly. Use it as the main kitchen-sink control surface.
|
|
</div>
|
|
<a href={companyPath} style={{ fontSize: "12px" }}>
|
|
{companyPath}
|
|
</a>
|
|
</MiniWidget>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function KitchenSinkConsole({ context }: { context: { companyId: string | null; companyPrefix?: string | null; projectId?: string | null; entityId?: string | null; entityType?: string | null } }) {
|
|
const companyId = context.companyId;
|
|
const overview = usePluginOverview(companyId);
|
|
const companies = usePluginData<CompanyRecord[]>("companies");
|
|
const projects = usePluginData<ProjectRecord[]>("projects", companyId ? { companyId } : {});
|
|
const issues = usePluginData<IssueRecord[]>("issues", companyId ? { companyId } : {});
|
|
const goals = usePluginData<GoalRecord[]>("goals", companyId ? { companyId } : {});
|
|
const agents = usePluginData<AgentRecord[]>("agents", companyId ? { companyId } : {});
|
|
|
|
const [issueTitle, setIssueTitle] = useState("Kitchen Sink demo issue");
|
|
const [goalTitle, setGoalTitle] = useState("Kitchen Sink demo goal");
|
|
const [stateScopeKind, setStateScopeKind] = useState("instance");
|
|
const [stateScopeId, setStateScopeId] = useState("");
|
|
const [stateNamespace, setStateNamespace] = useState("");
|
|
const [stateKey, setStateKey] = useState("demo");
|
|
const [stateValue, setStateValue] = useState("{\"hello\":\"world\"}");
|
|
const [entityType, setEntityType] = useState("demo-record");
|
|
const [entityTitle, setEntityTitle] = useState("Kitchen Sink Entity");
|
|
const [entityScopeKind, setEntityScopeKind] = useState("instance");
|
|
const [entityScopeId, setEntityScopeId] = useState("");
|
|
const [selectedProjectId, setSelectedProjectId] = useState("");
|
|
const [selectedIssueId, setSelectedIssueId] = useState("");
|
|
const [selectedGoalId, setSelectedGoalId] = useState("");
|
|
const [selectedAgentId, setSelectedAgentId] = useState("");
|
|
const [httpUrl, setHttpUrl] = useState<string>(DEFAULT_CONFIG.httpDemoUrl);
|
|
const [secretRef, setSecretRef] = useState("");
|
|
const [metricName, setMetricName] = useState("manual");
|
|
const [metricValue, setMetricValue] = useState("1");
|
|
const [assetContent, setAssetContent] = useState("Kitchen Sink asset demo");
|
|
const [workspaceId, setWorkspaceId] = useState("");
|
|
const [workspacePath, setWorkspacePath] = useState<string>(DEFAULT_CONFIG.workspaceScratchFile);
|
|
const [workspaceContent, setWorkspaceContent] = useState("Kitchen Sink wrote this file.");
|
|
const [commandKey, setCommandKey] = useState<string>(SAFE_COMMANDS[0]?.key ?? "pwd");
|
|
const [toolMessage, setToolMessage] = useState("Hello from the Kitchen Sink tool");
|
|
const [toolOutput, setToolOutput] = useState<unknown>(null);
|
|
const [jobOutput, setJobOutput] = useState<unknown>(null);
|
|
const [webhookOutput, setWebhookOutput] = useState<unknown>(null);
|
|
const [result, setResult] = useState<unknown>(null);
|
|
|
|
const stateQuery = usePluginData<StateValueData>("state-value", {
|
|
scopeKind: stateScopeKind,
|
|
scopeId: stateScopeId || undefined,
|
|
namespace: stateNamespace || undefined,
|
|
stateKey,
|
|
});
|
|
const entityQuery = usePluginData<EntityRecord[]>("entities", {
|
|
entityType,
|
|
scopeKind: entityScopeKind,
|
|
scopeId: entityScopeId || undefined,
|
|
limit: 25,
|
|
});
|
|
const workspaceQuery = usePluginData<Array<{ id: string; name: string; path: string }>>(
|
|
"workspaces",
|
|
companyId && selectedProjectId ? { companyId, projectId: selectedProjectId } : {},
|
|
);
|
|
const progressStream = usePluginStream<{ step: number; total: number; message: string }>(
|
|
STREAM_CHANNELS.progress,
|
|
companyId ? { companyId } : undefined,
|
|
);
|
|
const agentStream = usePluginStream<{ eventType: string; message: string | null }>(
|
|
STREAM_CHANNELS.agentChat,
|
|
companyId ? { companyId } : undefined,
|
|
);
|
|
|
|
const emitDemoEvent = usePluginAction("emit-demo-event");
|
|
const createIssue = usePluginAction("create-issue");
|
|
const advanceIssueStatus = usePluginAction("advance-issue-status");
|
|
const createGoal = usePluginAction("create-goal");
|
|
const advanceGoalStatus = usePluginAction("advance-goal-status");
|
|
const writeScopedState = usePluginAction("write-scoped-state");
|
|
const deleteScopedState = usePluginAction("delete-scoped-state");
|
|
const upsertEntity = usePluginAction("upsert-entity");
|
|
const writeActivity = usePluginAction("write-activity");
|
|
const writeMetric = usePluginAction("write-metric");
|
|
const httpFetch = usePluginAction("http-fetch");
|
|
const resolveSecret = usePluginAction("resolve-secret");
|
|
const createAsset = usePluginAction("create-asset");
|
|
const runProcess = usePluginAction("run-process");
|
|
const readWorkspaceFile = usePluginAction("read-workspace-file");
|
|
const writeWorkspaceScratch = usePluginAction("write-workspace-scratch");
|
|
const startProgressStream = usePluginAction("start-progress-stream");
|
|
const invokeAgent = usePluginAction("invoke-agent");
|
|
const pauseAgent = usePluginAction("pause-agent");
|
|
const resumeAgent = usePluginAction("resume-agent");
|
|
const askAgent = usePluginAction("ask-agent");
|
|
|
|
useEffect(() => {
|
|
if (!selectedProjectId && projects.data?.[0]?.id) setSelectedProjectId(projects.data[0].id);
|
|
}, [projects.data, selectedProjectId]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedIssueId && issues.data?.[0]?.id) setSelectedIssueId(issues.data[0].id);
|
|
}, [issues.data, selectedIssueId]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedGoalId && goals.data?.[0]?.id) setSelectedGoalId(goals.data[0].id);
|
|
}, [goals.data, selectedGoalId]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedAgentId && agents.data?.[0]?.id) setSelectedAgentId(agents.data[0].id);
|
|
}, [agents.data, selectedAgentId]);
|
|
|
|
useEffect(() => {
|
|
if (!workspaceId && workspaceQuery.data?.[0]?.id) setWorkspaceId(workspaceQuery.data[0].id);
|
|
}, [workspaceId, workspaceQuery.data]);
|
|
|
|
const projectRef = selectedProjectId || context.projectId || "";
|
|
|
|
async function refreshAll() {
|
|
overview.refresh();
|
|
projects.refresh();
|
|
issues.refresh();
|
|
goals.refresh();
|
|
agents.refresh();
|
|
stateQuery.refresh();
|
|
entityQuery.refresh();
|
|
workspaceQuery.refresh();
|
|
}
|
|
|
|
async function executeTool(name: string) {
|
|
if (!companyId || !selectedAgentId || !projectRef) {
|
|
setToolOutput({ error: "Select a company, project, and agent first." });
|
|
return;
|
|
}
|
|
try {
|
|
const toolName = `${PLUGIN_ID}:${name}`;
|
|
const body =
|
|
name === TOOL_NAMES.echo
|
|
? { message: toolMessage }
|
|
: name === TOOL_NAMES.createIssue
|
|
? { title: issueTitle, description: "Created through the tool dispatcher demo." }
|
|
: {};
|
|
const response = await hostFetchJson(`/api/plugins/tools/execute`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
tool: toolName,
|
|
parameters: body,
|
|
runContext: {
|
|
agentId: selectedAgentId,
|
|
runId: `kitchen-sink-${Date.now()}`,
|
|
companyId,
|
|
projectId: projectRef,
|
|
},
|
|
}),
|
|
});
|
|
setToolOutput(response);
|
|
await refreshAll();
|
|
} catch (error) {
|
|
setToolOutput({ error: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
}
|
|
|
|
async function fetchJobsAndTrigger() {
|
|
try {
|
|
const jobsResponse = await hostFetchJson<Array<{ id: string; jobKey: string }>>(`/api/plugins/${PLUGIN_ID}/jobs`);
|
|
const job = jobsResponse.find((entry) => entry.jobKey === JOB_KEYS.heartbeat) ?? jobsResponse[0];
|
|
if (!job) {
|
|
setJobOutput({ error: "No plugin jobs returned by the host." });
|
|
return;
|
|
}
|
|
const triggerResult = await hostFetchJson(`/api/plugins/${PLUGIN_ID}/jobs/${job.id}/trigger`, {
|
|
method: "POST",
|
|
});
|
|
setJobOutput({ jobs: jobsResponse, triggerResult });
|
|
overview.refresh();
|
|
} catch (error) {
|
|
setJobOutput({ error: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
}
|
|
|
|
async function sendWebhook() {
|
|
try {
|
|
const response = await hostFetchJson(`/api/plugins/${PLUGIN_ID}/webhooks/${WEBHOOK_KEYS.demo}`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
source: "kitchen-sink-ui",
|
|
sentAt: new Date().toISOString(),
|
|
}),
|
|
});
|
|
setWebhookOutput(response);
|
|
overview.refresh();
|
|
} catch (error) {
|
|
setWebhookOutput({ error: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div style={{ display: "grid", gap: "14px" }}>
|
|
<Section
|
|
title="Overview"
|
|
action={<button type="button" style={buttonStyle} onClick={() => refreshAll()}>Refresh</button>}
|
|
>
|
|
<div style={rowStyle}>
|
|
<Pill label={`Plugin: ${overview.data?.pluginId ?? PLUGIN_ID}`} />
|
|
<Pill label={`Version: ${overview.data?.version ?? "loading"}`} />
|
|
<Pill label={`Company: ${companyId ?? "none"}`} />
|
|
{context.entityType ? <Pill label={`Entity: ${context.entityType}`} /> : null}
|
|
</div>
|
|
{overview.data ? (
|
|
<>
|
|
<div style={{ display: "grid", gap: "8px", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))" }}>
|
|
<StatusLine label="Companies" value={overview.data.counts.companies} />
|
|
<StatusLine label="Projects" value={overview.data.counts.projects} />
|
|
<StatusLine label="Issues" value={overview.data.counts.issues} />
|
|
<StatusLine label="Goals" value={overview.data.counts.goals} />
|
|
<StatusLine label="Agents" value={overview.data.counts.agents} />
|
|
<StatusLine label="Entities" value={overview.data.counts.entities} />
|
|
</div>
|
|
<JsonBlock value={overview.data.config} />
|
|
</>
|
|
) : (
|
|
<div style={{ fontSize: "12px", opacity: 0.7 }}>Loading overview…</div>
|
|
)}
|
|
</Section>
|
|
|
|
<Section title="UI Surfaces">
|
|
<div style={rowStyle}>
|
|
<a href={hostPath(context.companyPrefix, `/plugins/${PLUGIN_ID}`)} style={{ fontSize: "12px" }}>Open plugin page</a>
|
|
{projectRef ? (
|
|
<a
|
|
href={hostPath(context.companyPrefix, `/projects/${projectRef}?tab=plugin:${PLUGIN_ID}:${SLOT_IDS.projectTab}`)}
|
|
style={{ fontSize: "12px" }}
|
|
>
|
|
Open project tab
|
|
</a>
|
|
) : null}
|
|
{selectedIssueId ? (
|
|
<a
|
|
href={hostPath(context.companyPrefix, `/issues/${selectedIssueId}`)}
|
|
style={{ fontSize: "12px" }}
|
|
>
|
|
Open selected issue
|
|
</a>
|
|
) : null}
|
|
</div>
|
|
<JsonBlock value={overview.data?.runtimeLaunchers ?? []} />
|
|
</Section>
|
|
|
|
<Section title="Paperclip Domain APIs">
|
|
<div style={{ display: "grid", gap: "12px", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))" }}>
|
|
<div style={subtleCardStyle}>
|
|
<strong>Companies</strong>
|
|
<MiniList
|
|
items={companies.data ?? []}
|
|
empty="No companies."
|
|
render={(item) => {
|
|
const company = item as CompanyRecord;
|
|
return <div>{company.name} <span style={{ opacity: 0.6 }}>({company.id.slice(0, 8)})</span></div>;
|
|
}}
|
|
/>
|
|
</div>
|
|
<div style={subtleCardStyle}>
|
|
<strong>Projects</strong>
|
|
<MiniList
|
|
items={projects.data ?? []}
|
|
empty="No projects."
|
|
render={(item) => {
|
|
const project = item as ProjectRecord;
|
|
return <div>{project.name} <span style={{ opacity: 0.6 }}>({project.status ?? "unknown"})</span></div>;
|
|
}}
|
|
/>
|
|
</div>
|
|
<div style={subtleCardStyle}>
|
|
<strong>Issues</strong>
|
|
<MiniList
|
|
items={issues.data ?? []}
|
|
empty="No issues."
|
|
render={(item) => {
|
|
const issue = item as IssueRecord;
|
|
return <div>{issue.title} <span style={{ opacity: 0.6 }}>({issue.status})</span></div>;
|
|
}}
|
|
/>
|
|
</div>
|
|
<div style={subtleCardStyle}>
|
|
<strong>Goals</strong>
|
|
<MiniList
|
|
items={goals.data ?? []}
|
|
empty="No goals."
|
|
render={(item) => {
|
|
const goal = item as GoalRecord;
|
|
return <div>{goal.title} <span style={{ opacity: 0.6 }}>({goal.status})</span></div>;
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
|
|
<Section title="Issue + Goal Actions">
|
|
<div style={{ display: "grid", gap: "10px", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))" }}>
|
|
<form
|
|
style={layoutStack}
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
if (!companyId) return;
|
|
void createIssue({ companyId, projectId: selectedProjectId || undefined, title: issueTitle })
|
|
.then((next) => {
|
|
setResult(next);
|
|
return refreshAll();
|
|
})
|
|
.catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) }));
|
|
}}
|
|
>
|
|
<strong>Create issue</strong>
|
|
<input style={inputStyle} value={issueTitle} onChange={(event) => setIssueTitle(event.target.value)} />
|
|
<button type="submit" style={primaryButtonStyle} disabled={!companyId}>Create issue</button>
|
|
</form>
|
|
<form
|
|
style={layoutStack}
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
if (!companyId || !selectedIssueId) return;
|
|
void advanceIssueStatus({ companyId, issueId: selectedIssueId, status: "in_review" })
|
|
.then((next) => {
|
|
setResult(next);
|
|
return refreshAll();
|
|
})
|
|
.catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) }));
|
|
}}
|
|
>
|
|
<strong>Advance selected issue</strong>
|
|
<select style={inputStyle} value={selectedIssueId} onChange={(event) => setSelectedIssueId(event.target.value)}>
|
|
{(issues.data ?? []).map((issue) => (
|
|
<option key={issue.id} value={issue.id}>{issue.title}</option>
|
|
))}
|
|
</select>
|
|
<button type="submit" style={buttonStyle} disabled={!companyId || !selectedIssueId}>Move to in_review</button>
|
|
</form>
|
|
<form
|
|
style={layoutStack}
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
if (!companyId) return;
|
|
void createGoal({ companyId, title: goalTitle })
|
|
.then((next) => {
|
|
setResult(next);
|
|
return refreshAll();
|
|
})
|
|
.catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) }));
|
|
}}
|
|
>
|
|
<strong>Create goal</strong>
|
|
<input style={inputStyle} value={goalTitle} onChange={(event) => setGoalTitle(event.target.value)} />
|
|
<button type="submit" style={primaryButtonStyle} disabled={!companyId}>Create goal</button>
|
|
</form>
|
|
<form
|
|
style={layoutStack}
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
if (!companyId || !selectedGoalId) return;
|
|
void advanceGoalStatus({ companyId, goalId: selectedGoalId, status: "active" })
|
|
.then((next) => {
|
|
setResult(next);
|
|
return refreshAll();
|
|
})
|
|
.catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) }));
|
|
}}
|
|
>
|
|
<strong>Advance selected goal</strong>
|
|
<select style={inputStyle} value={selectedGoalId} onChange={(event) => setSelectedGoalId(event.target.value)}>
|
|
{(goals.data ?? []).map((goal) => (
|
|
<option key={goal.id} value={goal.id}>{goal.title}</option>
|
|
))}
|
|
</select>
|
|
<button type="submit" style={buttonStyle} disabled={!companyId || !selectedGoalId}>Move to active</button>
|
|
</form>
|
|
</div>
|
|
</Section>
|
|
|
|
<Section title="State + Entities">
|
|
<div style={{ display: "grid", gap: "12px", gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))" }}>
|
|
<form
|
|
style={layoutStack}
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
void writeScopedState({
|
|
scopeKind: stateScopeKind,
|
|
scopeId: stateScopeId || undefined,
|
|
namespace: stateNamespace || undefined,
|
|
stateKey,
|
|
value: stateValue,
|
|
})
|
|
.then((next) => {
|
|
setResult(next);
|
|
stateQuery.refresh();
|
|
})
|
|
.catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) }));
|
|
}}
|
|
>
|
|
<strong>State</strong>
|
|
<input style={inputStyle} value={stateScopeKind} onChange={(event) => setStateScopeKind(event.target.value)} placeholder="scopeKind" />
|
|
<input style={inputStyle} value={stateScopeId} onChange={(event) => setStateScopeId(event.target.value)} placeholder="scopeId (optional)" />
|
|
<input style={inputStyle} value={stateNamespace} onChange={(event) => setStateNamespace(event.target.value)} placeholder="namespace (optional)" />
|
|
<input style={inputStyle} value={stateKey} onChange={(event) => setStateKey(event.target.value)} placeholder="stateKey" />
|
|
<textarea style={{ ...inputStyle, minHeight: "88px" }} value={stateValue} onChange={(event) => setStateValue(event.target.value)} />
|
|
<div style={rowStyle}>
|
|
<button type="submit" style={primaryButtonStyle}>Write state</button>
|
|
<button
|
|
type="button"
|
|
style={buttonStyle}
|
|
onClick={() => {
|
|
void deleteScopedState({
|
|
scopeKind: stateScopeKind,
|
|
scopeId: stateScopeId || undefined,
|
|
namespace: stateNamespace || undefined,
|
|
stateKey,
|
|
})
|
|
.then((next) => {
|
|
setResult(next);
|
|
stateQuery.refresh();
|
|
})
|
|
.catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) }));
|
|
}}
|
|
>
|
|
Delete state
|
|
</button>
|
|
</div>
|
|
<JsonBlock value={stateQuery.data ?? { loading: true }} />
|
|
</form>
|
|
<form
|
|
style={layoutStack}
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
void upsertEntity({
|
|
entityType,
|
|
title: entityTitle,
|
|
scopeKind: entityScopeKind,
|
|
scopeId: entityScopeId || undefined,
|
|
data: JSON.stringify({ createdAt: new Date().toISOString() }),
|
|
})
|
|
.then((next) => {
|
|
setResult(next);
|
|
entityQuery.refresh();
|
|
overview.refresh();
|
|
})
|
|
.catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) }));
|
|
}}
|
|
>
|
|
<strong>Entities</strong>
|
|
<input style={inputStyle} value={entityType} onChange={(event) => setEntityType(event.target.value)} placeholder="entityType" />
|
|
<input style={inputStyle} value={entityTitle} onChange={(event) => setEntityTitle(event.target.value)} placeholder="title" />
|
|
<input style={inputStyle} value={entityScopeKind} onChange={(event) => setEntityScopeKind(event.target.value)} placeholder="scopeKind" />
|
|
<input style={inputStyle} value={entityScopeId} onChange={(event) => setEntityScopeId(event.target.value)} placeholder="scopeId (optional)" />
|
|
<button type="submit" style={primaryButtonStyle}>Upsert entity</button>
|
|
<JsonBlock value={entityQuery.data ?? []} />
|
|
</form>
|
|
</div>
|
|
</Section>
|
|
|
|
<Section title="Events + Streams">
|
|
<div style={rowStyle}>
|
|
<button
|
|
type="button"
|
|
style={primaryButtonStyle}
|
|
onClick={() => {
|
|
if (!companyId) return;
|
|
void emitDemoEvent({ companyId, message: "Kitchen Sink manual event" })
|
|
.then((next) => {
|
|
setResult(next);
|
|
overview.refresh();
|
|
})
|
|
.catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) }));
|
|
}}
|
|
>
|
|
Emit demo event
|
|
</button>
|
|
<button
|
|
type="button"
|
|
style={buttonStyle}
|
|
onClick={() => {
|
|
if (!companyId) return;
|
|
void startProgressStream({ companyId, steps: 5 })
|
|
.then((next) => setResult(next))
|
|
.catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) }));
|
|
}}
|
|
>
|
|
Start progress stream
|
|
</button>
|
|
</div>
|
|
<div style={{ display: "grid", gap: "12px", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))" }}>
|
|
<div style={subtleCardStyle}>
|
|
<strong>Progress stream</strong>
|
|
<JsonBlock value={progressStream.events.slice(-8)} />
|
|
</div>
|
|
<div style={subtleCardStyle}>
|
|
<strong>Recent records</strong>
|
|
<JsonBlock value={overview.data?.recentRecords ?? []} />
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
|
|
<Section title="HTTP + Secrets + Assets + Activity + Metrics">
|
|
<div style={{ display: "grid", gap: "12px", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))" }}>
|
|
<form
|
|
style={layoutStack}
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
void httpFetch({ url: httpUrl })
|
|
.then((next) => setResult(next))
|
|
.catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) }));
|
|
}}
|
|
>
|
|
<strong>HTTP</strong>
|
|
<input style={inputStyle} value={httpUrl} onChange={(event) => setHttpUrl(event.target.value)} />
|
|
<button type="submit" style={buttonStyle}>Fetch URL</button>
|
|
</form>
|
|
<form
|
|
style={layoutStack}
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
void resolveSecret({ secretRef })
|
|
.then((next) => setResult(next))
|
|
.catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) }));
|
|
}}
|
|
>
|
|
<strong>Secrets</strong>
|
|
<input style={inputStyle} value={secretRef} onChange={(event) => setSecretRef(event.target.value)} placeholder="MY_SECRET_REF" />
|
|
<button type="submit" style={buttonStyle}>Resolve secret ref</button>
|
|
</form>
|
|
<form
|
|
style={layoutStack}
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
void createAsset({ filename: "kitchen-sink-demo.txt", content: assetContent })
|
|
.then((next) => {
|
|
setResult(next);
|
|
overview.refresh();
|
|
})
|
|
.catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) }));
|
|
}}
|
|
>
|
|
<strong>Assets</strong>
|
|
<textarea style={{ ...inputStyle, minHeight: "88px" }} value={assetContent} onChange={(event) => setAssetContent(event.target.value)} />
|
|
<button type="submit" style={buttonStyle}>Upload text asset</button>
|
|
</form>
|
|
<form
|
|
style={layoutStack}
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
if (!companyId) return;
|
|
void writeActivity({ companyId, entityType: context.entityType ?? undefined, entityId: context.entityId ?? undefined })
|
|
.then((next) => setResult(next))
|
|
.catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) }));
|
|
}}
|
|
>
|
|
<strong>Activity + Metrics</strong>
|
|
<input style={inputStyle} value={metricName} onChange={(event) => setMetricName(event.target.value)} placeholder="metric name" />
|
|
<input style={inputStyle} value={metricValue} onChange={(event) => setMetricValue(event.target.value)} placeholder="metric value" />
|
|
<div style={rowStyle}>
|
|
<button
|
|
type="button"
|
|
style={buttonStyle}
|
|
onClick={() => {
|
|
if (!companyId) return;
|
|
void writeMetric({ companyId, name: metricName, value: Number(metricValue || "1") })
|
|
.then((next) => setResult(next))
|
|
.catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) }));
|
|
}}
|
|
>
|
|
Write metric
|
|
</button>
|
|
<button type="submit" style={buttonStyle} disabled={!companyId}>Write activity</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</Section>
|
|
|
|
<Section title="Workspace + Process">
|
|
<div style={{ display: "grid", gap: "10px", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))" }}>
|
|
<div style={layoutStack}>
|
|
<strong>Select project/workspace</strong>
|
|
<select style={inputStyle} value={selectedProjectId} onChange={(event) => setSelectedProjectId(event.target.value)}>
|
|
<option value="">Select project</option>
|
|
{(projects.data ?? []).map((project) => (
|
|
<option key={project.id} value={project.id}>{project.name}</option>
|
|
))}
|
|
</select>
|
|
<select style={inputStyle} value={workspaceId} onChange={(event) => setWorkspaceId(event.target.value)}>
|
|
<option value="">Select workspace</option>
|
|
{(workspaceQuery.data ?? []).map((workspace) => (
|
|
<option key={workspace.id} value={workspace.id}>{workspace.name}</option>
|
|
))}
|
|
</select>
|
|
<JsonBlock value={workspaceQuery.data ?? []} />
|
|
</div>
|
|
<form
|
|
style={layoutStack}
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
if (!companyId || !selectedProjectId) return;
|
|
void writeWorkspaceScratch({
|
|
companyId,
|
|
projectId: selectedProjectId,
|
|
workspaceId: workspaceId || undefined,
|
|
relativePath: workspacePath,
|
|
content: workspaceContent,
|
|
})
|
|
.then((next) => setResult(next))
|
|
.catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) }));
|
|
}}
|
|
>
|
|
<strong>Workspace file</strong>
|
|
<input style={inputStyle} value={workspacePath} onChange={(event) => setWorkspacePath(event.target.value)} />
|
|
<textarea style={{ ...inputStyle, minHeight: "88px" }} value={workspaceContent} onChange={(event) => setWorkspaceContent(event.target.value)} />
|
|
<div style={rowStyle}>
|
|
<button type="submit" style={buttonStyle} disabled={!companyId || !selectedProjectId}>Write scratch file</button>
|
|
<button
|
|
type="button"
|
|
style={buttonStyle}
|
|
onClick={() => {
|
|
if (!companyId || !selectedProjectId) return;
|
|
void readWorkspaceFile({
|
|
companyId,
|
|
projectId: selectedProjectId,
|
|
workspaceId: workspaceId || undefined,
|
|
relativePath: workspacePath,
|
|
})
|
|
.then((next) => setResult(next))
|
|
.catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) }));
|
|
}}
|
|
>
|
|
Read file
|
|
</button>
|
|
</div>
|
|
</form>
|
|
<form
|
|
style={layoutStack}
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
if (!companyId || !selectedProjectId) return;
|
|
void runProcess({
|
|
companyId,
|
|
projectId: selectedProjectId,
|
|
workspaceId: workspaceId || undefined,
|
|
commandKey,
|
|
})
|
|
.then((next) => {
|
|
setResult(next);
|
|
overview.refresh();
|
|
})
|
|
.catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) }));
|
|
}}
|
|
>
|
|
<strong>Curated process demo</strong>
|
|
<select style={inputStyle} value={commandKey} onChange={(event) => setCommandKey(event.target.value)}>
|
|
{SAFE_COMMANDS.map((command) => (
|
|
<option key={command.key} value={command.key}>{command.label}</option>
|
|
))}
|
|
</select>
|
|
<button type="submit" style={buttonStyle} disabled={!companyId || !selectedProjectId}>Run command</button>
|
|
<JsonBlock value={overview.data?.lastProcessResult ?? { note: "No process run yet." }} />
|
|
</form>
|
|
</div>
|
|
</Section>
|
|
|
|
<Section title="Agents + Sessions">
|
|
<div style={{ display: "grid", gap: "12px", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))" }}>
|
|
<form
|
|
style={layoutStack}
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
if (!companyId || !selectedAgentId) return;
|
|
void invokeAgent({ companyId, agentId: selectedAgentId, prompt: "Kitchen Sink invoke demo" })
|
|
.then((next) => setResult(next))
|
|
.catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) }));
|
|
}}
|
|
>
|
|
<strong>Agent controls</strong>
|
|
<select style={inputStyle} value={selectedAgentId} onChange={(event) => setSelectedAgentId(event.target.value)}>
|
|
{(agents.data ?? []).map((agent) => (
|
|
<option key={agent.id} value={agent.id}>{agent.name}</option>
|
|
))}
|
|
</select>
|
|
<div style={rowStyle}>
|
|
<button type="submit" style={primaryButtonStyle} disabled={!companyId || !selectedAgentId}>Invoke</button>
|
|
<button
|
|
type="button"
|
|
style={buttonStyle}
|
|
onClick={() => {
|
|
if (!companyId || !selectedAgentId) return;
|
|
void pauseAgent({ companyId, agentId: selectedAgentId })
|
|
.then((next) => {
|
|
setResult(next);
|
|
agents.refresh();
|
|
})
|
|
.catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) }));
|
|
}}
|
|
>
|
|
Pause
|
|
</button>
|
|
<button
|
|
type="button"
|
|
style={buttonStyle}
|
|
onClick={() => {
|
|
if (!companyId || !selectedAgentId) return;
|
|
void resumeAgent({ companyId, agentId: selectedAgentId })
|
|
.then((next) => {
|
|
setResult(next);
|
|
agents.refresh();
|
|
})
|
|
.catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) }));
|
|
}}
|
|
>
|
|
Resume
|
|
</button>
|
|
</div>
|
|
</form>
|
|
<form
|
|
style={layoutStack}
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
if (!companyId || !selectedAgentId) return;
|
|
void askAgent({ companyId, agentId: selectedAgentId, prompt: "Give a short greeting from the Kitchen Sink plugin." })
|
|
.then((next) => setResult(next))
|
|
.catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) }));
|
|
}}
|
|
>
|
|
<strong>Agent chat stream</strong>
|
|
<button type="submit" style={buttonStyle} disabled={!companyId || !selectedAgentId}>Start chat demo</button>
|
|
<JsonBlock value={agentStream.events.slice(-12)} />
|
|
</form>
|
|
</div>
|
|
</Section>
|
|
|
|
<Section title="Jobs + Webhooks + Tools">
|
|
<div style={{ display: "grid", gap: "12px", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))" }}>
|
|
<div style={layoutStack}>
|
|
<strong>Job demo</strong>
|
|
<button type="button" style={buttonStyle} onClick={() => void fetchJobsAndTrigger()}>Trigger demo job</button>
|
|
<JsonBlock value={jobOutput ?? overview.data?.lastJob ?? { note: "No job output yet." }} />
|
|
</div>
|
|
<div style={layoutStack}>
|
|
<strong>Webhook demo</strong>
|
|
<button type="button" style={buttonStyle} onClick={() => void sendWebhook()}>Send demo webhook</button>
|
|
<JsonBlock value={webhookOutput ?? overview.data?.lastWebhook ?? { note: "No webhook yet." }} />
|
|
</div>
|
|
<div style={layoutStack}>
|
|
<strong>Tool dispatcher demo</strong>
|
|
<input style={inputStyle} value={toolMessage} onChange={(event) => setToolMessage(event.target.value)} />
|
|
<div style={rowStyle}>
|
|
<button type="button" style={buttonStyle} onClick={() => void executeTool(TOOL_NAMES.echo)}>Run echo tool</button>
|
|
<button type="button" style={buttonStyle} onClick={() => void executeTool(TOOL_NAMES.companySummary)}>Run summary tool</button>
|
|
<button type="button" style={buttonStyle} onClick={() => void executeTool(TOOL_NAMES.createIssue)}>Run create-issue tool</button>
|
|
</div>
|
|
<JsonBlock value={toolOutput ?? { note: "No tool output yet." }} />
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
|
|
<Section title="Latest Result">
|
|
<JsonBlock value={result ?? { note: "Run an action to see results here." }} />
|
|
</Section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function KitchenSinkPage({ context }: PluginPageProps) {
|
|
return (
|
|
<div style={layoutStack}>
|
|
<Section title="Kitchen Sink Plugin Page">
|
|
<div style={{ fontSize: "13px", lineHeight: 1.5 }}>
|
|
This page is the primary demo console for the Kitchen Sink example plugin. It is intentionally broad and exposes the current Paperclip plugin surface area in one place.
|
|
</div>
|
|
</Section>
|
|
<KitchenSinkPageWidgets context={context} />
|
|
<KitchenSinkConsole context={context} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function KitchenSinkSettingsPage({ context }: PluginSettingsPageProps) {
|
|
const { configJson, setConfigJson, loading, saving, error, save } = useSettingsConfig();
|
|
const [savedMessage, setSavedMessage] = useState<string | null>(null);
|
|
|
|
function setField(key: string, value: unknown) {
|
|
setConfigJson((current) => ({ ...current, [key]: value }));
|
|
}
|
|
|
|
async function onSubmit(event: FormEvent) {
|
|
event.preventDefault();
|
|
await save(configJson);
|
|
setSavedMessage("Saved");
|
|
window.setTimeout(() => setSavedMessage(null), 1500);
|
|
}
|
|
|
|
if (loading) {
|
|
return <div style={{ fontSize: "12px", opacity: 0.7 }}>Loading plugin config…</div>;
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={onSubmit} style={{ display: "grid", gap: "18px" }}>
|
|
<div style={{ display: "grid", gap: "12px", gridTemplateColumns: "minmax(0, 1.8fr) minmax(220px, 1fr)" }}>
|
|
<div style={{ display: "grid", gap: "8px" }}>
|
|
<strong>About</strong>
|
|
<div style={{ fontSize: "13px", lineHeight: 1.5 }}>
|
|
Kitchen Sink demonstrates the current Paperclip plugin API surface in one local, trusted example. It intentionally includes domain mutations, event handling, streams, tools, jobs, webhooks, and local workspace/process demos.
|
|
</div>
|
|
<div style={{ fontSize: "12px", opacity: 0.7 }}>
|
|
Current company context: {context.companyId ?? "none"}
|
|
</div>
|
|
</div>
|
|
<div style={{ display: "grid", gap: "8px" }}>
|
|
<strong>Danger / Trust Model</strong>
|
|
<div style={{ fontSize: "12px", lineHeight: 1.5 }}>
|
|
Workspace and process demos run as trusted local code. Keep process demos off unless you explicitly want to exercise local child process behavior.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: "grid", gap: "12px" }}>
|
|
<strong>Settings</strong>
|
|
<label style={rowStyle}>
|
|
<input
|
|
type="checkbox"
|
|
checked={configJson.showSidebarEntry !== false}
|
|
onChange={(event) => setField("showSidebarEntry", event.target.checked)}
|
|
/>
|
|
<span>Show sidebar entry</span>
|
|
</label>
|
|
<label style={rowStyle}>
|
|
<input
|
|
type="checkbox"
|
|
checked={configJson.showSidebarPanel !== false}
|
|
onChange={(event) => setField("showSidebarPanel", event.target.checked)}
|
|
/>
|
|
<span>Show sidebar panel</span>
|
|
</label>
|
|
<label style={rowStyle}>
|
|
<input
|
|
type="checkbox"
|
|
checked={configJson.showProjectSidebarItem !== false}
|
|
onChange={(event) => setField("showProjectSidebarItem", event.target.checked)}
|
|
/>
|
|
<span>Show project sidebar item</span>
|
|
</label>
|
|
<label style={rowStyle}>
|
|
<input
|
|
type="checkbox"
|
|
checked={configJson.showCommentAnnotation !== false}
|
|
onChange={(event) => setField("showCommentAnnotation", event.target.checked)}
|
|
/>
|
|
<span>Show comment annotation</span>
|
|
</label>
|
|
<label style={rowStyle}>
|
|
<input
|
|
type="checkbox"
|
|
checked={configJson.showCommentContextMenuItem !== false}
|
|
onChange={(event) => setField("showCommentContextMenuItem", event.target.checked)}
|
|
/>
|
|
<span>Show comment context action</span>
|
|
</label>
|
|
<label style={rowStyle}>
|
|
<input
|
|
type="checkbox"
|
|
checked={configJson.enableWorkspaceDemos !== false}
|
|
onChange={(event) => setField("enableWorkspaceDemos", event.target.checked)}
|
|
/>
|
|
<span>Enable workspace demos</span>
|
|
</label>
|
|
<label style={rowStyle}>
|
|
<input
|
|
type="checkbox"
|
|
checked={configJson.enableProcessDemos === true}
|
|
onChange={(event) => setField("enableProcessDemos", event.target.checked)}
|
|
/>
|
|
<span>Enable curated process demos</span>
|
|
</label>
|
|
<label style={{ display: "grid", gap: "6px" }}>
|
|
<span style={{ fontSize: "12px" }}>HTTP demo URL</span>
|
|
<input
|
|
style={inputStyle}
|
|
value={String(configJson.httpDemoUrl ?? DEFAULT_CONFIG.httpDemoUrl)}
|
|
onChange={(event) => setField("httpDemoUrl", event.target.value)}
|
|
/>
|
|
</label>
|
|
<label style={{ display: "grid", gap: "6px" }}>
|
|
<span style={{ fontSize: "12px" }}>Secret reference example</span>
|
|
<input
|
|
style={inputStyle}
|
|
value={String(configJson.secretRefExample ?? "")}
|
|
onChange={(event) => setField("secretRefExample", event.target.value)}
|
|
/>
|
|
</label>
|
|
<label style={{ display: "grid", gap: "6px" }}>
|
|
<span style={{ fontSize: "12px" }}>Workspace scratch file</span>
|
|
<input
|
|
style={inputStyle}
|
|
value={String(configJson.workspaceScratchFile ?? DEFAULT_CONFIG.workspaceScratchFile)}
|
|
onChange={(event) => setField("workspaceScratchFile", event.target.value)}
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
{error ? <div style={{ color: "var(--destructive, #c00)", fontSize: "12px" }}>{error}</div> : null}
|
|
|
|
<div style={rowStyle}>
|
|
<button type="submit" style={primaryButtonStyle} disabled={saving}>
|
|
{saving ? "Saving…" : "Save settings"}
|
|
</button>
|
|
{savedMessage ? <span style={{ fontSize: "12px", opacity: 0.7 }}>{savedMessage}</span> : null}
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export function KitchenSinkDashboardWidget({ context }: PluginWidgetProps) {
|
|
const overview = usePluginOverview(context.companyId);
|
|
const writeMetric = usePluginAction("write-metric");
|
|
|
|
return (
|
|
<div style={layoutStack}>
|
|
<div style={rowStyle}>
|
|
<strong>Kitchen Sink</strong>
|
|
<Pill label="dashboardWidget" />
|
|
</div>
|
|
<div style={{ fontSize: "12px", opacity: 0.7 }}>
|
|
Plugin runtime surface demo for the current company.
|
|
</div>
|
|
<div style={{ display: "grid", gap: "4px", fontSize: "12px" }}>
|
|
<div>Recent records: {overview.data?.recentRecords.length ?? 0}</div>
|
|
<div>Projects: {overview.data?.counts.projects ?? 0}</div>
|
|
<div>Issues: {overview.data?.counts.issues ?? 0}</div>
|
|
</div>
|
|
<div style={rowStyle}>
|
|
<a href={hostPath(context.companyPrefix, `/plugins/${PLUGIN_ID}`)} style={{ fontSize: "12px" }}>Open page</a>
|
|
<button
|
|
type="button"
|
|
style={buttonStyle}
|
|
onClick={() => {
|
|
if (!context.companyId) return;
|
|
void writeMetric({ companyId: context.companyId, name: "dashboard_click", value: 1 }).catch(console.error);
|
|
}}
|
|
>
|
|
Write metric
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function KitchenSinkSidebarLink({ context }: PluginSidebarProps) {
|
|
const config = usePluginConfigData();
|
|
if (config.data && config.data.showSidebarEntry === false) return null;
|
|
const href = hostPath(context.companyPrefix, `/plugins/${PLUGIN_ID}`);
|
|
const isActive = typeof window !== "undefined" && window.location.pathname === href;
|
|
return (
|
|
<a
|
|
href={href}
|
|
aria-current={isActive ? "page" : undefined}
|
|
className={[
|
|
"flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors",
|
|
isActive
|
|
? "bg-accent text-foreground"
|
|
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
|
|
].join(" ")}
|
|
>
|
|
<span className="relative shrink-0">
|
|
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<rect x="4" y="4" width="7" height="7" rx="1.5" />
|
|
<rect x="13" y="4" width="7" height="7" rx="1.5" />
|
|
<rect x="4" y="13" width="7" height="7" rx="1.5" />
|
|
<path d="M13 16.5h7" />
|
|
<path d="M16.5 13v7" />
|
|
</svg>
|
|
</span>
|
|
<span className="flex-1 truncate">
|
|
Kitchen Sink
|
|
</span>
|
|
</a>
|
|
);
|
|
}
|
|
|
|
export function KitchenSinkSidebarPanel() {
|
|
const context = useHostContext();
|
|
const config = usePluginConfigData();
|
|
const overview = usePluginOverview(context.companyId);
|
|
if (config.data && config.data.showSidebarPanel === false) return null;
|
|
return (
|
|
<div style={{ ...layoutStack, ...subtleCardStyle, fontSize: "12px" }}>
|
|
<strong>Kitchen Sink Panel</strong>
|
|
<div>Recent plugin records: {overview.data?.recentRecords.length ?? 0}</div>
|
|
<a href={hostPath(context.companyPrefix, `/plugins/${PLUGIN_ID}`)}>Open plugin page</a>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function KitchenSinkProjectSidebarItem({ context }: PluginProjectSidebarItemProps) {
|
|
const config = usePluginConfigData();
|
|
if (config.data && config.data.showProjectSidebarItem === false) return null;
|
|
return (
|
|
<a
|
|
href={hostPath(context.companyPrefix, `/projects/${context.entityId}?tab=plugin:${PLUGIN_ID}:${SLOT_IDS.projectTab}`)}
|
|
style={{ fontSize: "12px", textDecoration: "none" }}
|
|
>
|
|
Kitchen Sink
|
|
</a>
|
|
);
|
|
}
|
|
|
|
export function KitchenSinkProjectTab({ context }: PluginDetailTabProps) {
|
|
return <CompactSurfaceSummary label="Project Detail Tab" entityType="project" />;
|
|
}
|
|
|
|
export function KitchenSinkIssueTab({ context }: PluginDetailTabProps) {
|
|
return <CompactSurfaceSummary label="Issue Detail Tab" entityType="issue" />;
|
|
}
|
|
|
|
export function KitchenSinkTaskDetailView() {
|
|
return <CompactSurfaceSummary label="Task Detail View" entityType="issue" />;
|
|
}
|
|
|
|
export function KitchenSinkToolbarButton() {
|
|
const context = useHostContext();
|
|
const startProgress = usePluginAction("start-progress-stream");
|
|
return (
|
|
<button
|
|
type="button"
|
|
style={buttonStyle}
|
|
onClick={() => {
|
|
if (!context.companyId) return;
|
|
void startProgress({ companyId: context.companyId, steps: 3 }).catch(console.error);
|
|
}}
|
|
>
|
|
Kitchen Sink Action
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function KitchenSinkContextMenuItem() {
|
|
const context = useHostContext();
|
|
const writeActivity = usePluginAction("write-activity");
|
|
return (
|
|
<button
|
|
type="button"
|
|
style={buttonStyle}
|
|
onClick={() => {
|
|
if (!context.companyId) return;
|
|
void writeActivity({
|
|
companyId: context.companyId,
|
|
entityType: context.entityType ?? undefined,
|
|
entityId: context.entityId ?? undefined,
|
|
message: "Kitchen Sink context action clicked",
|
|
}).catch(console.error);
|
|
}}
|
|
>
|
|
Kitchen Sink Context
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function KitchenSinkCommentAnnotation({ context }: PluginCommentAnnotationProps) {
|
|
const config = usePluginConfigData();
|
|
const data = usePluginData<CommentContextData>(
|
|
"comment-context",
|
|
context.companyId
|
|
? { companyId: context.companyId, issueId: context.parentEntityId, commentId: context.entityId }
|
|
: {},
|
|
);
|
|
if (config.data && config.data.showCommentAnnotation === false) return null;
|
|
if (!data.data) return null;
|
|
return (
|
|
<div style={{ ...subtleCardStyle, fontSize: "11px" }}>
|
|
<strong>Kitchen Sink</strong>
|
|
<div>Comment length: {data.data.length}</div>
|
|
<div>Copied count: {data.data.copiedCount}</div>
|
|
<div style={{ opacity: 0.75 }}>{data.data.preview}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function KitchenSinkCommentContextMenuItem({ context }: PluginCommentContextMenuItemProps) {
|
|
const config = usePluginConfigData();
|
|
const copyCommentContext = usePluginAction("copy-comment-context");
|
|
const [status, setStatus] = useState<string | null>(null);
|
|
if (config.data && config.data.showCommentContextMenuItem === false) return null;
|
|
return (
|
|
<div style={rowStyle}>
|
|
<button
|
|
type="button"
|
|
style={buttonStyle}
|
|
onClick={() => {
|
|
if (!context.companyId) return;
|
|
void copyCommentContext({
|
|
companyId: context.companyId,
|
|
issueId: context.parentEntityId,
|
|
commentId: context.entityId,
|
|
})
|
|
.then(() => setStatus("Copied"))
|
|
.catch((error) => setStatus(error instanceof Error ? error.message : String(error)));
|
|
}}
|
|
>
|
|
Copy To Kitchen Sink
|
|
</button>
|
|
{status ? <span style={{ fontSize: "11px", opacity: 0.7 }}>{status}</span> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function KitchenSinkLauncherModal() {
|
|
const context = useHostContext();
|
|
return (
|
|
<div style={{ display: "grid", gap: "10px" }}>
|
|
<strong>Kitchen Sink Launcher Modal</strong>
|
|
<div style={{ fontSize: "12px", opacity: 0.7 }}>
|
|
This export exists so launcher infrastructure has a concrete modal target.
|
|
</div>
|
|
<JsonBlock value={context.renderEnvironment ?? { note: "No render environment metadata." }} />
|
|
</div>
|
|
);
|
|
}
|