This commit is contained in:
kacper 2026-03-12 09:25:15 -04:00
parent b7614eb3f8
commit db4ce8b14f
22 changed files with 3557 additions and 823 deletions

View file

@ -1,61 +1,275 @@
import { useCallback, useEffect } from "preact/hooks";
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { AgentIndicator } from "./components/AgentIndicator";
import { CardFeed } from "./components/CardFeed";
import { ControlBar, VoiceStatus } from "./components/Controls";
import { LogPanel } from "./components/LogPanel";
import { ToastContainer } from "./components/Toast";
import { useAudioMeter } from "./hooks/useAudioMeter";
import { usePTT } from "./hooks/usePTT";
import { useWebRTC } from "./hooks/useWebRTC";
import type { CardItem, CardMessageMetadata, JsonValue } from "./types";
export function App() {
const rtc = useWebRTC();
const audioLevel = useAudioMeter(rtc.remoteStream);
const SWIPE_THRESHOLD_PX = 64;
const SWIPE_DIRECTION_RATIO = 1.15;
const { agentStateOverride, handlePointerDown, handlePointerUp } = usePTT({
connected: rtc.connected,
onSendPtt: (pressed) => rtc.sendJson({ type: "voice-ptt", pressed }),
onBootstrap: rtc.connect,
});
interface AppRtcActions {
connect(): Promise<void>;
sendJson(
msg:
| { type: "command"; command: string }
| { type: "card-response"; card_id: string; value: string }
| { type: "voice-ptt"; pressed: boolean; metadata?: CardMessageMetadata },
): void;
setTextOnly(enabled: boolean): void;
sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise<void>;
connected: boolean;
connecting: boolean;
}
const effectiveAgentState = agentStateOverride ?? rtc.agentState;
function buildCardMetadata(card: CardItem): CardMessageMetadata {
const metadata: CardMessageMetadata = {
card_id: card.serverId,
card_slot: card.slot,
card_title: card.title,
card_lane: card.lane,
card_template_key: card.templateKey,
card_context_summary: card.contextSummary,
card_response_value: card.responseValue,
};
const liveContent = card.serverId
? window.__nanobotGetCardLiveContent?.(card.serverId)
: undefined;
if (liveContent !== undefined) metadata.card_live_content = liveContent as JsonValue;
return metadata;
}
useEffect(() => {
document.addEventListener("pointerdown", handlePointerDown, { passive: false });
document.addEventListener("pointerup", handlePointerUp, { passive: false });
document.addEventListener("pointercancel", handlePointerUp, { passive: false });
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("pointerup", handlePointerUp);
document.removeEventListener("pointercancel", handlePointerUp);
};
}, [handlePointerDown, handlePointerUp]);
function AgentCardContext({ card, onClear }: { card: CardItem; onClear(): void }) {
return (
<div id="agent-card-context" data-no-swipe="1">
<div class="agent-card-context-label">Using card</div>
<div class="agent-card-context-row">
<div class="agent-card-context-title">{card.title}</div>
<button
class="agent-card-context-clear"
type="button"
aria-label="Clear selected card context"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClear();
}}
>
Clear
</button>
</div>
</div>
);
}
function useSwipeHandlers(
composing: boolean,
view: "agent" | "feed",
setView: (view: "agent" | "feed") => void,
isInteractiveTarget: (target: EventTarget | null) => boolean,
) {
const swipeStartRef = useRef<{ x: number; y: number } | null>(null);
const onSwipeStart = useCallback(
(e: Event) => {
const pe = e as PointerEvent;
if (composing) return;
if (pe.pointerType === "mouse" && pe.button !== 0) return;
if (isInteractiveTarget(pe.target)) return;
swipeStartRef.current = { x: pe.clientX, y: pe.clientY };
},
[composing, isInteractiveTarget],
);
const onSwipeEnd = useCallback(
(e: Event) => {
const pe = e as PointerEvent;
const start = swipeStartRef.current;
swipeStartRef.current = null;
if (!start || composing) return;
const dx = pe.clientX - start.x;
const dy = pe.clientY - start.y;
if (Math.abs(dx) < SWIPE_THRESHOLD_PX) return;
if (Math.abs(dx) < Math.abs(dy) * SWIPE_DIRECTION_RATIO) return;
if (view === "agent" && dx < 0) setView("feed");
if (view === "feed" && dx > 0) setView("agent");
},
[composing, view, setView],
);
return { onSwipeStart, onSwipeEnd };
}
function useCardActions(
setView: (view: "agent" | "feed") => void,
setSelectedCardId: (cardId: string | null) => void,
) {
const handleAskCard = useCallback(
(card: CardItem) => {
if (!card.serverId) return;
setSelectedCardId(card.serverId);
setView("agent");
},
[setSelectedCardId, setView],
);
return { handleAskCard };
}
function useControlActions(rtc: AppRtcActions) {
const handleReset = useCallback(async () => {
const confirmed = window.confirm("Clear the current conversation context and start fresh?");
if (!confirmed) return;
await rtc.connect();
rtc.sendJson({ type: "command", command: "reset" });
}, [rtc]);
const handleChoice = useCallback(
(requestId: string, value: string) => {
rtc.sendJson({ type: "ui-response", request_id: requestId, value });
const handleToggleTextOnly = useCallback(
async (enabled: boolean) => {
rtc.setTextOnly(enabled);
if (enabled && !rtc.connected && !rtc.connecting) await rtc.connect();
},
[rtc],
);
return { handleReset, handleToggleTextOnly };
}
export function App() {
const rtc = useWebRTC();
const remoteAudioLevel = useAudioMeter(rtc.remoteStream);
const audioLevel = rtc.textOnly ? 0 : remoteAudioLevel;
const [view, setView] = useState<"agent" | "feed">("agent");
const [composing, setComposing] = useState(false);
const [selectedCardId, setSelectedCardId] = useState<string | null>(null);
const autoOpenedFeedRef = useRef(false);
const selectedCard = useMemo(
() =>
selectedCardId ? (rtc.cards.find((card) => card.serverId === selectedCardId) ?? null) : null,
[rtc.cards, selectedCardId],
);
const selectedCardMetadata = useCallback(
() => (selectedCard ? buildCardMetadata(selectedCard) : undefined),
[selectedCard],
);
const { agentStateOverride, handlePointerDown, handlePointerMove, handlePointerUp } = usePTT({
connected: rtc.connected && !rtc.textOnly,
onSendPtt: (pressed) =>
rtc.sendJson({ type: "voice-ptt", pressed, metadata: selectedCardMetadata() }),
onBootstrap: rtc.connect,
});
const effectiveAgentState = agentStateOverride ?? rtc.agentState;
const isInteractiveTarget = useCallback((target: EventTarget | null): boolean => {
if (!(target instanceof Element)) return false;
return Boolean(target.closest("button,textarea,input,a,[data-no-swipe='1']"));
}, []);
const { onSwipeStart, onSwipeEnd } = useSwipeHandlers(
composing,
view,
setView,
isInteractiveTarget,
);
useEffect(() => {
document.addEventListener("pointerdown", handlePointerDown, { passive: false });
document.addEventListener("pointermove", handlePointerMove, { passive: true });
document.addEventListener("pointerup", handlePointerUp, { passive: false });
document.addEventListener("pointercancel", handlePointerUp, { passive: false });
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerUp);
document.removeEventListener("pointercancel", handlePointerUp);
};
}, [handlePointerDown, handlePointerMove, handlePointerUp]);
useEffect(() => {
if (autoOpenedFeedRef.current || rtc.cards.length === 0) return;
autoOpenedFeedRef.current = true;
setView("feed");
}, [rtc.cards.length]);
useEffect(() => {
if (!selectedCardId) return;
if (rtc.cards.some((card) => card.serverId === selectedCardId)) return;
setSelectedCardId(null);
}, [rtc.cards, selectedCardId]);
const { handleToggleTextOnly } = useControlActions(rtc);
const { handleAskCard } = useCardActions(setView, setSelectedCardId);
const handleCardChoice = useCallback(
(cardId: string, value: string) => {
rtc.sendJson({ type: "card-response", card_id: cardId, value });
},
[rtc],
);
const handleSendMessage = useCallback(
async (text: string) => {
await rtc.sendTextMessage(text, selectedCardMetadata());
},
[rtc, selectedCardMetadata],
);
const handleResetWithSelection = useCallback(async () => {
const confirmed = window.confirm("Clear the current conversation context and start fresh?");
if (!confirmed) return;
setSelectedCardId(null);
await rtc.connect();
rtc.sendJson({ type: "command", command: "reset" });
}, [rtc]);
return (
<>
<ControlBar onReset={handleReset} />
<LogPanel lines={rtc.logLines} />
<AgentIndicator
state={effectiveAgentState}
connected={rtc.connected}
connecting={rtc.connecting}
audioLevel={audioLevel}
onPointerDown={() => {}}
onPointerUp={() => {}}
/>
<div id="swipe-shell" onPointerDown={onSwipeStart} onPointerUp={onSwipeEnd}>
<div id="swipe-track" class={view === "feed" ? "feed-active" : ""}>
<section class="workspace-panel workspace-agent">
{view === "agent" && (
<ControlBar
onReset={handleResetWithSelection}
textOnly={rtc.textOnly}
onToggleTextOnly={handleToggleTextOnly}
/>
)}
{view === "agent" && selectedCard && (
<AgentCardContext card={selectedCard} onClear={() => setSelectedCardId(null)} />
)}
{view === "agent" && (
<LogPanel
lines={rtc.logLines}
disabled={!rtc.connected}
onSendMessage={handleSendMessage}
onExpandChange={setComposing}
/>
)}
<AgentIndicator
state={effectiveAgentState}
connected={rtc.connected}
connecting={rtc.connecting}
audioLevel={audioLevel}
viewActive
onPointerDown={() => {}}
onPointerUp={() => {}}
/>
</section>
<section class="workspace-panel workspace-feed">
<CardFeed
cards={rtc.cards}
viewActive
onDismiss={rtc.dismissCard}
onChoice={handleCardChoice}
onAskCard={handleAskCard}
/>
</section>
</div>
</div>
<VoiceStatus text={rtc.voiceStatus} visible={rtc.statusVisible} />
<ToastContainer toasts={rtc.toasts} onDismiss={rtc.dismissToast} onChoice={handleChoice} />
</>
);
}

