2406 lines
90 KiB
TypeScript
2406 lines
90 KiB
TypeScript
import { useEffect, useMemo, useState, type CSSProperties, type FormEvent, type ReactNode } from "react";
|
|
import {
|
|
useHostContext,
|
|
usePluginAction,
|
|
usePluginData,
|
|
usePluginStream,
|
|
usePluginToast,
|
|
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,
|
|
PAGE_ROUTE,
|
|
PLUGIN_ID,
|
|
SAFE_COMMANDS,
|
|
SLOT_IDS,
|
|
STREAM_CHANNELS,
|
|
TOOL_NAMES,
|
|
WEBHOOK_KEYS,
|
|
} from "../constants.js";
|
|
import { AsciiArtAnimation } from "./AsciiArtAnimation.js";
|
|
|
|
type CompanyRecord = { id: string; name: string; issuePrefix?: string | null; status?: 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 HostIssueRecord = {
|
|
id: string;
|
|
title: string;
|
|
status: string;
|
|
priority?: string | null;
|
|
createdAt?: string;
|
|
};
|
|
type HostHeartbeatRunRecord = {
|
|
id: string;
|
|
status: string;
|
|
invocationSource?: string | null;
|
|
triggerDetail?: string | null;
|
|
createdAt?: string;
|
|
startedAt?: string | null;
|
|
finishedAt?: string | null;
|
|
agentId?: string | null;
|
|
};
|
|
type HostLiveRunRecord = HostHeartbeatRunRecord & {
|
|
agentName?: string | null;
|
|
issueId?: string | null;
|
|
};
|
|
|
|
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;
|
|
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)",
|
|
};
|
|
|
|
function toneButtonStyle(tone: "success" | "warn" | "info"): CSSProperties {
|
|
if (tone === "success") {
|
|
return {
|
|
...buttonStyle,
|
|
background: "color-mix(in srgb, #16a34a 18%, transparent)",
|
|
borderColor: "color-mix(in srgb, #16a34a 60%, var(--border))",
|
|
color: "#86efac",
|
|
};
|
|
}
|
|
if (tone === "warn") {
|
|
return {
|
|
...buttonStyle,
|
|
background: "color-mix(in srgb, #d97706 18%, transparent)",
|
|
borderColor: "color-mix(in srgb, #d97706 60%, var(--border))",
|
|
color: "#fcd34d",
|
|
};
|
|
}
|
|
return {
|
|
...buttonStyle,
|
|
background: "color-mix(in srgb, #2563eb 18%, transparent)",
|
|
borderColor: "color-mix(in srgb, #2563eb 60%, var(--border))",
|
|
color: "#93c5fd",
|
|
};
|
|
}
|
|
|
|
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 pluginPagePath(companyPrefix: string | null | undefined): string {
|
|
return hostPath(companyPrefix, `/${PAGE_ROUTE}`);
|
|
}
|
|
|
|
function getErrorMessage(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
function getObjectString(value: unknown, key: string): string | null {
|
|
if (!value || typeof value !== "object") return null;
|
|
const next = (value as Record<string, unknown>)[key];
|
|
return typeof next === "string" ? next : null;
|
|
}
|
|
|
|
function getObjectNumber(value: unknown, key: string): number | null {
|
|
if (!value || typeof value !== "object") return null;
|
|
const next = (value as Record<string, unknown>)[key];
|
|
return typeof next === "number" && Number.isFinite(next) ? next : null;
|
|
}
|
|
|
|
function isKitchenSinkDemoCompany(company: CompanyRecord): boolean {
|
|
return company.name.startsWith("Kitchen Sink Demo");
|
|
}
|
|
|
|
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 PaginatedDomainCard({
|
|
title,
|
|
items,
|
|
totalCount,
|
|
empty,
|
|
onLoadMore,
|
|
render,
|
|
}: {
|
|
title: string;
|
|
items: unknown[];
|
|
totalCount: number | null;
|
|
empty: string;
|
|
onLoadMore: () => void;
|
|
render: (item: unknown, index: number) => ReactNode;
|
|
}) {
|
|
const hasMore = totalCount !== null ? items.length < totalCount : false;
|
|
|
|
return (
|
|
<div style={subtleCardStyle}>
|
|
<div style={sectionHeaderStyle}>
|
|
<strong>{title}</strong>
|
|
{totalCount !== null ? <span style={mutedTextStyle}>{items.length} / {totalCount}</span> : null}
|
|
</div>
|
|
<MiniList items={items} empty={empty} render={render} />
|
|
{hasMore ? (
|
|
<div style={{ marginTop: "10px" }}>
|
|
<button type="button" style={buttonStyle} onClick={onLoadMore}>
|
|
Load 20 more
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</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>
|
|
<div style={mutedTextStyle}>
|
|
This surface demo shows the host context for the current mount point. The metric button records a demo counter so you can verify plugin metrics wiring from a contextual surface.
|
|
</div>
|
|
<JsonBlock value={context} />
|
|
<button
|
|
type="button"
|
|
style={buttonStyle}
|
|
onClick={() => {
|
|
if (!companyId) return;
|
|
void writeMetric({ name: "surface_click", value: 1, companyId }).catch(console.error);
|
|
}}
|
|
>
|
|
Record demo metric
|
|
</button>
|
|
{entityQuery.data ? <JsonBlock value={entityQuery.data} /> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function KitchenSinkPageWidgets({ context }: { context: PluginPageProps["context"] }) {
|
|
const overview = usePluginOverview(context.companyId);
|
|
const toast = usePluginToast();
|
|
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 [quickActionStatus, setQuickActionStatus] = useState<{
|
|
title: string;
|
|
body: string;
|
|
tone: "info" | "success" | "warn" | "error";
|
|
} | null>(null);
|
|
|
|
useEffect(() => {
|
|
const latest = progressStream.events.at(-1);
|
|
if (!latest) return;
|
|
setQuickActionStatus({
|
|
title: "Progress stream update",
|
|
body: latest.message ?? `Step ${latest.step ?? "?"}`,
|
|
tone: "info",
|
|
});
|
|
}, [progressStream.events]);
|
|
|
|
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={toneButtonStyle("success")}
|
|
onClick={() =>
|
|
toast({
|
|
title: "Kitchen Sink success toast",
|
|
body: "This is rendered by the host toast system from plugin UI.",
|
|
tone: "success",
|
|
})}
|
|
>
|
|
Success toast
|
|
</button>
|
|
<button
|
|
type="button"
|
|
style={toneButtonStyle("warn")}
|
|
onClick={() =>
|
|
toast({
|
|
title: "Kitchen Sink warning toast",
|
|
body: "Use this pattern for user-facing plugin feedback.",
|
|
tone: "warn",
|
|
})}
|
|
>
|
|
Warning toast
|
|
</button>
|
|
<button
|
|
type="button"
|
|
style={toneButtonStyle("info")}
|
|
onClick={() =>
|
|
toast({
|
|
title: "Open dashboard",
|
|
body: "Toasts can link back into host pages.",
|
|
tone: "info",
|
|
action: {
|
|
label: "Go",
|
|
href: hostPath(context.companyPrefix, "/dashboard"),
|
|
},
|
|
})}
|
|
>
|
|
Action toast
|
|
</button>
|
|
</div>
|
|
<div style={rowStyle}>
|
|
<button
|
|
type="button"
|
|
style={buttonStyle}
|
|
onClick={() => {
|
|
if (!context.companyId) return;
|
|
void emitDemoEvent({ companyId: context.companyId, message: "Triggered from Kitchen Sink page" })
|
|
.then((next) => {
|
|
overview.refresh();
|
|
const message = getObjectString(next, "message") ?? "Demo event emitted";
|
|
setQuickActionStatus({
|
|
title: "Event emitted",
|
|
body: message,
|
|
tone: "success",
|
|
});
|
|
toast({
|
|
title: "Event emitted",
|
|
body: message,
|
|
tone: "success",
|
|
});
|
|
})
|
|
.catch((error) => {
|
|
const message = getErrorMessage(error);
|
|
setQuickActionStatus({
|
|
title: "Event failed",
|
|
body: message,
|
|
tone: "error",
|
|
});
|
|
toast({
|
|
title: "Event failed",
|
|
body: message,
|
|
tone: "error",
|
|
});
|
|
});
|
|
}}
|
|
>
|
|
Emit event
|
|
</button>
|
|
<button
|
|
type="button"
|
|
style={buttonStyle}
|
|
onClick={() => {
|
|
if (!context.companyId) return;
|
|
void startProgressStream({ companyId: context.companyId, steps: 4 })
|
|
.then(() => {
|
|
setQuickActionStatus({
|
|
title: "Stream started",
|
|
body: "Watch the live progress updates below.",
|
|
tone: "info",
|
|
});
|
|
toast({
|
|
title: "Progress stream started",
|
|
body: "Live updates will appear in the quick action panel.",
|
|
tone: "info",
|
|
});
|
|
})
|
|
.catch((error) => {
|
|
const message = getErrorMessage(error);
|
|
setQuickActionStatus({
|
|
title: "Stream failed",
|
|
body: message,
|
|
tone: "error",
|
|
});
|
|
toast({
|
|
title: "Progress stream failed",
|
|
body: message,
|
|
tone: "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 })
|
|
.then((next) => {
|
|
overview.refresh();
|
|
const value = getObjectNumber(next, "value") ?? 1;
|
|
const body = `Recorded demo.page_quick_action = ${value}`;
|
|
setQuickActionStatus({
|
|
title: "Metric recorded",
|
|
body,
|
|
tone: "success",
|
|
});
|
|
toast({
|
|
title: "Metric recorded",
|
|
body,
|
|
tone: "success",
|
|
});
|
|
})
|
|
.catch((error) => {
|
|
const message = getErrorMessage(error);
|
|
setQuickActionStatus({
|
|
title: "Metric failed",
|
|
body: message,
|
|
tone: "error",
|
|
});
|
|
toast({
|
|
title: "Metric failed",
|
|
body: message,
|
|
tone: "error",
|
|
});
|
|
});
|
|
}}
|
|
>
|
|
Write metric
|
|
</button>
|
|
</div>
|
|
<div style={{ display: "grid", gap: "6px" }}>
|
|
<div style={mutedTextStyle}>
|
|
Recent progress events: {progressStream.events.length}
|
|
</div>
|
|
{quickActionStatus ? (
|
|
<div
|
|
style={{
|
|
...subtleCardStyle,
|
|
borderColor:
|
|
quickActionStatus.tone === "error"
|
|
? "color-mix(in srgb, #dc2626 45%, var(--border))"
|
|
: quickActionStatus.tone === "warn"
|
|
? "color-mix(in srgb, #d97706 45%, var(--border))"
|
|
: quickActionStatus.tone === "success"
|
|
? "color-mix(in srgb, #16a34a 45%, var(--border))"
|
|
: "color-mix(in srgb, #2563eb 45%, var(--border))",
|
|
}}
|
|
>
|
|
<div style={{ fontSize: "12px", fontWeight: 600 }}>{quickActionStatus.title}</div>
|
|
<div style={mutedTextStyle}>{quickActionStatus.body}</div>
|
|
</div>
|
|
) : null}
|
|
{progressStream.events.length > 0 ? (
|
|
<JsonBlock value={progressStream.events.slice(-3)} />
|
|
) : null}
|
|
</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,
|
|
lastProcessResult: overview.data?.lastProcessResult ?? null,
|
|
}}
|
|
/>
|
|
</MiniWidget>
|
|
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function KitchenSinkIssueCrudDemo({ context }: { context: PluginPageProps["context"] }) {
|
|
const toast = usePluginToast();
|
|
const [issues, setIssues] = useState<HostIssueRecord[]>([]);
|
|
const [drafts, setDrafts] = useState<Record<string, { title: string; status: string }>>({});
|
|
const [createTitle, setCreateTitle] = useState("Kitchen Sink demo issue");
|
|
const [createDescription, setCreateDescription] = useState("Created from the Kitchen Sink embedded page.");
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
async function loadIssues() {
|
|
if (!context.companyId) return;
|
|
setLoading(true);
|
|
try {
|
|
const result = await hostFetchJson<HostIssueRecord[]>(`/api/companies/${context.companyId}/issues`);
|
|
const nextIssues = result.slice(0, 8);
|
|
setIssues(nextIssues);
|
|
setDrafts(
|
|
Object.fromEntries(
|
|
nextIssues.map((issue) => [issue.id, { title: issue.title, status: issue.status }]),
|
|
),
|
|
);
|
|
setError(null);
|
|
} catch (nextError) {
|
|
setError(getErrorMessage(nextError));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void loadIssues();
|
|
}, [context.companyId]);
|
|
|
|
async function handleCreate() {
|
|
if (!context.companyId || !createTitle.trim()) return;
|
|
try {
|
|
await hostFetchJson(`/api/companies/${context.companyId}/issues`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
title: createTitle.trim(),
|
|
description: createDescription.trim() || undefined,
|
|
status: "todo",
|
|
priority: "medium",
|
|
}),
|
|
});
|
|
toast({ title: "Issue created", body: createTitle.trim(), tone: "success" });
|
|
setCreateTitle("Kitchen Sink demo issue");
|
|
setCreateDescription("Created from the Kitchen Sink embedded page.");
|
|
await loadIssues();
|
|
} catch (nextError) {
|
|
toast({ title: "Issue create failed", body: getErrorMessage(nextError), tone: "error" });
|
|
}
|
|
}
|
|
|
|
async function handleSave(issueId: string) {
|
|
const draft = drafts[issueId];
|
|
if (!draft) return;
|
|
try {
|
|
await hostFetchJson(`/api/issues/${issueId}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({
|
|
title: draft.title.trim(),
|
|
status: draft.status,
|
|
}),
|
|
});
|
|
toast({ title: "Issue updated", body: draft.title.trim(), tone: "success" });
|
|
await loadIssues();
|
|
} catch (nextError) {
|
|
toast({ title: "Issue update failed", body: getErrorMessage(nextError), tone: "error" });
|
|
}
|
|
}
|
|
|
|
async function handleDelete(issueId: string) {
|
|
try {
|
|
await hostFetchJson(`/api/issues/${issueId}`, { method: "DELETE" });
|
|
toast({ title: "Issue deleted", tone: "info" });
|
|
await loadIssues();
|
|
} catch (nextError) {
|
|
toast({ title: "Issue delete failed", body: getErrorMessage(nextError), tone: "error" });
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Section title="Issue CRUD">
|
|
<div style={mutedTextStyle}>
|
|
This is a regular embedded React page inside Paperclip calling the board API directly. It creates, updates, and deletes issues for the current company.
|
|
</div>
|
|
{!context.companyId ? (
|
|
<div style={mutedTextStyle}>Select a company to use issue demos.</div>
|
|
) : (
|
|
<>
|
|
<div style={{ display: "grid", gap: "10px", gridTemplateColumns: "minmax(0, 1.4fr) minmax(0, 1fr) auto" }}>
|
|
<input style={inputStyle} value={createTitle} onChange={(event) => setCreateTitle(event.target.value)} placeholder="Issue title" />
|
|
<input style={inputStyle} value={createDescription} onChange={(event) => setCreateDescription(event.target.value)} placeholder="Issue description" />
|
|
<button type="button" style={primaryButtonStyle} onClick={() => void handleCreate()}>
|
|
Create issue
|
|
</button>
|
|
</div>
|
|
{loading ? <div style={mutedTextStyle}>Loading issues…</div> : null}
|
|
{error ? <div style={{ ...mutedTextStyle, color: "var(--destructive, #dc2626)" }}>{error}</div> : null}
|
|
<div style={{ display: "grid", gap: "10px" }}>
|
|
{issues.map((issue) => {
|
|
const draft = drafts[issue.id] ?? { title: issue.title, status: issue.status };
|
|
return (
|
|
<div key={issue.id} style={subtleCardStyle}>
|
|
<div style={{ display: "grid", gap: "10px", gridTemplateColumns: "minmax(0, 1.6fr) 140px auto auto" }}>
|
|
<input
|
|
style={inputStyle}
|
|
value={draft.title}
|
|
onChange={(event) =>
|
|
setDrafts((current) => ({
|
|
...current,
|
|
[issue.id]: { ...draft, title: event.target.value },
|
|
}))}
|
|
/>
|
|
<select
|
|
style={inputStyle}
|
|
value={draft.status}
|
|
onChange={(event) =>
|
|
setDrafts((current) => ({
|
|
...current,
|
|
[issue.id]: { ...draft, status: event.target.value },
|
|
}))}
|
|
>
|
|
<option value="backlog">backlog</option>
|
|
<option value="todo">todo</option>
|
|
<option value="in_progress">in_progress</option>
|
|
<option value="in_review">in_review</option>
|
|
<option value="done">done</option>
|
|
<option value="blocked">blocked</option>
|
|
<option value="cancelled">cancelled</option>
|
|
</select>
|
|
<button type="button" style={buttonStyle} onClick={() => void handleSave(issue.id)}>
|
|
Save
|
|
</button>
|
|
<button type="button" style={buttonStyle} onClick={() => void handleDelete(issue.id)}>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{!loading && issues.length === 0 ? <div style={mutedTextStyle}>No issues yet for this company.</div> : null}
|
|
</div>
|
|
</>
|
|
)}
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
function KitchenSinkCompanyCrudDemo({ context }: { context: PluginPageProps["context"] }) {
|
|
const toast = usePluginToast();
|
|
const [companies, setCompanies] = useState<CompanyRecord[]>([]);
|
|
const [drafts, setDrafts] = useState<Record<string, { name: string; status: string }>>({});
|
|
const [newCompanyName, setNewCompanyName] = useState(`Kitchen Sink Demo ${new Date().toLocaleTimeString()}`);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
async function loadCompanies() {
|
|
setLoading(true);
|
|
try {
|
|
const result = await hostFetchJson<Array<CompanyRecord & { status?: string }>>("/api/companies");
|
|
setCompanies(result);
|
|
setDrafts(
|
|
Object.fromEntries(
|
|
result.map((company) => [company.id, { name: company.name, status: company.status ?? "active" }]),
|
|
),
|
|
);
|
|
setError(null);
|
|
} catch (nextError) {
|
|
setError(getErrorMessage(nextError));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void loadCompanies();
|
|
}, []);
|
|
|
|
async function handleCreate() {
|
|
const trimmed = newCompanyName.trim();
|
|
if (!trimmed) return;
|
|
const name = trimmed.startsWith("Kitchen Sink Demo") ? trimmed : `Kitchen Sink Demo ${trimmed}`;
|
|
try {
|
|
await hostFetchJson("/api/companies", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
name,
|
|
description: "Created from the Kitchen Sink example plugin page.",
|
|
}),
|
|
});
|
|
toast({ title: "Demo company created", body: name, tone: "success" });
|
|
setNewCompanyName(`Kitchen Sink Demo ${Date.now()}`);
|
|
await loadCompanies();
|
|
} catch (nextError) {
|
|
toast({ title: "Company create failed", body: getErrorMessage(nextError), tone: "error" });
|
|
}
|
|
}
|
|
|
|
async function handleSave(companyId: string) {
|
|
const draft = drafts[companyId];
|
|
if (!draft) return;
|
|
try {
|
|
await hostFetchJson(`/api/companies/${companyId}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({
|
|
name: draft.name.trim(),
|
|
status: draft.status,
|
|
}),
|
|
});
|
|
toast({ title: "Company updated", body: draft.name.trim(), tone: "success" });
|
|
await loadCompanies();
|
|
} catch (nextError) {
|
|
toast({ title: "Company update failed", body: getErrorMessage(nextError), tone: "error" });
|
|
}
|
|
}
|
|
|
|
async function handleDelete(company: CompanyRecord) {
|
|
try {
|
|
await hostFetchJson(`/api/companies/${company.id}`, { method: "DELETE" });
|
|
toast({ title: "Demo company deleted", body: company.name, tone: "info" });
|
|
await loadCompanies();
|
|
} catch (nextError) {
|
|
toast({ title: "Company delete failed", body: getErrorMessage(nextError), tone: "error" });
|
|
}
|
|
}
|
|
|
|
const currentCompany = companies.find((company) => company.id === context.companyId) ?? null;
|
|
const demoCompanies = companies.filter(isKitchenSinkDemoCompany);
|
|
|
|
return (
|
|
<Section title="Company CRUD">
|
|
<div style={mutedTextStyle}>
|
|
The worker SDK currently exposes company reads. This page shows a pragmatic embedded-app pattern for broader board actions by calling the host REST API directly.
|
|
</div>
|
|
<div style={subtleCardStyle}>
|
|
<div style={rowStyle}>
|
|
<strong>Current Company</strong>
|
|
{currentCompany ? <Pill label={currentCompany.issuePrefix ?? "no-prefix"} /> : null}
|
|
</div>
|
|
<div style={{ fontSize: "12px" }}>{currentCompany?.name ?? "No current company selected"}</div>
|
|
</div>
|
|
<div style={{ display: "grid", gap: "10px", gridTemplateColumns: "minmax(0, 1fr) auto" }}>
|
|
<input
|
|
style={inputStyle}
|
|
value={newCompanyName}
|
|
onChange={(event) => setNewCompanyName(event.target.value)}
|
|
placeholder="Kitchen Sink Demo Company"
|
|
/>
|
|
<button type="button" style={primaryButtonStyle} onClick={() => void handleCreate()}>
|
|
Create demo company
|
|
</button>
|
|
</div>
|
|
{loading ? <div style={mutedTextStyle}>Loading companies…</div> : null}
|
|
{error ? <div style={{ ...mutedTextStyle, color: "var(--destructive, #dc2626)" }}>{error}</div> : null}
|
|
<div style={{ display: "grid", gap: "10px" }}>
|
|
{demoCompanies.map((company) => {
|
|
const draft = drafts[company.id] ?? { name: company.name, status: "active" };
|
|
const isCurrent = company.id === context.companyId;
|
|
return (
|
|
<div key={company.id} style={subtleCardStyle}>
|
|
<div style={{ display: "grid", gap: "10px", gridTemplateColumns: "minmax(0, 1.5fr) 120px auto auto" }}>
|
|
<input
|
|
style={inputStyle}
|
|
value={draft.name}
|
|
onChange={(event) =>
|
|
setDrafts((current) => ({
|
|
...current,
|
|
[company.id]: { ...draft, name: event.target.value },
|
|
}))}
|
|
/>
|
|
<select
|
|
style={inputStyle}
|
|
value={draft.status}
|
|
onChange={(event) =>
|
|
setDrafts((current) => ({
|
|
...current,
|
|
[company.id]: { ...draft, status: event.target.value },
|
|
}))}
|
|
>
|
|
<option value="active">active</option>
|
|
<option value="paused">paused</option>
|
|
<option value="archived">archived</option>
|
|
</select>
|
|
<button type="button" style={buttonStyle} onClick={() => void handleSave(company.id)}>
|
|
Save
|
|
</button>
|
|
<button type="button" style={buttonStyle} onClick={() => void handleDelete(company)} disabled={isCurrent}>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
{isCurrent ? <div style={{ ...mutedTextStyle, marginTop: "8px" }}>Current company cannot be deleted from this demo.</div> : null}
|
|
</div>
|
|
);
|
|
})}
|
|
{!loading && demoCompanies.length === 0 ? (
|
|
<div style={mutedTextStyle}>No demo companies yet. Create one above and manage it from this page.</div>
|
|
) : null}
|
|
</div>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
function KitchenSinkTopRow({ context }: { context: PluginPageProps["context"] }) {
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gap: "14px",
|
|
gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))",
|
|
alignItems: "stretch",
|
|
}}
|
|
>
|
|
<Section title="Embedded App Demo">
|
|
<div style={{ fontSize: "13px", lineHeight: 1.5 }}>
|
|
Plugins can host their own React page and behave like a native company page. Kitchen Sink now uses this route as a practical demo app, then keeps the lower-level worker console below for the rest of the SDK surface.
|
|
</div>
|
|
</Section>
|
|
<div style={{ display: "grid", gap: "14px" }}>
|
|
<Section title="Plugin Page Route">
|
|
<div style={mutedTextStyle}>
|
|
The company sidebar entry opens this route directly, so the plugin feels like a first-class company page instead of a settings subpage.
|
|
</div>
|
|
<a href={pluginPagePath(context.companyPrefix)} style={{ fontSize: "12px" }}>
|
|
{pluginPagePath(context.companyPrefix)}
|
|
</a>
|
|
</Section>
|
|
<Section title="Paperclip Animation">
|
|
<div style={mutedTextStyle}>
|
|
This is the same Paperclip ASCII treatment used in onboarding, copied into the example plugin so the package stays self-contained.
|
|
</div>
|
|
<AsciiArtAnimation />
|
|
</Section>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function KitchenSinkStorageDemo({ context }: { context: PluginPageProps["context"] }) {
|
|
const toast = usePluginToast();
|
|
const stateKey = "revenue_clicker";
|
|
const revenueState = usePluginData<StateValueData>(
|
|
"state-value",
|
|
context.companyId
|
|
? { scopeKind: "company", scopeId: context.companyId, stateKey }
|
|
: {},
|
|
);
|
|
const writeScopedState = usePluginAction("write-scoped-state");
|
|
const deleteScopedState = usePluginAction("delete-scoped-state");
|
|
|
|
const currentValue = useMemo(() => {
|
|
const raw = revenueState.data?.value;
|
|
if (typeof raw === "number") return raw;
|
|
const parsed = Number(raw ?? 0);
|
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
}, [revenueState.data?.value]);
|
|
|
|
async function adjust(delta: number) {
|
|
if (!context.companyId) return;
|
|
try {
|
|
await writeScopedState({
|
|
scopeKind: "company",
|
|
scopeId: context.companyId,
|
|
stateKey,
|
|
value: currentValue + delta,
|
|
});
|
|
revenueState.refresh();
|
|
} catch (nextError) {
|
|
toast({ title: "Storage write failed", body: getErrorMessage(nextError), tone: "error" });
|
|
}
|
|
}
|
|
|
|
async function reset() {
|
|
if (!context.companyId) return;
|
|
try {
|
|
await deleteScopedState({
|
|
scopeKind: "company",
|
|
scopeId: context.companyId,
|
|
stateKey,
|
|
});
|
|
toast({ title: "Revenue counter reset", tone: "info" });
|
|
revenueState.refresh();
|
|
} catch (nextError) {
|
|
toast({ title: "Storage reset failed", body: getErrorMessage(nextError), tone: "error" });
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Section title="Plugin Storage">
|
|
<div style={mutedTextStyle}>
|
|
This clicker persists into plugin-scoped company storage. A real revenue plugin could store counters, sync cursors, or cached external IDs the same way.
|
|
</div>
|
|
{!context.companyId ? (
|
|
<div style={mutedTextStyle}>Select a company to use company-scoped plugin storage.</div>
|
|
) : (
|
|
<>
|
|
<div style={{ display: "grid", gap: "4px" }}>
|
|
<div style={{ fontSize: "26px", fontWeight: 700 }}>{currentValue}</div>
|
|
<div style={mutedTextStyle}>Stored at `company/{context.companyId}/{stateKey}`</div>
|
|
</div>
|
|
<div style={rowStyle}>
|
|
{[-10, -1, 1, 10].map((delta) => (
|
|
<button key={delta} type="button" style={buttonStyle} onClick={() => void adjust(delta)}>
|
|
{delta > 0 ? `+${delta}` : delta}
|
|
</button>
|
|
))}
|
|
<button type="button" style={buttonStyle} onClick={() => void reset()}>
|
|
Reset
|
|
</button>
|
|
</div>
|
|
<JsonBlock value={revenueState.data ?? { scopeKind: "company", stateKey, value: 0 }} />
|
|
</>
|
|
)}
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
function KitchenSinkHostIntegrationDemo({ context }: { context: PluginPageProps["context"] }) {
|
|
const [liveRuns, setLiveRuns] = useState<HostLiveRunRecord[]>([]);
|
|
const [recentRuns, setRecentRuns] = useState<HostHeartbeatRunRecord[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
async function loadRuns() {
|
|
if (!context.companyId) return;
|
|
setLoading(true);
|
|
try {
|
|
const [nextLiveRuns, nextRecentRuns] = await Promise.all([
|
|
hostFetchJson<HostLiveRunRecord[]>(`/api/companies/${context.companyId}/live-runs?minCount=5`),
|
|
hostFetchJson<HostHeartbeatRunRecord[]>(`/api/companies/${context.companyId}/heartbeat-runs?limit=5`),
|
|
]);
|
|
setLiveRuns(nextLiveRuns);
|
|
setRecentRuns(nextRecentRuns);
|
|
setError(null);
|
|
} catch (nextError) {
|
|
setError(getErrorMessage(nextError));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void loadRuns();
|
|
}, [context.companyId]);
|
|
|
|
return (
|
|
<Section title="Host Integrations">
|
|
<div style={mutedTextStyle}>
|
|
Plugin pages can feel like native Paperclip pages. This section demonstrates host toasts, company-scoped routing, and reading live heartbeat data from the embedded page.
|
|
</div>
|
|
<div style={subtleCardStyle}>
|
|
<div style={rowStyle}>
|
|
<strong>Company Route</strong>
|
|
<Pill label={pluginPagePath(context.companyPrefix)} />
|
|
</div>
|
|
<div style={mutedTextStyle}>
|
|
This page is mounted as a real company route instead of living only under `/plugins/:pluginId`.
|
|
</div>
|
|
</div>
|
|
{!context.companyId ? (
|
|
<div style={mutedTextStyle}>Select a company to read run data.</div>
|
|
) : (
|
|
<div style={{ display: "grid", gap: "12px", gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))" }}>
|
|
<div style={subtleCardStyle}>
|
|
<div style={sectionHeaderStyle}>
|
|
<strong>Live Runs</strong>
|
|
<button type="button" style={buttonStyle} onClick={() => void loadRuns()}>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
{loading ? <div style={mutedTextStyle}>Loading run data…</div> : null}
|
|
{error ? <div style={{ ...mutedTextStyle, color: "var(--destructive, #dc2626)" }}>{error}</div> : null}
|
|
<MiniList
|
|
items={liveRuns}
|
|
empty="No live runs right now."
|
|
render={(item) => {
|
|
const run = item as HostLiveRunRecord;
|
|
return (
|
|
<div style={{ display: "grid", gap: "6px", fontSize: "12px" }}>
|
|
<div style={rowStyle}>
|
|
<strong>{run.status}</strong>
|
|
{run.agentName ? <Pill label={run.agentName} /> : null}
|
|
</div>
|
|
<div>{run.id}</div>
|
|
{run.agentId ? (
|
|
<a href={hostPath(context.companyPrefix, `/agents/${run.agentId}/runs/${run.id}`)}>
|
|
Open run
|
|
</a>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div style={subtleCardStyle}>
|
|
<strong>Recent Heartbeats</strong>
|
|
<MiniList
|
|
items={recentRuns}
|
|
empty="No recent heartbeat runs."
|
|
render={(item) => {
|
|
const run = item as HostHeartbeatRunRecord;
|
|
return (
|
|
<div style={{ display: "grid", gap: "6px", fontSize: "12px" }}>
|
|
<div style={rowStyle}>
|
|
<strong>{run.status}</strong>
|
|
{run.invocationSource ? <Pill label={run.invocationSource} /> : null}
|
|
</div>
|
|
<div>{run.id}</div>
|
|
</div>
|
|
);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
function KitchenSinkEmbeddedApp({ context }: { context: PluginPageProps["context"] }) {
|
|
return (
|
|
<div style={{ display: "grid", gap: "14px" }}>
|
|
<KitchenSinkTopRow context={context} />
|
|
<KitchenSinkStorageDemo context={context} />
|
|
<KitchenSinkIssueCrudDemo context={context} />
|
|
<KitchenSinkCompanyCrudDemo context={context} />
|
|
<KitchenSinkHostIntegrationDemo context={context} />
|
|
</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 [companiesLimit, setCompaniesLimit] = useState(20);
|
|
const [projectsLimit, setProjectsLimit] = useState(20);
|
|
const [issuesLimit, setIssuesLimit] = useState(20);
|
|
const [goalsLimit, setGoalsLimit] = useState(20);
|
|
const companies = usePluginData<CompanyRecord[]>("companies", { limit: companiesLimit });
|
|
const projects = usePluginData<ProjectRecord[]>("projects", companyId ? { companyId, limit: projectsLimit } : {});
|
|
const issues = usePluginData<IssueRecord[]>("issues", companyId ? { companyId, limit: issuesLimit } : {});
|
|
const goals = usePluginData<GoalRecord[]>("goals", companyId ? { companyId, limit: goalsLimit } : {});
|
|
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 [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 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(() => {
|
|
setProjectsLimit(20);
|
|
setIssuesLimit(20);
|
|
setGoalsLimit(20);
|
|
}, [companyId]);
|
|
|
|
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={pluginPagePath(context.companyPrefix)} 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))" }}>
|
|
<PaginatedDomainCard
|
|
title="Companies"
|
|
items={companies.data ?? []}
|
|
totalCount={overview.data?.counts.companies ?? null}
|
|
empty="No companies."
|
|
onLoadMore={() => setCompaniesLimit((current) => current + 20)}
|
|
render={(item) => {
|
|
const company = item as CompanyRecord;
|
|
return <div>{company.name} <span style={{ opacity: 0.6 }}>({company.id.slice(0, 8)})</span></div>;
|
|
}}
|
|
/>
|
|
<PaginatedDomainCard
|
|
title="Projects"
|
|
items={projects.data ?? []}
|
|
totalCount={overview.data?.counts.projects ?? null}
|
|
empty="No projects."
|
|
onLoadMore={() => setProjectsLimit((current) => current + 20)}
|
|
render={(item) => {
|
|
const project = item as ProjectRecord;
|
|
return <div>{project.name} <span style={{ opacity: 0.6 }}>({project.status ?? "unknown"})</span></div>;
|
|
}}
|
|
/>
|
|
<PaginatedDomainCard
|
|
title="Issues"
|
|
items={issues.data ?? []}
|
|
totalCount={overview.data?.counts.issues ?? null}
|
|
empty="No issues."
|
|
onLoadMore={() => setIssuesLimit((current) => current + 20)}
|
|
render={(item) => {
|
|
const issue = item as IssueRecord;
|
|
return <div>{issue.title} <span style={{ opacity: 0.6 }}>({issue.status})</span></div>;
|
|
}}
|
|
/>
|
|
<PaginatedDomainCard
|
|
title="Goals"
|
|
items={goals.data ?? []}
|
|
totalCount={overview.data?.counts.goals ?? null}
|
|
empty="No goals."
|
|
onLoadMore={() => setGoalsLimit((current) => current + 20)}
|
|
render={(item) => {
|
|
const goal = item as GoalRecord;
|
|
return <div>{goal.title} <span style={{ opacity: 0.6 }}>({goal.status})</span></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 + 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();
|
|
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}>
|
|
<KitchenSinkPageWidgets context={context} />
|
|
<KitchenSinkEmbeddedApp 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={pluginPagePath(context.companyPrefix)} 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 = pluginPagePath(context.companyPrefix);
|
|
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={pluginPagePath(context.companyPrefix)}>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>
|
|
);
|
|
}
|