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, CardSelectionRange, JsonValue } from "./types"; const SWIPE_THRESHOLD_PX = 64; const SWIPE_DIRECTION_RATIO = 1.15; const CARD_SELECTION_EVENT = "nanobot:card-selection-change"; 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 toNullableNumber(value: JsonValue | undefined): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } function readCardSelection(cardId: string | null | undefined): CardSelectionRange | null { const raw = window.__nanobotGetCardSelection?.(cardId); if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; const record = raw as Record; if (record.kind !== "git_diff_range") return null; if (typeof record.file_label !== "string" || typeof record.range_label !== "string") return null; const filePath = typeof record.file_path === "string" ? record.file_path : record.file_label; const label = typeof record.label === "string" ? record.label : `${record.file_label} ยท ${record.range_label}`; return { kind: "git_diff_range", file_path: filePath, file_label: record.file_label, range_label: record.range_label, label, old_start: toNullableNumber(record.old_start), old_end: toNullableNumber(record.old_end), new_start: toNullableNumber(record.new_start), new_end: toNullableNumber(record.new_end), }; } 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 selection = card.serverId ? readCardSelection(card.serverId) : null; if (selection) { metadata.card_selection = selection as unknown as JsonValue; metadata.card_selection_label = selection.label; } const liveContent = card.serverId ? window.__nanobotGetCardLiveContent?.(card.serverId) : undefined; if (liveContent !== undefined) metadata.card_live_content = liveContent as JsonValue; return metadata; } function AgentCardContext({ card, selection, onClear, }: { card: CardItem; selection: CardSelectionRange | null; onClear(): void; }) { const label = selection ? "Using diff context" : "Using card"; const title = selection?.file_label || card.title; const meta = selection?.range_label || ""; return (
{label}
{title}
{meta &&
{meta}
}
); } 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 [selectionVersion, setSelectionVersion] = useState(0); const autoOpenedFeedRef = useRef(false); const selectedCard = useMemo( () => selectedCardId ? (rtc.cards.find((card) => card.serverId === selectedCardId) ?? null) : null, [rtc.cards, selectedCardId], ); const selectedCardSelection = useMemo( () => (selectedCardId ? readCardSelection(selectedCardId) : null), [selectedCardId, selectionVersion], ); const selectedCardMetadata = useCallback( () => (selectedCard ? buildCardMetadata(selectedCard) : undefined), [selectedCard, selectionVersion], ); 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]); useEffect(() => { const handleSelectionChange = (event: Event) => { const detail = (event as CustomEvent<{ cardId?: string; selection?: JsonValue | null }>) .detail; setSelectionVersion((current) => current + 1); const cardId = typeof detail?.cardId === "string" ? detail.cardId : ""; if (cardId && detail?.selection) setSelectedCardId(cardId); }; window.addEventListener(CARD_SELECTION_EVENT, handleSelectionChange as EventListener); return () => { window.removeEventListener(CARD_SELECTION_EVENT, handleSelectionChange as EventListener); }; }, []); const { handleToggleTextOnly } = useControlActions(rtc); const { handleAskCard } = useCardActions(setView, setSelectedCardId); const clearSelectedCardContext = useCallback(() => { if (selectedCardId) window.__nanobotClearCardSelection?.(selectedCardId); setSelectedCardId(null); }, [selectedCardId]); 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; if (selectedCardId) window.__nanobotClearCardSelection?.(selectedCardId); setSelectedCardId(null); await rtc.connect(); rtc.sendJson({ type: "command", command: "reset" }); }, [rtc, selectedCardId]); return ( <>
{view === "agent" && ( )} {view === "agent" && selectedCard && ( )} {view === "agent" && ( )} {}} onPointerUp={() => {}} />
); }