View file

@ -4,7 +4,7 @@ const AudioContextCtor =
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
: undefined;
export interface AudioMeter {
interface AudioMeter {
connect(stream: MediaStream): void;
getLevel(): number;
destroy(): void;

View file

@ -7,6 +7,7 @@ interface Props {
connected: boolean;
connecting: boolean;
audioLevel: number;
viewActive: boolean;
onPointerDown(): void;
onPointerUp(): void;
}
@ -16,6 +17,7 @@ export function AgentIndicator({
connected,
connecting,
audioLevel,
viewActive,
onPointerDown,
onPointerUp,
}: Props) {
@ -49,7 +51,11 @@ export function AgentIndicator({
}, [audioLevel]);
return (
<div id="agentIndicator" class={`agentIndicator visible ${state}`} data-ptt="1">
<div
id="agentIndicator"
class={`agentIndicator${viewActive ? " visible" : ""} ${state}`}
data-ptt="1"
>
<div
id="agentViz"
class="agentViz"

View file

@ -0,0 +1,353 @@
import { marked } from "marked";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import type { CardItem, CardLane, JsonValue } from "../types";
const EXECUTABLE_SCRIPT_TYPES = new Set([
"",
"text/javascript",
"application/javascript",
"module",
]);
const cardLiveContentStore = new Map<string, JsonValue>();
function readCardState(script: HTMLScriptElement | null): Record<string, unknown> {
const root = script?.closest("[data-nanobot-card-root]");
if (!(root instanceof HTMLElement)) return {};
const stateEl = root.querySelector('script[data-card-state][type="application/json"]');
if (!(stateEl instanceof HTMLScriptElement)) return {};
try {
const parsed = JSON.parse(stateEl.textContent || "{}");
return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
} catch {
return {};
}
}
function resolveCardRoot(target: HTMLScriptElement | HTMLElement | null): HTMLElement | null {
if (!(target instanceof HTMLElement)) return null;
if (target.matches("[data-nanobot-card-root]")) return target;
return target.closest("[data-nanobot-card-root]");
}
function setCardLiveContent(
target: HTMLScriptElement | HTMLElement | null,
snapshot: JsonValue | null | undefined,
): void {
const root = resolveCardRoot(target);
const cardId = root?.dataset.cardId?.trim();
if (!cardId) return;
if (snapshot === null || snapshot === undefined) {
cardLiveContentStore.delete(cardId);
return;
}
try {
cardLiveContentStore.set(cardId, JSON.parse(JSON.stringify(snapshot)) as JsonValue);
} catch {
cardLiveContentStore.delete(cardId);
}
}
function getCardLiveContent(cardId: string | null | undefined): JsonValue | undefined {
const key = (cardId || "").trim();
if (!key) return undefined;
const value = cardLiveContentStore.get(key);
if (value === undefined) return undefined;
try {
return JSON.parse(JSON.stringify(value)) as JsonValue;
} catch {
return undefined;
}
}
function ensureCardStateHelper(): void {
if (!window.__nanobotGetCardState) {
window.__nanobotGetCardState = readCardState;
}
if (!window.__nanobotSetCardLiveContent) {
window.__nanobotSetCardLiveContent = setCardLiveContent;
}
if (!window.__nanobotGetCardLiveContent) {
window.__nanobotGetCardLiveContent = getCardLiveContent;
}
}
declare global {
interface Window {
__nanobotGetCardState?: (script: HTMLScriptElement | null) => Record<string, unknown>;
__nanobotSetCardLiveContent?: (
target: HTMLScriptElement | HTMLElement | null,
snapshot: JsonValue | null | undefined,
) => void;
__nanobotGetCardLiveContent?: (cardId: string | null | undefined) => JsonValue | undefined;
}
}
const LANE_TITLES: Record<CardLane, string> = {
attention: "Attention",
work: "Work",
context: "Context",
history: "History",
};
const LANE_ORDER: CardLane[] = ["attention", "work", "context", "history"];
interface CardProps {
card: CardItem;
onDismiss(id: number): void;
onChoice(cardId: string, value: string): void;
onAskCard(card: CardItem): void;
}
function MoreIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="6" cy="12" r="1.75" fill="currentColor" />
<circle cx="12" cy="12" r="1.75" fill="currentColor" />
<circle cx="18" cy="12" r="1.75" fill="currentColor" />
</svg>
);
}
function CardTextBody({ card }: { card: CardItem }) {
const bodyRef = useRef<HTMLDivElement>(null);
useEffect(() => {
ensureCardStateHelper();
const root = bodyRef.current;
if (!root) return;
const scripts = Array.from(root.querySelectorAll("script"));
for (const oldScript of scripts) {
const type = (oldScript.getAttribute("type") || "").trim().toLowerCase();
if (!EXECUTABLE_SCRIPT_TYPES.has(type)) continue;
const runtimeScript = document.createElement("script");
for (const attr of oldScript.attributes) runtimeScript.setAttribute(attr.name, attr.value);
runtimeScript.text = oldScript.textContent || "";
oldScript.replaceWith(runtimeScript);
}
return () => {
window.__nanobotSetCardLiveContent?.(bodyRef.current, null);
};
}, [card.id, card.content]);
const looksLikeHtml = /^\s*<[a-zA-Z]/.test(card.content);
const html = looksLikeHtml ? card.content : (marked.parse(card.content) as string);
return <div ref={bodyRef} class="card-body" dangerouslySetInnerHTML={{ __html: html }} />;
}
function CardQuestionBody({
card,
responding,
onChoice,
}: {
card: CardItem;
responding: boolean;
onChoice(cardId: string, value: string): void;
}) {
const canAnswer = card.state === "active" && !responding && !!card.serverId;
return (
<>
{card.content && <div class="card-body">{card.content}</div>}
<div class="card-question">{card.question}</div>
<div class="card-choices">
{(card.choices ?? []).map((label) => (
<button
key={label}
class="card-choice-btn"
type="button"
disabled={!canAnswer}
onClick={(e) => {
e.stopPropagation();
if (!card.serverId) return;
onChoice(card.serverId, label);
}}
>
{label}
</button>
))}
</div>
{card.responseValue && <div class="card-response">Selected: {card.responseValue}</div>}
</>
);
}
function CardHeader({
card,
onDismiss,
onAskCard,
}: {
card: CardItem;
onDismiss(): void;
onAskCard(card: CardItem): void;
}) {
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!menuOpen) return;
const handlePointerDown = (event: PointerEvent) => {
if (!(event.target instanceof Node)) return;
if (menuRef.current?.contains(event.target)) return;
setMenuOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setMenuOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [menuOpen]);
return (
<header class={`card-header${card.kind === "text" ? " floating" : ""}`}>
{card.kind !== "text" && (
<div class="card-title-wrap">
<div class="card-title-line">
{card.title && <span class="card-title">{card.title}</span>}
</div>
<div class="card-meta">
{card.state !== "active" && (
<span class={`card-state state-${card.state}`}>{card.state}</span>
)}
</div>
</div>
)}
<div ref={menuRef} class="card-menu-wrap">
<button
class={`card-menu-trigger${menuOpen ? " open" : ""}`}
type="button"
aria-label="Card actions"
aria-expanded={menuOpen}
onClick={(e) => {
e.stopPropagation();
setMenuOpen((current) => !current);
}}
>
<MoreIcon />
</button>
{menuOpen && (
<div class="card-menu" role="menu">
{card.kind === "text" && (
<button
class="card-menu-item"
type="button"
role="menuitem"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
onAskCard(card);
}}
>
Ask Nanobot
</button>
)}
<button
class="card-menu-item danger"
type="button"
role="menuitem"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
onDismiss();
}}
>
Dismiss
</button>
</div>
)}
</div>
</header>
);
}
function Card({ card, onDismiss, onChoice, onAskCard }: CardProps) {
const [dismissing, setDismissing] = useState(false);
const [responding, setResponding] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
useEffect(() => {
if (card.state !== "active") setResponding(false);
}, [card.state]);
const dismiss = () => {
if (dismissing) return;
setDismissing(true);
timerRef.current = setTimeout(() => onDismiss(card.id), 220);
};
return (
<article class={`card kind-${card.kind}${dismissing ? " dismissing" : ""} state-${card.state}`}>
<CardHeader card={card} onDismiss={dismiss} onAskCard={onAskCard} />
{card.kind === "text" ? (
<CardTextBody card={card} />
) : (
<CardQuestionBody
card={card}
responding={responding}
onChoice={(cardId, value) => {
setResponding(true);
onChoice(cardId, value);
}}
/>
)}
{card.contextSummary && card.kind !== "text" && (
<footer class="card-footer">{card.contextSummary}</footer>
)}
</article>
);
}
interface CardFeedProps {
cards: CardItem[];
viewActive: boolean;
onDismiss(id: number): void;
onChoice(cardId: string, value: string): void;
onAskCard(card: CardItem): void;
}
export function CardFeed({ cards, viewActive, onDismiss, onChoice, onAskCard }: CardFeedProps) {
const groups = useMemo(
() =>
LANE_ORDER.map((lane) => ({
lane,
title: LANE_TITLES[lane],
cards: cards.filter((card) => card.lane === lane),
})).filter((group) => group.cards.length > 0),
[cards],
);
return (
<div id="card-feed" class={viewActive ? "feed-view" : ""}>
{groups.map((group) => (
<section key={group.lane} class="card-group">
<div class="card-group-title">{group.title}</div>
<div class="card-group-list">
{group.cards.map((card) => (
<Card
key={card.id}
card={card}
onDismiss={onDismiss}
onChoice={onChoice}
onAskCard={onAskCard}
/>
))}
</div>
</section>
))}
</div>
);
}

