diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 046f23310d..429ccf95ef 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -504,8 +504,7 @@ (rx/of (dwu/append-undo entry stack-undo?))) (rx/empty)))))) - (rx/take-until stoper-s)) - (rx/of (mcp/notify-other-tabs-disconnect))))) + (rx/take-until stoper-s))))) ptk/EffectEvent (effect [_ _ _] diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs index 61e1116b49..f2c3c069db 100644 --- a/frontend/src/app/main/data/workspace/mcp.cljs +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -10,13 +10,10 @@ [app.common.uri :as u] [app.config :as cf] [app.main.broadcast :as mbc] - [app.main.data.event :as ev] - [app.main.data.notifications :as ntf] [app.main.data.plugins :as dp] [app.main.repo :as rp] [app.main.store :as st] [app.plugins.register :as preg] - [app.util.i18n :refer [tr]] [app.util.timers :as ts] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) @@ -39,6 +36,14 @@ (defonce interval-sub (atom nil)) +(defn connect-mcp + [] + (ptk/reify ::connect-mcp + ptk/WatchEvent + (watch [_ _ _] + (rx/of (mbc/event :mcp/force-disconnect {}) + (ptk/data-event ::connect))))) + (defn finalize-workspace? [event] (= (ptk/type event) :app.main.data.workspace/finalize-workspace)) @@ -72,45 +77,6 @@ (rx/dispose! @interval-sub) (reset! interval-sub nil))) -(declare manage-mcp-notification) - -(defn handle-pong - [{:keys [id data]}] - (ptk/reify ::handle-pong - ptk/UpdateEvent - (update [_ state] - (let [mcp-state (get state :mcp)] - (cond - (= "connected" (:connection-status data)) - (update state :mcp assoc :connected-tab id) - - (and (= "disconnected" (:connection-status data)) - (= id (:connected-tab mcp-state))) - (update state :mcp dissoc :connected-tab) - - :else - state))) - - ptk/WatchEvent - (watch [_ _ _] - (rx/of (manage-mcp-notification))))) - -;; This event will arrive when a new workspace is open in another tab -(defn handle-ping - [] - (ptk/reify ::handle-ping - ptk/WatchEvent - (watch [_ state _] - (let [conn-status (get-in state [:mcp :connection-status])] - (rx/of (mbc/event :mcp/pong {:connection-status conn-status})))))) - -(defn notify-other-tabs-disconnect - [] - (ptk/reify ::notify-other-tabs-disconnect - ptk/WatchEvent - (watch [_ _ _] - (rx/of (mbc/event :mcp/pong {:connection-status "disconnected"}))))) - ;; This event will arrive when the mcp is enabled in the dashboard (defn update-mcp-status [value] @@ -121,12 +87,10 @@ ptk/WatchEvent (watch [_ _ _] - (rx/merge - (rx/of (manage-mcp-notification)) - (case value - true (rx/of (ptk/data-event ::connect)) - false (rx/of (ptk/data-event ::disconnect)) - nil))))) + (case value + true (rx/of (connect-mcp)) + false (rx/of (ptk/data-event ::disconnect)) + nil)))) (defn update-mcp-connection-status [value] @@ -137,20 +101,13 @@ ptk/WatchEvent (watch [_ _ _] - (rx/of (manage-mcp-notification) - (mbc/event :mcp/pong {:connection-status value}))))) - -(defn connect-mcp - [] - (ptk/reify ::connect-mcp - ptk/UpdateEvent - (update [_ state] - (update state :mcp assoc :connected-tab (:session-id state))) - - ptk/WatchEvent - (watch [_ _ _] - (rx/of (mbc/event :mcp/force-disconect {}) - (ptk/data-event ::connect))))) + ;; Only one MCP plugin instance may be active across browser tabs. + ;; When this tab becomes connected, tell every other tab to + ;; disconnect (which also stops their reconnect watcher). Otherwise + ;; several tabs stay connected at once and the MCP server reports + ;; "multiple instances connected" and the agent fails. + (when (= "connected" value) + (rx/of (mbc/event :mcp/force-disconnect {})))))) ;; This event will arrive when the user selects disconnect on the menu ;; or there is a broadcast message for disconnection @@ -166,33 +123,6 @@ (effect [_ _ _] (stop-reconnect-watcher!)))) -(defn- manage-mcp-notification - [] - (ptk/reify ::manage-mcp-notification - ptk/WatchEvent - (watch [_ state _] - (let [mcp-state (get state :mcp) - - mcp-enabled? (-> state :profile :props :mcp-enabled) - - current-tab-id (get state :session-id) - connected-tab-id (get mcp-state :connected-tab)] - - (if mcp-enabled? - (if (= connected-tab-id current-tab-id) - (rx/of (ntf/hide)) - (rx/of (ntf/dialog - {:content (tr "notifications.mcp.active-in-another-tab") - :cancel {:label (tr "labels.dismiss") - :callback #(st/emit! (ntf/hide) - (ev/event {::ev/name "dismiss-mcp-tab-switch" - ::ev/origin "workspace-notification"}))} - :accept {:label (tr "labels.switch") - :callback #(st/emit! (connect-mcp) - (ev/event {::ev/name "confirm-mcp-tab-switch" - ::ev/origin "workspace-notification"}))}}))) - (rx/of (ntf/hide))))))) - (defn init-mcp [stream] ;; Wait for plugins runtime to be initialized before starting the MCP plugin. @@ -240,7 +170,7 @@ (ptk/reify ::init ptk/UpdateEvent (update [_ state] - (update state :mcp assoc :connected-tab (:session-id state) :active true)) + (update state :mcp assoc :active true)) ptk/WatchEvent (watch [_ state stream] @@ -255,22 +185,8 @@ (rx/merge (init-mcp stream) - (rx/of (mbc/event :mcp/ping {})) - (->> mbc/stream - (rx/filter (mbc/type? :mcp/ping)) - (rx/filter (fn [{:keys [id]}] - (not= session-id id))) - (rx/map handle-ping)) - - (->> mbc/stream - (rx/filter (mbc/type? :mcp/pong)) - (rx/filter (fn [{:keys [id]}] - (not= session-id id))) - (rx/map handle-pong)) - - (->> mbc/stream - (rx/filter (mbc/type? :mcp/force-disconect)) + (rx/filter (mbc/type? :mcp/force-disconnect)) (rx/filter (fn [{:keys [id]}] (not= session-id id))) (rx/map deref) @@ -280,9 +196,9 @@ (->> mbc/stream (rx/filter (mbc/type? :mcp/enable)) (rx/mapcat (fn [_] - ;; NOTE: we don't need an explicit - ;; connect because the plugin has - ;; auto-connect + ;; Re-init so the force-disconnect + ;; listener is set up now that MCP + ;; is enabled. (rx/of (update-mcp-status true) (init))))) @@ -290,7 +206,6 @@ (rx/filter (mbc/type? :mcp/disable)) (rx/mapcat (fn [_] (rx/of (update-mcp-status false) - (init) (user-disconnect-mcp)))))) (rx/take-until stoper-s)))))) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index cc77c0a14d..b87b0eff43 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -10,6 +10,7 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.files.helpers :as cph] + [app.common.time :as ct] [app.common.types.shape-tree :as ctt] [app.common.types.shape.layout :as ctl] [app.common.types.tokens-lib :as ctob] @@ -155,6 +156,14 @@ (def mcp (l/derived :mcp st/state)) +(def mcp-key-expired? + (l/derived (fn [state] + (when-let [expires-at (some->> (:access-tokens state) + (some #(when (= (:type %) "mcp") %)) + :expires-at)] + (> (ct/now) expires-at))) + st/state)) + (def workspace-drawing (l/derived :workspace-drawing st/state)) diff --git a/frontend/src/app/main/ui/settings/integrations.cljs b/frontend/src/app/main/ui/settings/integrations.cljs index 50b5bd57bd..e7d51438bc 100644 --- a/frontend/src/app/main/ui/settings/integrations.cljs +++ b/frontend/src/app/main/ui/settings/integrations.cljs @@ -406,18 +406,16 @@ (mf/defc mcp-server-section* {::mf/private true} [] - (let [tokens (mf/deref refs/access-tokens) - profile (mf/deref refs/profile) + (let [tokens (mf/deref refs/access-tokens) + profile (mf/deref refs/profile) + mcp-key-expired? (mf/deref refs/mcp-key-expired?) mcp-key (some #(when (= (:type %) "mcp") %) tokens) mcp-token (:token mcp-key "") mcp-url (dm/str cf/mcp-server-url "?userToken=" mcp-token) mcp-enabled? (true? (-> profile :props :mcp-enabled)) - expires-at (:expires-at mcp-key) - expired? (and (some? expires-at) (> (ct/now) expires-at)) - - show-enabled? (and mcp-enabled? (false? expired?)) + show-enabled? (and mcp-enabled? (false? mcp-key-expired?)) tooltip-id (mf/use-id) @@ -494,7 +492,7 @@ (tr "integrations.mcp-server.status")] [:div {:class (stl/css :mcp-server-block)} - (when expired? + (when mcp-key-expired? [:> notification-pill* {:level :error :type :context} [:div {:class (stl/css :mcp-server-notification)} @@ -517,7 +515,7 @@ (when (and (false? mcp-enabled?) (nil? mcp-key)) [:div {:class (stl/css :mcp-server-switch-cover) :on-click handle-generate-mcp-key}]) - (when (true? expired?) + (when (true? mcp-key-expired?) [:div {:class (stl/css :mcp-server-switch-cover) :on-click handle-regenerate-mcp-key}])]]] diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index 20a50c729a..53d51a7970 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -10,7 +10,6 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] - [app.common.time :as ct] [app.common.uuid :as uuid] [app.config :as cf] [app.main.data.common :as dcm] @@ -791,20 +790,15 @@ [{:keys [on-close]}] (let [plugins? (features/active-feature? @st/state "plugins/runtime") - profile (mf/deref refs/profile) - mcp (mf/deref refs/mcp) - tokens (mf/deref refs/access-tokens) - - expires-at (some->> tokens - (some #(when (= (:type %) "mcp") %)) - :expires-at) - expired? (and (some? expires-at) (> (ct/now) expires-at)) + profile (mf/deref refs/profile) + mcp (mf/deref refs/mcp) + mcp-key-expired? (mf/deref refs/mcp-key-expired?) mcp-enabled? (true? (-> profile :props :mcp-enabled)) mcp-connection (get mcp :connection-status) mcp-connected? (= mcp-connection "connected") - show-enabled? (and mcp-enabled? (false? expired?)) + show-enabled? (and mcp-enabled? (false? mcp-key-expired?)) on-nav-to-integrations (mf/use-fn @@ -843,7 +837,7 @@ :pos-6 plugins?) :on-close on-close} - (when (and show-enabled? (not expired?)) + (when (and show-enabled? (not mcp-key-expired?)) [:> dropdown-menu-item* {:id "mcp-menu-toggle-mcp-plugin" :class (stl/css :base-menu-item :submenu-item) :on-click on-toggle-mcp-plugin @@ -865,7 +859,6 @@ (mf/defc menu* [{:keys [layout file]}] (let [profile (mf/deref refs/profile) - mcp (mf/deref refs/mcp) show-menu* (mf/use-state false) show-menu? (deref show-menu*) @@ -1062,11 +1055,8 @@ :class (stl/css :item-arrow)}]]) (when (contains? cf/flags :mcp) - (let [tokens (mf/deref refs/access-tokens) - expires-at (some->> tokens - (some #(when (= (:type %) "mcp") %)) - :expires-at) - expired? (and (some? expires-at) (> (ct/now) expires-at)) + (let [mcp (mf/deref refs/mcp) + mcp-key-expired? (mf/deref refs/mcp-key-expired?) mcp-enabled? (true? (-> profile :props :mcp-enabled)) mcp-connection (get mcp :connection-status) @@ -1075,7 +1065,7 @@ active? (and mcp-enabled? mcp-connected?) failed? (or (and mcp-enabled? mcp-error?) - (true? expired?))] + (true? mcp-key-expired?))] [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-menu-click diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.cljs b/frontend/src/app/main/ui/workspace/top_toolbar.cljs index 67ae4d8956..6b36ae2664 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.cljs +++ b/frontend/src/app/main/ui/workspace/top_toolbar.cljs @@ -8,15 +8,18 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.geom.point :as gpt] + [app.config :as cf] [app.main.data.event :as ev] [app.main.data.modal :as modal] [app.main.data.workspace :as dw] [app.main.data.workspace.common :as dwc] + [app.main.data.workspace.mcp :as mcp] [app.main.data.workspace.media :as dwm] [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 :refer [file-uploader]] [app.main.ui.context :as ctx] [app.main.ui.icons :as deprecated-icon] @@ -26,6 +29,61 @@ [okulary.core :as l] [rumext.v2 :as mf])) +(mf/defc mcp-indicator* + [] + (let [profile (mf/deref refs/profile) + mcp (mf/deref refs/mcp) + mcp-key-expired? (mf/deref refs/mcp-key-expired?) + + mcp-enabled? (true? (-> profile :props :mcp-enabled)) + mcp-connected? (= "connected" (:connection-status mcp)) + show-indicator? (and mcp-enabled? (false? mcp-key-expired?)) + + mcp-menu-open* (mf/use-state false) + mcp-menu-open? (deref mcp-menu-open*) + + toggle-mcp-menu + (mf/use-fn + (fn [event] + (dom/stop-propagation event) + (swap! mcp-menu-open* not))) + + close-mcp-menu + (mf/use-fn + #(reset! mcp-menu-open* false)) + + connect-mcp + (mf/use-fn + #(st/emit! (mcp/connect-mcp) + (ev/event {::ev/name "connect-mcp-plugin" + ::ev/origin "workspace:toolbar"})))] + (when show-indicator? + [:li + [:button + {:title (tr "workspace.toolbar.mcp") + :aria-label (tr "workspace.toolbar.mcp") + :class (stl/css-case :main-toolbar-options-button true + :mcp-button true + :selected mcp-menu-open?) + :on-click toggle-mcp-menu + :data-tool "mcp" + :data-testid "mcp-btn"} + [:span {:class (stl/css-case :mcp-status-dot true + :connected mcp-connected?)}] + [:span {:class (stl/css-case :mcp-button-label true + :connected mcp-connected?)} + (tr "workspace.toolbar.mcp")]] + [:> dropdown-menu* {:show mcp-menu-open? + :on-close close-mcp-menu + :class (stl/css :mcp-menu)} + (if mcp-connected? + [:li {:class (stl/css :mcp-menu-info) + :role "presentation"} + (tr "workspace.toolbar.mcp-connected")] + [:> dropdown-menu-item* {:class (stl/css :mcp-menu-item) + :on-click connect-mcp} + (tr "workspace.toolbar.mcp-connect-here")])]]))) + (mf/defc image-upload* {::mf/wrap [mf/memo]} [] @@ -221,12 +279,13 @@ {:title "Debugging tool" :class (stl/css-case :main-toolbar-options-button true :selected (contains? layout :debug-panel)) :on-click toggle-debug-panel} - deprecated-icon/bug]])]] + deprecated-icon/bug]]) + + (when (contains? cf/flags :mcp) + [:> mcp-indicator*])]] [:button {:title (tr "workspace.toolbar.toggle-toolbar") :aria-label (tr "workspace.toolbar.toggle-toolbar") :class (stl/css :toolbar-handler) :on-click toggle-toolbar} [:div {:class (stl/css :toolbar-handler-btn)}]]]))) - - diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.scss b/frontend/src/app/main/ui/workspace/top_toolbar.scss index e543964db0..7a185eb692 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.scss +++ b/frontend/src/app/main/ui/workspace/top_toolbar.scss @@ -5,6 +5,9 @@ // Copyright (c) KALEIDOS INC Sucursal en España SL @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as t; .main-toolbar { cursor: initial; @@ -83,6 +86,102 @@ } } +.mcp-button { + display: flex; + align-items: center; + gap: var(--sp-xs); + width: fit-content; + margin-inline-start: var(--sp-xs); + padding-inline: var(--sp-s); +} + +.mcp-button-label { + @include t.use-typography("body-small"); + + &.connected { + color: var(--color-accent-primary); + } +} + +.mcp-status-dot { + // Connection indicator placed before the label, vertically centered: + // a muted gray when disconnected, the primary accent when connected. + position: relative; + 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); + } +} + +.mcp-menu { + @include deprecated.menu-shadow; + + position: absolute; + inset-block-start: calc(100% + var(--sp-xs)); + inset-inline-start: var(--sp-xs); + z-index: var(--z-index-dropdown); + margin: 0; + padding: var(--sp-xs); + list-style: none; + border: $b-1 solid var(--panel-border-color); + border-radius: $br-8; + background-color: var(--menu-background-color); +} + +// Non-interactive informational text inside the menu (no hover, not focusable). +.mcp-menu-info { + @include t.use-typography("body-small"); + + padding: var(--sp-s) var(--sp-m); + color: var(--color-foreground-secondary); + white-space: nowrap; +} + +.mcp-menu-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); + white-space: nowrap; + + &:hover { + background-color: var(--menu-background-color-hover); + } +} + .toolbar-handler { @include deprecated.flex-center; @include deprecated.button-style; diff --git a/frontend/test/frontend_tests/data/workspace_mcp_test.cljs b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs new file mode 100644 index 0000000000..e81dab6780 --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs @@ -0,0 +1,50 @@ +;; 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 INC Sucursal en España SL + +(ns frontend-tests.data.workspace-mcp-test + (:require + [app.main.data.workspace.mcp :as mcp] + [cljs.test :as t :include-macros true] + [potok.v2.core :as ptk])) + +(t/deftest test-set-mcp-active + (t/testing "sets :active to true" + (let [state {:mcp {:active false}} + result (ptk/update (mcp/set-mcp-active true) state)] + (t/is (true? (get-in result [:mcp :active]))))) + + (t/testing "sets :active to false" + (let [state {:mcp {:active true}} + result (ptk/update (mcp/set-mcp-active false) state)] + (t/is (false? (get-in result [:mcp :active])))))) + +(t/deftest test-update-mcp-status + (t/testing "enables MCP in profile props" + (let [state {:profile {:props {:mcp-enabled false}}} + result (ptk/update (mcp/update-mcp-status true) state)] + (t/is (true? (get-in result [:profile :props :mcp-enabled]))))) + + (t/testing "disables MCP in profile props" + (let [state {:profile {:props {:mcp-enabled true}}} + result (ptk/update (mcp/update-mcp-status false) state)] + (t/is (false? (get-in result [:profile :props :mcp-enabled])))))) + +(t/deftest test-update-mcp-connection-status + (t/testing "sets connection status to connected" + (let [state {:mcp {:connection-status "disconnected"}} + result (ptk/update (mcp/update-mcp-connection-status "connected") state)] + (t/is (= "connected" (get-in result [:mcp :connection-status]))))) + + (t/testing "sets connection status to disconnected" + (let [state {:mcp {:connection-status "connected"}} + result (ptk/update (mcp/update-mcp-connection-status "disconnected") state)] + (t/is (= "disconnected" (get-in result [:mcp :connection-status])))))) + +(t/deftest test-init-sets-active + (t/testing "init sets :mcp :active to true" + (let [state {:mcp {:active false}} + result (ptk/update (mcp/init) state)] + (t/is (true? (get-in result [:mcp :active])))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 5f9078f910..c1a9025f90 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -7,6 +7,7 @@ [frontend-tests.data.uploads-test] [frontend-tests.data.viewer-test] [frontend-tests.data.workspace-colors-test] + [frontend-tests.data.workspace-mcp-test] [frontend-tests.data.workspace-media-test] [frontend-tests.data.workspace-texts-test] [frontend-tests.data.workspace-thumbnails-test] @@ -55,6 +56,7 @@ 'frontend-tests.data.uploads-test 'frontend-tests.data.viewer-test 'frontend-tests.data.workspace-colors-test + 'frontend-tests.data.workspace-mcp-test 'frontend-tests.data.workspace-media-test 'frontend-tests.data.workspace-texts-test 'frontend-tests.data.workspace-thumbnails-test diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 3ad9c6e30c..6ee5b9c4fa 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -4043,9 +4043,6 @@ msgstr "Invitation sent successfully" msgid "notifications.invitation-link-copied" msgstr "Invitation link copied" -msgid "notifications.mcp.active-in-another-tab" -msgstr "MCP is active in another tab. Switch here?" - msgid "notifications.mcp.active-in-this-tab" msgstr "MCP is now active in this tab." @@ -8986,6 +8983,15 @@ msgstr "Create board. Click and drag to define its size. (%s)" msgid "workspace.toolbar.image" msgstr "Image (%s)" +msgid "workspace.toolbar.mcp" +msgstr "MCP" + +msgid "workspace.toolbar.mcp-connected" +msgstr "MCP connected" + +msgid "workspace.toolbar.mcp-connect-here" +msgstr "Connect here" + #: src/app/main/ui/workspace/top_toolbar.cljs:143 msgid "workspace.toolbar.move" msgstr "Move (%s)" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index f72aff6a1c..d0a915e4a8 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -3952,9 +3952,6 @@ msgstr "Invitación enviada con éxito" msgid "notifications.invitation-link-copied" msgstr "Enlace de invitacion copiado" -msgid "notifications.mcp.active-in-another-tab" -msgstr "MCP está activo en otra pestaña. ¿Cambiar a esta?" - msgid "notifications.mcp.active-in-this-tab" msgstr "MCP está ahora activo en esta pestaña." @@ -8708,6 +8705,15 @@ msgstr "Crear tablero. Click y arrastrar para definir el tamaño. (%s)" msgid "workspace.toolbar.image" msgstr "Imagen (%s)" +msgid "workspace.toolbar.mcp" +msgstr "MCP" + +msgid "workspace.toolbar.mcp-connected" +msgstr "MCP conectado" + +msgid "workspace.toolbar.mcp-connect-here" +msgstr "Conectar aquí" + #: src/app/main/ui/workspace/top_toolbar.cljs:143 msgid "workspace.toolbar.move" msgstr "Mover (%s)"