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 { AgentState, CardItem, CardMessageMetadata, CardSelectionRange, JsonValue, LogLine, } from "./types"; const SWIPE_THRESHOLD_PX = 64; const SWIPE_DIRECTION_RATIO = 1.15; const CARD_SELECTION_EVENT = "nanobot:card-selection-change"; type WorkspaceView = "agent" | "feed"; 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: WorkspaceView, setView: (view: WorkspaceView) => 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: WorkspaceView) => 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 useGlobalPointerBindings({ handlePointerDown, handlePointerMove, handlePointerUp, }: { handlePointerDown: (event: Event) => void; handlePointerMove: (event: Event) => void; handlePointerUp: (event: Event) => void; }) { 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]); } 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 }; } function useSelectedCardActions({ rtc, selectedCardId, setSelectedCardId, selectedCardMetadata, }: { rtc: AppRtcActions & { sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise; }; selectedCardId: string | null; setSelectedCardId: (cardId: string | null) => void; selectedCardMetadata: () => CardMessageMetadata | undefined; }) { const clearSelectedCardContext = useCallback(() => { if (selectedCardId) window.__nanobotClearCardSelection?.(selectedCardId); setSelectedCardId(null); }, [selectedCardId, 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; clearSelectedCardContext(); await rtc.connect(); rtc.sendJson({ type: "command", command: "reset" }); }, [clearSelectedCardContext, rtc]); return { clearSelectedCardContext, handleCardChoice, handleSendMessage, handleResetWithSelection, }; } function useSelectedCardContext( cards: CardItem[], selectedCardId: string | null, selectionVersion: number, ) { const selectedCard = useMemo( () => selectedCardId ? (cards.find((card) => card.serverId === selectedCardId) ?? null) : null, [cards, selectedCardId], ); const selectedCardSelection = useMemo( () => (selectedCardId ? readCardSelection(selectedCardId) : null), [selectedCardId, selectionVersion], ); const selectedCardMetadata = useCallback( () => (selectedCard ? buildCardMetadata(selectedCard) : undefined), [selectedCard, selectionVersion], ); return { selectedCard, selectedCardSelection, selectedCardMetadata }; } function useCardSelectionLifecycle({ cards, selectedCardId, setSelectedCardId, setSelectionVersion, setView, }: { cards: CardItem[]; selectedCardId: string | null; setSelectedCardId: (cardId: string | null) => void; setSelectionVersion: (updater: (current: number) => number) => void; setView: (view: WorkspaceView) => void; }) { const autoOpenedFeedRef = useRef(false); useEffect(() => { if (autoOpenedFeedRef.current || cards.length === 0) return; autoOpenedFeedRef.current = true; setView("feed"); }, [cards.length, setView]); useEffect(() => { if (!selectedCardId) return; if (cards.some((card) => card.serverId === selectedCardId)) return; setSelectedCardId(null); }, [cards, selectedCardId, setSelectedCardId]); 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); }; }, [setSelectedCardId, setSelectionVersion]); } function AgentWorkspace({ active, selectedCard, selectedCardSelection, onClearSelectedCardContext, onReset, textOnly, onToggleTextOnly, logLines, connected, onSendMessage, onExpandChange, effectiveAgentState, connecting, audioLevel, }: { active: boolean; selectedCard: CardItem | null; selectedCardSelection: CardSelectionRange | null; onClearSelectedCardContext(): void; onReset(): Promise; textOnly: boolean; onToggleTextOnly(enabled: boolean): Promise; logLines: LogLine[]; connected: boolean; onSendMessage(text: string): Promise; onExpandChange(expanded: boolean): void; effectiveAgentState: AgentState; connecting: boolean; audioLevel: number; }) { return (
{active && ( )} {active && selectedCard && ( )} {active && ( )} {}} onPointerUp={() => {}} />
); } function FeedWorkspace({ cards, onDismiss, onChoice, onAskCard, }: { cards: CardItem[]; onDismiss(id: number): void; onChoice(cardId: string, value: string): void; onAskCard(card: CardItem): void; }) { return (
); } export function App() { const rtc = useWebRTC(); const remoteAudioLevel = useAudioMeter(rtc.remoteStream); const audioLevel = rtc.textOnly ? 0 : remoteAudioLevel; const [view, setView] = useState("agent"); const [composing, setComposing] = useState(false); const [selectedCardId, setSelectedCardId] = useState(null); const [selectionVersion, setSelectionVersion] = useState(0); const { selectedCard, selectedCardSelection, selectedCardMetadata } = useSelectedCardContext( rtc.cards, selectedCardId, selectionVersion, ); const { agentStateOverride, handlePointerDown, handlePointerMove, handlePointerUp } = usePTT({ connected: rtc.connected && !rtc.textOnly, currentAgentState: rtc.agentState, onSendPtt: (pressed) => rtc.sendJson({ type: "voice-ptt", pressed, metadata: selectedCardMetadata() }), onBootstrap: rtc.connect, onInterrupt: () => rtc.sendJson({ type: "command", command: "reset" }), }); 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, ); useGlobalPointerBindings({ handlePointerDown, handlePointerMove, handlePointerUp }); useCardSelectionLifecycle({ cards: rtc.cards, selectedCardId, setSelectedCardId, setSelectionVersion, setView, }); const { handleToggleTextOnly } = useControlActions(rtc); const { handleAskCard } = useCardActions(setView, setSelectedCardId); const { clearSelectedCardContext, handleCardChoice, handleSendMessage, handleResetWithSelection, } = useSelectedCardActions({ rtc, selectedCardId, setSelectedCardId, selectedCardMetadata, }); return ( <>
); }