View file

@ -3,6 +3,63 @@ interface VoiceStatusProps {
visible: boolean;
}
function SpeakerIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 9v6h4l5 4V5L9 9H5Z" fill="currentColor" />
<path
d="M17 9.5a4 4 0 0 1 0 5"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-width="1.8"
/>
<path
d="M18.8 7a7 7 0 0 1 0 10"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-width="1.8"
/>
</svg>
);
}
function TextBubbleIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M6 7.5h12a2.5 2.5 0 0 1 2.5 2.5v5A2.5 2.5 0 0 1 18 17.5H11l-4.5 3v-3H6A2.5 2.5 0 0 1 3.5 15v-5A2.5 2.5 0 0 1 6 7.5Z"
fill="currentColor"
/>
<path d="M8 11h8" fill="none" stroke="#fff" stroke-linecap="round" stroke-width="1.5" />
<path d="M8 14h5" fill="none" stroke="#fff" stroke-linecap="round" stroke-width="1.5" />
</svg>
);
}
function ResetIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M6.5 8A7 7 0 1 1 5 12"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-width="1.9"
/>
<path
d="M6.5 4.5V8H10"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.9"
/>
</svg>
);
}
export function VoiceStatus({ text, visible }: VoiceStatusProps) {
return (
<div id="voiceStatus" class={visible ? "visible" : ""}>
@ -13,22 +70,52 @@ export function VoiceStatus({ text, visible }: VoiceStatusProps) {
interface ControlBarProps {
onReset(): void;
textOnly: boolean;
onToggleTextOnly(enabled: boolean): void;
}
export function ControlBar({ onReset }: ControlBarProps) {
export function ControlBar({ onReset, textOnly, onToggleTextOnly }: ControlBarProps) {
const toggleLabel = textOnly ? "Text-only mode on" : "Voice mode on";
return (
<div id="controls">
<button
id="resetSessionBtn"
class="control-btn"
id="textOnlyToggleBtn"
class={`control-switch${textOnly ? " active" : ""}`}
type="button"
role="switch"
aria-checked={textOnly}
aria-label={toggleLabel}
title={toggleLabel}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggleTextOnly(!textOnly);
}}
>
<span class="control-switch-shell" aria-hidden="true">
<span class="control-switch-icon control-switch-icon-speaker">
<SpeakerIcon />
</span>
<span class="control-switch-icon control-switch-icon-text">
<TextBubbleIcon />
</span>
<span class="control-switch-thumb" />
</span>
</button>
<button
id="resetSessionBtn"
class="control-icon-btn"
type="button"
aria-label="Reset context"
title="Reset context"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onReset();
}}
>
Reset
<ResetIcon />
</button>
</div>
);

View file

@ -0,0 +1,45 @@
interface FABProps {
view: "agent" | "feed";
unreadCount: number;
pttActive: boolean;
}
function IconAgent() {
return (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true">
<circle cx="11" cy="11" r="4" fill="currentColor" opacity="0.9" />
<circle cx="11" cy="11" r="8" stroke="currentColor" stroke-width="1.5" opacity="0.45" />
<circle cx="11" cy="11" r="10.25" stroke="currentColor" stroke-width="1.5" opacity="0.2" />
</svg>
);
}
function IconFeed() {
return (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true">
<rect x="3" y="4" width="16" height="3.5" rx="1.75" fill="currentColor" opacity="0.9" />
<rect x="3" y="9.25" width="16" height="3.5" rx="1.75" fill="currentColor" opacity="0.6" />
<rect x="3" y="14.5" width="10" height="3.5" rx="1.75" fill="currentColor" opacity="0.35" />
</svg>
);
}
export function FAB({ view, unreadCount, pttActive }: FABProps) {
const label =
view === "agent" ? "Switch to feed (hold to talk)" : "Switch to agent (hold to talk)";
const badgeVisible = unreadCount > 0 && view === "agent";
return (
<button
id="fab"
type="button"
aria-label={label}
data-ptt="1"
data-fab="1"
class={pttActive ? "ptt-active" : ""}
>
{view === "agent" ? <IconFeed /> : <IconAgent />}
{badgeVisible && <span id="fab-badge">{unreadCount > 99 ? "99+" : unreadCount}</span>}
</button>
);
}

View file

@ -1,38 +1,199 @@
import { useEffect, useRef } from "preact/hooks";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import type { LogLine } from "../types";
interface Props {
lines: LogLine[];
disabled: boolean;
onSendMessage(text: string): Promise<void>;
onExpandChange?(expanded: boolean): void;
}
export function LogPanel({ lines }: Props) {
const innerRef = useRef<HTMLDivElement>(null);
interface LogViewProps {
lines: LogLine[];
scrollRef: { current: HTMLElement | null };
}
// Scroll to top (newest line — column-reverse layout) after each update
useEffect(() => {
const el = innerRef.current?.parentElement;
if (el) el.scrollTop = 0;
}, [lines]);
function SendIcon() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path d="M2 9L16 2L9.5 16L8 10.5L2 9Z" fill="currentColor" />
</svg>
);
}
function CloseIcon() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path d="M14 4L4 14M4 4L14 14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function formatLine(line: LogLine): string {
const time = line.timestamp ? new Date(line.timestamp).toLocaleTimeString() : "";
const role = line.role.trim().toLowerCase();
if (role === "nanobot") {
return `[${time}] ${line.text.replace(/^(?:nanobot|napbot)\b\s*[:>-]?\s*/i, "")}`;
}
if (role === "tool") {
return `[${time}] tool: ${line.text}`;
}
return `[${time}] ${line.role}: ${line.text}`;
}
function LogCompose({
disabled,
sending,
text,
setText,
onClose,
onSend,
}: {
disabled: boolean;
sending: boolean;
text: string;
setText(value: string): void;
onClose(): void;
onSend(): void;
}) {
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSend();
}
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
},
[onClose, onSend],
);
return (
<div id="log">
<div id="log-inner" ref={innerRef}>
{lines.map((line) => {
const time = line.timestamp ? new Date(line.timestamp).toLocaleTimeString() : "";
const role = line.role.trim().toLowerCase();
let text: string;
if (role === "nanobot") {
text = `[${time}] ${line.text.replace(/^(?:nanobot|napbot)\b\s*[:>-]?\s*/i, "")}`;
} else {
text = `[${time}] ${line.role}: ${line.text}`;
}
return (
<div key={line.id} class={`line ${line.role}`}>
{text}
</div>
);
})}
<div id="log-compose">
<textarea
id="log-compose-input"
placeholder="Type a message to nanobot..."
disabled={disabled || sending}
value={text}
onInput={(e) => setText((e.target as HTMLTextAreaElement).value)}
onKeyDown={onKeyDown}
/>
<div id="log-compose-actions">
<button id="log-close-btn" type="button" aria-label="Close" onClick={onClose}>
<CloseIcon />
</button>
<button
id="log-send-btn"
type="button"
aria-label="Send message"
disabled={disabled || sending || text.trim().length === 0}
onClick={onSend}
>
<SendIcon />
</button>
</div>
</div>
);
}
function ExpandedLogView({ lines, scrollRef }: LogViewProps) {
return (
<div
id="log-scroll"
ref={(node) => {
scrollRef.current = node;
}}
>
<div id="log-inner">
{lines.map((line) => (
<div key={line.id} class={`line ${line.role}`}>
{formatLine(line)}
</div>
))}
</div>
</div>
);
}
function CollapsedLogView({ lines, scrollRef, onExpand }: LogViewProps & { onExpand(): void }) {
return (
<button
id="log-collapsed"
ref={(node) => {
scrollRef.current = node;
}}
type="button"
aria-label="Open message composer"
onClick={onExpand}
>
<div id="log-inner">
{lines.map((line) => (
<span key={line.id} class={`line ${line.role}`}>
{formatLine(line)}
</span>
))}
</div>
</button>
);
}
export function LogPanel({ lines, disabled, onSendMessage, onExpandChange }: Props) {
const [expanded, setExpanded] = useState(false);
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
const scrollRef = useRef<HTMLElement>(null);
useEffect(() => onExpandChange?.(expanded), [expanded, onExpandChange]);
useEffect(() => () => onExpandChange?.(false), [onExpandChange]);
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [lines, expanded]);
const collapse = useCallback(() => {
setExpanded(false);
setText("");
}, []);
const expand = useCallback(() => {
if (!expanded) setExpanded(true);
}, [expanded]);
const send = useCallback(async () => {
const message = text.trim();
if (!message || sending || disabled) return;
setSending(true);
try {
await onSendMessage(message);
setText("");
} catch (err) {
window.alert(`Could not send message: ${String(err)}`);
} finally {
setSending(false);
}
}, [disabled, onSendMessage, sending, text]);
return (
<div id="log" class={expanded ? "expanded" : ""} data-no-swipe="1">
{expanded ? (
<ExpandedLogView lines={lines} scrollRef={scrollRef} />
) : (
<CollapsedLogView lines={lines} scrollRef={scrollRef} onExpand={expand} />
)}
{expanded && (
<LogCompose
disabled={disabled}
sending={sending}
text={text}
setText={setText}
onClose={collapse}
onSend={() => {
void send();
}}
/>
)}
</div>
);
}

