Spaces:
Running
Running
File size: 12,784 Bytes
b8df5df | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 | 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 ? 'Reconnect — let an agent poll again' : 'Disconnect — tell 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 ? 'disconnected — a 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>
);
}
|