feat: add multi-agent beta remote office support (#62)

* Remote openclaw connection enabled and agent added

* 2 worlds connected

* Performance improvement

* Performance improvements

* Added documentation

* feat(office): add multi-agent beta remote office support

Add a second-office beta that can mirror remote Claw3D presence or derive remote gateway presence so teams can visualize and message agents across instances. Harden the new remote flows, document setup, and keep the branch green with full validation.

Made-with: Cursor

---------

Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
Co-authored-by: iamlukethedev <lucas.guilherme@smartwayslfl.com>
This commit is contained in:
Luke The Dev
2026-03-25 11:14:20 -05:00
committed by GitHub
parent 1185f7a9f0
commit a202cdc80f
31 changed files with 4326 additions and 467 deletions
@@ -0,0 +1,144 @@
import {
memo,
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
} from "react";
export type RemoteAgentChatMessage = {
id: string;
role: "user" | "system";
text: string;
timestampMs: number;
};
type RemoteAgentChatPanelProps = {
agentName: string;
canSend: boolean;
sending: boolean;
draft: string;
error: string | null;
messages: RemoteAgentChatMessage[];
disabledReason?: string | null;
onDraftChange: (value: string) => void;
onSend: (message: string) => void;
};
const formatTimestamp = (timestampMs: number) =>
new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
minute: "2-digit",
hour12: true,
}).format(new Date(timestampMs));
export const RemoteAgentChatPanel = memo(function RemoteAgentChatPanel({
agentName,
canSend,
sending,
draft,
error,
messages,
disabledReason,
onDraftChange,
onSend,
}: RemoteAgentChatPanelProps) {
const [draftValue, setDraftValue] = useState(draft);
const feedRef = useRef<HTMLDivElement | null>(null);
const sendDisabled = !canSend || sending || !draftValue.trim();
const helperText = useMemo(() => {
if (disabledReason?.trim()) return disabledReason.trim();
if (sending) return "Forwarding your message to the remote gateway.";
return "Text-only relay. Remote replies are not mirrored here yet.";
}, [disabledReason, sending]);
useEffect(() => {
setDraftValue(draft);
}, [draft]);
useEffect(() => {
if (!feedRef.current) return;
feedRef.current.scrollTop = feedRef.current.scrollHeight;
}, [messages, sending]);
const handleSend = () => {
const trimmed = draftValue.trim();
if (!trimmed || sendDisabled) return;
onSend(trimmed);
};
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key !== "Enter" || event.shiftKey) return;
event.preventDefault();
handleSend();
};
return (
<div className="flex min-h-0 flex-1 flex-col bg-[#0e0a04]">
<div className="border-b border-white/10 px-4 py-3">
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.18em] text-cyan-300/70">
Remote Agent
</div>
<div className="mt-1 text-sm font-medium text-white">{agentName}</div>
<div className="mt-2 font-mono text-[11px] text-white/45">{helperText}</div>
</div>
<div ref={feedRef} className="flex-1 space-y-3 overflow-y-auto px-4 py-4">
{messages.length === 0 ? (
<div className="rounded border border-dashed border-white/10 bg-black/10 px-3 py-3 font-mono text-[11px] text-white/35">
Send a plain-text note to this remote agent.
</div>
) : (
messages.map((message) => (
<div
key={message.id}
className={`max-w-[85%] rounded px-3 py-2 ${
message.role === "user"
? "ml-auto bg-cyan-500/15 text-cyan-50"
: "bg-white/6 text-white/80"
}`}
>
<div className="whitespace-pre-wrap break-words text-[13px] leading-5">
{message.text}
</div>
<div className="mt-2 font-mono text-[10px] text-white/35">
{formatTimestamp(message.timestampMs)}
</div>
</div>
))
)}
</div>
<div className="border-t border-white/10 px-4 py-3">
{error ? (
<div className="mb-3 rounded border border-red-500/35 bg-red-500/10 px-3 py-2 font-mono text-[11px] text-red-100">
{error}
</div>
) : null}
<textarea
value={draftValue}
onChange={(event) => {
const nextValue = event.target.value;
setDraftValue(nextValue);
onDraftChange(nextValue);
}}
onKeyDown={handleKeyDown}
placeholder="Message the remote agent."
className="min-h-[92px] w-full resize-none rounded border border-white/10 bg-black/20 px-3 py-2 text-sm text-white outline-none transition focus:border-cyan-400/50"
/>
<div className="mt-3 flex items-center justify-between gap-3">
<div className="font-mono text-[10px] text-white/35">Enter sends. Shift+Enter adds a line.</div>
<button
type="button"
onClick={handleSend}
disabled={sendDisabled}
className="rounded border border-cyan-400/40 bg-cyan-500/10 px-3 py-1.5 font-mono text-[11px] font-medium uppercase tracking-[0.14em] text-cyan-100 transition hover:border-cyan-300/60 hover:bg-cyan-500/15 disabled:cursor-not-allowed disabled:opacity-45"
>
{sending ? "Sending..." : "Send"}
</button>
</div>
</div>
</div>
);
});
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { CURATED_ELEVENLABS_VOICES } from "@/lib/voiceReply/catalog";
type SettingsPanelProps = {
@@ -10,6 +11,18 @@ type SettingsPanelProps = {
officeTitle: string;
officeTitleLoaded: boolean;
onOfficeTitleChange: (title: string) => void;
remoteOfficeEnabled: boolean;
remoteOfficeSourceKind: "presence_endpoint" | "openclaw_gateway";
remoteOfficeLabel: string;
remoteOfficePresenceUrl: string;
remoteOfficeGatewayUrl: string;
remoteOfficeTokenConfigured: boolean;
onRemoteOfficeEnabledChange: (enabled: boolean) => void;
onRemoteOfficeSourceKindChange: (kind: "presence_endpoint" | "openclaw_gateway") => void;
onRemoteOfficeLabelChange: (label: string) => void;
onRemoteOfficePresenceUrlChange: (url: string) => void;
onRemoteOfficeGatewayUrlChange: (url: string) => void;
onRemoteOfficeTokenChange: (token: string) => void;
voiceRepliesEnabled: boolean;
voiceRepliesVoiceId: string | null;
voiceRepliesSpeed: number;
@@ -28,6 +41,18 @@ export function SettingsPanel({
officeTitle,
officeTitleLoaded,
onOfficeTitleChange,
remoteOfficeEnabled,
remoteOfficeSourceKind,
remoteOfficeLabel,
remoteOfficePresenceUrl,
remoteOfficeGatewayUrl,
remoteOfficeTokenConfigured,
onRemoteOfficeEnabledChange,
onRemoteOfficeSourceKindChange,
onRemoteOfficeLabelChange,
onRemoteOfficePresenceUrlChange,
onRemoteOfficeGatewayUrlChange,
onRemoteOfficeTokenChange,
voiceRepliesEnabled,
voiceRepliesVoiceId,
voiceRepliesSpeed,
@@ -42,6 +67,7 @@ export function SettingsPanel({
? gatewayStatus.charAt(0).toUpperCase() + gatewayStatus.slice(1)
: "Unknown";
const gatewayDisconnectDisabled = gatewayStatus !== "connected";
const [remoteOfficeTokenDraft, setRemoteOfficeTokenDraft] = useState("");
return (
<div className="px-4 py-4">
@@ -99,6 +125,189 @@ export function SettingsPanel({
</button>
</div>
</div>
<div className="mt-3 rounded-lg border border-cyan-500/10 bg-black/20 px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-medium text-white">Remote office</div>
<div className="mt-1 text-[10px] text-white/75">
Attach a second read-only office from either another Claw3D or a remote OpenClaw gateway.
</div>
</div>
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-200/70">
{remoteOfficeEnabled ? "Enabled" : "Disabled"}
</span>
</div>
<div className="ui-settings-row mt-3 flex min-h-[72px] items-center justify-between gap-6 rounded-lg border border-cyan-500/10 bg-black/15 px-4 py-3">
<div className="flex items-center gap-3">
<button
type="button"
role="switch"
aria-label="Remote office"
aria-checked={remoteOfficeEnabled}
className={`ui-switch self-center ${remoteOfficeEnabled ? "ui-switch--on" : ""}`}
onClick={() => onRemoteOfficeEnabledChange(!remoteOfficeEnabled)}
>
<span className="ui-switch-thumb" />
</button>
<div className="flex flex-col">
<span className="text-[11px] font-medium text-white">Show second office</span>
<span className="text-[10px] text-white/80">
Remote agents stay visible but non-interactive.
</span>
</div>
</div>
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-200/70">
{remoteOfficeTokenConfigured ? "Token set" : "No token"}
</span>
</div>
<div className="mt-3 grid gap-3">
<div>
<div className="mb-1 text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
Source type
</div>
<select
value={remoteOfficeSourceKind}
onChange={(event) =>
onRemoteOfficeSourceKindChange(
event.target.value as "presence_endpoint" | "openclaw_gateway"
)
}
className="w-full rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 text-[11px] text-cyan-100 outline-none transition-colors focus:border-cyan-400/30"
>
<option value="presence_endpoint">Remote Claw3D presence endpoint</option>
<option value="openclaw_gateway">Remote OpenClaw gateway</option>
</select>
<div className="mt-1 text-[10px] text-white/50">
Use a presence endpoint when the other machine runs Claw3D. Use gateway mode when the other machine only runs OpenClaw.
</div>
</div>
<div>
<div className="mb-1 text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
Label
</div>
<input
type="text"
value={remoteOfficeLabel}
maxLength={48}
onChange={(event) => onRemoteOfficeLabelChange(event.target.value)}
placeholder="Remote Office"
className="w-full rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 text-[11px] uppercase tracking-[0.14em] text-cyan-100 outline-none transition-colors placeholder:text-cyan-100/30 focus:border-cyan-400/30"
/>
</div>
{remoteOfficeSourceKind === "presence_endpoint" ? (
<>
<div>
<div className="mb-1 text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
Presence URL
</div>
<input
type="url"
value={remoteOfficePresenceUrl}
onChange={(event) => onRemoteOfficePresenceUrlChange(event.target.value)}
placeholder="https://other-office.example.com/api/office/presence"
className="w-full rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 text-[11px] text-cyan-100 outline-none transition-colors placeholder:text-cyan-100/30 focus:border-cyan-400/30"
/>
<div className="mt-1 text-[10px] text-white/50">
Studio polls this endpoint server-side when the other machine is also running Claw3D.
</div>
</div>
<div>
<div className="mb-1 text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
Optional token
</div>
<div className="flex items-center gap-2">
<input
type="password"
value={remoteOfficeTokenDraft}
onChange={(event) => setRemoteOfficeTokenDraft(event.target.value)}
placeholder={remoteOfficeTokenConfigured ? "Token configured. Enter a new one to replace it." : "Enter token"}
className="min-w-0 flex-1 rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 text-[11px] text-cyan-100 outline-none transition-colors placeholder:text-cyan-100/30 focus:border-cyan-400/30"
/>
<button
type="button"
onClick={() => {
onRemoteOfficeTokenChange(remoteOfficeTokenDraft);
setRemoteOfficeTokenDraft("");
}}
className="rounded-md border border-cyan-500/20 bg-cyan-500/10 px-3 py-2 text-[10px] font-medium uppercase tracking-[0.14em] text-cyan-100 transition-colors hover:border-cyan-400/40 hover:bg-cyan-500/15"
>
Save
</button>
{remoteOfficeTokenConfigured ? (
<button
type="button"
onClick={() => {
onRemoteOfficeTokenChange("");
setRemoteOfficeTokenDraft("");
}}
className="rounded-md border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-[10px] font-medium uppercase tracking-[0.14em] text-rose-100 transition-colors hover:border-rose-400/40 hover:bg-rose-500/15"
>
Clear
</button>
) : null}
</div>
</div>
</>
) : (
<>
<div>
<div className="mb-1 text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
Gateway URL
</div>
<input
type="text"
value={remoteOfficeGatewayUrl}
onChange={(event) => onRemoteOfficeGatewayUrlChange(event.target.value)}
placeholder="wss://remote-gateway.example.com"
className="w-full rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 text-[11px] text-cyan-100 outline-none transition-colors placeholder:text-cyan-100/30 focus:border-cyan-400/30"
/>
<div className="mt-1 text-[10px] text-white/50">
Claw3D connects from the browser directly to the remote OpenClaw gateway and derives a read-only presence snapshot.
</div>
</div>
<div>
<div className="mb-1 text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
Shared gateway token
</div>
<div className="flex items-center gap-2">
<input
type="password"
value={remoteOfficeTokenDraft}
onChange={(event) => setRemoteOfficeTokenDraft(event.target.value)}
placeholder={remoteOfficeTokenConfigured ? "Token configured. Enter a new one to replace it." : "Enter token"}
className="min-w-0 flex-1 rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 text-[11px] text-cyan-100 outline-none transition-colors placeholder:text-cyan-100/30 focus:border-cyan-400/30"
/>
<button
type="button"
onClick={() => {
onRemoteOfficeTokenChange(remoteOfficeTokenDraft);
setRemoteOfficeTokenDraft("");
}}
className="rounded-md border border-cyan-500/20 bg-cyan-500/10 px-3 py-2 text-[10px] font-medium uppercase tracking-[0.14em] text-cyan-100 transition-colors hover:border-cyan-400/40 hover:bg-cyan-500/15"
>
Save
</button>
{remoteOfficeTokenConfigured ? (
<button
type="button"
onClick={() => {
onRemoteOfficeTokenChange("");
setRemoteOfficeTokenDraft("");
}}
className="rounded-md border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-[10px] font-medium uppercase tracking-[0.14em] text-rose-100 transition-colors hover:border-rose-400/40 hover:bg-rose-500/15"
>
Clear
</button>
) : null}
</div>
<div className="mt-1 text-[10px] text-white/50">
Optional. Browser-based remote presence and messaging can work without it when the remote gateway already allows your Control UI origin.
</div>
</div>
</>
)}
</div>
</div>
<div className="mt-3 rounded-lg border border-cyan-500/10 bg-black/20 px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div>