View file

@ -0,0 +1,188 @@
import { useCallback, useRef, useState } from "preact/hooks";
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? "";
interface TextInputProps {
disabled: boolean;
onExpandChange?(expanded: boolean): void;
}
function ComposeIcon() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path
d="M3 13.5V15.5H5L13.5 7L11.5 5L3 13.5ZM15.2 5.3C15.6 4.9 15.6 4.3 15.2 3.9L14.1 2.8C13.7 2.4 13.1 2.4 12.7 2.8L11.9 3.6L13.9 5.6L15.2 5.3Z"
fill="currentColor"
/>
</svg>
);
}
function SendIcon() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path d="M2 9L16 2L9.5 16L8 10.5L2 9Z" fill="currentColor" />
</svg>
);
}
function CloseIcon() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path d="M14 4L4 14M4 4L14 14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
interface ExpandedBarProps {
text: string;
disabled: boolean;
sending: boolean;
inputRef: { current: HTMLTextAreaElement | null };
onInput(val: string): void;
onKeyDown(e: KeyboardEvent): void;
onBlur(): void;
onSend(): void;
onClose(): void;
stopProp(e: Event): void;
}
function ExpandedBar({
text,
disabled,
sending,
inputRef,
onInput,
onKeyDown,
onBlur,
onSend,
onClose,
stopProp,
}: ExpandedBarProps) {
return (
<div id="text-input-bar" onPointerDown={stopProp} onPointerUp={stopProp}>
<textarea
ref={inputRef}
id="text-input"
placeholder="Type a message…"
disabled={disabled || sending}
value={text}
onInput={(e) => onInput((e.target as HTMLTextAreaElement).value)}
onKeyDown={onKeyDown}
onBlur={onBlur}
/>
<div id="text-input-actions">
<button id="text-close-btn" type="button" aria-label="Close" onClick={onClose}>
<CloseIcon />
</button>
<button
id="text-send-btn"
type="button"
aria-label="Send message"
disabled={disabled || sending || text.trim().length === 0}
onClick={onSend}
>
<SendIcon />
</button>
</div>
</div>
);
}
function useExpandState(onExpandChange?: (v: boolean) => void) {
const [expanded, setExpanded] = useState(false);
const set = useCallback(
(val: boolean) => {
setExpanded(val);
onExpandChange?.(val);
},
[onExpandChange],
);
return [expanded, set] as const;
}
export function TextInput({ disabled, onExpandChange }: TextInputProps) {
const [text, setText] = useState("");
const [expanded, setExpandedWithCb] = useExpandState(onExpandChange);
const [sending, setSending] = useState(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const collapse = useCallback(() => {
setText("");
setExpandedWithCb(false);
inputRef.current?.blur();
}, [setExpandedWithCb]);
const send = useCallback(async () => {
const msg = text.trim();
if (!msg || sending) return;
setSending(true);
try {
const url = BACKEND_URL ? `${BACKEND_URL}/message` : "/message";
await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: msg }),
});
collapse();
} finally {
setSending(false);
}
}, [text, sending, collapse]);
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
if (e.key === "Escape") collapse();
},
[send, collapse],
);
const onBlur = useCallback(() => {
if (text.trim().length === 0) {
setTimeout(() => {
if (document.activeElement !== inputRef.current) setExpandedWithCb(false);
}, 150);
}
}, [text]);
const stopProp = useCallback((e: Event) => e.stopPropagation(), []);
const expand = useCallback(() => {
setExpandedWithCb(true);
requestAnimationFrame(() => inputRef.current?.focus());
}, [setExpandedWithCb]);
if (!expanded) {
return (
<button
id="text-compose-btn"
type="button"
aria-label="Type a message"
onPointerDown={stopProp}
onPointerUp={stopProp}
onClick={expand}
>
<ComposeIcon />
</button>
);
}
return (
<ExpandedBar
text={text}
disabled={disabled}
sending={sending}
inputRef={inputRef}
onInput={setText}
onKeyDown={onKeyDown}
onBlur={onBlur}
onSend={send}
onClose={collapse}
stopProp={stopProp}
/>
);
}

View file

