Files
nihalashetty ae67bff5a3 feat: deep-agent canvas, live observability, and multi-environment tooling
Self-hosted platform for building, testing, and shipping LangChain/LangGraph agents. Deep-agent sub-agents on the canvas, a live tracing/observability timeline, auto-provisioned built-in tools with import/export, per-environment tool variables, streamed evaluations, and per-user auth token forwarding.
2026-07-28 01:49:19 +05:30

409 lines
26 KiB
TypeScript

"use client";
/* Screens for the platform features: Channels, Triggers, Datasets (eval),
and the live-agent Handoff inbox. */
import { useCallback, useEffect, useState } from "react";
import { Icon } from "../icons";
import { Field, Modal } from "../primitives";
import { api, Channel, Dataset, EvalReport, EvalResult, Handoff, Trigger, Workflow, openSSE } from "@/lib/api";
/* Validate the Cases JSON before it is POSTed: it must be a non-empty array of objects
that each carry a string `input`. Returns the parsed cases (or null) + a human error so
the modal can block Create instead of silently saving an empty / un-runnable dataset. */
function parseCases(text: string): { cases: any[] | null; error: string | null } {
let v: unknown;
try { v = JSON.parse(text); } catch { return { cases: null, error: "Not valid JSON." }; }
if (!Array.isArray(v)) return { cases: null, error: "Expected a JSON array of cases." };
if (v.length === 0) return { cases: null, error: "Add at least one case." };
for (const c of v) {
if (typeof c !== "object" || c === null || typeof (c as any).input !== "string")
return { cases: null, error: 'Each case needs a string "input" field.' };
}
return { cases: v as any[], error: null };
}
const EMPTY_DATASET_FORM = { name: "", workflow_id: "", score_mode: "contains", items: '[\n {"input": "what are your hours?", "expected": "9am"}\n]' };
/* One labelled block in an expanded eval result (input / expected / answer / reason). */
function ResultField({ label, value }: { label: string; value: string }) {
return (
<div className="col" style={{ gap: 3 }}>
<div className="t-micro">{label}</div>
<div className="t-caption" style={{ whiteSpace: "pre-wrap", wordBreak: "break-word", color: "var(--fg-1)", maxHeight: 220, overflowY: "auto" }}>{value}</div>
</div>
);
}
/* Per-case run metrics, shown as chips once a case finishes. */
const fmtLatency = (ms?: number) => (ms == null ? "" : ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`);
const fmtTokens = (t?: number) => (t == null ? "" : t >= 1000 ? `${(t / 1000).toFixed(1)}k tok` : `${t} tok`);
/* Status pill for one case: running (spinner), pass, fail, or an inconclusive status
(run_failed / unavailable / error) rendered as a warning rather than a plain fail. */
function CaseStatus({ result }: { result?: EvalResult }) {
if (!result) return <span className="pill pill-muted row gap1" style={{ alignItems: "center" }}><Icon name="refresh" size={11} className="spin" />running</span>;
if (result.passed) return <span className="pill pill-ok">pass</span>;
const inconclusive = result.status && result.status !== "scored";
if (inconclusive) return <span className="pill pill-warn" title={result.reason || undefined}>{result.status === "run_failed" ? "run failed" : result.status}</span>;
return <span className="pill pill-err">fail</span>;
}
const SCORING_HELP: Record<string, string> = {
contains: "Pass if the answer contains the expected text (case-insensitive).",
exact: "Pass if the answer exactly equals the expected text.",
regex: "Pass if the expected pattern (regex) matches the answer.",
judge: "An LLM grades whether the answer satisfies the expected behavior.",
};
function Header({ title, subtitle, action }: { title: string; subtitle?: string; action?: React.ReactNode }) {
return (
<div className="row spread" style={{ marginBottom: 18 }}>
<div>
<div className="t-display">{title}</div>
{subtitle && <div className="fg-1" style={{ marginTop: 2 }}>{subtitle}</div>}
</div>
{action}
</div>
);
}
function Shell({ children }: { children: React.ReactNode }) {
return <div className="scroll-y" style={{ flex: 1, padding: "24px 28px" }}><div className="fade-up" style={{ maxWidth: 1600, margin: "0 auto" }}>{children}</div></div>;
}
function useWorkflows(pid?: string) {
const [wfs, setWfs] = useState<Workflow[]>([]);
useEffect(() => { if (pid) api.listWorkflows(pid).then(setWfs).catch(() => setWfs([])); }, [pid]);
return wfs;
}
/* ============ CHANNELS ============ */
type ChannelForm = { id?: string; type: string; name: string; workflow_id: string; config: any };
const BLANK_CHANNEL: ChannelForm = { type: "email", name: "", workflow_id: "", config: {} };
export function ChannelsScreen({ project }: { project: any }) {
const [channels, setChannels] = useState<Channel[]>([]);
const wfs = useWorkflows(project?.id);
const [open, setOpen] = useState(false);
const [form, setForm] = useState<ChannelForm>(BLANK_CHANNEL);
const reload = useCallback(() => { if (project?.id) api.listChannels(project.id).then(setChannels).catch(() => setChannels([])); }, [project?.id]);
useEffect(() => { reload(); }, [reload]);
const setSmtp = (patch: any) => setForm((f) => ({ ...f, config: { ...f.config, smtp: { ...(f.config.smtp || {}), ...patch } } }));
const smtp = (form.config || {}).smtp || {};
async function save() {
if (!form.name.trim()) return;
if (form.id) await api.updateChannel(project.id, form.id, { name: form.name, workflow_id: form.workflow_id || undefined, config: form.config });
else await api.createChannel(project.id, { type: form.type, name: form.name, workflow_id: form.workflow_id || undefined, config: form.config });
setOpen(false); setForm(BLANK_CHANNEL); reload();
}
function edit(ch: Channel) { setForm({ id: ch.id, type: ch.type, name: ch.name, workflow_id: ch.workflow_id || "", config: ch.config || {} }); setOpen(true); }
async function remove(id: string) { if (window.confirm("Delete this channel?")) { await api.deleteChannel(project.id, id); reload(); } }
const urlOf = (ch: Channel) => ch.inbound_url;
return (
<Shell>
<Header title="Channels" subtitle="Deploy a workflow to an email surface."
action={<button className="btn btn-primary btn-sm" onClick={() => { setForm(BLANK_CHANNEL); setOpen(true); }}><Icon name="plus" size={14} />New channel</button>} />
<div className="col gap2">
{channels.map((ch) => (
<div key={ch.id} className="card" style={{ padding: 14 }}>
<div className="row spread">
<div className="row gap2"><Icon name="msg" size={16} /><span className="t-h3">{ch.name}</span><span className="typechip">{ch.type}</span>{!ch.enabled && <span className="pill pill-muted">disabled</span>}</div>
<div className="row gap2"><button className="btn btn-secondary btn-sm" onClick={() => edit(ch)}><Icon name="edit" size={13} />Configure</button><button className="iconbtn" title="Delete" onClick={() => remove(ch.id)}><Icon name="trash" size={14} /></button></div>
</div>
{urlOf(ch) && <div className="mono-sm fg-2" style={{ marginTop: 8, wordBreak: "break-all" }}>{urlOf(ch)}</div>}
</div>
))}
{channels.length === 0 && <div className="fg-2 t-caption">No channels yet. Create one to deploy this project's workflow.</div>}
</div>
<Modal open={open} onClose={() => setOpen(false)} title={form.id ? "Configure channel" : "New channel"} width={500}
footer={<><button className="btn btn-ghost" onClick={() => setOpen(false)}>Cancel</button><button className="btn btn-primary" onClick={save}>{form.id ? "Save" : "Create"}</button></>}>
<Field label="Name"><input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="Support channel" /></Field>
<Field label="Workflow" help="Which workflow handles messages on this channel."><select className="select" value={form.workflow_id} onChange={(e) => setForm((f) => ({ ...f, workflow_id: e.target.value }))}><option value="">First active workflow</option>{wfs.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}</select></Field>
{form.type === "email" && (
<>
<div className="field-help" style={{ marginTop: 0 }}>Outbound SMTP for replies. Inbound mail is posted to the channel&apos;s inbound URL by your provider (Mailgun/SendGrid/Postmark) or an IMAP relay.</div>
<div className="row gap3">
<Field label="SMTP host"><input className="input mono" value={smtp.host || ""} onChange={(e) => setSmtp({ host: e.target.value })} placeholder="smtp.sendgrid.net" /></Field>
<Field label="Port"><input className="input mono" type="number" value={smtp.port ?? 587} onChange={(e) => setSmtp({ port: Number(e.target.value) })} /></Field>
</div>
<div className="row gap3">
<Field label="Username"><input className="input mono" value={smtp.username || ""} onChange={(e) => setSmtp({ username: e.target.value })} /></Field>
<Field label="From address"><input className="input mono" value={smtp.from || ""} onChange={(e) => setSmtp({ from: e.target.value })} placeholder="support@yourco.com" /></Field>
</div>
<Field label="Password secret ref" help="A secret holding the SMTP password (Settings → Secrets)."><input className="input mono" value={smtp.password_ref || ""} onChange={(e) => setSmtp({ password_ref: e.target.value })} placeholder="secret://proj/smtp_password" /></Field>
</>
)}
</Modal>
</Shell>
);
}
/* ============ TRIGGERS ============ */
export function TriggersScreen({ project }: { project: any }) {
const [triggers, setTriggers] = useState<Trigger[]>([]);
useEffect(() => { if (project?.id) api.listTriggers(project.id).then(setTriggers).catch(() => setTriggers([])); }, [project?.id]);
return (
<Shell>
<Header title="Triggers" subtitle="Event entry points, synced from your workflows' trigger nodes (Webhook / Schedule / Email / Chat / App Event)." />
<div className="col gap2">
{triggers.map((t) => (
<div key={t.id} className="card" style={{ padding: 14 }}>
<div className="row spread">
<div className="row gap2"><Icon name="bolt" size={15} /><span className="t-h3" style={{ textTransform: "capitalize" }}>{t.kind.replace("_", " ")}</span><span className="typechip">{t.node_id}</span>{!t.enabled && <span className="pill pill-muted">disabled</span>}</div>
{t.last_fired_at && <span className="fg-2 t-caption">last fired {new Date(t.last_fired_at).toLocaleString()}</span>}
</div>
{t.webhook_url && <div className="mono-sm fg-2" style={{ marginTop: 8, wordBreak: "break-all" }}>POST {t.webhook_url}</div>}
{t.config?.cron && <div className="mono-sm fg-2" style={{ marginTop: 8 }}>cron: {t.config.cron}</div>}
{t.config?.every_minutes && <div className="mono-sm fg-2" style={{ marginTop: 8 }}>every {t.config.every_minutes} min</div>}
{t.config?.poll_url && <div className="mono-sm fg-2" style={{ marginTop: 8, wordBreak: "break-all" }}>polls {t.config.poll_url}</div>}
</div>
))}
{triggers.length === 0 && <div className="fg-2 t-caption">No triggers. Add a trigger node (Webhook / Schedule / …) to a workflow and publish it.</div>}
</div>
</Shell>
);
}
/* ============ DATASETS / EVAL ============ */
export function DatasetsScreen({ project }: { project: any }) {
const [datasets, setDatasets] = useState<Dataset[]>([]);
const wfs = useWorkflows(project?.id);
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<Dataset | null>(null);
const [form, setForm] = useState(EMPTY_DATASET_FORM);
const [report, setReport] = useState<EvalReport | null>(null);
const [ranDataset, setRanDataset] = useState<Dataset | null>(null);
const [expanded, setExpanded] = useState<Set<number>>(new Set());
const [runError, setRunError] = useState<string | null>(null);
const [running, setRunning] = useState<string | null>(null);
// Live streaming state: the ordered case list (from the `start` frame) and each case's result
// as it finishes (`item` frames), so the result view renders immediately and fills in live.
const [liveItems, setLiveItems] = useState<{ index: number; input: string; expected: string }[] | null>(null);
const [liveResults, setLiveResults] = useState<Record<number, EvalResult>>({});
const reload = useCallback(() => { if (project?.id) api.listDatasets(project.id).then(setDatasets).catch(() => setDatasets([])); }, [project?.id]);
useEffect(() => { reload(); }, [reload]);
// Guard the form so we never save a dataset that can't be run: a name, a bound
// workflow, and a valid non-empty case list are all required.
const { cases, error: casesError } = parseCases(form.items);
const canSave = form.name.trim() !== "" && form.workflow_id !== "" && !!cases;
function openCreate() { setEditing(null); setForm(EMPTY_DATASET_FORM); setOpen(true); }
function openEdit(d: Dataset) {
setEditing(d);
setForm({ name: d.name, workflow_id: d.workflow_id || "", score_mode: d.score_mode, items: JSON.stringify(d.items ?? [], null, 2) });
setOpen(true);
}
async function save() {
if (!canSave || !cases) return;
const body = { name: form.name.trim(), workflow_id: form.workflow_id, score_mode: form.score_mode, items: cases };
if (editing) await api.updateDataset(project.id, editing.id, body);
else await api.createDataset(project.id, body);
setOpen(false); reload();
}
async function remove(d: Dataset) {
if (!window.confirm(`Delete dataset "${d.name}"?\n\nThis removes its cases and last run result. This cannot be undone.`)) return;
await api.deleteDataset(project.id, d.id);
if (editing?.id === d.id) setOpen(false);
reload();
}
async function run(d: Dataset) {
// Seed the live view from the dataset's own cases so every row shows up the instant Run is
// clicked (before the first server frame), then stream results in as each case finishes.
const seed = (d.items || []).map((it: any, i: number) => ({ index: i, input: it?.input ?? "", expected: it?.expected ?? "" }));
setRunning(d.id); setReport(null); setRunError(null); setRanDataset(d); setExpanded(new Set());
setLiveItems(seed); setLiveResults({});
try {
await openSSE(api.runDatasetStreamUrl(project.id, d.id), (frame) => {
if (frame.event === "start") setLiveItems(frame.data.items);
else if (frame.event === "item") { const r = frame.data as EvalResult & { index: number }; setLiveResults((prev) => ({ ...prev, [r.index]: r })); }
else if (frame.event === "done") setReport(frame.data as EvalReport);
else if (frame.event === "error") setRunError(frame.data?.error || "The run failed.");
}, { method: "POST" });
} catch (e: any) {
setRunError(e?.message || "The run request failed.");
} finally {
setRunning(null); reload();
}
}
// Live-view derived numbers (used by the result card below).
const rows = liveItems;
const total = rows?.length ?? 0;
const doneResults = Object.values(liveResults);
const doneCount = doneResults.length;
const passSoFar = doneResults.filter((r) => r.passed).length;
const isRunningNow = running !== null;
const summary = report?.summary;
const totalTokens = summary?.tokens ?? doneResults.reduce((a, r) => a + (r.tokens || 0), 0);
const progressPct = total ? Math.round((doneCount / total) * 100) : 0;
return (
<Shell>
<Header title="Evaluations" subtitle="Run input expected-output datasets against a workflow to score quality and catch regressions."
action={<button className="btn btn-primary btn-sm" onClick={openCreate}><Icon name="plus" size={14} />New dataset</button>} />
<div className="col gap2">
{datasets.map((d) => (
<div key={d.id} className="card" style={{ padding: 14 }}>
<div className="row spread">
<div className="row gap2"><Icon name="validate" size={15} /><span className="t-h3">{d.name}</span><span className="typechip">{d.score_mode}</span><span className="fg-2 t-caption">{d.n_items} cases</span>{!d.workflow_id && <span className="pill pill-warn">no workflow</span>}</div>
<div className="row gap2">
{d.last_pass_rate != null && <span className={`pill ${d.last_pass_rate >= 0.8 ? "pill-ok" : "pill-muted"}`}>{Math.round(d.last_pass_rate * 100)}% pass</span>}
<button className="btn btn-secondary btn-sm" disabled={running === d.id || !d.workflow_id} title={!d.workflow_id ? "Bind a workflow to this dataset before running it" : undefined} onClick={() => run(d)}>{running === d.id ? <Icon name="refresh" size={13} className="spin" /> : <Icon name="play" size={13} />}{running === d.id ? (total ? `Running ${doneCount}/${total}` : "Running") : "Run"}</button>
<button className="iconbtn" title="Edit dataset" onClick={() => openEdit(d)}><Icon name="edit" size={15} /></button>
<button className="iconbtn" title="Delete dataset" onClick={() => remove(d)}><Icon name="trash" size={15} /></button>
</div>
</div>
</div>
))}
{datasets.length === 0 && <div className="fg-2 t-caption">No datasets yet.</div>}
</div>
{runError && (
<div className="card" style={{ padding: 14, marginTop: 16, borderColor: "var(--err)", background: "var(--err-bg)" }}>
<div className="t-h3" style={{ color: "var(--err)", marginBottom: 2 }}>Run failed</div>
<div className="t-caption" style={{ color: "var(--fg-1)" }}>{runError}</div>
</div>
)}
{rows && (
<div className="card" style={{ padding: 16, marginTop: 16 }}>
<div className="row spread" style={{ marginBottom: 4 }}>
<div className="t-h3">
{summary
? <>Last run{ranDataset ? ` · ${ranDataset.name}` : ""} · {summary.passed}/{summary.total} passed ({Math.round(summary.pass_rate * 100)}%)</>
: <>Running{ranDataset ? ` · ${ranDataset.name}` : ""} · {doneCount}/{total} done{doneCount > 0 ? ` · ${passSoFar} passed` : ""}</>}
</div>
<div className="row gap2">
{totalTokens > 0 && <span className="chip chip-mono"><Icon name="bolt" size={12} />{fmtTokens(totalTokens)}</span>}
{isRunningNow && <Icon name="refresh" size={14} className="spin" />}
</div>
</div>
{/* Progress bar: fills as cases finish so the run is visibly in motion. */}
<div style={{ height: 4, borderRadius: 2, background: "var(--bg-3)", overflow: "hidden", margin: "6px 0 10px" }}>
<div style={{ height: "100%", width: `${progressPct}%`, background: summary ? "var(--ok)" : "var(--accent)", transition: "width .3s var(--ease)" }} />
</div>
<div className="fg-2 t-caption" style={{ marginBottom: 4 }}>Select a finished case to see its output{ranDataset?.score_mode === "judge" ? " and the judge's reason" : ""}.</div>
{rows.map((row) => {
const i = row.index;
const r = liveResults[i];
const done = !!r;
const isOpen = expanded.has(i);
return (
<div key={i} style={{ borderTop: "1px solid var(--line)" }}>
<button className="row spread" style={{ width: "100%", padding: "8px 0", background: "none", border: "none", cursor: done ? "pointer" : "default", textAlign: "left", color: "inherit", opacity: done ? 1 : 0.7 }}
onClick={() => { if (done) setExpanded((prev) => { const n = new Set(prev); if (n.has(i)) n.delete(i); else n.add(i); return n; }); }}>
<span className="row gap2" style={{ flex: 1, minWidth: 0 }}>
<Icon name={isOpen ? "chevdown" : "chevright"} size={14} style={{ opacity: done ? 1 : 0.3 }} />
<span className="t-caption truncate">{(r?.input ?? row.input) || "(empty input)"}</span>
</span>
<span className="row gap2" style={{ alignItems: "center", flexShrink: 0 }}>
{done && r.latency_ms != null && <span className="fg-2 t-micro row gap1" style={{ alignItems: "center" }}><Icon name="clock" size={11} />{fmtLatency(r.latency_ms)}</span>}
{done && r.tokens != null && r.tokens > 0 && <span className="fg-2 t-micro">{fmtTokens(r.tokens)}</span>}
<CaseStatus result={r} />
</span>
</button>
{isOpen && done && (
<div className="col" style={{ gap: 10, padding: "2px 0 12px 22px" }}>
{r.expected && <ResultField label="Expected" value={r.expected} />}
<ResultField label="Output" value={r.answer || "(no output)"} />
{r.reason && <ResultField label={ranDataset?.score_mode === "judge" ? "Judge reason" : "Reason"} value={r.reason} />}
</div>
)}
</div>
);
})}
</div>
)}
<Modal open={open} onClose={() => setOpen(false)} title={editing ? "Edit dataset" : "New dataset"} width={520}
footer={<><button className="btn btn-ghost" onClick={() => setOpen(false)}>Cancel</button><button className="btn btn-primary" onClick={save} disabled={!canSave}>{editing ? "Save" : "Create"}</button></>}>
<Field label="Name" required><input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="Smoke tests" /></Field>
<Field label="Workflow" required help={wfs.length === 0 ? "No workflows yet — create and publish one first." : "The workflow each case is run against."}><select className="select" value={form.workflow_id} onChange={(e) => setForm((f) => ({ ...f, workflow_id: e.target.value }))}><option value="">Select…</option>{wfs.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}</select></Field>
<Field label="Scoring" help={SCORING_HELP[form.score_mode]}><select className="select" value={form.score_mode} onChange={(e) => setForm((f) => ({ ...f, score_mode: e.target.value }))}><option value="contains">contains</option><option value="exact">exact</option><option value="regex">regex</option><option value="judge">LLM judge</option></select></Field>
<Field label="Cases (JSON)" help='Array of {"input": "...", "expected": "..."}'>
<textarea className="textarea mono" rows={6} style={{ fontSize: 12 }} value={form.items} onChange={(e) => setForm((f) => ({ ...f, items: e.target.value }))} />
{casesError && <div className="t-caption" style={{ color: "var(--err)", marginTop: 6 }}>{casesError}</div>}
</Field>
</Modal>
</Shell>
);
}
/* ============ HANDOFF INBOX ============ */
export function HandoffScreen({ project }: { project: any }) {
const [items, setItems] = useState<Handoff[]>([]);
const [reply, setReply] = useState<Record<string, string>>({});
const [filter, setFilter] = useState<"open" | "closed">("open");
const [openCount, setOpenCount] = useState(0);
const reload = useCallback(async () => {
if (!project?.id) return;
try {
const current = await api.listHandoffs(project.id, filter);
setItems(current);
if (filter === "open") setOpenCount(current.length);
else setOpenCount((await api.listHandoffs(project.id, "open")).length);
} catch { /* keep the last good queue visible during a transient refresh failure */ }
}, [project?.id, filter]);
useEffect(() => {
reload();
const timer = window.setInterval(reload, 10_000);
return () => window.clearInterval(timer);
}, [reload]);
async function send(h: Handoff) {
const msg = (reply[h.id] || "").trim();
if (!msg) return;
await api.replyHandoff(project.id, h.id, msg);
setReply((r) => ({ ...r, [h.id]: "" }));
window.dispatchEvent(new CustomEvent("forge:counts-changed"));
reload();
}
return (
<Shell>
<Header
title="Agent inbox"
subtitle="Conversations escalated to a human. Replying resumes the paused run and delivers your message over its channel."
action={(
<div className="row gap2">
<span className="badge" title="Open handoffs">{openCount} unread</span>
{(["open", "closed"] as const).map((value) => (
<button
key={value}
className={filter === value ? "btn btn-secondary btn-sm" : "btn btn-ghost btn-sm"}
onClick={() => setFilter(value)}
>
{value === "open" ? "Open" : "Closed"}
</button>
))}
</div>
)}
/>
<div className="col gap2">
{items.map((h) => (
<div key={h.id} className="card" style={{ padding: 14 }}>
<div className="row spread" style={{ marginBottom: 8 }}>
<div className="row gap2"><Icon name="user" size={15} /><span className="t-h3">{h.customer || "Customer"}</span></div>
<div className="row gap2">
<span className="fg-2 t-caption">{h.reason}</span>
{filter === "closed" && <span className="pill">{h.status}</span>}
</div>
</div>
{h.customer_message && <div style={{ background: "var(--bg-3)", padding: "8px 11px", borderRadius: 10, fontSize: 13, marginBottom: 8 }}>{h.customer_message}</div>}
{filter === "open" ? (
<div className="row gap2">
<input className="input" placeholder="Type your reply…" value={reply[h.id] || ""} onChange={(e) => setReply((r) => ({ ...r, [h.id]: e.target.value }))} onKeyDown={(e) => e.key === "Enter" && send(h)} style={{ flex: 1 }} />
<button className="btn btn-primary btn-sm" onClick={() => send(h)} disabled={!(reply[h.id] || "").trim()}><Icon name="bolt" size={13} />Reply &amp; resume</button>
</div>
) : (
<div className="t-caption fg-2">Closed {h.at ? `· opened ${new Date(h.at).toLocaleString()}` : ""}</div>
)}
</div>
))}
{items.length === 0 && <div className="fg-2 t-caption">{filter === "open" ? "No open handoffs. Add a Human Handoff node to a workflow to route conversations here." : "No closed handoffs yet."}</div>}
</div>
</Shell>
);
}