2026-03-12 09:25:15 -04:00
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
2026-03-06 22:51:19 -05:00
|
|
|
import { AgentIndicator } from "./components/AgentIndicator";
|
2026-03-12 09:25:15 -04:00
|
|
|
import { CardFeed } from "./components/CardFeed";
|
2026-03-06 22:51:19 -05:00
|
|
|
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";
|
2026-03-14 20:21:44 -04:00
|
|
|
import type {
|
|
|
|
|
AgentState,
|
|
|
|
|
CardItem,
|
|
|
|
|
CardMessageMetadata,
|
|
|
|
|
CardSelectionRange,
|
|
|
|
|
JsonValue,
|
|
|
|
|
LogLine,
|
|
|
|
|
} from "./types";
|
2026-03-12 09:25:15 -04:00
|
|
|
|
|
|
|
|
const SWIPE_THRESHOLD_PX = 64;
|
|
|
|
|
const SWIPE_DIRECTION_RATIO = 1.15;
|
2026-03-14 12:10:39 -04:00
|
|
|
const CARD_SELECTION_EVENT = "nanobot:card-selection-change";
|
2026-03-14 20:21:44 -04:00
|
|
|
type WorkspaceView = "agent" | "feed";
|
2026-03-12 09:25:15 -04:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 12:10:39 -04:00
|
|
|
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<string, JsonValue>;
|
|
|
|
|
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),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 09:25:15 -04:00
|
|
|
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,
|
|
|
|
|
};
|
2026-03-14 12:10:39 -04:00
|
|
|
const selection = card.serverId ? readCardSelection(card.serverId) : null;
|
|
|
|
|
if (selection) {
|
|
|
|
|
metadata.card_selection = selection as unknown as JsonValue;
|
|
|
|
|
metadata.card_selection_label = selection.label;
|
|
|
|
|
}
|
2026-03-12 09:25:15 -04:00
|
|
|
const liveContent = card.serverId
|
|
|
|
|
? window.__nanobotGetCardLiveContent?.(card.serverId)
|
|
|
|
|
: undefined;
|
|
|
|
|
if (liveContent !== undefined) metadata.card_live_content = liveContent as JsonValue;
|
|
|
|
|
return metadata;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 12:10:39 -04:00
|
|
|
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 || "";
|
|
|
|
|
|
2026-03-12 09:25:15 -04:00
|
|
|
return (
|
|
|
|
|
<div id="agent-card-context" data-no-swipe="1">
|
2026-03-14 12:10:39 -04:00
|
|
|
<div class="agent-card-context-label">{label}</div>
|
2026-03-12 09:25:15 -04:00
|
|
|
<div class="agent-card-context-row">
|
2026-03-14 12:10:39 -04:00
|
|
|
<div class="agent-card-context-main">
|
|
|
|
|
<div class="agent-card-context-title">{title}</div>
|
|
|
|
|
{meta && <div class="agent-card-context-meta">{meta}</div>}
|
|
|
|
|
</div>
|
2026-03-12 09:25:15 -04:00
|
|
|
<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,
|
2026-03-14 20:21:44 -04:00
|
|
|
view: WorkspaceView,
|
|
|
|
|
setView: (view: WorkspaceView) => void,
|
2026-03-12 09:25:15 -04:00
|
|
|
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(
|
2026-03-14 20:21:44 -04:00
|
|
|
setView: (view: WorkspaceView) => void,
|
2026-03-12 09:25:15 -04:00
|
|
|
setSelectedCardId: (cardId: string | null) => void,
|
|
|
|
|
) {
|
|
|
|
|
const handleAskCard = useCallback(
|
|
|
|
|
(card: CardItem) => {
|
|
|
|
|
if (!card.serverId) return;
|
|
|
|
|
setSelectedCardId(card.serverId);
|
|
|
|
|
setView("agent");
|
|
|
|
|
},
|
|
|
|
|
[setSelectedCardId, setView],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return { handleAskCard };
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 20:21:44 -04:00
|
|
|
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]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 09:25:15 -04:00
|
|
|
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 };
|
|
|
|
|
}
|
2026-03-06 22:51:19 -05:00
|
|
|
|
2026-03-14 20:21:44 -04:00
|
|
|
function useSelectedCardActions({
|
|
|
|
|
rtc,
|
|
|
|
|
selectedCardId,
|
|
|
|
|
setSelectedCardId,
|
|
|
|
|
selectedCardMetadata,
|
|
|
|
|
}: {
|
|
|
|
|
rtc: AppRtcActions & {
|
|
|
|
|
sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise<void>;
|
|
|
|
|
};
|
|
|
|
|
selectedCardId: string | null;
|
|
|
|
|
setSelectedCardId: (cardId: string | null) => void;
|
|
|
|
|
selectedCardMetadata: () => CardMessageMetadata | undefined;
|
|
|
|
|
}) {
|
|
|
|
|
const clearSelectedCardContext = useCallback(() => {
|
|
|
|
|
if (selectedCardId) window.__nanobotClearCardSelection?.(selectedCardId);
|
|
|
|
|
setSelectedCardId(null);
|
|
|
|
|
}, [selectedCardId, setSelectedCardId]);
|
2026-03-06 22:51:19 -05:00
|
|
|
|
2026-03-14 20:21:44 -04:00
|
|
|
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]);
|
2026-03-12 09:25:15 -04:00
|
|
|
|
2026-03-14 20:21:44 -04:00
|
|
|
return {
|
|
|
|
|
clearSelectedCardContext,
|
|
|
|
|
handleCardChoice,
|
|
|
|
|
handleSendMessage,
|
|
|
|
|
handleResetWithSelection,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function useSelectedCardContext(
|
|
|
|
|
cards: CardItem[],
|
|
|
|
|
selectedCardId: string | null,
|
|
|
|
|
selectionVersion: number,
|
|
|
|
|
) {
|
2026-03-12 09:25:15 -04:00
|
|
|
const selectedCard = useMemo(
|
|
|
|
|
() =>
|
2026-03-14 20:21:44 -04:00
|
|
|
selectedCardId ? (cards.find((card) => card.serverId === selectedCardId) ?? null) : null,
|
|
|
|
|
[cards, selectedCardId],
|
2026-03-12 09:25:15 -04:00
|
|
|
);
|
2026-03-14 12:10:39 -04:00
|
|
|
const selectedCardSelection = useMemo(
|
|
|
|
|
() => (selectedCardId ? readCardSelection(selectedCardId) : null),
|
|
|
|
|
[selectedCardId, selectionVersion],
|
|
|
|
|
);
|
2026-03-12 09:25:15 -04:00
|
|
|
const selectedCardMetadata = useCallback(
|
|
|
|
|
() => (selectedCard ? buildCardMetadata(selectedCard) : undefined),
|
2026-03-14 12:10:39 -04:00
|
|
|
[selectedCard, selectionVersion],
|
2026-03-12 09:25:15 -04:00
|
|
|
);
|
|
|
|
|
|
2026-03-14 20:21:44 -04:00
|
|
|
return { selectedCard, selectedCardSelection, selectedCardMetadata };
|
|
|
|
|
}
|
2026-03-12 09:25:15 -04:00
|
|
|
|
2026-03-14 20:21:44 -04:00
|
|
|
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);
|
2026-03-06 22:51:19 -05:00
|
|
|
|
2026-03-12 09:25:15 -04:00
|
|
|
useEffect(() => {
|
2026-03-14 20:21:44 -04:00
|
|
|
if (autoOpenedFeedRef.current || cards.length === 0) return;
|
2026-03-12 09:25:15 -04:00
|
|
|
autoOpenedFeedRef.current = true;
|
|
|
|
|
setView("feed");
|
2026-03-14 20:21:44 -04:00
|
|
|
}, [cards.length, setView]);
|
2026-03-12 09:25:15 -04:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!selectedCardId) return;
|
2026-03-14 20:21:44 -04:00
|
|
|
if (cards.some((card) => card.serverId === selectedCardId)) return;
|
2026-03-12 09:25:15 -04:00
|
|
|
setSelectedCardId(null);
|
2026-03-14 20:21:44 -04:00
|
|
|
}, [cards, selectedCardId, setSelectedCardId]);
|
2026-03-06 22:51:19 -05:00
|
|
|
|
2026-03-14 12:10:39 -04:00
|
|
|
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);
|
|
|
|
|
};
|
2026-03-14 20:21:44 -04:00
|
|
|
}, [setSelectedCardId, setSelectionVersion]);
|
|
|
|
|
}
|
2026-03-14 12:10:39 -04:00
|
|
|
|
2026-03-14 20:21:44 -04:00
|
|
|
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<void>;
|
|
|
|
|
textOnly: boolean;
|
|
|
|
|
onToggleTextOnly(enabled: boolean): Promise<void>;
|
|
|
|
|
logLines: LogLine[];
|
|
|
|
|
connected: boolean;
|
|
|
|
|
onSendMessage(text: string): Promise<void>;
|
|
|
|
|
onExpandChange(expanded: boolean): void;
|
|
|
|
|
effectiveAgentState: AgentState;
|
|
|
|
|
connecting: boolean;
|
|
|
|
|
audioLevel: number;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<section class="workspace-panel workspace-agent">
|
|
|
|
|
{active && (
|
|
|
|
|
<ControlBar onReset={onReset} textOnly={textOnly} onToggleTextOnly={onToggleTextOnly} />
|
|
|
|
|
)}
|
|
|
|
|
{active && selectedCard && (
|
|
|
|
|
<AgentCardContext
|
|
|
|
|
card={selectedCard}
|
|
|
|
|
selection={selectedCardSelection}
|
|
|
|
|
onClear={onClearSelectedCardContext}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{active && (
|
|
|
|
|
<LogPanel
|
|
|
|
|
lines={logLines}
|
|
|
|
|
disabled={!connected}
|
|
|
|
|
onSendMessage={onSendMessage}
|
|
|
|
|
onExpandChange={onExpandChange}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<AgentIndicator
|
|
|
|
|
state={effectiveAgentState}
|
|
|
|
|
connected={connected}
|
|
|
|
|
connecting={connecting}
|
|
|
|
|
audioLevel={audioLevel}
|
|
|
|
|
viewActive
|
|
|
|
|
onPointerDown={() => {}}
|
|
|
|
|
onPointerUp={() => {}}
|
|
|
|
|
/>
|
|
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-03-12 09:25:15 -04:00
|
|
|
|
2026-03-14 20:21:44 -04:00
|
|
|
function FeedWorkspace({
|
|
|
|
|
cards,
|
|
|
|
|
onDismiss,
|
|
|
|
|
onChoice,
|
|
|
|
|
onAskCard,
|
|
|
|
|
}: {
|
|
|
|
|
cards: CardItem[];
|
|
|
|
|
onDismiss(id: number): void;
|
|
|
|
|
onChoice(cardId: string, value: string): void;
|
|
|
|
|
onAskCard(card: CardItem): void;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<section class="workspace-panel workspace-feed">
|
|
|
|
|
<CardFeed
|
|
|
|
|
cards={cards}
|
|
|
|
|
viewActive
|
|
|
|
|
onDismiss={onDismiss}
|
|
|
|
|
onChoice={onChoice}
|
|
|
|
|
onAskCard={onAskCard}
|
|
|
|
|
/>
|
|
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-03-14 12:10:39 -04:00
|
|
|
|
2026-03-14 20:21:44 -04:00
|
|
|
export function App() {
|
|
|
|
|
const rtc = useWebRTC();
|
|
|
|
|
const remoteAudioLevel = useAudioMeter(rtc.remoteStream);
|
|
|
|
|
const audioLevel = rtc.textOnly ? 0 : remoteAudioLevel;
|
|
|
|
|
|
|
|
|
|
const [view, setView] = useState<WorkspaceView>("agent");
|
|
|
|
|
const [composing, setComposing] = useState(false);
|
|
|
|
|
const [selectedCardId, setSelectedCardId] = useState<string | null>(null);
|
|
|
|
|
const [selectionVersion, setSelectionVersion] = useState(0);
|
|
|
|
|
const { selectedCard, selectedCardSelection, selectedCardMetadata } = useSelectedCardContext(
|
|
|
|
|
rtc.cards,
|
|
|
|
|
selectedCardId,
|
|
|
|
|
selectionVersion,
|
2026-03-06 22:51:19 -05:00
|
|
|
);
|
2026-03-14 20:21:44 -04:00
|
|
|
|
|
|
|
|
const { agentStateOverride, handlePointerDown, handlePointerMove, handlePointerUp } = usePTT({
|
|
|
|
|
connected: rtc.connected && !rtc.textOnly,
|
2026-03-24 08:54:47 -04:00
|
|
|
currentAgentState: rtc.agentState,
|
2026-03-14 20:21:44 -04:00
|
|
|
onSendPtt: (pressed) =>
|
|
|
|
|
rtc.sendJson({ type: "voice-ptt", pressed, metadata: selectedCardMetadata() }),
|
|
|
|
|
onBootstrap: rtc.connect,
|
2026-03-24 08:54:47 -04:00
|
|
|
onInterrupt: () => rtc.sendJson({ type: "command", command: "reset" }),
|
2026-03-14 20:21:44 -04:00
|
|
|
});
|
|
|
|
|
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,
|
2026-03-12 09:25:15 -04:00
|
|
|
);
|
2026-03-14 20:21:44 -04:00
|
|
|
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,
|
|
|
|
|
});
|
2026-03-06 22:51:19 -05:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
2026-03-12 09:25:15 -04:00
|
|
|
<div id="swipe-shell" onPointerDown={onSwipeStart} onPointerUp={onSwipeEnd}>
|
|
|
|
|
<div id="swipe-track" class={view === "feed" ? "feed-active" : ""}>
|
2026-03-14 20:21:44 -04:00
|
|
|
<AgentWorkspace
|
|
|
|
|
active={view === "agent"}
|
|
|
|
|
selectedCard={selectedCard}
|
|
|
|
|
selectedCardSelection={selectedCardSelection}
|
|
|
|
|
onClearSelectedCardContext={clearSelectedCardContext}
|
|
|
|
|
onReset={handleResetWithSelection}
|
|
|
|
|
textOnly={rtc.textOnly}
|
|
|
|
|
onToggleTextOnly={handleToggleTextOnly}
|
|
|
|
|
logLines={rtc.logLines}
|
|
|
|
|
connected={rtc.connected}
|
|
|
|
|
onSendMessage={handleSendMessage}
|
|
|
|
|
onExpandChange={setComposing}
|
|
|
|
|
effectiveAgentState={effectiveAgentState}
|
|
|
|
|
connecting={rtc.connecting}
|
|
|
|
|
audioLevel={audioLevel}
|
|
|
|
|
/>
|
|
|
|
|
<FeedWorkspace
|
|
|
|
|
cards={rtc.cards}
|
|
|
|
|
onDismiss={rtc.dismissCard}
|
|
|
|
|
onChoice={handleCardChoice}
|
|
|
|
|
onAskCard={handleAskCard}
|
|
|
|
|
/>
|
2026-03-12 09:25:15 -04:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-06 22:51:19 -05:00
|
|
|
<VoiceStatus text={rtc.voiceStatus} visible={rtc.statusVisible} />
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|