mirror of
https://github.com/penpot/penpot.git
synced 2026-03-30 00:00:45 +02:00
✨ Add new MCP plugin UI changes (#8699)
* ✨ Add new MCP plugin UI changes * 📎 Fix tool status misleading
This commit is contained in:
@@ -3,12 +3,83 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Penpot plugin example</title>
|
||||
<title>Penpot MCP Plugin</title>
|
||||
</head>
|
||||
<body>
|
||||
<button type="button" data-appearance="secondary" data-handler="connect-mcp">Connect to MCP server</button>
|
||||
<div class="plugin-container">
|
||||
<div id="connection-status" class="status-pill" data-status="idle">
|
||||
<span class="status-dot"></span>
|
||||
<span id="status-text" class="body-s">Not connected</span>
|
||||
</div>
|
||||
|
||||
<div id="connection-status" style="margin-top: 10px; font-size: 12px; color: #666">Not connected</div>
|
||||
<button type="button" id="connect-btn" data-appearance="primary" data-handler="connect-mcp">
|
||||
Connect MCP Server
|
||||
</button>
|
||||
<button type="button" id="disconnect-btn" data-appearance="secondary" data-handler="disconnect-mcp" hidden>
|
||||
Disconnect MCP Server
|
||||
</button>
|
||||
|
||||
<details class="collapsible-section" id="execution-status">
|
||||
<summary class="collapsible-header">
|
||||
<svg
|
||||
class="collapsible-arrow"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="12"
|
||||
height="12"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z" />
|
||||
</svg>
|
||||
<span class="body-s">Execution status</span>
|
||||
</summary>
|
||||
|
||||
<div class="collapsible-body">
|
||||
<span class="body-s tool-label">Current task</span>
|
||||
<div class="tool-display">
|
||||
<svg
|
||||
class="tool-icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M22.7 19l-9.1-9.1c.9-2.3.4-5-1.5-6.9-2-2-5-2.4-7.4-1.3L9 6 6 9 1.6 4.7C.4 7.1.9 10.1 2.9 12.1c1.9 1.9 4.6 2.4 6.9 1.5l9.1 9.1c.4.4 1 .4 1.4 0l2.3-2.3c.5-.4.5-1.1.1-1.4z"
|
||||
/>
|
||||
</svg>
|
||||
<span id="current-task" class="body-s">---</span>
|
||||
</div>
|
||||
|
||||
<div class="code-section-header">
|
||||
<span class="body-s tool-label">Executed code</span>
|
||||
<button type="button" id="copy-code-btn" class="copy-btn" title="Copy code" disabled>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
id="executed-code"
|
||||
class="code-textarea"
|
||||
readonly
|
||||
placeholder="No code executed yet..."
|
||||
></textarea>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
BIN
mcp/packages/plugin/public/icon.jpg
Normal file
BIN
mcp/packages/plugin/public/icon.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "Penpot MCP Plugin",
|
||||
"code": "plugin.js",
|
||||
"icon": "icon.jpg",
|
||||
"version": 2,
|
||||
"description": "This plugin enables interaction with the Penpot MCP server",
|
||||
"permissions": ["content:read", "content:write", "library:read", "library:write", "comment:read", "comment:write"]
|
||||
|
||||
@@ -6,21 +6,33 @@ document.body.dataset.theme = searchParams.get("theme") ?? "light";
|
||||
|
||||
// WebSocket connection management
|
||||
let ws: WebSocket | null = null;
|
||||
const statusElement = document.getElementById("connection-status");
|
||||
|
||||
const statusPill = document.getElementById("connection-status") as HTMLElement;
|
||||
const statusText = document.getElementById("status-text") as HTMLElement;
|
||||
const currentTaskEl = document.getElementById("current-task") as HTMLElement;
|
||||
const executedCodeEl = document.getElementById("executed-code") as HTMLTextAreaElement;
|
||||
const copyCodeBtn = document.getElementById("copy-code-btn") as HTMLButtonElement;
|
||||
const connectBtn = document.getElementById("connect-btn") as HTMLButtonElement;
|
||||
const disconnectBtn = document.getElementById("disconnect-btn") as HTMLButtonElement;
|
||||
|
||||
/**
|
||||
* Updates the connection status display element.
|
||||
* Updates the status pill and button visibility based on connection state.
|
||||
*
|
||||
* @param status - the base status text to display
|
||||
* @param isConnectedState - whether the connection is in a connected state (affects color)
|
||||
* @param message - optional additional message to append to the status
|
||||
* @param code - the connection state code ("idle" | "connecting" | "connected" | "disconnected" | "error")
|
||||
* @param label - human-readable label to display inside the pill
|
||||
*/
|
||||
function updateConnectionStatus(code: string, status: string, isConnectedState: boolean, message?: string): void {
|
||||
if (statusElement) {
|
||||
const displayText = message ? `${status}: ${message}` : status;
|
||||
statusElement.textContent = displayText;
|
||||
statusElement.style.color = isConnectedState ? "var(--accent-primary)" : "var(--error-700)";
|
||||
function updateConnectionStatus(code: string, label: string): void {
|
||||
if (statusPill) {
|
||||
statusPill.dataset.status = code;
|
||||
}
|
||||
if (statusText) {
|
||||
statusText.textContent = label;
|
||||
}
|
||||
|
||||
const isConnected = code === "connected";
|
||||
if (connectBtn) connectBtn.hidden = isConnected;
|
||||
if (disconnectBtn) disconnectBtn.hidden = !isConnected;
|
||||
|
||||
parent.postMessage(
|
||||
{
|
||||
type: "update-connection-status",
|
||||
@@ -30,6 +42,34 @@ function updateConnectionStatus(code: string, status: string, isConnectedState:
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the "Current task" display with the currently executing task name.
|
||||
*
|
||||
* @param taskName - the task name to display, or null to reset to "---"
|
||||
*/
|
||||
function updateCurrentTask(taskName: string | null): void {
|
||||
if (currentTaskEl) {
|
||||
currentTaskEl.textContent = taskName ?? "---";
|
||||
}
|
||||
if (taskName === null) {
|
||||
updateExecutedCode(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the executed code textarea with the last code run during task execution.
|
||||
*
|
||||
* @param code - the code string to display, or null to clear
|
||||
*/
|
||||
function updateExecutedCode(code: string | null): void {
|
||||
if (executedCodeEl) {
|
||||
executedCodeEl.value = code ?? "";
|
||||
}
|
||||
if (copyCodeBtn) {
|
||||
copyCodeBtn.disabled = !code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a task response back to the MCP server via WebSocket.
|
||||
*
|
||||
@@ -49,7 +89,7 @@ function sendTaskResponse(response: any): void {
|
||||
*/
|
||||
function connectToMcpServer(baseUrl?: string, token?: string): void {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
updateConnectionStatus("connected", "Already connected", true);
|
||||
updateConnectionStatus("connected", "Connected");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -62,17 +102,22 @@ function connectToMcpServer(baseUrl?: string, token?: string): void {
|
||||
}
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
updateConnectionStatus("connecting", "Connecting...", false);
|
||||
updateConnectionStatus("connecting", "Connecting...");
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("Connected to MCP server");
|
||||
updateConnectionStatus("connected", "Connected to MCP server", true);
|
||||
updateConnectionStatus("connected", "Connected");
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
console.log("Received from MCP server:", event.data);
|
||||
const request = JSON.parse(event.data);
|
||||
// Track the current task received from the MCP server
|
||||
if (request.task) {
|
||||
updateCurrentTask(request.task);
|
||||
updateExecutedCode(request.params?.code ?? null);
|
||||
}
|
||||
// Forward the task request to the plugin for execution
|
||||
parent.postMessage(request, "*");
|
||||
} catch (error) {
|
||||
@@ -84,8 +129,9 @@ function connectToMcpServer(baseUrl?: string, token?: string): void {
|
||||
// If we've send the error update we don't send the disconnect as well
|
||||
if (!wsError) {
|
||||
console.log("Disconnected from MCP server");
|
||||
const message = event.reason || undefined;
|
||||
updateConnectionStatus("disconnected", "Disconnected", false, message);
|
||||
const label = event.reason ? `Disconnected: ${event.reason}` : "Disconnected";
|
||||
updateConnectionStatus("disconnected", label);
|
||||
updateCurrentTask(null);
|
||||
}
|
||||
ws = null;
|
||||
};
|
||||
@@ -94,19 +140,34 @@ function connectToMcpServer(baseUrl?: string, token?: string): void {
|
||||
console.error("WebSocket error:", error);
|
||||
wsError = error;
|
||||
// note: WebSocket error events typically don't contain detailed error messages
|
||||
updateConnectionStatus("error", "Connection error", false);
|
||||
updateConnectionStatus("error", "Connection error");
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to connect to MCP server:", error);
|
||||
const message = error instanceof Error ? error.message : undefined;
|
||||
updateConnectionStatus("error", "Connection failed", false, message);
|
||||
const reason = error instanceof Error ? error.message : undefined;
|
||||
const label = reason ? `Connection failed: ${reason}` : "Connection failed";
|
||||
updateConnectionStatus("error", label);
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelector("[data-handler='connect-mcp']")?.addEventListener("click", () => {
|
||||
copyCodeBtn?.addEventListener("click", () => {
|
||||
const code = executedCodeEl?.value;
|
||||
if (!code) return;
|
||||
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
copyCodeBtn.classList.add("copied");
|
||||
setTimeout(() => copyCodeBtn.classList.remove("copied"), 1500);
|
||||
});
|
||||
});
|
||||
|
||||
connectBtn?.addEventListener("click", () => {
|
||||
connectToMcpServer();
|
||||
});
|
||||
|
||||
disconnectBtn?.addEventListener("click", () => {
|
||||
ws?.close();
|
||||
});
|
||||
|
||||
// Listen plugin.ts messages
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.data.type === "start-server") {
|
||||
|
||||
@@ -10,8 +10,8 @@ const taskHandlers: TaskHandler[] = [new ExecuteCodeTaskHandler()];
|
||||
|
||||
// Open the plugin UI (main.ts)
|
||||
penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}`, {
|
||||
width: 158,
|
||||
height: 200,
|
||||
width: 236,
|
||||
height: 210,
|
||||
hidden: !!mcp,
|
||||
} as any);
|
||||
|
||||
|
||||
@@ -1,10 +1,178 @@
|
||||
@import "@penpot/plugin-styles/styles.css";
|
||||
|
||||
body {
|
||||
line-height: 1.5;
|
||||
padding: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-block-end: 0.75rem;
|
||||
.plugin-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-16) var(--spacing-8);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Status pill ─────────────────────────────────────────────────── */
|
||||
|
||||
.status-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8) var(--spacing-16);
|
||||
border-radius: var(--spacing-8);
|
||||
border: 1px solid var(--background-quaternary);
|
||||
color: var(--foreground-secondary);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.status-pill[data-status="connected"] {
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.status-pill[data-status="disconnected"],
|
||||
.status-pill[data-status="error"] {
|
||||
border-color: var(--error-500);
|
||||
color: var(--error-500);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Collapsible section ─────────────────────────────────────────── */
|
||||
|
||||
.collapsible-section {
|
||||
border: 1px solid var(--background-quaternary);
|
||||
border-radius: var(--spacing-8);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.collapsible-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
cursor: pointer;
|
||||
color: var(--foreground-secondary);
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.collapsible-header::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.collapsible-arrow {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
details[open] > .collapsible-header .collapsible-arrow {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.collapsible-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) var(--spacing-12) var(--spacing-12);
|
||||
border-top: 1px solid var(--background-quaternary);
|
||||
}
|
||||
|
||||
/* ── Tool section ────────────────────────────────────────────────── */
|
||||
|
||||
.tool-label {
|
||||
color: var(--foreground-secondary);
|
||||
}
|
||||
|
||||
.tool-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-radius: var(--spacing-8);
|
||||
background-color: var(--background-tertiary);
|
||||
color: var(--foreground-secondary);
|
||||
min-height: 32px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── Code section ────────────────────────────────────────────────── */
|
||||
|
||||
.code-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
.code-textarea {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
resize: vertical;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-radius: var(--spacing-8);
|
||||
border: 1px solid var(--background-quaternary);
|
||||
background-color: var(--background-tertiary);
|
||||
color: var(--foreground-secondary);
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--background-quaternary);
|
||||
border-radius: var(--spacing-4);
|
||||
background-color: transparent;
|
||||
color: var(--foreground-secondary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.copy-btn:hover:not(:disabled) {
|
||||
background-color: var(--background-tertiary);
|
||||
color: var(--foreground-primary);
|
||||
}
|
||||
|
||||
.copy-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.copy-btn.copied {
|
||||
color: var(--accent-primary);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* ── Action buttons ──────────────────────────────────────────────── */
|
||||
|
||||
#connect-btn,
|
||||
#disconnect-btn {
|
||||
width: 100%;
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user