import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { RemoteInfo, RemoteMessage, Session } from '../types'; import { REMOTE_STATE_LABEL } from '../types'; import * as api from '../api'; import Logo from './Logo'; import { renderMarkdown } from '../lib/markdown'; import { CloseGlyph, StopGlyph, PlayGlyph, ShareGlyph, AckGlyph } from './icons'; // Looks like the terminal, is not one: no PTY, no xterm.js, no WebSocket. The // agent's real TUI is running on its own machine — what crosses the wire is // messages, so this renders markdown into a mono-styled log with a composer // underneath. See docs/remote-agents.md §7. const POLL_MS = 2000; // the app's existing /api/tree cadence const MAX_RENDER = 2000; // a human-paced conversation, not a 6 MB transcript const fmtAgo = (ts?: number | null) => { if (!ts) return null; const s = Math.max(0, Math.round((Date.now() - ts) / 1000)); if (s < 10) return 'just now'; if (s < 60) return `${s}s ago`; if (s < 3600) return `${Math.round(s / 60)}m ago`; return `${Math.round(s / 3600)}h ago`; }; export default function RemotePane({ session, focused, zoom = 100, dragId, onDragActive, onFocus, onClose, onRename, }: { session: Session; focused?: boolean; zoom?: number; dragId?: string; onDragActive?: (dragging: boolean) => void; onFocus?: () => void; onClose: () => void; onRename?: (name: string) => void; }) { const name = session.remote?.name || ''; const [info, setInfo] = useState(null); const [messages, setMessages] = useState([]); const [draft, setDraft] = useState(''); const [sending, setSending] = useState(false); const [editing, setEditing] = useState(false); const [titleDraft, setTitleDraft] = useState(session.name); const [connectOpen, setConnectOpen] = useState(false); const [prompt, setPrompt] = useState(null); const [copied, setCopied] = useState(false); const [err, setErr] = useState(null); const bodyRef = useRef(null); const inputRef = useRef(null); const popRef = useRef(null); const cursor = useRef(0); const atBottom = useRef(true); const autoOpened = useRef(false); // One font size for the log, the composer and the status line, so the pane // reads as one surface and the zoom control moves all of it together. const fontSize = `${(13 * zoom) / 100}px`; const absorb = useCallback((incoming: RemoteMessage[]) => { if (!incoming.length) return; setMessages((prev) => { const seen = new Set(prev.map((m) => m.seq)); const merged = [...prev, ...incoming.filter((m) => !seen.has(m.seq))]; merged.sort((a, b) => a.seq - b.seq); return merged.length > MAX_RENDER ? merged.slice(-MAX_RENDER) : merged; }); cursor.current = Math.max(cursor.current, ...incoming.map((m) => m.seq)); }, []); const refresh = useCallback(async () => { try { const r = await api.getRemoteLog(session.id, cursor.current); const { messages: msgs, ...rest } = r; setInfo(rest); absorb(msgs); setErr(null); } catch { setErr('lost contact with the manager'); } }, [session.id, absorb]); useEffect(() => { cursor.current = 0; setMessages([]); refresh(); const t = setInterval(refresh, POLL_MS); return () => clearInterval(t); }, [refresh]); // Stay pinned to the newest message unless the operator has scrolled up to // read something — then leave their scroll position alone. useEffect(() => { const el = bodyRef.current; if (el && atBottom.current) el.scrollTop = el.scrollHeight; }, [messages]); const loadPrompt = useCallback(async () => { if (!name) return; try { setPrompt(await api.getRemotePrompt(name)); } catch { setPrompt('could not load the connect prompt'); } }, [name]); // Nothing has ever spoken from the other side, so this pane's whole job right // now is pairing: open the connect popover once, unasked. It closes like any // other popover and never re-opens itself. const neverConnected = !!info && !info.connected && !messages.some((m) => m.role === 'agent'); useEffect(() => { if (!neverConnected || autoOpened.current) return; autoOpened.current = true; setConnectOpen(true); loadPrompt(); }, [neverConnected, loadPrompt]); // Anchored popover, so dismissal works the way every other popover does. useEffect(() => { if (!connectOpen) return; const onDown = (e: MouseEvent) => { if (popRef.current && !popRef.current.contains(e.target as Node)) setConnectOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setConnectOpen(false); }; document.addEventListener('mousedown', onDown); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); }; }, [connectOpen]); const toggleConnect = () => { setConnectOpen((open) => { if (!open && prompt === null) loadPrompt(); return !open; }); }; const copy = () => { if (!prompt) return; navigator.clipboard?.writeText(prompt).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1600); }).catch(() => {}); }; const send = async () => { const text = draft.trim(); if (!text || sending) return; setSending(true); setDraft(''); atBottom.current = true; try { await api.sayToRemote(session.id, text); await refresh(); } catch { setErr('could not send — the message was not delivered'); setDraft(text); } finally { setSending(false); } }; const togglePaused = async () => { if (!info) return; try { setInfo(await api.setRemotePaused(session.id, !info.paused)); await refresh(); } catch { setErr('could not change the connection'); } }; const commitName = () => { setEditing(false); const next = titleDraft.trim(); if (next && next !== session.name) onRename?.(next); }; const state = info?.state || session.state; const paused = info?.paused ?? !!session.remote?.paused; const peer = info?.peer || null; const stateLabel = REMOTE_STATE_LABEL[state]; const seenAgo = fmtAgo(info?.lastSeenAt); const MAX_ROWS = 10; useEffect(() => { const el = inputRef.current; if (!el) return; el.style.height = 'auto'; // let it report its natural content height const lh = parseFloat(getComputedStyle(el).lineHeight) || 20; const cap = lh * MAX_ROWS; el.style.height = `${Math.min(el.scrollHeight, cap)}px`; el.style.overflowY = el.scrollHeight > cap ? 'auto' : 'hidden'; }, [draft, fontSize]); const rendered = useMemo( () => messages.map((m) => ({ ...m, html: m.role === 'agent' ? renderMarkdown(m.text) : '' })), [messages], ); return (
{/* The standard three-column pane header: identity left, name centred, actions right — same as every other agent's pane. Everything else the operator might want to know lives in the status line under the composer, the way a CLI keeps its context on one bottom row. */}
{ e.dataTransfer.setData('text/plain', dragId); e.dataTransfer.effectAllowed = 'move'; onDragActive?.(true); } : undefined} onDragEnd={dragId ? () => onDragActive?.(false) : undefined} >
{editing ? ( e.stopPropagation()} onChange={(e) => setTitleDraft(e.target.value)} onBlur={commitName} onKeyDown={(e) => { if (e.key === 'Enter') commitName(); if (e.key === 'Escape') setEditing(false); }} /> ) : ( { setTitleDraft(session.name); setEditing(true); } : undefined} >{session.name} )}
{connectOpen && (
e.stopPropagation()}>
connect an agent as {name}
{prompt ?? 'loading…'}
paste into a coding CLI on the machine you want to work from · nothing here is secret
)}
{ const el = bodyRef.current; if (el) atBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40; }} style={{ fontSize }} > {rendered.map((m) => ( m.role === 'system' ? (
· {m.text}
) : m.role === 'user' ? (
{m.text} {/* Both states come from the highest seq a poll actually returned, and claim nothing beyond it: the agent either has this message or has not collected it yet. */} {m.seq <= (info?.deliveredThrough ?? 0) ? : pending}
) : (
) ))} {err &&
{err}
}