feat: unify card runtime and event-driven web ui
Some checks failed
CI / Backend Checks (push) Failing after 36s
CI / Frontend Checks (push) Failing after 40s

This commit is contained in:
kacper 2026-04-06 15:42:53 -04:00
parent 0edf8c3fef
commit 4dfb7ca3cc
105 changed files with 17382 additions and 8505 deletions

View file

@ -1,512 +1,159 @@
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 { useState } from "preact/hooks";
import {
AgentWorkspace,
FeedWorkspace,
SwipeWorkspace,
useAppPresentation,
useSessionDrawerEdgeSwipe,
} from "./appShell/presentation";
import { VoiceStatus } from "./components/Controls";
import { SessionDrawer } from "./components/SessionDrawer";
import { WorkbenchOverlay } from "./components/WorkbenchOverlay";
import { useWebRTC } from "./hooks/useWebRTC";
import type {
AgentState,
CardItem,
CardMessageMetadata,
CardSelectionRange,
JsonValue,
LogLine,
} from "./types";
import type { ThemeName, ThemeOption } from "./theme/themes";
import { useThemePreference } from "./theme/useThemePreference";
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<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;
}
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),
};
}
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 (
<div id="agent-card-context" data-no-swipe="1">
<div class="agent-card-context-label">{label}</div>
<div class="agent-card-context-row">
<div class="agent-card-context-main">
<div class="agent-card-context-title">{title}</div>
{meta && <div class="agent-card-context-meta">{meta}</div>}
</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: 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({
function AgentPanel({
app,
rtc,
selectedCardId,
setSelectedCardId,
selectedCardMetadata,
sessionDrawerOpen,
setSessionDrawerOpen,
sessionDrawerEdgeGesture,
activeTheme,
themeOptions,
onSelectTheme,
}: {
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]);
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<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;
app: ReturnType<typeof useAppPresentation>;
rtc: ReturnType<typeof useWebRTC>;
sessionDrawerOpen: boolean;
setSessionDrawerOpen(open: boolean): void;
sessionDrawerEdgeGesture: ReturnType<typeof useSessionDrawerEdgeSwipe>;
activeTheme: ThemeName;
themeOptions: ThemeOption[];
onSelectTheme(themeName: ThemeName): void;
}) {
return (
<section class="workspace-panel workspace-agent">
{active && (
<ControlBar onReset={onReset} textOnly={textOnly} onToggleTextOnly={onToggleTextOnly} />
)}
{active && selectedCard && (
<AgentCardContext
card={selectedCard}
selection={selectedCardSelection}
onClear={onClearSelectedCardContext}
<AgentWorkspace
active={app.view === "agent"}
selectedCard={app.selectedCard}
selectedCardSelection={app.selectedCardSelection}
selectedCardContextLabel={app.selectedCardContextLabel}
onClearSelectedCardContext={app.clearSelectedCardContext}
textOnly={rtc.textOnly}
onToggleTextOnly={app.handleToggleTextOnly}
sessionDrawerEdge={
!sessionDrawerOpen ? (
<div
id="session-drawer-edge"
data-no-swipe="1"
onTouchStart={sessionDrawerEdgeGesture.onTouchStart}
onTouchMove={sessionDrawerEdgeGesture.onTouchMove}
onTouchEnd={sessionDrawerEdgeGesture.onTouchEnd}
onTouchCancel={sessionDrawerEdgeGesture.onTouchCancel}
/>
) : null
}
logLines={rtc.logLines}
connected={rtc.connected}
onSendMessage={app.handleSendMessage}
effectiveAgentState={app.effectiveAgentState}
textStreaming={rtc.textStreaming}
connecting={rtc.connecting}
audioLevel={app.audioLevel}
sessionDrawer={
<SessionDrawer
open={sessionDrawerOpen}
progress={sessionDrawerEdgeGesture.progress}
dragging={sessionDrawerEdgeGesture.progress > 0 && !sessionDrawerOpen}
sessions={rtc.sessions}
activeSessionId={rtc.activeSessionId}
busy={rtc.sessionLoading || rtc.textStreaming}
onClose={() => setSessionDrawerOpen(false)}
onCreate={async () => {
await rtc.createSession();
setSessionDrawerOpen(false);
}}
onSelect={async (chatId) => {
await rtc.switchSession(chatId);
setSessionDrawerOpen(false);
}}
onRename={async (chatId, title) => {
await rtc.renameSession(chatId, title);
}}
onDelete={async (chatId) => {
await rtc.deleteSession(chatId);
setSessionDrawerOpen(false);
}}
activeTheme={activeTheme}
themeOptions={themeOptions}
onSelectTheme={onSelectTheme}
/>
)}
{active && (
<LogPanel
lines={logLines}
disabled={!connected}
onSendMessage={onSendMessage}
onExpandChange={onExpandChange}
}
workbenchOverlay={
<WorkbenchOverlay
items={rtc.workbenchItems}
onDismiss={rtc.dismissWorkbenchItem}
onPromote={rtc.promoteWorkbenchItem}
/>
)}
<AgentIndicator
state={effectiveAgentState}
connected={connected}
connecting={connecting}
audioLevel={audioLevel}
viewActive
onPointerDown={() => {}}
onPointerUp={() => {}}
/>
</section>
}
/>
);
}
function FeedWorkspace({
cards,
onDismiss,
onChoice,
onAskCard,
function FeedPanel({
app,
rtc,
}: {
cards: CardItem[];
onDismiss(id: number): void;
onChoice(cardId: string, value: string): void;
onAskCard(card: CardItem): void;
app: ReturnType<typeof useAppPresentation>;
rtc: ReturnType<typeof useWebRTC>;
}) {
return (
<section class="workspace-panel workspace-feed">
<CardFeed
cards={cards}
viewActive
onDismiss={onDismiss}
onChoice={onChoice}
onAskCard={onAskCard}
/>
</section>
<FeedWorkspace
cards={rtc.cards}
onDismiss={rtc.dismissCard}
onChoice={app.handleCardChoice}
onAskCard={app.handleAskCard}
/>
);
}
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,
);
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,
const app = useAppPresentation(rtc);
const theme = useThemePreference();
const [sessionDrawerOpen, setSessionDrawerOpen] = useState(false);
const sessionDrawerEdgeGesture = useSessionDrawerEdgeSwipe({
enabled: app.view === "agent" && !sessionDrawerOpen,
onOpen: () => setSessionDrawerOpen(true),
});
return (
<>
<div id="swipe-shell" onPointerDown={onSwipeStart} onPointerUp={onSwipeEnd}>
<div id="swipe-track" class={view === "feed" ? "feed-active" : ""}>
<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}
<SwipeWorkspace
view={app.view}
trackStyle={app.trackStyle}
onSwipeStart={app.onSwipeStart}
onSwipeMove={app.onSwipeMove}
onSwipeEnd={app.onSwipeEnd}
onSwipeCancel={app.onSwipeCancel}
onTouchStart={app.onTouchStart}
onTouchMove={app.onTouchMove}
onTouchEnd={app.onTouchEnd}
onTouchCancel={app.onTouchCancel}
agentWorkspace={
<AgentPanel
app={app}
rtc={rtc}
sessionDrawerOpen={sessionDrawerOpen}
setSessionDrawerOpen={setSessionDrawerOpen}
sessionDrawerEdgeGesture={sessionDrawerEdgeGesture}
activeTheme={theme.themeName}
themeOptions={theme.themeOptions}
onSelectTheme={theme.setThemeName}
/>
<FeedWorkspace
cards={rtc.cards}
onDismiss={rtc.dismissCard}
onChoice={handleCardChoice}
onAskCard={handleAskCard}
/>
</div>
</div>
}
feedWorkspace={<FeedPanel app={app} rtc={rtc} />}
/>
<VoiceStatus text={rtc.voiceStatus} visible={rtc.statusVisible} />
</>
);