Add new MCP plugin UI changes (#8699)

*  Add new MCP plugin UI changes

* 📎 Fix tool status misleading
This commit is contained in:
Juan de la Cruz
2026-03-23 11:20:37 +01:00
committed by GitHub
parent 094ef3d6fe
commit 884cdbbf8d
6 changed files with 329 additions and 28 deletions

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@@ -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"]

View File

@@ -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") {

View File

@@ -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);

View File

@@ -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);
}