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 { useAudioMeter } from "./hooks/useAudioMeter"; import { usePTT } from "./hooks/usePTT"; import { useWebRTC } from "./hooks/useWebRTC"; import type { CardItem, CardMessageMetadata, JsonValue } from "./types"; const SWIPE_THRESHOLD_PX = 64; const SWIPE_DIRECTION_RATIO = 1.15; interface AppRtcActions { connect(): Promise; 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; connected: boolean; connecting: boolean; } 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; } function AgentCardContext({ card, onClear }: { card: CardItem; onClear(): void }) { return (
Using card
{card.title}
); } 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 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(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 ( <>
{view === "agent" && ( )} {view === "agent" && selectedCard && ( setSelectedCardId(null)} /> )} {view === "agent" && ( )} {}} onPointerUp={() => {}} />
); }