@ -1,109 +0,0 @@
import { marked } from "marked";
import { useEffect, useRef, useState } from "preact/hooks";
import type { ToastItem } from "../types";
interface ToastProps {
toast: ToastItem;
onDismiss(id: number): void;
onChoice(requestId: string, value: string): void;
}
function Toast({ toast, onDismiss, onChoice }: ToastProps) {
const [dismissing, setDismissing] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dismiss = () => {
if (dismissing) return;
setDismissing(true);
const t = setTimeout(() => onDismiss(toast.id), 400);
timerRef.current = t;
};
useEffect(() => {
if (toast.kind !== "choice" && toast.durationMs > 0) {
const t = setTimeout(dismiss, toast.durationMs);
timerRef.current = t;
return () => clearTimeout(t);
}
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const bodyHtml = (): string => {
if (toast.kind === "choice") return "";
if (toast.kind === "image") return "";
const looksLikeHtml = /^\s*<[a-zA-Z]/.test(toast.content);
if (looksLikeHtml) return toast.content;
return marked.parse(toast.content) as string;
};
return (
<div class={`toast${dismissing ? " dismissing" : ""}`}>
<div class="toast-header">
{toast.title && <span class="toast-title">{toast.title}</span>}
<button
class="toast-close"
type="button"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
dismiss();
}}
>
×
</button>
</div>
{toast.kind === "image" && (
<img class="toast-image" src={toast.content} alt={toast.title || "image"} />
)}
{toast.kind === "text" && (
<div class="toast-body" dangerouslySetInnerHTML={{ __html: bodyHtml() }} />
)}
{toast.kind === "choice" && (
<>
<div class="toast-body">{toast.question}</div>
<div class="toast-choices">
{(toast.choices ?? []).map((label) => (
<button
key={label}
class="toast-choice-btn"
type="button"
onClick={(e) => {
e.stopPropagation();
onChoice(toast.requestId ?? "", label);
dismiss();
}}
>
{label}
</button>
))}
</div>
</>
)}
{toast.kind !== "choice" && toast.durationMs > 0 && (
<div class="toast-progress" style={{ animationDuration: `${toast.durationMs}ms` }} />
)}
</div>
);
}
interface ContainerProps {
toasts: ToastItem[];
onDismiss(id: number): void;
onChoice(requestId: string, value: string): void;
}
export function ToastContainer({ toasts, onDismiss, onChoice }: ContainerProps) {
return (
<div id="toast-container">
{toasts.map((t) => (
<Toast key={t.id} toast={t} onDismiss={onDismiss} onChoice={onChoice} />
))}
</div>
);
}

View file

@ -1,15 +1,20 @@
import { useCallback, useRef, useState } from "preact/hooks";
import type { AgentState } from "../types";
const HOLD_MS = 300;
const MOVE_CANCEL_PX = 16;
interface UsePTTOptions {
connected: boolean;
onSendPtt(pressed: boolean): void;
onBootstrap(): Promise<void>;
onTap?(): void; // called on a short press (< HOLD_MS) that didn't activate PTT
}
interface PTTState {
agentStateOverride: AgentState | null;
handlePointerDown(e: Event): Promise<void>;
handlePointerMove(e: Event): void;
handlePointerUp(e: Event): void;
}
@ -18,14 +23,18 @@ function dispatchMicEnable(enabled: boolean): void {
}
/** Manages push-to-talk pointer events and mic enable/disable. */
export function usePTT({ connected, onSendPtt, onBootstrap }: UsePTTOptions): PTTState {
export function usePTT({ connected, onSendPtt, onBootstrap, onTap }: UsePTTOptions): PTTState {
const [agentStateOverride, setAgentStateOverride] = useState<AgentState | null>(null);
const activePointers = useRef(new Set<number>());
const appStartedRef = useRef(false);
const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pttFiredRef = useRef(false);
const pointerStartRef = useRef<{ x: number; y: number } | null>(null);
const beginPTT = useCallback(() => {
if (!connected) return;
if (agentStateOverride === "listening") return;
pttFiredRef.current = true;
setAgentStateOverride("listening");
dispatchMicEnable(true);
onSendPtt(true);
@ -52,19 +61,57 @@ export function usePTT({ connected, onSendPtt, onBootstrap }: UsePTTOptions): PT
appStartedRef.current = true;
await onBootstrap();
}
if (activePointers.current.size === 1) beginPTT();
if (activePointers.current.size !== 1) return;
pttFiredRef.current = false;
pointerStartRef.current = { x: pe.clientX, y: pe.clientY };
// Delay activation slightly so horizontal swipe gestures can cancel.
holdTimerRef.current = setTimeout(beginPTT, HOLD_MS);
},
[onBootstrap, beginPTT],
);
const handlePointerMove = useCallback((e: Event) => {
if (pttFiredRef.current) return;
if (holdTimerRef.current === null) return;
const pe = e as PointerEvent;
if (!activePointers.current.has(pe.pointerId)) return;
const start = pointerStartRef.current;
if (!start) return;
const dx = Math.abs(pe.clientX - start.x);
const dy = Math.abs(pe.clientY - start.y);
if (dx > MOVE_CANCEL_PX || dy > MOVE_CANCEL_PX) {
clearTimeout(holdTimerRef.current);
holdTimerRef.current = null;
}
}, []);
const handlePointerUp = useCallback(
(e: Event) => {
const pe = e as PointerEvent;
// Ignore pointers we never tracked (didn't hit a data-ptt target on down)
if (!activePointers.current.has(pe.pointerId)) return;
activePointers.current.delete(pe.pointerId);
if (activePointers.current.size === 0) endPTT();
if (activePointers.current.size !== 0) return;
// Cancel hold timer if it hasn't fired yet
if (holdTimerRef.current !== null) {
clearTimeout(holdTimerRef.current);
holdTimerRef.current = null;
}
pointerStartRef.current = null;
if (pttFiredRef.current) {
endPTT();
} else {
// PTT never fired → short tap.
onTap?.();
}
},
[endPTT],
[endPTT, onTap],
);
return { agentStateOverride, handlePointerDown, handlePointerUp };
return { agentStateOverride, handlePointerDown, handlePointerMove, handlePointerUp };
}

View file

@ -1,54 +0,0 @@
import { useCallback, useRef, useState } from "preact/hooks";
import type { AgentState } from "../types";
export interface PushToTalkState {
pttPressed: boolean;
micStream: MediaStream | null;
beginPTT(): void;
endPTT(): void;
}
interface UsePushToTalkOptions {
connected: boolean;
agentState: AgentState;
onPttChange(pressed: boolean): void;
onSetAgentState(state: AgentState): void;
onShowStatus(text: string, persistMs?: number): void;
}
export function usePushToTalk({
connected,
onPttChange,
onSetAgentState,
onShowStatus,
}: UsePushToTalkOptions): PushToTalkState {
const [pttPressed, setPttPressed] = useState(false);
const micStreamRef = useRef<MediaStream | null>(null);
// Attach mic stream from RTCPeerConnection tracks — caller passes it via micStream prop
// Here we track from the parent. Mic enable/disable is done by the parent hook.
const beginPTT = useCallback(() => {
if (!connected) return;
if (pttPressed) return;
setPttPressed(true);
onPttChange(true);
onSetAgentState("listening");
onShowStatus("Listening...");
}, [connected, pttPressed, onPttChange, onSetAgentState, onShowStatus]);
const endPTT = useCallback(() => {
if (!pttPressed) return;
setPttPressed(false);
onPttChange(false);
onSetAgentState("idle");
if (connected) onShowStatus("Hold anywhere to talk", 1800);
}, [pttPressed, onPttChange, onSetAgentState, onShowStatus, connected]);
return {
pttPressed,
micStream: micStreamRef.current,
beginPTT,
endPTT,
};
}

View file

