✨ 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 <dominik.jain@oraios-ai.de>
This commit is contained in:
Dominik Jain 2026-10-02 13:19:11 +00:00 committed by alonso.torres
parent be17fabc6a
commit 54cce66c13
6 changed files with 276 additions and 242 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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