Greasy Fork is available in English.
Sync text from React app to manus.im chat box and click send on trigger
// ==UserScript==
// @name React App to Manus.im Sync (v2.0 - Chatbox + Send Button)
// @namespace http://tampermonkey.net/
// @version 2.1
// @description Sync text from React app to manus.im chat box and click send on trigger
// @author Samuel r Nason Tomaszewski, Yunnuo Zhang (modified)
// @match https://manus.im/*
// @match https://www.manus.im/*
// @match *://localhost:*/*
// @match *://192.168.30.7:*/*
// @match *://10.*.*.*:*/*
// @grant GM_setValue
// @grant GM_getValue
// @grant window.focus
// @license MIT
// ==/UserScript==
(function () {
"use strict";
const MESSAGE_TYPES = {
RECIPIENT: "recipient", // kept for compatibility with your React app UI
SUBJECT: "subject", // kept for compatibility with your React app UI
BODY: "body",
};
function debugLog(message) {
console.log(`[Manus Sync Debug] ${message}`);
}
function getMessageType(buttonText) {
const baseText = (buttonText || "").split(":")[0].trim();
if (baseText.startsWith("Recipient")) return MESSAGE_TYPES.RECIPIENT;
if (baseText.startsWith("Subject")) return MESSAGE_TYPES.SUBJECT;
if (baseText.startsWith("Body")) return MESSAGE_TYPES.BODY;
return "";
}
function updateSyncedText(messageType, text) {
if (!messageType) return;
GM_setValue("syncedText", {
type: messageType,
content: text,
timestamp: Date.now(),
});
// Persistent caches for send operations
if (messageType === MESSAGE_TYPES.SUBJECT) GM_setValue("lastSubject", text);
if (messageType === MESSAGE_TYPES.BODY) GM_setValue("lastBody", text);
debugLog(`Stored text type=${messageType}, len=${(text || "").length}`);
}
// -----------------------------
// React app side (unchanged logic)
// -----------------------------
function checkReactAppState() {
const selectedButton = document.querySelector(".context-button.selected");
const textDisplay = document.querySelector('.text-display[contenteditable="true"]');
if (selectedButton && textDisplay) {
const messageType = getMessageType(selectedButton.textContent);
const currentText = textDisplay.textContent || "";
const lastSync = GM_getValue("syncedText");
if (!lastSync || lastSync.content !== currentText) {
if (messageType === MESSAGE_TYPES.RECIPIENT) {
GM_setValue("recipientContent", currentText);
} else {
updateSyncedText(messageType, currentText);
}
}
}
// Button trigger logic (send/compose)
const sentButton = document.querySelector(".keyboard-button.sent");
if (sentButton) {
const lastTrigger = GM_getValue("buttonTrigger");
if (sentButton.textContent === "Sent!") {
if (!lastTrigger || lastTrigger.status !== "pending" || lastTrigger.action !== "send") {
debugLog("React: Send button changed to Sent! -> trigger send");
GM_setValue("buttonTrigger", { timestamp: Date.now(), status: "pending", action: "send" });
setTimeout(() => {
const normalButton = document.querySelector(".keyboard-button.sendemail.default");
if (normalButton && normalButton.textContent === "Send") {
GM_setValue("buttonTrigger", { timestamp: Date.now(), status: "reset", action: "send" });
}
}, 2000);
}
} else if (sentButton.textContent === "Start composing") {
if (!lastTrigger || lastTrigger.status !== "pending" || lastTrigger.action !== "compose") {
debugLog("React: Compose button changed -> trigger compose");
GM_setValue("buttonTrigger", { timestamp: Date.now(), status: "pending", action: "compose" });
setTimeout(() => {
const normalButton = document.querySelector(".keyboard-button.compose.default");
if (normalButton && normalButton.textContent === "Compose") {
GM_setValue("buttonTrigger", { timestamp: Date.now(), status: "reset", action: "compose" });
}
}, 2000);
}
}
}
}
function handleReactApp() {
debugLog("Initializing React app polling");
setInterval(checkReactAppState, 500);
}
// -----------------------------
// Manus.im side
// -----------------------------
// Target editor is the contenteditable tiptap ProseMirror:
// <div contenteditable="true" class="tiptap ProseMirror" ...>
const manusSelectors = {
[MESSAGE_TYPES.BODY]: [
'div.tiptap.ProseMirror[contenteditable="true"]',
'div.ProseMirror[contenteditable="true"]',
'div[contenteditable="true"][translate="no"]',
],
// Provided send button has a very distinctive SVG path; we’ll match on that.
// We'll still keep some fallback heuristics.
SEND_BUTTON_FALLBACKS: [
'button[type="submit"]',
'button[aria-label*="Send"]',
'button[title*="Send"]',
'button.rounded-full',
],
};
function escapeHtml(s) {
return (s || "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function findManusEditor() {
for (const sel of manusSelectors[MESSAGE_TYPES.BODY]) {
const el = document.querySelector(sel);
if (el) return el;
}
return null;
}
// Your provided SVG path snippet (unique “up arrow”)
const SEND_ICON_PATH_SNIPPET = "M7.91699 15.0642";
function findManusSendButton() {
// 1) Best: find button that contains that SVG path snippet
const buttons = Array.from(document.querySelectorAll("button"));
for (const btn of buttons) {
const html = btn.innerHTML || "";
if (html.includes(SEND_ICON_PATH_SNIPPET)) return btn;
}
// 2) Fallbacks
for (const sel of manusSelectors.SEND_BUTTON_FALLBACKS) {
const el = document.querySelector(sel);
if (el) return el;
}
return null;
}
function setManusEditorContent(editorEl, content) {
// TipTap/ProseMirror usually expects <p> nodes; we’ll replace with a single <p>
// and fire input/keydown to make the UI enable the send button.
const safe = escapeHtml(content).replaceAll("\n", "<br/>");
editorEl.focus();
// ProseMirror likes actual paragraph nodes
editorEl.innerHTML = `<p>${safe || "<br/>"}</p>`;
// Fire events that commonly update framework state
editorEl.dispatchEvent(new InputEvent("input", { bubbles: true }));
editorEl.dispatchEvent(new Event("change", { bubbles: true }));
editorEl.dispatchEvent(new KeyboardEvent("keyup", { key: " ", bubbles: true }));
editorEl.dispatchEvent(new KeyboardEvent("keydown", { key: "Backspace", bubbles: true }));
editorEl.dispatchEvent(new KeyboardEvent("keyup", { key: "Backspace", bubbles: true }));
// Store for recovery
editorEl.setAttribute("data-synced-value", content || "");
}
function updateManusElement(type, content) {
if (type !== MESSAGE_TYPES.BODY) {
// Manus wiring: only BODY goes to chat box
debugLog(`Ignoring type=${type} on manus.im (BODY-only wiring)`);
return;
}
const editor = findManusEditor();
if (!editor) {
debugLog("Manus editor not found (chatbox contenteditable).");
return;
}
debugLog(`Updating Manus chat editor with len=${(content || "").length}`);
setManusEditorContent(editor, content || "");
// Restore if editor clears on focus
editor.addEventListener(
"focus",
function () {
const syncedValue = this.getAttribute("data-synced-value") || "";
const current = (this.textContent || "").trim();
if (syncedValue && !current) {
debugLog("Editor was empty on focus; restoring synced value");
setManusEditorContent(this, syncedValue);
}
},
{ passive: true }
);
}
function clickManusSend() {
const sendBtn = findManusSendButton();
if (!sendBtn) {
debugLog("Send button not found on manus.im");
return;
}
// If disabled, we’ll still try after a short wait (often enables after input)
const isDisabled =
sendBtn.disabled ||
sendBtn.getAttribute("aria-disabled") === "true" ||
sendBtn.classList.contains("cursor-not-allowed");
if (isDisabled) {
debugLog("Send button looks disabled; retrying in 250ms...");
setTimeout(() => {
const btn2 = findManusSendButton();
if (btn2 && !(btn2.disabled || btn2.getAttribute("aria-disabled") === "true")) {
debugLog("Send button enabled on retry; clicking send");
btn2.click();
} else if (btn2) {
debugLog("Send button still disabled on retry; clicking anyway");
btn2.click();
}
}, 250);
return;
}
debugLog("Clicking Manus send button now");
sendBtn.click();
}
function handleManus() {
let lastTimestamp = 0;
let lastButtonTimestamp = 0;
let sendInProgress = false;
debugLog("Manus handler initialized");
setInterval(() => {
// Handle triggers from React app
const buttonTrigger = GM_getValue("buttonTrigger");
if (buttonTrigger && buttonTrigger.timestamp > lastButtonTimestamp) {
lastButtonTimestamp = buttonTrigger.timestamp;
if (buttonTrigger.status === "pending" && buttonTrigger.action === "send") {
debugLog("Received SEND trigger from React app");
sendInProgress = true;
// Safety timeout
setTimeout(() => {
sendInProgress = false;
}, 5000);
// Populate editor from stored body, then send
const bodyVal =
(GM_getValue("syncedText")?.type === MESSAGE_TYPES.BODY
? GM_getValue("syncedText")?.content
: null) || GM_getValue("lastBody") || "";
if (!bodyVal) {
debugLog("No body content available to send.");
sendInProgress = false;
return;
}
updateManusElement(MESSAGE_TYPES.BODY, bodyVal);
// Give the app a moment to enable the send button
setTimeout(() => {
clickManusSend();
setTimeout(() => {
sendInProgress = false;
debugLog("Send flow complete; resuming normal sync");
}, 800);
}, 200);
} else if (buttonTrigger.status === "pending" && buttonTrigger.action === "compose") {
// Manus doesn’t have “compose”; ignore safely
debugLog("Received COMPOSE trigger (ignored on manus.im)");
}
}
// Regular sync (when body changes)
if (!sendInProgress) {
const syncedData = GM_getValue("syncedText");
if (syncedData && syncedData.timestamp > lastTimestamp) {
lastTimestamp = syncedData.timestamp;
debugLog(`New synced data received type=${syncedData.type}`);
updateManusElement(syncedData.type, syncedData.content);
}
}
}, 500);
}
// Track whether React tab is open (your original pattern)
async function checkReactOpen() {
GM_setValue("reactOpen", !window.location.hostname.includes("manus.im"));
}
checkReactOpen();
setInterval(checkReactOpen, 1000);
// Boot
debugLog("Waiting 1000ms for page to load...");
setTimeout(() => {
const host = window.location.hostname;
if (host.includes("manus.im")) {
debugLog("Initializing Manus handler (v2.0)");
handleManus();
} else {
debugLog("Initializing React app handler (v2.0)");
handleReactApp();
}
}, 1000);
})();