Some checks failed
Wire list_peers and set_summary MCP tools to the broker's WS protocol instead of returning stubs. Peers can now discover each other, see status/summary, and route messages by display name. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
349 lines
13 KiB
TypeScript
349 lines
13 KiB
TypeScript
"use client";
|
|
import { motion, AnimatePresence } from "motion/react";
|
|
import { useState } from "react";
|
|
|
|
export type PeerStatus = "idle" | "working" | "dnd" | "offline";
|
|
export type MessageType = "ask_mesh" | "self_nominate" | "direct" | "broadcast";
|
|
|
|
export interface StreamPeer {
|
|
id: string;
|
|
name: string;
|
|
status: PeerStatus;
|
|
/** e.g. "macOS · payments-api" or "iOS · push-relay" */
|
|
machine: string;
|
|
surface?: "terminal" | "phone" | "slack";
|
|
}
|
|
|
|
export interface StreamMessage {
|
|
/** stable unique key */
|
|
key: string;
|
|
/** peer id or display name */
|
|
from: string;
|
|
/** peer id, "tag:xxx", "*", or null (self-nominate) */
|
|
to: string | null;
|
|
type: MessageType;
|
|
/** plaintext for demo, undefined for live (broker never sees it) */
|
|
text?: string;
|
|
/** truncated base64url — what the broker actually sees */
|
|
ciphertext: string;
|
|
/** absolute time, optional — used by live dashboard */
|
|
createdAt?: Date;
|
|
}
|
|
|
|
const STATUS_DOT: Record<PeerStatus, string> = {
|
|
idle: "bg-emerald-500",
|
|
working: "bg-[var(--cm-clay)] animate-pulse",
|
|
dnd: "bg-[#c46686]",
|
|
offline: "bg-[var(--cm-fg-tertiary)]",
|
|
};
|
|
|
|
const TYPE_CHIP: Record<MessageType, { label: string; className: string }> = {
|
|
ask_mesh: {
|
|
label: "broadcast",
|
|
className:
|
|
"border-[var(--cm-border)] bg-[var(--cm-bg)] text-[var(--cm-clay)]",
|
|
},
|
|
broadcast: {
|
|
label: "broadcast",
|
|
className:
|
|
"border-[var(--cm-border)] bg-[var(--cm-bg)] text-[var(--cm-clay)]",
|
|
},
|
|
self_nominate: {
|
|
label: "hand-raise",
|
|
className: "border-emerald-500/40 bg-emerald-500/10 text-emerald-500",
|
|
},
|
|
direct: {
|
|
label: "direct",
|
|
className:
|
|
"border-[var(--cm-border)] bg-[var(--cm-bg)] text-[var(--cm-fg-secondary)]",
|
|
},
|
|
};
|
|
|
|
const TYPE_ICON: Record<MessageType, React.ReactNode> = {
|
|
ask_mesh: (
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
|
<path d="M12 3v18M3 12h18" />
|
|
</svg>
|
|
),
|
|
broadcast: (
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
|
<path d="M12 3v18M3 12h18" />
|
|
</svg>
|
|
),
|
|
self_nominate: (
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
|
<path d="M12 19V5M5 12l7-7 7 7" />
|
|
</svg>
|
|
),
|
|
direct: (
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
|
<path d="M5 12h14M13 5l7 7-7 7" />
|
|
</svg>
|
|
),
|
|
};
|
|
|
|
const surfaceGlyph = (s?: StreamPeer["surface"]) => {
|
|
if (s === "phone")
|
|
return (
|
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none">
|
|
<rect x="7" y="2" width="10" height="20" rx="2" stroke="currentColor" strokeWidth="2" />
|
|
<circle cx="12" cy="18" r="1" fill="currentColor" />
|
|
</svg>
|
|
);
|
|
if (s === "slack")
|
|
return (
|
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none">
|
|
<rect x="10" y="3" width="2" height="6" rx="1" stroke="currentColor" strokeWidth="2" />
|
|
<rect x="12" y="15" width="2" height="6" rx="1" stroke="currentColor" strokeWidth="2" />
|
|
<rect x="3" y="10" width="6" height="2" rx="1" stroke="currentColor" strokeWidth="2" />
|
|
<rect x="15" y="12" width="6" height="2" rx="1" stroke="currentColor" strokeWidth="2" />
|
|
</svg>
|
|
);
|
|
return (
|
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none">
|
|
<rect x="2" y="4" width="20" height="16" rx="2" stroke="currentColor" strokeWidth="2" />
|
|
<path d="M6 9l3 3-3 3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
</svg>
|
|
);
|
|
};
|
|
|
|
const resolveName = (id: string, peers: StreamPeer[]) =>
|
|
peers.find((p) => p.id === id)?.name ?? id;
|
|
|
|
export interface MeshStreamProps {
|
|
peers: StreamPeer[];
|
|
messages: StreamMessage[];
|
|
/** text shown in stream header, right of # */
|
|
channelLabel?: string;
|
|
/** override the "N peers online" hint */
|
|
peersHint?: string;
|
|
/** override empty-state message */
|
|
emptyLabel?: string;
|
|
/** footer content (stats / progress bar / timers) */
|
|
footer?: React.ReactNode;
|
|
}
|
|
|
|
export const MeshStream = ({
|
|
peers,
|
|
messages,
|
|
channelLabel = "live-stream",
|
|
peersHint,
|
|
emptyLabel = "Waiting for messages…",
|
|
footer,
|
|
}: MeshStreamProps) => {
|
|
const [focusedPeer, setFocusedPeer] = useState<string | null>(null);
|
|
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
|
|
|
|
const onlineCount = peers.filter((p) => p.status !== "offline").length;
|
|
const filtered = focusedPeer
|
|
? messages.filter((m) => m.from === focusedPeer || m.to === focusedPeer)
|
|
: messages;
|
|
|
|
return (
|
|
<div className="grid min-h-[480px] grid-cols-1 md:grid-cols-[220px_1fr]">
|
|
{/* peers sidebar */}
|
|
<aside
|
|
className="border-b border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/20 p-4 md:border-b-0 md:border-r"
|
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
|
>
|
|
<div
|
|
className="mb-3 flex items-center justify-between text-[10px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]"
|
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
|
>
|
|
<span>{peersHint ?? `peers · ${onlineCount} online`}</span>
|
|
{focusedPeer && (
|
|
<button
|
|
onClick={() => setFocusedPeer(null)}
|
|
className="text-[var(--cm-clay)] hover:underline"
|
|
aria-label="Clear filter"
|
|
>
|
|
clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
{peers.length === 0 ? (
|
|
<p
|
|
className="text-[12px] text-[var(--cm-fg-tertiary)]"
|
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
|
>
|
|
no peers online
|
|
</p>
|
|
) : (
|
|
<ul className="space-y-1">
|
|
{peers.map((p) => {
|
|
const active = focusedPeer === p.id;
|
|
return (
|
|
<li key={p.id}>
|
|
<button
|
|
onClick={() => setFocusedPeer(active ? null : p.id)}
|
|
className={
|
|
"group flex w-full items-center gap-2.5 rounded-[var(--cm-radius-xs)] px-2 py-1.5 text-left transition-colors " +
|
|
(active
|
|
? "bg-[var(--cm-clay)]/15"
|
|
: "hover:bg-[var(--cm-bg)]")
|
|
}
|
|
>
|
|
<span
|
|
className={
|
|
"h-2 w-2 flex-shrink-0 rounded-full " +
|
|
STATUS_DOT[p.status]
|
|
}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<span
|
|
className={
|
|
"truncate text-[13px] " +
|
|
(active
|
|
? "font-medium text-[var(--cm-clay)]"
|
|
: "text-[var(--cm-fg)]")
|
|
}
|
|
>
|
|
{p.name}
|
|
</span>
|
|
<span className="text-[var(--cm-fg-tertiary)]">
|
|
{surfaceGlyph(p.surface)}
|
|
</span>
|
|
</div>
|
|
<div
|
|
className="truncate text-[10px] text-[var(--cm-fg-tertiary)]"
|
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
|
>
|
|
{p.machine}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</aside>
|
|
|
|
{/* message stream */}
|
|
<div
|
|
className="relative flex flex-col"
|
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
|
>
|
|
<div
|
|
className="flex items-center gap-2 border-b border-[var(--cm-border)] px-4 py-2.5"
|
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
|
>
|
|
<span className="text-[var(--cm-clay)]">#</span>
|
|
<span className="text-[13px] font-medium text-[var(--cm-fg)]">
|
|
{channelLabel}
|
|
</span>
|
|
<span className="text-[11px] text-[var(--cm-fg-tertiary)]">
|
|
{focusedPeer
|
|
? `filtered: ${resolveName(focusedPeer, peers)}`
|
|
: "all peers · E2E encrypted"}
|
|
</span>
|
|
</div>
|
|
<ol className="flex-1 space-y-3 overflow-y-auto p-4">
|
|
{filtered.length === 0 && (
|
|
<li
|
|
className="py-8 text-center text-[13px] text-[var(--cm-fg-tertiary)]"
|
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
|
>
|
|
{emptyLabel}
|
|
</li>
|
|
)}
|
|
<AnimatePresence initial={false}>
|
|
{filtered.map((m) => (
|
|
<motion.li
|
|
key={m.key}
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{
|
|
duration: 0.4,
|
|
ease: [0.22, 0.61, 0.36, 1],
|
|
}}
|
|
onMouseEnter={() => setHoveredKey(m.key)}
|
|
onMouseLeave={() => setHoveredKey(null)}
|
|
className="group relative"
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<div className="flex-shrink-0 pt-0.5">
|
|
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-[var(--cm-bg-elevated)] text-[10px] font-medium uppercase text-[var(--cm-fg-secondary)]">
|
|
{resolveName(m.from, peers).slice(0, 2)}
|
|
</div>
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="mb-1 flex flex-wrap items-center gap-2">
|
|
<span className="text-[13px] font-medium text-[var(--cm-fg)]">
|
|
{resolveName(m.from, peers)}
|
|
</span>
|
|
{m.to && (
|
|
<>
|
|
<span className="text-[11px] text-[var(--cm-fg-tertiary)]">
|
|
→
|
|
</span>
|
|
<span
|
|
className="text-[12px] text-[var(--cm-fg-secondary)]"
|
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
|
>
|
|
{m.to.startsWith("tag:") || m.to === "*"
|
|
? m.to
|
|
: resolveName(m.to, peers)}
|
|
</span>
|
|
</>
|
|
)}
|
|
<span
|
|
className={
|
|
"inline-flex items-center gap-1 rounded-[4px] border px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wider " +
|
|
TYPE_CHIP[m.type].className
|
|
}
|
|
>
|
|
{TYPE_ICON[m.type]}
|
|
{TYPE_CHIP[m.type].label}
|
|
</span>
|
|
{m.createdAt && (
|
|
<span
|
|
className="text-[10px] text-[var(--cm-fg-tertiary)]"
|
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
|
>
|
|
{m.createdAt.toLocaleTimeString()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{m.text && (
|
|
<p
|
|
className="text-[14px] leading-[1.55] text-[var(--cm-fg-secondary)]"
|
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
|
>
|
|
{m.text}
|
|
</p>
|
|
)}
|
|
{hoveredKey === m.key && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 4 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="mt-2 rounded-[var(--cm-radius-xs)] border border-dashed border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)]/50 px-3 py-2"
|
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
|
>
|
|
<div className="mb-1 text-[9px] uppercase tracking-wider text-[var(--cm-clay)]">
|
|
broker sees only this
|
|
</div>
|
|
<code className="block break-all text-[11px] text-[var(--cm-fg-tertiary)]">
|
|
{m.ciphertext}
|
|
{m.ciphertext && !m.text && "…"}
|
|
</code>
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.li>
|
|
))}
|
|
</AnimatePresence>
|
|
</ol>
|
|
{footer && (
|
|
<div className="border-t border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30">
|
|
{footer}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|