Leandro von Werra
Remote agents: a pane that talks to an agent on your own machine (#16)
b8df5df unverified
Raw
History Blame Contribute Delete
12.8 kB
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<RemoteInfo | null>(null);
const [messages, setMessages] = useState<RemoteMessage[]>([]);
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<string | null>(null);
const [copied, setCopied] = useState(false);
const [err, setErr] = useState<string | null>(null);
const bodyRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const popRef = useRef<HTMLDivElement | null>(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 (
<div className={`slot${focused ? ' focused' : ''}`} onMouseDown={onFocus}>
{/* 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. */}
<div
className={`pane-head${dragId ? ' draggable' : ''}`}
draggable={!!dragId}
onDragStart={dragId ? (e) => { e.dataTransfer.setData('text/plain', dragId); e.dataTransfer.effectAllowed = 'move'; onDragActive?.(true); } : undefined}
onDragEnd={dragId ? () => onDragActive?.(false) : undefined}
>
<div className="ph-left">
<Logo cli="remote" size={16} tint="#5ec2e0" />
<span className={`status ${state}`} title={stateLabel} />
</div>
{editing ? (
<input
className="ph-title-input" autoFocus value={titleDraft}
onMouseDown={(e) => e.stopPropagation()}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitName}
onKeyDown={(e) => { if (e.key === 'Enter') commitName(); if (e.key === 'Escape') setEditing(false); }}
/>
) : (
<span
className="ph-title"
title={onRename ? 'double-click to rename' : undefined}
onDoubleClick={onRename ? () => { setTitleDraft(session.name); setEditing(true); } : undefined}
>{session.name}</span>
)}
<div className="ph-right">
<div className="rp-pop-wrap" ref={popRef}>
<button
className={`mini-btn${connectOpen ? ' on' : ''}`}
title="Connect an agent — show the prompt to copy"
onClick={(e) => { e.stopPropagation(); toggleConnect(); }}
><ShareGlyph /></button>
{connectOpen && (
<div className="rp-pop" onMouseDown={(e) => e.stopPropagation()}>
<div className="rp-pop-head">
<span>connect an agent as <b>{name}</b></span>
<span className="spacer" />
<button className="mini-btn" onClick={copy} disabled={!prompt}>{copied ? 'copied' : 'copy'}</button>
<button className="mini-btn" onClick={() => setConnectOpen(false)}><CloseGlyph /></button>
</div>
<pre className="rp-pop-prompt">{prompt ?? 'loading…'}</pre>
<div className="rp-pop-foot">
paste into a coding CLI on the machine you want to work from · nothing here is secret
</div>
</div>
)}
</div>
<button
className="mini-btn"
title={paused ? 'Reconnectlet an agent poll again' : 'Disconnecttell the agent to stop'}
onClick={(e) => { e.stopPropagation(); togglePaused(); }}
>{paused ? <PlayGlyph /> : <StopGlyph />}</button>
<button className="mini-btn ph-close" title="Close" onClick={(e) => { e.stopPropagation(); onClose(); }}><CloseGlyph /></button>
</div>
</div>
<div
className="rp-body"
ref={bodyRef}
onScroll={() => {
const el = bodyRef.current;
if (el) atBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}}
style={{ fontSize }}
>
{rendered.map((m) => (
m.role === 'system' ? (
<div key={m.seq} className="rp-sys">· {m.text}</div>
) : m.role === 'user' ? (
<div key={m.seq} className="rp-user">
<span className="rp-caret"></span>
<span className="rp-user-text">{m.text}</span>
{/* 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)
? <AckGlyph className="rp-ack" />
: <span className="rp-pending" title="the agent has not collected this yet">pending</span>}
</div>
) : (
<div key={m.seq} className="rp-agent" dangerouslySetInnerHTML={{ __html: m.html }} />
)
))}
{err && <div className="rp-err">{err}</div>}
</div>
<div className="rp-composer" style={{ fontSize }}>
<span className="rp-caret"></span>
<textarea
ref={inputRef}
className="rp-input"
rows={1}
value={draft}
placeholder={paused ? 'disconnecteda message will wait in the log' : 'message this agent'}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
/>
</div>
{/* The bottom status row: state, where the agent actually is, when we last
heard from it. */}
<div className="rp-status" style={{ fontSize }}>
<span className={`rp-state ${state}`}>{stateLabel}</span>
{peer?.harness && <><span className="rp-dot">·</span><span>{peer.harness}</span></>}
{peer?.cwd && <><span className="rp-dot">·</span><span className="rp-cwd" title={peer.cwd}>{peer.cwd}</span></>}
{peer?.host && <><span className="rp-dot">·</span><span>on {peer.host}</span></>}
{!peer && <><span className="rp-dot">·</span><span className="rp-cwd">remote-agents/{name}/</span></>}
<span className="spacer" />
{seenAgo && <span className="rp-seen">last seen {seenAgo}</span>}
<span className="rp-hint">↵ send · ⇧↵ newline</span>
</div>
</div>
);
}