@ -1,160 +1,148 @@
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import type { AgentState, ClientMessage, LogLine, ServerMessage, ToastItem } from "../types";
import type {
AgentState,
CardItem,
CardLane,
CardMessageMetadata,
CardState,
ClientMessage,
LogLine,
ServerMessage,
} from "../types";
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? "";
let toastIdCounter = 0;
let cardIdCounter = 0;
let logIdCounter = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
const LANE_RANK: Record<CardLane, number> = {
attention: 0,
work: 1,
context: 2,
history: 3,
};
export interface WebRTCState {
const STATE_RANK: Record<CardState, number> = {
active: 0,
stale: 1,
resolved: 2,
superseded: 3,
archived: 4,
};
interface WebRTCState {
connected: boolean;
connecting: boolean;
textOnly: boolean;
agentState: AgentState;
logLines: LogLine[];
toasts: ToastItem[];
cards: CardItem[];
voiceStatus: string;
statusVisible: boolean;
remoteAudioEl: HTMLAudioElement | null;
remoteStream: MediaStream | null;
sendJson(msg: ClientMessage): void;
dismissToast(id: number): void;
sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise<void>;
dismissCard(id: number): void;
setTextOnly(enabled: boolean): void;
connect(): Promise<void>;
}
type AppendLine = (role: string, text: string, timestamp: string) => void;
type AddToast = (item: Omit<ToastItem, "id">) => number;
type UpsertCard = (item: Omit<CardItem, "id">) => void;
type SetAgentState = (updater: (prev: AgentState) => AgentState) => void;
interface IdleFallbackControls {
clear(): void;
schedule(delayMs?: number): void;
}
// ---------------------------------------------------------------------------
// Message handlers (pure functions, outside hook to reduce complexity)
// ---------------------------------------------------------------------------
interface RTCRefs {
pcRef: { current: RTCPeerConnection | null };
dcRef: { current: RTCDataChannel | null };
remoteAudioRef: { current: HTMLAudioElement | null };
micSendersRef: { current: RTCRtpSender[] };
}
interface RTCCallbacks {
setConnected: (v: boolean) => void;
setConnecting: (v: boolean) => void;
setRemoteStream: (s: MediaStream | null) => void;
showStatus: (text: string, persistMs?: number) => void;
appendLine: AppendLine;
onDcMessage: (raw: string) => void;
onDcOpen: () => void;
closePC: () => void;
}
function compareCards(a: CardItem, b: CardItem): number {
const laneDiff = LANE_RANK[a.lane] - LANE_RANK[b.lane];
if (laneDiff !== 0) return laneDiff;
const stateDiff = STATE_RANK[a.state] - STATE_RANK[b.state];
if (stateDiff !== 0) return stateDiff;
if (a.priority !== b.priority) return b.priority - a.priority;
const updatedDiff = b.updatedAt.localeCompare(a.updatedAt);
if (updatedDiff !== 0) return updatedDiff;
return b.createdAt.localeCompare(a.createdAt);
}
function sortCards(items: CardItem[]): CardItem[] {
return [...items].sort(compareCards);
}
function toCardItem(msg: Extract<ServerMessage, { type: "card" }>): Omit<CardItem, "id"> {
return {
serverId: msg.id,
kind: msg.kind,
content: msg.content,
title: msg.title,
question: msg.question || undefined,
choices: msg.choices.length > 0 ? msg.choices : undefined,
responseValue: msg.response_value || undefined,
slot: msg.slot || undefined,
lane: msg.lane,
priority: msg.priority,
state: msg.state,
templateKey: msg.template_key || undefined,
contextSummary: msg.context_summary || undefined,
createdAt: msg.created_at || new Date().toISOString(),
updatedAt: msg.updated_at || new Date().toISOString(),
};
}
function handleTypedMessage(
msg: Extract<ServerMessage, { type: string }>,
setAgentState: SetAgentState,
appendLine: AppendLine,
addToast: AddToast,
upsertCard: UpsertCard,
idleFallback: IdleFallbackControls,
): void {
if (msg.type === "agent_state") {
const s = (msg as { type: "agent_state"; state: AgentState }).state;
setAgentState((prev) => (prev === "listening" ? prev : s));
idleFallback.clear();
setAgentState((prev) => (prev === "listening" ? prev : msg.state));
return;
}
if (msg.type === "message") {
const mm = msg as { type: "message"; content: string; is_progress: boolean };
if (!mm.is_progress) appendLine("nanobot", mm.content, "");
if (msg.is_tool_hint) {
appendLine("tool", msg.content, msg.timestamp);
return;
}
if (!msg.is_progress) {
appendLine(msg.role, msg.content, msg.timestamp);
idleFallback.schedule();
}
return;
}
if (msg.type === "toast") {
const tm = msg as {
type: "toast";
kind: "text" | "image";
content: string;
title: string;
duration_ms: number;
};
addToast({
kind: tm.kind,
content: tm.content,
title: tm.title,
durationMs: tm.duration_ms ?? 6000,
});
return;
}
if (msg.type === "choice") {
const cm = msg as {
type: "choice";
request_id: string;
question: string;
choices: string[];
title: string;
};
addToast({
kind: "choice",
content: "",
title: cm.title || "",
durationMs: 0,
requestId: cm.request_id,
question: cm.question,
choices: cm.choices,
});
if (msg.type === "card") {
upsertCard(toCardItem(msg));
idleFallback.schedule();
return;
}
if (msg.type === "error") {
appendLine("system", (msg as { type: "error"; error: string }).error, "");
}
// pong and rtc-* are no-ops
}
function parseLegacyToast(text: string, addToast: AddToast): void {
console.log("[toast] parseLegacyToast raw text:", text);
try {
const t = JSON.parse(text);
console.log("[toast] parsed toast object:", t);
addToast({
kind: t.kind || "text",
content: t.content || "",
title: t.title || "",
durationMs: typeof t.duration_ms === "number" ? t.duration_ms : 6000,
});
} catch {
console.log("[toast] JSON parse failed, using raw text as content");
addToast({ kind: "text", content: text, title: "", durationMs: 6000 });
appendLine("system", msg.error, "");
idleFallback.schedule();
}
}
function parseLegacyChoice(text: string, addToast: AddToast): void {
try {
const c = JSON.parse(text);
addToast({
kind: "choice",
content: "",
title: c.title || "",
durationMs: 0,
requestId: c.request_id || "",
question: c.question || "",
choices: Array.isArray(c.choices) ? c.choices : [],
});
} catch {
/* ignore malformed */
}
}
function handleLegacyMessage(
rm: { role: string; text: string; timestamp?: string },
setAgentState: SetAgentState,
appendLine: AppendLine,
addToast: AddToast,
): void {
const role = (rm.role || "system").toString();
const text = (rm.text || "").toString();
const ts = rm.timestamp || "";
if (role === "agent-state") {
const newState = text.trim() as AgentState;
setAgentState((prev) => (prev === "listening" ? prev : newState));
return;
}
if (role === "toast") {
parseLegacyToast(text, addToast);
return;
}
if (role === "choice") {
parseLegacyChoice(text, addToast);
return;
}
if (role === "wisper") return; // suppress debug
appendLine(role, text, ts);
}
// ---------------------------------------------------------------------------
// WebRTC helpers
// ---------------------------------------------------------------------------
async function acquireMicStream(): Promise<MediaStream> {
try {
return await navigator.mediaDevices.getUserMedia({
@ -185,7 +173,7 @@ function waitForIceComplete(pc: RTCPeerConnection): Promise<void> {
}
};
pc.addEventListener("icegatheringstatechange", check);
setTimeout(resolve, 5000); // safety timeout
setTimeout(resolve, 5000);
});
}
@ -202,28 +190,11 @@ async function exchangeSdp(
return resp.json() as Promise<{ sdp: string; rtcType: string }>;
}
// ---------------------------------------------------------------------------
// Hook internals
// ---------------------------------------------------------------------------
interface RTCRefs {
pcRef: { current: RTCPeerConnection | null };
dcRef: { current: RTCDataChannel | null };
remoteAudioRef: { current: HTMLAudioElement | null };
micSendersRef: { current: RTCRtpSender[] };
}
interface RTCCallbacks {
setConnected: (v: boolean) => void;
setConnecting: (v: boolean) => void;
setRemoteStream: (s: MediaStream | null) => void;
showStatus: (text: string, persistMs?: number) => void;
appendLine: AppendLine;
onDcMessage: (raw: string) => void;
closePC: () => void;
}
async function runConnect(refs: RTCRefs, cbs: RTCCallbacks): Promise<void> {
async function runConnect(
refs: RTCRefs,
cbs: RTCCallbacks,
opts: { textOnly: boolean },
): Promise<void> {
if (refs.pcRef.current) return;
if (!window.RTCPeerConnection) {
cbs.showStatus("WebRTC unavailable in this browser.", 4000);
@ -234,10 +205,12 @@ async function runConnect(refs: RTCRefs, cbs: RTCCallbacks): Promise<void> {
let micStream: MediaStream | null = null;
try {
micStream = await acquireMicStream();
micStream.getAudioTracks().forEach((t) => {
t.enabled = false;
});
if (!opts.textOnly) {
micStream = await acquireMicStream();
micStream.getAudioTracks().forEach((track) => {
track.enabled = false;
});
}
const pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] });
refs.pcRef.current = pc;
@ -260,8 +233,9 @@ async function runConnect(refs: RTCRefs, cbs: RTCCallbacks): Promise<void> {
dc.onopen = () => {
cbs.setConnected(true);
cbs.setConnecting(false);
cbs.showStatus("Hold anywhere to talk", 2500);
cbs.showStatus(opts.textOnly ? "Text-only mode enabled" : "Hold anywhere to talk", 2500);
cbs.appendLine("system", "Connected.", new Date().toISOString());
cbs.onDcOpen();
};
dc.onclose = () => {
cbs.appendLine("system", "Disconnected.", new Date().toISOString());
@ -269,11 +243,13 @@ async function runConnect(refs: RTCRefs, cbs: RTCCallbacks): Promise<void> {
};
dc.onmessage = (e) => cbs.onDcMessage(e.data as string);
const stream = micStream;
stream.getAudioTracks().forEach((track) => {
pc.addTrack(track, stream);
});
refs.micSendersRef.current = pc.getSenders().filter((s) => s.track?.kind === "audio");
refs.micSendersRef.current = [];
if (micStream) {
micStream.getAudioTracks().forEach((track) => {
pc.addTrack(track, micStream as MediaStream);
});
refs.micSendersRef.current = pc.getSenders().filter((s) => s.track?.kind === "audio");
}
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
@ -287,31 +263,114 @@ async function runConnect(refs: RTCRefs, cbs: RTCCallbacks): Promise<void> {
cbs.appendLine("system", `Connection failed: ${err}`, new Date().toISOString());
cbs.showStatus("Connection failed.", 3000);
cbs.closePC();
if (micStream)
micStream.getTracks().forEach((t) => {
t.stop();
});
micStream?.getTracks().forEach((track) => {
track.stop();
});
}
}
// ---------------------------------------------------------------------------
// Message state sub-hook
// ---------------------------------------------------------------------------
function useBackendActions() {
const sendTextMessage = useCallback(async (text: string, metadata?: CardMessageMetadata) => {
const message = text.trim();
if (!message) return;
const url = BACKEND_URL ? `${BACKEND_URL}/message` : "/message";
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: message, metadata: metadata ?? {} }),
});
if (!resp.ok) throw new Error(`Send failed (${resp.status})`);
}, []);
interface MessageState {
agentState: AgentState;
logLines: LogLine[];
toasts: ToastItem[];
appendLine: AppendLine;
addToast: AddToast;
dismissToast: (id: number) => void;
onDcMessage: (raw: string) => void;
return { sendTextMessage };
}
function useMessageState(): MessageState {
function useCardPolling(loadPersistedCards: () => Promise<void>) {
useEffect(() => {
loadPersistedCards().catch(() => {});
const pollId = window.setInterval(() => {
loadPersistedCards().catch(() => {});
}, 10000);
const onVisible = () => {
if (document.visibilityState === "visible") loadPersistedCards().catch(() => {});
};
window.addEventListener("focus", onVisible);
document.addEventListener("visibilitychange", onVisible);
return () => {
window.clearInterval(pollId);
window.removeEventListener("focus", onVisible);
document.removeEventListener("visibilitychange", onVisible);
};
}, [loadPersistedCards]);
}
function useRemoteAudioBindings({
textOnly,
connected,
showStatus,
remoteAudioRef,
micSendersRef,
dcRef,
textOnlyRef,
}: {
textOnly: boolean;
connected: boolean;
showStatus: (text: string, persistMs?: number) => void;
remoteAudioRef: { current: HTMLAudioElement | null };
micSendersRef: { current: RTCRtpSender[] };
dcRef: { current: RTCDataChannel | null };
textOnlyRef: { current: boolean };
}) {
useEffect(() => {
textOnlyRef.current = textOnly;
}, [textOnly, textOnlyRef]);
useEffect(() => {
const audio = new Audio();
audio.autoplay = true;
(audio as HTMLAudioElement & { playsInline: boolean }).playsInline = true;
audio.muted = textOnlyRef.current;
remoteAudioRef.current = audio;
return () => {
audio.srcObject = null;
};
}, [remoteAudioRef, textOnlyRef]);
useEffect(() => {
const handler = (e: Event) => {
const enabled = (e as CustomEvent<{ enabled: boolean }>).detail?.enabled ?? false;
micSendersRef.current.forEach((sender) => {
if (sender.track) sender.track.enabled = enabled && !textOnlyRef.current;
});
};
window.addEventListener("nanobot-mic-enable", handler);
return () => window.removeEventListener("nanobot-mic-enable", handler);
}, [micSendersRef, textOnlyRef]);
useEffect(() => {
if (remoteAudioRef.current) {
remoteAudioRef.current.muted = textOnly;
if (textOnly) remoteAudioRef.current.pause();
else remoteAudioRef.current.play().catch(() => {});
}
micSendersRef.current.forEach((sender) => {
if (sender.track) sender.track.enabled = false;
});
if (textOnly) {
const dc = dcRef.current;
if (dc?.readyState === "open") {
dc.send(JSON.stringify({ type: "voice-ptt", pressed: false } satisfies ClientMessage));
}
}
if (connected) showStatus(textOnly ? "Text-only mode enabled" : "Hold anywhere to talk", 2000);
}, [connected, dcRef, micSendersRef, remoteAudioRef, showStatus, textOnly]);
}
function useMessageState() {
const [agentState, setAgentState] = useState<AgentState>("idle");
const [logLines, setLogLines] = useState<LogLine[]>([]);
const [toasts, setToasts] = useState<ToastItem[]>([]);
const [cards, setCards] = useState<CardItem[]>([]);
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const appendLine = useCallback((role: string, text: string, timestamp: string) => {
setLogLines((prev) => {
@ -323,144 +382,268 @@ function useMessageState(): MessageState {
});
}, []);
const addToast = useCallback((item: Omit<ToastItem, "id">) => {
const id = toastIdCounter++;
setToasts((prev) => [{ ...item, id }, ...prev]);
return id;
const upsertCard = useCallback((item: Omit<CardItem, "id">) => {
setCards((prev) => {
const existingIndex = item.serverId
? prev.findIndex((card) => card.serverId === item.serverId)
: -1;
if (existingIndex >= 0) {
const next = [...prev];
next[existingIndex] = { ...next[existingIndex], ...item };
return sortCards(next);
}
return sortCards([...prev, { ...item, id: cardIdCounter++ }]);
});
}, []);
const dismissToast = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
const dismissCard = useCallback((id: number) => {
setCards((prev) => {
const card = prev.find((entry) => entry.id === id);
if (card?.serverId) {
const url = BACKEND_URL
? `${BACKEND_URL}/cards/${card.serverId}`
: `/cards/${card.serverId}`;
fetch(url, { method: "DELETE" }).catch(() => {});
}
return prev.filter((entry) => entry.id !== id);
});
}, []);
const clearIdleFallback = useCallback(() => {
if (idleTimerRef.current) {
clearTimeout(idleTimerRef.current);
idleTimerRef.current = null;
}
}, []);
const scheduleIdleFallback = useCallback(
(delayMs = 450) => {
clearIdleFallback();
idleTimerRef.current = setTimeout(() => {
idleTimerRef.current = null;
setAgentState((prev) => {
if (prev === "listening" || prev === "speaking") return prev;
return "idle";
});
}, delayMs);
},
[clearIdleFallback],
);
useEffect(() => clearIdleFallback, [clearIdleFallback]);
const loadPersistedCards = useCallback(async () => {
try {
const url = BACKEND_URL ? `${BACKEND_URL}/cards` : "/cards";
const resp = await fetch(url, { cache: "no-store" });
if (!resp.ok) {
console.warn(`[cards] /cards returned ${resp.status}`);
return;
}
const rawCards = (await resp.json()) as Array<
| Extract<ServerMessage, { type: "card" }>
| (Omit<Extract<ServerMessage, { type: "card" }>, "type"> & { type?: "card" })
>;
setCards((prev) => {
const byServerId = new Map(
prev.filter((card) => card.serverId).map((card) => [card.serverId as string, card.id]),
);
const next = rawCards.map((raw) => {
const card = toCardItem({
type: "card",
...(raw as Omit<Extract<ServerMessage, { type: "card" }>, "type">),
});
return {
...card,
id:
card.serverId && byServerId.has(card.serverId)
? (byServerId.get(card.serverId) as number)
: cardIdCounter++,
};
});
return sortCards(next);
});
} catch (err) {
console.warn("[cards] failed to load persisted cards", err);
}
}, []);
const onDcMessage = useCallback(
(raw: string) => {
console.log("[dc] onDcMessage raw:", raw);
let msg: ServerMessage;
try {
msg = JSON.parse(raw);
} catch {
console.log("[dc] JSON parse failed for raw message");
return;
}
if ("type" in msg) {
console.log("[dc] typed message, type:", (msg as { type: string }).type);
handleTypedMessage(
msg as Extract<ServerMessage, { type: string }>,
setAgentState,
appendLine,
addToast,
);
} else {
console.log("[dc] legacy message, role:", (msg as { role: string }).role);
handleLegacyMessage(
msg as { role: string; text: string; timestamp?: string },
setAgentState,
appendLine,
addToast,
);
}
if (typeof msg !== "object" || msg === null || !("type" in msg)) return;
handleTypedMessage(
msg as Extract<ServerMessage, { type: string }>,
setAgentState,
appendLine,
upsertCard,
{ clear: clearIdleFallback, schedule: scheduleIdleFallback },
);
},
[appendLine, addToast],
[appendLine, clearIdleFallback, scheduleIdleFallback, upsertCard],
);
return { agentState, logLines, toasts, appendLine, addToast, dismissToast, onDcMessage };
return { agentState, logLines, cards, appendLine, dismissCard, loadPersistedCards, onDcMessage };
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
function usePeerConnectionControls({
textOnly,
connected,
appendLine,
onDcMessage,
loadPersistedCards,
showStatus,
refs,
setConnected,
setConnecting,
setRemoteStream,
textOnlyRef,
}: {
textOnly: boolean;
connected: boolean;
appendLine: AppendLine;
onDcMessage: (raw: string) => void;
loadPersistedCards: () => Promise<void>;
showStatus: (text: string, persistMs?: number) => void;
refs: RTCRefs;
setConnected: (value: boolean) => void;
setConnecting: (value: boolean) => void;
setRemoteStream: (stream: MediaStream | null) => void;
textOnlyRef: { current: boolean };
}) {
const closePC = useCallback(() => {
refs.dcRef.current?.close();
refs.dcRef.current = null;
refs.pcRef.current?.close();
refs.pcRef.current = null;
refs.micSendersRef.current = [];
setConnected(false);
setConnecting(false);
if (refs.remoteAudioRef.current) refs.remoteAudioRef.current.srcObject = null;
setRemoteStream(null);
}, [refs, setConnected, setConnecting, setRemoteStream]);
const connect = useCallback(async () => {
await runConnect(
refs,
{
setConnected,
setConnecting,
setRemoteStream,
showStatus,
appendLine,
onDcMessage,
onDcOpen: () => {
loadPersistedCards().catch(() => {});
},
closePC,
},
{ textOnly: textOnlyRef.current },
);
}, [
appendLine,
closePC,
loadPersistedCards,
onDcMessage,
refs,
setConnected,
setConnecting,
setRemoteStream,
showStatus,
textOnlyRef,
]);
useEffect(() => {
if (textOnly || !connected || refs.micSendersRef.current.length > 0) return;
closePC();
connect().catch(() => {});
}, [closePC, connect, connected, refs.micSendersRef, textOnly]);
return { closePC, connect };
}
export function useWebRTC(): WebRTCState {
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(false);
const [textOnly, setTextOnlyState] = useState(false);
const [voiceStatus, setVoiceStatus] = useState("");
const [statusVisible, setStatusVisible] = useState(false);
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
const pcRef = useRef<RTCPeerConnection | null>(null);
const dcRef = useRef<RTCDataChannel | null>(null);
const remoteAudioRef = useRef<HTMLAudioElement | null>(null);
const refs: RTCRefs = {
pcRef: useRef<RTCPeerConnection | null>(null),
dcRef: useRef<RTCDataChannel | null>(null),
remoteAudioRef: useRef<HTMLAudioElement | null>(null),
micSendersRef: useRef<RTCRtpSender[]>([]),
};
const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const micSendersRef = useRef<RTCRtpSender[]>([]);
const textOnlyRef = useRef(false);
const { sendTextMessage } = useBackendActions();
const { agentState, logLines, cards, appendLine, dismissCard, loadPersistedCards, onDcMessage } =
useMessageState();
const { agentState, logLines, toasts, appendLine, dismissToast, onDcMessage } = useMessageState();
// Create audio element once
useEffect(() => {
const audio = new Audio();
audio.autoplay = true;
(audio as HTMLAudioElement & { playsInline: boolean }).playsInline = true;
remoteAudioRef.current = audio;
return () => {
audio.srcObject = null;
};
const setTextOnly = useCallback((enabled: boolean) => {
textOnlyRef.current = enabled;
setTextOnlyState(enabled);
}, []);
useEffect(() => {
const handler = (e: Event) => {
const enabled = (e as CustomEvent<{ enabled: boolean }>).detail?.enabled ?? false;
micSendersRef.current.forEach((sender) => {
if (sender.track) sender.track.enabled = enabled;
});
};
window.addEventListener("nanobot-mic-enable", handler);
return () => window.removeEventListener("nanobot-mic-enable", handler);
}, []);
const showStatus = useCallback((text: string, persistMs = 0) => {
setVoiceStatus(text);
setStatusVisible(true);
if (statusTimerRef.current) clearTimeout(statusTimerRef.current);
if (persistMs > 0) {
if (persistMs > 0)
statusTimerRef.current = setTimeout(() => setStatusVisible(false), persistMs);
}
}, []);
const sendJson = useCallback(
(msg: ClientMessage) => {
const dc = refs.dcRef.current;
if (dc?.readyState === "open") dc.send(JSON.stringify(msg));
},
[refs.dcRef],
);
const sendJson = useCallback((msg: ClientMessage) => {
const dc = dcRef.current;
if (!dc || dc.readyState !== "open") return;
dc.send(JSON.stringify(msg));
}, []);
const closePC = useCallback(() => {
dcRef.current?.close();
dcRef.current = null;
pcRef.current?.close();
pcRef.current = null;
micSendersRef.current = [];
setConnected(false);
setConnecting(false);
if (remoteAudioRef.current) remoteAudioRef.current.srcObject = null;
setRemoteStream(null);
}, []);
const connect = useCallback(async () => {
const refs: RTCRefs = { pcRef, dcRef, remoteAudioRef, micSendersRef };
const cbs: RTCCallbacks = {
setConnected,
setConnecting,
setRemoteStream,
showStatus,
appendLine,
onDcMessage,
closePC,
};
await runConnect(refs, cbs);
}, [setConnected, setConnecting, setRemoteStream, showStatus, appendLine, onDcMessage, closePC]);
useCardPolling(loadPersistedCards);
useRemoteAudioBindings({
textOnly,
connected,
showStatus,
remoteAudioRef: refs.remoteAudioRef,
micSendersRef: refs.micSendersRef,
dcRef: refs.dcRef,
textOnlyRef,
});
const { connect } = usePeerConnectionControls({
textOnly,
connected,
appendLine,
onDcMessage,
loadPersistedCards,
showStatus,
refs,
setConnected,
setConnecting,
setRemoteStream,
textOnlyRef,
});
return {
connected,
connecting,
textOnly,
agentState,
logLines,
toasts,
cards,
voiceStatus,
statusVisible,
remoteAudioEl: remoteAudioRef.current,
remoteAudioEl: refs.remoteAudioRef.current,
remoteStream,
sendJson,
dismissToast,
sendTextMessage,
dismissCard,
setTextOnly,
connect,
};
}

File diff suppressed because it is too large Load diff

View file

@ -1,23 +1,62 @@
// Shared TypeScript types for the Nanobot UI
export type AgentState = "idle" | "listening" | "thinking" | "speaking";
export type CardLane = "attention" | "work" | "context" | "history";
export type CardState = "active" | "stale" | "resolved" | "superseded" | "archived";
export type JsonValue =
| string
| number
| boolean
| null
| { [key: string]: JsonValue }
| JsonValue[];
export interface CardMessageMetadata {
card_id?: string;
card_slot?: string;
card_title?: string;
card_lane?: CardLane;
card_template_key?: string;
card_context_summary?: string;
card_response_value?: string;
card_live_content?: JsonValue;
}
// Messages sent FROM backend TO frontend via DataChannel
export type ServerMessage =
| { type: "agent_state"; state: AgentState }
| { type: "message"; content: string; is_progress: boolean }
| { type: "toast"; kind: "text" | "image"; content: string; title: string; duration_ms: number }
| { type: "choice"; request_id: string; question: string; choices: string[]; title: string }
| {
type: "message";
role: string;
content: string;
is_progress: boolean;
is_tool_hint: boolean;
timestamp: string;
}
| {
type: "card";
id: string;
kind: "text" | "question";
title: string;
content: string;
question: string;
choices: string[];
response_value: string;
slot: string;
lane: CardLane;
priority: number;
state: CardState;
template_key: string;
context_summary: string;
created_at: string;
updated_at: string;
}
| { type: "error"; error: string }
| { type: "pong" }
// Legacy wire format still used by backend (role-based)
| { role: string; text: string; timestamp?: string };
| { type: "pong" };
// Messages sent FROM frontend TO backend via DataChannel
export type ClientMessage =
| { type: "voice-ptt"; pressed: boolean }
| { type: "voice-ptt"; pressed: boolean; metadata?: CardMessageMetadata }
| { type: "command"; command: string }
| { type: "ui-response"; request_id: string; value: string }
| { type: "card-response"; card_id: string; value: string }
| { type: "ping" };
export interface LogLine {
@ -27,19 +66,21 @@ export interface LogLine {
timestamp: string;
}
export interface ToastItem {
export interface CardItem {
id: number;
kind: "text" | "image" | "choice";
serverId?: string;
kind: "text" | "question";
content: string;
title: string;
durationMs: number;
// For choice toasts
requestId?: string;
question?: string;
choices?: string[];
}
export interface RTCState {
connected: boolean;
connecting: boolean;
responseValue?: string;
slot?: string;
lane: CardLane;
priority: number;
state: CardState;
templateKey?: string;
contextSummary?: string;
createdAt: string;
updatedAt: string;
}