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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user