From 54cce66c13e569a5870d6bb922b6e6a09bf2a267 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 2 Oct 2026 13:19:11 +0000 Subject: [PATCH] :sparkles: Share the MCP connection menu with the dashboard Connection status: - Replace the dashboard's action-only link with an explicit connected label and indicator, using the theme's existing accent colour. - Reflect the actual connection state independently of connection intent. Shared controls: - Extract the toolbar's MCP menu and styles into a shared component used by both the dashboard and workspace. - Provide connection status, session ID, copy, connect and disconnect actions in both locations, preserving hover and keyboard behaviour. - Keep dashboard and toolbar event origins distinct and update the dashboard connection instructions. Validation: - Confirm dashboard disconnect/reconnect, menu opening, keyboard navigation and Escape dismissal in the running app. - Pass live compilation, targeted ClojureScript and SCSS lint, formatting checks and git diff checks. AI-assisted-by: gpt-6 Signed-off-by: Dominik Jain --- docs/mcp/index.md | 2 +- .../src/app/main/ui/components/mcp_menu.cljs | 141 ++++++++++++++++++ .../src/app/main/ui/components/mcp_menu.scss | 127 ++++++++++++++++ .../src/app/main/ui/dashboard/sidebar.cljs | 22 +-- .../app/main/ui/workspace/top_toolbar.cljs | 120 +-------------- .../app/main/ui/workspace/top_toolbar.scss | 106 ------------- 6 files changed, 276 insertions(+), 242 deletions(-) create mode 100644 frontend/src/app/main/ui/components/mcp_menu.cljs create mode 100644 frontend/src/app/main/ui/components/mcp_menu.scss diff --git a/docs/mcp/index.md b/docs/mcp/index.md index 47a4c55803..cce812b516 100644 --- a/docs/mcp/index.md +++ b/docs/mcp/index.md @@ -81,7 +81,7 @@ Some important concepts for users: Connect the tabs you want to use: -* **Remote MCP**: choose **MCP: Connect** in the dashboard sidebar or **MCP → Connect** in a file's toolbar. Use **Disconnect** in the same place to disconnect that tab. +* **Remote MCP**: choose **MCP → Connect** in the dashboard sidebar or a file's toolbar. Use **Disconnect** in the same place to disconnect that tab. * **Local MCP**: run the plugin in each file and connect it to your local server. Each plugin connection has its own session ID. * Connecting or disconnecting one tab does not disconnect the others. * A connected tab stays connected while navigating between the dashboard and files. Its session ID stays the same; requests operate on the currently open file. diff --git a/frontend/src/app/main/ui/components/mcp_menu.cljs b/frontend/src/app/main/ui/components/mcp_menu.cljs new file mode 100644 index 0000000000..f38e4f377b --- /dev/null +++ b/frontend/src/app/main/ui/components/mcp_menu.cljs @@ -0,0 +1,141 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.main.ui.components.mcp-menu + (:require-macros [app.main.style :as stl]) + (:require + [app.main.data.event :as ev] + [app.main.data.notifications :as ntf] + [app.main.data.workspace.mcp :as mcp] + [app.main.store :as st] + [app.main.ui.components.dropdown-menu :refer [dropdown-menu* dropdown-menu-item*]] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.util.clipboard :as clipboard] + [app.util.dom :as dom] + [app.util.i18n :refer [tr]] + [app.util.timers :as ts] + [rumext.v2 :as mf])) + +(defn- cancel-timer! + [timer-ref*] + (when-let [timer (mf/ref-val timer-ref*)] + (ts/dispose! timer) + (mf/set-ref-val! timer-ref* nil))) + +(mf/defc mcp-menu* + {::mf/wrap [mf/memo]} + [{:keys [is-mcp-connected is-connection-requested session-id dashboard]}] + (let [copied-text (tr "workspace.toolbar.mcp-session-copied") + copy-error (tr "errors.clipboard-api-unavailable") + menu-open* (mf/use-state false) + menu-open? (deref menu-open*) + + open-timer* (mf/use-ref nil) + close-timer* (mf/use-ref nil) + + on-open-menu + (mf/use-fn + (fn [event] + (dom/stop-propagation event) + (cancel-timer! open-timer*) + (cancel-timer! close-timer*) + (reset! menu-open* true))) + + on-close-menu + (mf/use-fn + (fn [] + (cancel-timer! open-timer*) + (cancel-timer! close-timer*) + (reset! menu-open* false))) + + on-display-menu + (mf/use-fn + (fn [] + (cancel-timer! close-timer*) + (cancel-timer! open-timer*) + (mf/set-ref-val! + open-timer* + (ts/schedule 350 + #(do + (reset! menu-open* true) + (mf/set-ref-val! open-timer* nil)))))) + + on-hide-menu + (mf/use-fn + (fn [] + (cancel-timer! open-timer*) + (cancel-timer! close-timer*) + (mf/set-ref-val! + close-timer* + (ts/schedule 350 + #(do + (reset! menu-open* false) + (mf/set-ref-val! close-timer* nil)))))) + + on-connect + (mf/use-fn + (mf/deps dashboard) + #(st/emit! (mcp/connect-mcp) + (ev/event {::ev/name "connect-mcp-plugin" + ::ev/origin (if dashboard "dashboard:sidebar" "workspace:toolbar")}))) + + on-disconnect + (mf/use-fn + #(st/emit! (mcp/user-disconnect-mcp))) + + on-copy-session + (mf/use-fn + (mf/deps session-id copied-text copy-error) + (fn [] + (-> (clipboard/to-clipboard session-id) + (.then #(st/emit! (ntf/info copied-text))) + (.catch #(st/emit! (ntf/error copy-error))))))] + + (mf/with-effect [] + (fn [] + (cancel-timer! open-timer*) + (cancel-timer! close-timer*))) + + [:div {:class (stl/css-case :mcp-tool true :dashboard dashboard) + :on-pointer-enter on-display-menu + :on-pointer-leave on-hide-menu} + [:> button* {:variant "ghost" + :on-click on-open-menu + :aria-haspopup true + :aria-expanded menu-open? + :aria-pressed menu-open? + :data-tool "mcp" + :data-testid "mcp-btn"} + [:div {:class (stl/css-case :toolbar-mcp-button true + :selected menu-open?)} + [:span {:class (stl/css-case :toolbar-mcp-button-dot true + :connected is-mcp-connected)}] + [:span {:class (stl/css-case :toolbar-mcp-button-label true + :connected is-mcp-connected)} + (if (and dashboard is-mcp-connected) + (tr "workspace.toolbar.mcp-connected") + (tr "workspace.toolbar.mcp"))]]] + + [:div {:class (stl/css :toolbar-mcp-menu)} + [:> dropdown-menu* {:show menu-open? + :on-close on-close-menu + :class (stl/css :toolbar-mcp-dropdown)} + (when (or is-mcp-connected session-id) + [:li {:class (stl/css :toolbar-mcp-dropdown-info) + :role "presentation"} + (when is-mcp-connected + [:span (tr "workspace.toolbar.mcp-connected")]) + (when session-id + [:span (tr "workspace.toolbar.mcp-session-id" session-id)])]) + (when session-id + [:> dropdown-menu-item* {:class (stl/css :toolbar-mcp-dropdown-item) + :on-click on-copy-session} + (tr "workspace.toolbar.mcp-copy-session-id")]) + [:> dropdown-menu-item* {:class (stl/css :toolbar-mcp-dropdown-item) + :on-click (if is-connection-requested on-disconnect on-connect)} + (if is-connection-requested + (tr "workspace.header.menu.mcp.plugin.status.disconnect") + (tr "workspace.header.menu.mcp.plugin.status.connect"))]]]])) diff --git a/frontend/src/app/main/ui/components/mcp_menu.scss b/frontend/src/app/main/ui/components/mcp_menu.scss new file mode 100644 index 0000000000..f4c69ba4c8 --- /dev/null +++ b/frontend/src/app/main/ui/components/mcp_menu.scss @@ -0,0 +1,127 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS SUBSIDIARY SL + +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/spacing.scss" as *; +@use "ds/typography.scss" as t; +@use "ds/z-index.scss" as *; + +.mcp-tool { + position: relative; +} + +.dashboard { + margin-inline: var(--sp-l); +} + +.toolbar-mcp-button { + display: flex; + align-items: center; + gap: var(--sp-xs); +} + +.toolbar-mcp-button-label { + &.connected { + color: var(--color-accent-primary); + } +} + +.toolbar-mcp-button-dot { + position: relative; + display: flex; + flex-shrink: 0; + inline-size: px2rem(6); + block-size: px2rem(6); + border-radius: 50%; + background-color: var(--color-background-disabled); + + &.connected { + background-color: var(--color-accent-primary); + } + + // One-shot "blob" ripple that confirms the moment the tab becomes + // connected (e.g. after using "Switch here"). Triggered purely by the + // `.connected` class appearing, in sync with the color change. + &.connected::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 50%; + background-color: var(--color-accent-primary); + opacity: 0; + animation: mcp-status-blob 0.5s ease-out; + } +} + +@keyframes mcp-status-blob { + from { + opacity: 0.5; + transform: scale(1); + } + + to { + opacity: 0; + transform: scale(2.5); + } +} + +.toolbar-mcp-menu { + position: absolute; + left: 0; + top: $sz-36; +} + +// Same look as the workspace main menu (`.base-menu` in main_menu.scss). +// The toolbar overrides `--menu-background-color`, so restore the global +// menu value here. +.toolbar-mcp-dropdown { + --menu-background-color: var(--color-background-tertiary); + + display: flex; + flex-direction: column; + gap: var(--sp-xs); + z-index: var(--z-index-dropdown); + margin: 0; + padding: var(--sp-xs); + border: $b-2 solid var(--panel-border-color); + border-radius: $br-8; + background-color: var(--menu-background-color); + box-shadow: 0 0 $sz-12 0 var(--menu-shadow-color); +} + +.toolbar-mcp-dropdown-info { + @include t.use-typography("body-small"); + + display: flex; + flex-direction: column; + gap: var(--sp-xs); + padding: var(--sp-s) var(--sp-m); + border-radius: $br-8; + color: var(--color-foreground-secondary); + background-color: var(--color-background-primary); + font-size: px2rem(11); + white-space: nowrap; +} + +.toolbar-mcp-dropdown-item { + @include t.use-typography("body-small"); + + cursor: pointer; + display: flex; + align-items: center; + padding: var(--sp-s) var(--sp-m); + border-radius: $br-8; + color: var(--menu-foreground-color); + background-color: var(--menu-background-color); + white-space: nowrap; + + &:hover { + --menu-foreground-color: var(--menu-foreground-color-hover); + --menu-background-color: var(--menu-background-color-hover); + } +} diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 386d1dc791..8787c290ca 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -17,13 +17,13 @@ [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] [app.main.data.team :as dtm] - [app.main.data.workspace.mcp :as mcp] [app.main.refs :as refs] [app.main.router :as rt] [app.main.store :as st] [app.main.ui.components.dropdown-menu :refer [dropdown-menu* dropdown-menu-item*]] [app.main.ui.components.link :refer [link*]] + [app.main.ui.components.mcp-menu :refer [mcp-menu*]] [app.main.ui.dashboard.check-updates :as dcu] [app.main.ui.dashboard.comments :refer [comments-icon* comments-section]] [app.main.ui.dashboard.inline-edition :refer [inline-edition]] @@ -257,12 +257,6 @@ team-id (get team :id) mcp-state (mf/deref refs/mcp) - mcp-requested? (:connection-requested mcp-state) - toggle-mcp (mf/use-fn - (mf/deps mcp-requested?) - #(st/emit! (if mcp-requested? - (mcp/user-disconnect-mcp) - (mcp/connect-mcp)))) projects? (= section :dashboard-recent) fonts? (= section :dashboard-fonts) @@ -394,16 +388,10 @@ (:enabled mcp-state) (:token-valid mcp-state)) [:div {:class (stl/css :sidebar-content-section)} - [:ul {:class (stl/css :sidebar-nav)} - [:li {:class (stl/css :sidebar-nav-item)} - [:> link* {:action toggle-mcp - :keyboard-action toggle-mcp - :class (stl/css :sidebar-link)} - [:span {:class (stl/css :element-title)} - "MCP: " - (if mcp-requested? - (tr "workspace.header.menu.mcp.plugin.status.disconnect") - (tr "workspace.header.menu.mcp.plugin.status.connect"))]]]]]) + [:> mcp-menu* {:dashboard true + :is-mcp-connected (= "connected" (:connection-status mcp-state)) + :is-connection-requested (:connection-requested mcp-state) + :session-id (:session-id mcp-state)}]]) [:div {:class (stl/css :sidebar-content-section) :data-testid "pinned-projects"} diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.cljs b/frontend/src/app/main/ui/workspace/top_toolbar.cljs index 0a10cf095d..7a7f767621 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.cljs +++ b/frontend/src/app/main/ui/workspace/top_toolbar.cljs @@ -12,24 +12,20 @@ [app.config :as cf] [app.main.data.event :as ev] [app.main.data.modal :as modal] - [app.main.data.notifications :as ntf] [app.main.data.workspace :as dw] [app.main.data.workspace.common :as dwc] [app.main.data.workspace.drawing.common :as dwdc] - [app.main.data.workspace.mcp :as mcp] [app.main.data.workspace.media :as dwm] [app.main.data.workspace.path.state :as pst] [app.main.data.workspace.shortcuts :as sc] [app.main.features :as features] [app.main.refs :as refs] [app.main.store :as st] - [app.main.ui.components.dropdown-menu :refer [dropdown-menu* dropdown-menu-item*]] [app.main.ui.components.file-uploader :as file-uploader] + [app.main.ui.components.mcp-menu :refer [mcp-menu*]] [app.main.ui.context :as ctx] - [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :as i] - [app.util.clipboard :as clipboard] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [app.util.keyboard :as kbd] @@ -297,118 +293,6 @@ :ref ref :on-selected on-selected}]])) -(mf/defc mcp-tool* - {::mf/private true - ::mf/wrap [mf/memo]} - [{:keys [is-mcp-connected is-connection-requested session-id]}] - (let [copied-text (tr "workspace.toolbar.mcp-session-copied") - copy-error (tr "errors.clipboard-api-unavailable") - menu-open* (mf/use-state false) - menu-open? (deref menu-open*) - - open-timer* (mf/use-ref nil) - close-timer* (mf/use-ref nil) - - on-open-menu - (mf/use-fn - (fn [event] - (dom/stop-propagation event) - (cancel-timer! open-timer*) - (cancel-timer! close-timer*) - (reset! menu-open* true))) - - on-close-menu - (mf/use-fn - (fn [] - (cancel-timer! open-timer*) - (cancel-timer! close-timer*) - (reset! menu-open* false))) - - on-display-menu - (mf/use-fn - (fn [] - (cancel-timer! close-timer*) - (cancel-timer! open-timer*) - (mf/set-ref-val! - open-timer* - (ts/schedule 350 - #(do - (reset! menu-open* true) - (mf/set-ref-val! open-timer* nil)))))) - - on-hide-menu - (mf/use-fn - (fn [] - (cancel-timer! open-timer*) - (cancel-timer! close-timer*) - (mf/set-ref-val! - close-timer* - (ts/schedule 350 - #(do - (reset! menu-open* false) - (mf/set-ref-val! close-timer* nil)))))) - - on-connect - (mf/use-fn - #(st/emit! (mcp/connect-mcp) - (ev/event {::ev/name "connect-mcp-plugin" - ::ev/origin "workspace:toolbar"}))) - - on-disconnect - (mf/use-fn - #(st/emit! (mcp/user-disconnect-mcp))) - - on-copy-session - (mf/use-fn - (mf/deps session-id copied-text copy-error) - (fn [] - (-> (clipboard/to-clipboard session-id) - (.then #(st/emit! (ntf/info copied-text))) - (.catch #(st/emit! (ntf/error copy-error))))))] - - (mf/with-effect [] - (fn [] - (cancel-timer! open-timer*) - (cancel-timer! close-timer*))) - - [:div {:on-pointer-enter on-display-menu - :on-pointer-leave on-hide-menu} - [:> button* {:variant "ghost" - :on-click on-open-menu - :aria-haspopup true - :aria-expanded menu-open? - :aria-pressed menu-open? - :data-tool "mcp" - :data-testid "mcp-btn"} - [:div {:class (stl/css-case :toolbar-mcp-button true - :selected menu-open?)} - [:span {:class (stl/css-case :toolbar-mcp-button-dot true - :connected is-mcp-connected)}] - [:span {:class (stl/css-case :toolbar-mcp-button-label true - :connected is-mcp-connected)} - (tr "workspace.toolbar.mcp")]]] - - [:div {:class (stl/css :toolbar-mcp-menu)} - [:> dropdown-menu* {:show menu-open? - :on-close on-close-menu - :class (stl/css :toolbar-mcp-dropdown)} - (when (or is-mcp-connected session-id) - [:li {:class (stl/css :toolbar-mcp-dropdown-info) - :role "presentation"} - (when is-mcp-connected - [:span (tr "workspace.toolbar.mcp-connected")]) - (when session-id - [:span (tr "workspace.toolbar.mcp-session-id" session-id)])]) - (when session-id - [:> dropdown-menu-item* {:class (stl/css :toolbar-mcp-dropdown-item) - :on-click on-copy-session} - (tr "workspace.toolbar.mcp-copy-session-id")]) - [:> dropdown-menu-item* {:class (stl/css :toolbar-mcp-dropdown-item) - :on-click (if is-connection-requested on-disconnect on-connect)} - (if is-connection-requested - (tr "workspace.header.menu.mcp.plugin.status.disconnect") - (tr "workspace.header.menu.mcp.plugin.status.connect"))]]]])) - (mf/defc top-toolbar* {::mf/wrap [mf/memo]} [{:keys [layout]}] @@ -545,7 +429,7 @@ (when mcp-show? [:li {:class (stl/css :toolbar-option)} - [:> mcp-tool* {:is-mcp-connected mcp-connected? + [:> mcp-menu* {:is-mcp-connected mcp-connected? :is-connection-requested (:connection-requested mcp) :session-id (:session-id mcp)}]])] diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.scss b/frontend/src/app/main/ui/workspace/top_toolbar.scss index 7f5637c8f5..71ad771b3c 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.scss +++ b/frontend/src/app/main/ui/workspace/top_toolbar.scss @@ -101,112 +101,6 @@ } } -.toolbar-mcp-button { - display: flex; - align-items: center; - gap: var(--sp-xs); -} - -.toolbar-mcp-button-label { - &.connected { - color: var(--color-accent-primary); - } -} - -.toolbar-mcp-button-dot { - position: relative; - display: flex; - flex-shrink: 0; - inline-size: px2rem(6); - block-size: px2rem(6); - border-radius: 50%; - background-color: var(--color-background-disabled); - - &.connected { - background-color: var(--color-accent-primary); - } - - // One-shot "blob" ripple that confirms the moment the tab becomes - // connected (e.g. after using "Switch here"). Triggered purely by the - // `.connected` class appearing, in sync with the color change. - &.connected::after { - content: ""; - position: absolute; - inset: 0; - border-radius: 50%; - background-color: var(--color-accent-primary); - opacity: 0; - animation: mcp-status-blob 0.5s ease-out; - } -} - -@keyframes mcp-status-blob { - from { - opacity: 0.5; - transform: scale(1); - } - - to { - opacity: 0; - transform: scale(2.5); - } -} - -.toolbar-mcp-menu { - position: absolute; - left: 0; - top: $sz-36; -} - -// Same look as the workspace main menu (`.base-menu` in main_menu.scss). -// The toolbar overrides `--menu-background-color`, so restore the global -// menu value here. -.toolbar-mcp-dropdown { - --menu-background-color: var(--color-background-tertiary); - - display: flex; - flex-direction: column; - gap: var(--sp-xs); - z-index: var(--z-index-dropdown); - margin: 0; - padding: var(--sp-xs); - border: $b-2 solid var(--panel-border-color); - border-radius: $br-8; - background-color: var(--menu-background-color); - box-shadow: 0 0 $sz-12 0 var(--menu-shadow-color); -} - -.toolbar-mcp-dropdown-info { - @include t.use-typography("body-small"); - - display: flex; - flex-direction: column; - gap: var(--sp-xs); - padding: var(--sp-s) var(--sp-m); - border-radius: $br-8; - color: var(--color-foreground-secondary); - background-color: var(--color-background-primary); - font-size: px2rem(11); - white-space: nowrap; -} - -.toolbar-mcp-dropdown-item { - @include t.use-typography("body-small"); - - display: flex; - align-items: center; - padding: var(--sp-s) var(--sp-m); - border-radius: $br-8; - color: var(--menu-foreground-color); - background-color: var(--menu-background-color); - white-space: nowrap; - - &:hover { - --menu-foreground-color: var(--menu-foreground-color-hover); - --menu-background-color: var(--menu-background-color-hover); - } -} - .toolbar-separator { --separator-color: var(--color-background-quaternary);