diff --git a/MULTI_AGENT_BETA.md b/MULTI_AGENT_BETA.md new file mode 100644 index 0000000..d3533c7 --- /dev/null +++ b/MULTI_AGENT_BETA.md @@ -0,0 +1,251 @@ +# Multi-Agent Beta + +This document explains the current multi-agent beta in Claw3D: what it does, how the two connection modes work, and how to connect a second office. + +## What This Beta Does + +Claw3D can render a second office inside the same 3D scene so you can visualize agents from another machine. + +Today the beta supports: + +- showing a second office in the same world; +- displaying remote agents as read-only presence; +- optionally sending a plain-text message to a remote agent; +- keeping the remote side isolated from your local files and office controls. + +This is a beta feature. It is designed for visibility and lightweight cross-office messaging, not full shared-state collaboration. + +## Mental Model + +There are always two roles: + +- **Local office**: the Claw3D instance you are currently using; +- **Remote office**: another Claw3D instance or another OpenClaw gateway you want to visualize. + +The remote office can be connected in one of two ways: + +1. **Remote Claw3D presence endpoint**. +2. **Remote OpenClaw gateway**. + +## Connection Modes + +### 1. Remote Claw3D Presence Endpoint + +Use this when the other machine is also running Claw3D. + +How it works: + +- your local Claw3D server polls the remote Claw3D `presence` endpoint; +- it also tries to load the remote office `layout` snapshot; +- the local 3D scene renders the remote office as a read-only clone inside the same world. + +Typical URL: + +```text +https://other-office.example.com/api/office/presence +``` + +This mode is best when you want the remote side to feel like another full Claw3D office. + +### 2. Remote OpenClaw Gateway + +Use this when the other machine only runs OpenClaw and does not run Claw3D. + +How it works: + +- the browser connects directly to the remote gateway; +- Claw3D derives a read-only presence snapshot from gateway data such as `agents.list`, `status`, and `sessions.preview`; +- because there is no remote Claw3D layout endpoint, the second office uses a fallback office visualization. + +Typical URL: + +```text +ws://remote-host:18789 +``` + +or: + +```text +wss://remote-host.example.com +``` + +If you paste an `http://` or `https://` URL into gateway mode, Claw3D normalizes it to `ws://` or `wss://` before connecting. + +This mode is best when you want remote agent visibility without requiring a second Claw3D deployment. + +## What You Can See + +When the beta is enabled, you can: + +- see a second office in the same environment; +- see remote agents appear in that office; +- see remote agents move and change basic activity state; +- click a remote agent and open a text-only messaging panel. + +## What You Cannot See + +The remote office is intentionally limited. + +You cannot: + +- inspect the remote machine filesystem; +- browse the remote agent chat history in full; +- control the remote office furniture or builder state; +- take over the remote instance as if it were local. + +The goal is cross-office visualization, not remote workstation access. + +## Remote Messaging + +Remote messaging is currently a lightweight relay. + +What it does: + +- lets you send a plain-text note to a remote agent; +- is available from the remote agent chat panel; +- is designed to avoid exposing remote files or tool output in the Claw3D UI. + +Current limitations: + +- remote replies are not mirrored back into the panel yet; +- the panel currently shows your sent message plus delivery/system feedback; +- this is not a shared transcript viewer. + +## How To Connect + +### Prerequisites + +Before enabling the second office, make sure: + +- your local Claw3D is already working with your local OpenClaw gateway; +- you know which remote mode you want to use; +- the remote machine is reachable from your machine or browser; +- any required token, origin allowlist, or private-network access is already configured. + +### Setup Steps + +1. Start your local Claw3D instance. +2. Open the office UI. +3. Open the office settings panel. +4. Turn on `Show second office`. +5. Choose the correct `Source type`. +6. Fill the matching connection fields. + +### Setup For `Remote Claw3D presence endpoint` + +Use: + +- `Source type`: `Remote Claw3D presence endpoint`. +- `Presence URL`: the remote `/api/office/presence` URL. +- `Optional token`: only if that remote Claw3D endpoint is protected. + +Example: + +```text +https://other-office.example.com/api/office/presence +``` + +Expected behavior: + +- the second office appears inside the world; +- remote agents show up when the remote office has active presence; +- if the remote layout snapshot is unavailable, Claw3D falls back to a default/fallback office rendering for the remote side. + +### Setup For `Remote OpenClaw gateway` + +Use: + +- `Source type`: `Remote OpenClaw gateway`. +- `Gateway URL`: the remote gateway WebSocket URL. +- `Shared gateway token`: optional when the gateway already allows your Control UI origin and connection model. + +Examples: + +```text +ws://remote-host:18789 +``` + +```text +wss://remote-host.example.com +``` + +Expected behavior: + +- the second office appears inside the world; +- remote agents are derived from gateway presence data; +- the office shell is a fallback visualization, not a true remote layout clone from another Claw3D instance. + +## Recommended Network Patterns + +### Same private network + +Use a reachable private IP or local hostname for the remote Claw3D endpoint or OpenClaw gateway. + +### Tailscale + +Tailscale is a good fit for this beta because it lets both sides connect over a private network without exposing services publicly. + +Common patterns: + +- remote Claw3D endpoint over `https://.ts.net/api/office/presence`; +- remote OpenClaw gateway over `wss://.ts.net` if you are proxying the gateway through HTTPS/WSS; +- direct gateway over `ws://:18789` when both devices can reach the service privately. + +## Disable Behavior + +If you turn `Show second office` off: + +- the extra office should disappear from the 3D scene; +- the path/outdoor connection should disappear; +- remote office presence and layout hooks should stop driving the scene. + +This lets you return to a single-office view. + +## Troubleshooting + +### No remote agents appear + +Check: + +- the remote URL is correct; +- the remote machine is actually reachable; +- the remote service is running; +- the selected `Source type` matches the service you are pointing at. + +### Presence endpoint works but the remote layout does not + +That usually means the other machine has Claw3D presence available but not a layout snapshot yet. The beta should still render a fallback remote office. + +### Gateway mode connects but messaging fails + +In gateway mode, the browser connects directly to the remote gateway. That means the remote gateway may still reject the connection based on origin policy or other gateway-side security rules. + +If that happens, check: + +- the remote gateway URL; +- whether the remote gateway allows your Control UI origin; +- whether the remote gateway expects a token or device-auth flow you have not configured. + +### You can reach an HTTPS page but gateway mode still fails + +Opening a web page in the browser does not automatically mean the OpenClaw gateway WebSocket is reachable. + +Examples: + +- `https://host` may be reachable while `ws://host:18789` is not; +- a website reverse proxy may exist even though the raw gateway port is closed; +- the remote side may need a dedicated WSS proxy path for the gateway. + +## Current Beta Limitations + +- The second office is read-only. +- Remote replies are not mirrored into the local remote-chat panel yet. +- Gateway mode derives presence from gateway snapshots rather than a real remote Claw3D layout. +- Browser-based gateway mode depends on the remote gateway allowing the connection from your Control UI origin. +- This feature is still evolving and should be treated as beta, not final production-grade multi-tenant collaboration. + +## Summary + +Use `Remote Claw3D presence endpoint` when the other side runs Claw3D and you want the most complete office visualization. + +Use `Remote OpenClaw gateway` when the other side only runs OpenClaw and you mainly want remote agent presence plus lightweight text messaging. diff --git a/README.md b/README.md index 335f305..75b64d7 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,7 @@ See [`.env.example`](.env.example) for the full local development template. - [`VISION.md`](VISION.md): project direction and long-term guardrails. - [`ARCHITECTURE.md`](ARCHITECTURE.md): system boundaries, data flow, and major trade-offs. - [`TUTORIAL.md`](TUTORIAL.md): detailed step-by-step setup for OpenClaw + Tailscale + Claw3D. +- [`MULTI_AGENT_BETA.md`](MULTI_AGENT_BETA.md): remote office beta setup, connection modes, and limitations. - [`CODE_DOCUMENTATION.md`](CODE_DOCUMENTATION.md): practical code map, extension points, and contributor onboarding order. - [`CONTRIBUTING.md`](CONTRIBUTING.md): local workflow, testing, and PR expectations. - [`SUPPORT.md`](SUPPORT.md): where to ask for help and how to route reports. diff --git a/src/app/api/office/layout/route.ts b/src/app/api/office/layout/route.ts new file mode 100644 index 0000000..15f21bd --- /dev/null +++ b/src/app/api/office/layout/route.ts @@ -0,0 +1,116 @@ +import { NextResponse } from "next/server"; +import { + deriveRemoteLayoutUrlFromPresenceUrl, + normalizeOfficeLayoutSnapshot, + type OfficeLayoutSnapshot, +} from "@/lib/office/layoutSnapshot"; +import { loadOfficeLayoutSnapshot, saveOfficeLayoutSnapshot } from "@/lib/office/layoutSnapshotStore"; +import { loadStudioSettings } from "@/lib/studio/settings-store"; +import { resolveOfficePreference } from "@/lib/studio/settings"; + +export const runtime = "nodejs"; +const REMOTE_LAYOUT_TIMEOUT_MS = 10_000; + +const fetchRemoteOfficeLayoutSnapshot = async (params: { + layoutUrl: string; + token?: string | null; +}): Promise => { + const headers: Record = { + Accept: "application/json", + }; + const token = params.token?.trim() ?? ""; + if (token) { + headers.Authorization = `Bearer ${token}`; + headers["X-Claw3D-Office-Token"] = token; + } + const abortController = new AbortController(); + const timeoutId = setTimeout(() => { + abortController.abort(); + }, REMOTE_LAYOUT_TIMEOUT_MS); + let response: Response; + try { + response = await fetch(params.layoutUrl, { + method: "GET", + headers, + cache: "no-store", + signal: abortController.signal, + }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error("Remote office layout request timed out."); + } + throw error; + } finally { + clearTimeout(timeoutId); + } + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error(`Remote office layout request failed with status ${response.status}.`); + } + const payload = (await response.json()) as unknown; + return normalizeOfficeLayoutSnapshot(payload, ""); +}; + +export async function GET(request: Request) { + try { + const url = new URL(request.url); + const source = url.searchParams.get("source")?.trim() || "local"; + if (source === "remote") { + const settings = loadStudioSettings(); + const gatewayUrl = settings.gateway?.url?.trim() || ""; + const officePreference = resolveOfficePreference(settings, gatewayUrl); + if ( + !officePreference.remoteOfficeEnabled || + officePreference.remoteOfficeSourceKind !== "presence_endpoint" || + !officePreference.remoteOfficePresenceUrl.trim() + ) { + return NextResponse.json({ snapshot: null }, { headers: { "Cache-Control": "no-store" } }); + } + const layoutUrl = deriveRemoteLayoutUrlFromPresenceUrl( + officePreference.remoteOfficePresenceUrl, + ); + if (!layoutUrl) { + return NextResponse.json({ snapshot: null }, { headers: { "Cache-Control": "no-store" } }); + } + const snapshot = await fetchRemoteOfficeLayoutSnapshot({ + layoutUrl, + token: officePreference.remoteOfficeToken, + }); + return NextResponse.json( + { snapshot }, + { headers: { "Cache-Control": "no-store" } }, + ); + } + const gatewayUrl = url.searchParams.get("gatewayUrl")?.trim() || ""; + const settings = loadStudioSettings(); + const resolvedGatewayUrl = gatewayUrl || settings.gateway?.url?.trim() || ""; + const snapshot = loadOfficeLayoutSnapshot(resolvedGatewayUrl); + return NextResponse.json( + { snapshot }, + { headers: { "Cache-Control": "no-store" } }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to load office layout."; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function PUT(request: Request) { + try { + const body = (await request.json()) as { snapshot?: unknown }; + const snapshot = normalizeOfficeLayoutSnapshot(body.snapshot, ""); + if (!snapshot) { + return NextResponse.json({ error: "Invalid office layout snapshot." }, { status: 400 }); + } + saveOfficeLayoutSnapshot(snapshot); + return NextResponse.json( + { snapshot }, + { headers: { "Cache-Control": "no-store" } }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to save office layout."; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/office/presence/route.ts b/src/app/api/office/presence/route.ts index 28250bd..acd3ccb 100644 --- a/src/app/api/office/presence/route.ts +++ b/src/app/api/office/presence/route.ts @@ -1,17 +1,60 @@ import { NextResponse } from "next/server"; -import { loadOfficePresenceSnapshot } from "@/lib/office/presence"; +import { + fetchRemoteOfficePresenceSnapshot, + loadOfficePresenceSnapshot, +} from "@/lib/office/presence"; +import { loadStudioSettings } from "@/lib/studio/settings-store"; +import { resolveOfficePreference } from "@/lib/studio/settings"; export const runtime = "nodejs"; export async function GET(request: Request) { try { const url = new URL(request.url); + const source = url.searchParams.get("source")?.trim() || "local"; const workspaceId = url.searchParams.get("workspaceId")?.trim() || "default"; + if (source === "remote") { + const settings = loadStudioSettings(); + const gatewayUrl = settings.gateway?.url?.trim() || ""; + const officePreference = resolveOfficePreference(settings, gatewayUrl); + if ( + !officePreference.remoteOfficeEnabled || + !officePreference.remoteOfficePresenceUrl.trim() + ) { + return NextResponse.json( + { + workspaceId: "remote", + timestamp: new Date().toISOString(), + agents: [], + }, + { headers: { "Cache-Control": "no-store" } } + ); + } + const startedAt = Date.now(); + console.info("[office-presence] Fetching remote office presence.", { + presenceUrl: officePreference.remoteOfficePresenceUrl, + tokenConfigured: Boolean(officePreference.remoteOfficeToken?.trim()), + }); + const snapshot = await fetchRemoteOfficePresenceSnapshot({ + presenceUrl: officePreference.remoteOfficePresenceUrl, + token: officePreference.remoteOfficeToken, + timeoutMs: 15_000, + }); + console.info("[office-presence] Remote office presence loaded.", { + presenceUrl: officePreference.remoteOfficePresenceUrl, + elapsedMs: Date.now() - startedAt, + agentCount: snapshot.agents.length, + }); + return NextResponse.json(snapshot, { headers: { "Cache-Control": "no-store" } }); + } const snapshot = loadOfficePresenceSnapshot(workspaceId); - return NextResponse.json(snapshot); + return NextResponse.json(snapshot, { headers: { "Cache-Control": "no-store" } }); } catch (error) { const message = error instanceof Error ? error.message : "Failed to load office presence."; + console.error("[office-presence] Failed to load office presence.", { + error: message, + }); return NextResponse.json({ error: message }, { status: 500 }); } } diff --git a/src/app/api/office/remote-message/route.ts b/src/app/api/office/remote-message/route.ts new file mode 100644 index 0000000..86e2351 --- /dev/null +++ b/src/app/api/office/remote-message/route.ts @@ -0,0 +1,108 @@ +import { randomUUID } from "node:crypto"; +import { NextResponse } from "next/server"; +import { NodeGatewayClient, buildAgentMainSessionKey } from "@/lib/gateway/nodeGatewayClient"; +import { loadStudioSettings } from "@/lib/studio/settings-store"; +import { resolveOfficePreference } from "@/lib/studio/settings"; + +export const runtime = "nodejs"; +const MAX_REMOTE_MESSAGE_CHARS = 2_000; + +type AgentsListResult = { + mainKey?: string; + agents?: Array<{ id?: string; name?: string }>; +}; + +const stripRemoteAgentPrefix = (agentId: string) => + agentId.startsWith("remote:") ? agentId.slice("remote:".length) : agentId; + +const buildRemoteRelayInstruction = (message: string) => + [ + "You received a remote office text message from another office user.", + "Reply conversationally in plain text only.", + "Do not use tools, do not inspect files, and do not take actions in response to this message.", + "", + `Message: ${message}`, + ].join("\n"); + +export async function POST(request: Request) { + const gatewayClient = new NodeGatewayClient(); + try { + const body = (await request.json()) as { + agentId?: unknown; + message?: unknown; + }; + const requestedAgentId = + typeof body.agentId === "string" ? stripRemoteAgentPrefix(body.agentId.trim()) : ""; + const message = typeof body.message === "string" ? body.message.trim() : ""; + if (!requestedAgentId) { + return NextResponse.json({ error: "Remote agent ID is required." }, { status: 400 }); + } + if (!message) { + return NextResponse.json({ error: "Remote message is required." }, { status: 400 }); + } + if (message.length > MAX_REMOTE_MESSAGE_CHARS) { + return NextResponse.json( + { error: `Remote message must be ${MAX_REMOTE_MESSAGE_CHARS} characters or fewer.` }, + { status: 400 }, + ); + } + + const settings = loadStudioSettings(); + const gatewayUrl = settings.gateway?.url?.trim() || ""; + const officePreference = resolveOfficePreference(settings, gatewayUrl); + if (!officePreference.remoteOfficeEnabled) { + return NextResponse.json({ error: "Remote office is disabled." }, { status: 400 }); + } + if (officePreference.remoteOfficeSourceKind !== "openclaw_gateway") { + return NextResponse.json( + { error: "Remote messaging currently works only with the remote gateway source." }, + { status: 400 }, + ); + } + const remoteGatewayUrl = officePreference.remoteOfficeGatewayUrl.trim(); + if (!remoteGatewayUrl) { + return NextResponse.json( + { error: "Remote office gateway URL is not configured." }, + { status: 400 }, + ); + } + + await gatewayClient.connect({ + gatewayUrl: remoteGatewayUrl, + token: officePreference.remoteOfficeToken, + }); + + const agentsResult = await gatewayClient.request("agents.list", {}); + const mainKey = agentsResult.mainKey?.trim() || "main"; + const remoteAgents = Array.isArray(agentsResult.agents) ? agentsResult.agents : []; + if (remoteAgents.length === 0) { + return NextResponse.json( + { error: "Remote agent list is unavailable right now." }, + { status: 503 }, + ); + } + if (!remoteAgents.some((agent) => (agent.id?.trim() ?? "") === requestedAgentId)) { + return NextResponse.json({ error: "Remote agent is no longer available." }, { status: 404 }); + } + + const sessionKey = buildAgentMainSessionKey(requestedAgentId, mainKey); + await gatewayClient.request("chat.send", { + sessionKey, + message: buildRemoteRelayInstruction(message), + deliver: false, + idempotencyKey: randomUUID(), + }); + + return NextResponse.json({ + ok: true, + agentId: requestedAgentId, + sessionKey, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to send remote office message."; + return NextResponse.json({ error: message }, { status: 500 }); + } finally { + gatewayClient.close(); + } +} diff --git a/src/app/api/path-suggestions/route.ts b/src/app/api/path-suggestions/route.ts index 3179f66..beb08d6 100644 --- a/src/app/api/path-suggestions/route.ts +++ b/src/app/api/path-suggestions/route.ts @@ -43,10 +43,27 @@ const normalizeQuery = (query: string): string => { }; const resolveRealPath = (value: string): string => { + const absolutePath = path.resolve(value); try { - return fs.realpathSync(value); + return fs.realpathSync(absolutePath); } catch { - return path.resolve(value); + const missingSegments: string[] = []; + let currentPath = absolutePath; + while (true) { + if (fs.existsSync(currentPath)) { + try { + return path.join(fs.realpathSync(currentPath), ...missingSegments.reverse()); + } catch { + return path.join(currentPath, ...missingSegments.reverse()); + } + } + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) { + return absolutePath; + } + missingSegments.push(path.basename(currentPath)); + currentPath = parentPath; + } } }; diff --git a/src/features/agents/state/store.tsx b/src/features/agents/state/store.tsx index 577bd1b..c6b5c55 100644 --- a/src/features/agents/state/store.tsx +++ b/src/features/agents/state/store.tsx @@ -137,6 +137,8 @@ const initialState: AgentStoreState = { error: null, }; +const MAX_AGENT_TRANSCRIPT_ENTRIES = 600; + const areStringArraysEqual = (left: string[], right: string[]): boolean => { if (left.length !== right.length) return false; for (let i = 0; i < left.length; i += 1) { @@ -147,7 +149,7 @@ const areStringArraysEqual = (left: string[], right: string[]): boolean => { const ensureTranscriptEntries = (agent: AgentState): TranscriptEntry[] => { if (Array.isArray(agent.transcriptEntries)) { - return agent.transcriptEntries; + return agent.transcriptEntries.slice(-MAX_AGENT_TRANSCRIPT_ENTRIES); } return buildTranscriptEntriesFromLines({ lines: agent.outputLines, @@ -155,7 +157,19 @@ const ensureTranscriptEntries = (agent: AgentState): TranscriptEntry[] => { source: "legacy", startSequence: 0, confirmed: true, - }); + }).slice(-MAX_AGENT_TRANSCRIPT_ENTRIES); +}; + +const trimTranscriptEntries = (entries: TranscriptEntry[]): TranscriptEntry[] => { + return entries.length <= MAX_AGENT_TRANSCRIPT_ENTRIES + ? entries + : entries.slice(-MAX_AGENT_TRANSCRIPT_ENTRIES); +}; + +const trimOutputLines = (lines: string[]): string[] => { + return lines.length <= MAX_AGENT_TRANSCRIPT_ENTRIES + ? lines + : lines.slice(-MAX_AGENT_TRANSCRIPT_ENTRIES); }; const nextTranscriptSequenceCounter = ( @@ -171,18 +185,18 @@ const createRuntimeAgentState = ( existing?: AgentState | null ): AgentState => { const sameSessionKey = existing?.sessionKey === seed.sessionKey; - const outputLines = sameSessionKey ? (existing?.outputLines ?? []) : []; + const outputLines = sameSessionKey ? trimOutputLines(existing?.outputLines ?? []) : []; const queuedMessages = sameSessionKey ? [...(existing?.queuedMessages ?? [])] : []; const transcriptEntries = sameSessionKey ? Array.isArray(existing?.transcriptEntries) - ? existing.transcriptEntries + ? trimTranscriptEntries(existing.transcriptEntries) : buildTranscriptEntriesFromLines({ lines: outputLines, sessionKey: seed.sessionKey, source: "legacy", startSequence: 0, confirmed: true, - }) + }).slice(-MAX_AGENT_TRANSCRIPT_ENTRIES) : []; return { ...seed, @@ -297,11 +311,12 @@ const reducer = (state: AgentStoreState, action: Action): AgentStoreState => { const normalized = TRANSCRIPT_V2_ENABLED ? sortTranscriptEntries(patchedTranscriptEntries) : [...patchedTranscriptEntries]; - transcriptMutated = !areTranscriptEntriesEqual(existingEntries, normalized); - nextEntries = normalized; - nextOutputLines = buildOutputLinesFromTranscriptEntries(normalized); + const trimmed = trimTranscriptEntries(normalized); + transcriptMutated = !areTranscriptEntriesEqual(existingEntries, trimmed); + nextEntries = trimmed; + nextOutputLines = trimOutputLines(buildOutputLinesFromTranscriptEntries(trimmed)); } else if (patchHasOutputLines) { - const patchedOutputLines = patch.outputLines as string[]; + const patchedOutputLines = trimOutputLines(patch.outputLines as string[]); const rebuilt = buildTranscriptEntriesFromLines({ lines: patchedOutputLines, sessionKey: nextSessionKey || agent.sessionKey, @@ -310,10 +325,11 @@ const reducer = (state: AgentStoreState, action: Action): AgentStoreState => { confirmed: true, }); const normalized = TRANSCRIPT_V2_ENABLED ? sortTranscriptEntries(rebuilt) : rebuilt; + const trimmed = trimTranscriptEntries(normalized); transcriptMutated = !areStringArraysEqual(agent.outputLines, patchedOutputLines); - nextEntries = normalized; + nextEntries = trimmed; nextOutputLines = TRANSCRIPT_V2_ENABLED - ? buildOutputLinesFromTranscriptEntries(normalized) + ? trimOutputLines(buildOutputLinesFromTranscriptEntries(trimmed)) : [...patchedOutputLines]; } @@ -375,7 +391,10 @@ const reducer = (state: AgentStoreState, action: Action): AgentStoreState => { sequenceKey: nextSequence, }); if (!nextEntry) { - return { ...agent, outputLines: [...agent.outputLines, action.line] }; + return { + ...agent, + outputLines: trimOutputLines([...agent.outputLines, action.line]), + }; } const nextEntryId = nextEntry.entryId.trim(); const existingIndex = @@ -402,18 +421,22 @@ const reducer = (state: AgentStoreState, action: Action): AgentStoreState => { }); return acc; }, []); - nextEntries = TRANSCRIPT_V2_ENABLED ? sortTranscriptEntries(replaced) : replaced; + nextEntries = trimTranscriptEntries( + TRANSCRIPT_V2_ENABLED ? sortTranscriptEntries(replaced) : replaced + ); } else { const appended = [...existingEntries, nextEntry]; - nextEntries = TRANSCRIPT_V2_ENABLED ? sortTranscriptEntries(appended) : appended; + nextEntries = trimTranscriptEntries( + TRANSCRIPT_V2_ENABLED ? sortTranscriptEntries(appended) : appended + ); } return { ...agent, outputLines: TRANSCRIPT_V2_ENABLED || hasReplacement - ? buildOutputLinesFromTranscriptEntries(nextEntries) - : [...agent.outputLines, action.line], + ? trimOutputLines(buildOutputLinesFromTranscriptEntries(nextEntries)) + : trimOutputLines([...agent.outputLines, action.line]), transcriptEntries: nextEntries, transcriptRevision: (agent.transcriptRevision ?? 0) + 1, transcriptSequenceCounter: Math.max( diff --git a/src/features/office/components/RemoteAgentChatPanel.tsx b/src/features/office/components/RemoteAgentChatPanel.tsx new file mode 100644 index 0000000..9b46f6f --- /dev/null +++ b/src/features/office/components/RemoteAgentChatPanel.tsx @@ -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(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) => { + if (event.key !== "Enter" || event.shiftKey) return; + event.preventDefault(); + handleSend(); + }; + + return ( +
+
+
+ Remote Agent +
+
{agentName}
+
{helperText}
+
+ +
+ {messages.length === 0 ? ( +
+ Send a plain-text note to this remote agent. +
+ ) : ( + messages.map((message) => ( +
+
+ {message.text} +
+
+ {formatTimestamp(message.timestampMs)} +
+
+ )) + )} +
+ +
+ {error ? ( +
+ {error} +
+ ) : null} +