Files
oh-my-pi/packages/collab-web/src/components/agents/AgentDrawer.tsx
T
can1357 8fa1f6c250 feat: added shared collab wire protocol and web guest collaboration client
- Added @oh-my-pi/pi-wire and reworked collab protocol types into shared contracts.
- Added wire-compatibility guards in coding-agent host to block unsupported events.
- Added standalone collab-web package with guest UI, mock-host tooling, and local relay.
- Added secure room-link validation, WebCrypto framing, and safer socket routing.
2026-06-12 11:54:41 +02:00

185 lines
5.7 KiB
TypeScript

import type { AgentSnapshot, SessionEntry, SubagentProgressPayload } from "@oh-my-pi/pi-wire";
import { OctagonX, RotateCcw, SendHorizontal, X } from "lucide-react";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import type { GuestClient } from "../../lib/client";
import { fmtCost, fmtDuration, fmtTokens } from "../../lib/format";
import { parseJsonl } from "../../lib/jsonl";
import type { TranscriptProps } from "../transcript/Transcript";
import { Transcript } from "../transcript/Transcript";
const EMPTY_TOOLS: TranscriptProps["activeTools"] = new Map();
const POLL_MS = 1200;
export function AgentDrawer(props: {
agent: AgentSnapshot;
progress?: SubagentProgressPayload;
client: GuestClient;
onClose(): void;
}): ReactNode {
const { agent, progress, client, onClose } = props;
const [entries, setEntries] = useState<readonly SessionEntry[]>([]);
const [draft, setDraft] = useState("");
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
// Live transcript: poll the host-side session file while the drawer is
// open, appending parsed JSONL entries. State resets when the agent
// changes; the interval and any in-flight reply are dropped on cleanup.
useEffect(() => {
setEntries([]);
if (!agent.hasSessionFile) return;
let disposed = false;
let inFlight = false;
let cursor = 0;
let carry = "";
let acc: readonly SessionEntry[] = [];
const poll = async (): Promise<void> => {
if (disposed || inFlight) return;
inFlight = true;
try {
const reply = await client.fetchTranscript(agent.id, cursor);
if (disposed || reply === null) return; // timeout/error → keep polling
cursor = reply.newSize;
if (!reply.text) return;
const parsed = parseJsonl(reply.text, carry);
carry = parsed.carry;
const fresh: SessionEntry[] = [];
for (const item of parsed.items) {
if (typeof item !== "object" || item === null) continue;
if ((item as { type?: unknown }).type === "session") continue;
fresh.push(item as SessionEntry);
}
if (fresh.length > 0) {
acc = [...acc, ...fresh];
setEntries(acc);
}
} finally {
inFlight = false;
}
};
void poll();
const timer = setInterval(() => {
void poll();
}, POLL_MS);
return () => {
disposed = true;
clearInterval(timer);
};
}, [agent.id, agent.hasSessionFile, client]);
const sendChat = () => {
const text = draft.trim();
if (!text) return;
client.sendAgentCmd("chat", agent.id, text);
setDraft("");
};
const p = progress?.progress;
const model = p?.resolvedModel;
const ctxPct =
p?.contextTokens !== undefined && p.contextWindow
? Math.min(100, (p.contextTokens / p.contextWindow) * 100)
: null;
return (
<aside className="ag-drawer" role="dialog" aria-label={agent.displayName}>
<header className="ag-drawer-head">
<div className="ag-drawer-title">
<span className="ag-drawer-name">{agent.displayName}</span>
<span className={`ag-chip ag-chip--${agent.status}`}>{agent.status}</span>
{model ? <span className="ag-chip ag-chip--model">{model}</span> : null}
</div>
<div className="ag-drawer-actions">
{agent.status === "running" ? (
<button
type="button"
className="ag-btn ag-btn--danger"
onClick={() => client.sendAgentCmd("kill", agent.id)}
>
<OctagonX size={13} aria-hidden />
kill
</button>
) : null}
{agent.status === "parked" || agent.status === "aborted" ? (
<button type="button" className="ag-btn" onClick={() => client.sendAgentCmd("revive", agent.id)}>
<RotateCcw size={13} aria-hidden />
revive
</button>
) : null}
<button type="button" className="ag-iconbtn" aria-label="close" onClick={onClose}>
<X size={15} aria-hidden />
</button>
</div>
</header>
{p ? (
<div className="ag-stats">
<span className="ag-stat">
<span className="ag-stat-label">tok</span>
<span className="ag-stat-value">{fmtTokens(p.tokens)}</span>
</span>
{ctxPct !== null ? (
<span className="ag-stat" title={`context ${fmtTokens(p.contextTokens ?? 0)}`}>
<span className="ag-stat-label">ctx</span>
<span className="ag-gauge">
<span
className={ctxPct > 80 ? "ag-gauge-fill ag-gauge-fill--warn" : "ag-gauge-fill"}
style={{ width: `${ctxPct}%` }}
/>
</span>
</span>
) : null}
<span className="ag-stat">
<span className="ag-stat-label">cost</span>
<span className="ag-stat-value">{fmtCost(p.cost)}</span>
</span>
<span className="ag-stat">
<span className="ag-stat-label">tools</span>
<span className="ag-stat-value">{p.toolCount}</span>
</span>
<span className="ag-stat">
<span className="ag-stat-value">{fmtDuration(p.durationMs)}</span>
</span>
</div>
) : null}
<div className="ag-drawer-body">
{agent.hasSessionFile ? (
<Transcript
compact
entries={entries}
stream={null}
streamDone={false}
activeTools={EMPTY_TOOLS}
working={agent.status === "running"}
/>
) : (
<div className="ag-empty">no transcript available</div>
)}
</div>
<form
className="ag-chat"
onSubmit={e => {
e.preventDefault();
sendChat();
}}
>
<input
className="ag-chat-input"
value={draft}
placeholder={`message ${agent.displayName}…`}
onChange={e => setDraft(e.target.value)}
/>
<button type="submit" className="ag-iconbtn" aria-label="send" disabled={draft.trim().length === 0}>
<SendHorizontal size={15} aria-hidden />
</button>
</form>
</aside>
);
}