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} />
</>
);

View file

@ -0,0 +1,905 @@
import type { ComponentChildren } from "preact";
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import {
clearCardSelection,
getCardLiveContent,
getCardSelection,
subscribeCardSelection,
} from "../cardRuntime/store";
import { AgentIndicator } from "../components/AgentIndicator";
import { CardFeed } from "../components/CardFeed";
import { ControlBar } from "../components/Controls";
import { LogPanel } from "../components/LogPanel";
import { useAudioMeter } from "../hooks/useAudioMeter";
import { usePTT } from "../hooks/usePTT";
import type { WebRTCState } from "../hooks/webrtc/types";
import type {
AgentState,
CardItem,
CardMessageMetadata,
CardSelectionRange,
JsonValue,
LogLine,
} from "../types";
const SWIPE_THRESHOLD_PX = 64;
const SWIPE_INTENT_PX = 12;
const SWIPE_COMMIT_RATIO = 0.18;
const SWIPE_DIRECTION_RATIO = 1.15;
const SESSION_DRAWER_OPEN_THRESHOLD_PX = 52;
const SESSION_DRAWER_MAX_WIDTH_PX = 336;
const SESSION_DRAWER_GUTTER_PX = 28;
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;
}
interface SwipeState {
pointerId: number;
x: number;
y: number;
dragging: boolean;
}
interface EdgeSwipeState {
identifier: number;
x: number;
y: number;
}
type EdgeSwipeOutcome = "idle" | "track" | "cancel" | "open";
function findTouchById(touches: TouchList, identifier: number): Touch | null {
for (let i = 0; i < touches.length; i += 1) {
const touch = touches.item(i);
if (touch && touch.identifier === identifier) return touch;
}
return null;
}
function isSwipeInteractiveTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return false;
return Boolean(
target.closest("textarea,input,select,[contenteditable='true'],[data-no-swipe='1']"),
);
}
function getViewportWidth(): number {
return window.innerWidth || document.documentElement.clientWidth || 1;
}
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 = getCardSelection(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 ? getCardLiveContent(card.serverId) : undefined;
if (liveContent !== undefined) metadata.card_live_content = liveContent as JsonValue;
return metadata;
}
function formatCardContextLabel(
card: CardItem | null,
selection: CardSelectionRange | null,
): string | null {
if (!card) return null;
if (selection) {
return `diff: ${selection.file_label}${selection.range_label ? ` ${selection.range_label}` : ""}`;
}
const title = card.title?.trim();
if (!title) return "card";
if (card.templateKey === "todo-item-live") return `task: ${title}`;
if (card.templateKey === "upcoming-conditions-live") return `event: ${title}`;
if (card.templateKey === "list-total-live") return `tracker: ${title}`;
return `card: ${title}`;
}
function AgentCardContext({
card,
selection,
onClear,
textMode = false,
}: {
card: CardItem;
selection: CardSelectionRange | null;
onClear(): void;
textMode?: boolean;
}) {
const label = textMode
? "Next message uses this context"
: selection
? "Using diff context"
: "Using card";
const title = selection?.file_label || card.title;
const meta = selection?.range_label || card.contextSummary || "";
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>}
{textMode ? (
<div class="agent-card-context-note">
Ask your follow-up and Nanobot will include this card in the conversation context.
</div>
) : null}
</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 getSwipeDelta(swipe: SwipeState, event: PointerEvent): { dx: number; dy: number } {
return {
dx: event.clientX - swipe.x,
dy: event.clientY - swipe.y,
};
}
function hasSwipeIntent(dx: number, dy: number): boolean {
return Math.abs(dx) >= SWIPE_INTENT_PX || Math.abs(dy) >= SWIPE_INTENT_PX;
}
function isVerticalSwipeDominant(dx: number, dy: number): boolean {
return Math.abs(dx) < Math.abs(dy) * SWIPE_DIRECTION_RATIO;
}
function clampSwipeOffset(view: WorkspaceView, dx: number, width: number): number {
const minOffset = view === "agent" ? -width : 0;
const maxOffset = view === "agent" ? 0 : width;
return Math.max(minOffset, Math.min(maxOffset, dx));
}
function getCommittedSwipeView(
view: WorkspaceView,
dx: number,
width: number,
): WorkspaceView | null {
const progress = width > 0 ? Math.abs(dx) / width : 0;
const crossedThreshold = Math.abs(dx) >= SWIPE_THRESHOLD_PX || progress >= SWIPE_COMMIT_RATIO;
if (!crossedThreshold) return null;
if (view === "agent" && dx < 0) return "feed";
if (view === "feed" && dx > 0) return "agent";
return null;
}
function releaseCapturedPointer(currentTarget: EventTarget | null, pointerId: number): void {
const element = currentTarget as HTMLElement | null;
if (element?.hasPointerCapture?.(pointerId)) element.releasePointerCapture(pointerId);
}
export function useSwipeTrackStyle(view: WorkspaceView, swipeOffsetPx: number, swiping: boolean) {
return useMemo(() => {
const base = view === "feed" ? "-50%" : "0%";
return {
transform:
swipeOffsetPx === 0
? `translateX(${base})`
: `translateX(calc(${base} + ${swipeOffsetPx}px))`,
transition: swiping ? "none" : "transform 0.28s ease",
};
}, [swipeOffsetPx, swiping, view]);
}
function getEdgeSwipeOutcome(dx: number, dy: number): EdgeSwipeOutcome {
if (!hasSwipeIntent(dx, dy)) return "idle";
if (isVerticalSwipeDominant(dx, dy) || dx <= 0) return "cancel";
if (dx < SESSION_DRAWER_OPEN_THRESHOLD_PX) return "track";
return "open";
}
function getEdgeSwipeTouch(
touches: TouchList,
swipe: EdgeSwipeState | null,
): { swipe: EdgeSwipeState; touch: Touch } | null {
if (!swipe) return null;
const touch = findTouchById(touches, swipe.identifier);
if (!touch) return null;
return { swipe, touch };
}
function getSessionDrawerWidth(viewportWidth: number): number {
return Math.max(
1,
Math.min(SESSION_DRAWER_MAX_WIDTH_PX, viewportWidth - SESSION_DRAWER_GUTTER_PX),
);
}
function getSessionDrawerProgress(dx: number, viewportWidth: number): number {
return Math.max(0, Math.min(1, dx / getSessionDrawerWidth(viewportWidth)));
}
export function useSessionDrawerEdgeSwipe({
enabled,
onOpen,
}: {
enabled: boolean;
onOpen(): void;
}) {
const edgeSwipeRef = useRef<EdgeSwipeState | null>(null);
const [progress, setProgress] = useState(0);
const clearEdgeSwipe = useCallback(() => {
edgeSwipeRef.current = null;
setProgress(0);
}, []);
const onTouchStart = useCallback(
(e: Event) => {
if (!enabled) return;
const te = e as TouchEvent;
if (te.touches.length !== 1) return;
const touch = te.touches.item(0);
if (!touch) return;
edgeSwipeRef.current = {
identifier: touch.identifier,
x: touch.clientX,
y: touch.clientY,
};
},
[enabled],
);
const onTouchMove = useCallback(
(e: Event) => {
const te = e as TouchEvent;
if (!enabled) return;
const trackedTouch = getEdgeSwipeTouch(te.touches, edgeSwipeRef.current);
if (!trackedTouch) return;
const { swipe, touch } = trackedTouch;
const dx = touch.clientX - swipe.x;
const dy = touch.clientY - swipe.y;
const outcome = getEdgeSwipeOutcome(dx, dy);
if (outcome === "idle") return;
if (outcome === "cancel") {
clearEdgeSwipe();
return;
}
if (te.cancelable) te.preventDefault();
setProgress(
getSessionDrawerProgress(
dx,
window.innerWidth || document.documentElement.clientWidth || 1,
),
);
if (outcome === "track") return;
},
[clearEdgeSwipe, enabled],
);
const onTouchEnd = useCallback(
(e: Event) => {
const te = e as TouchEvent;
const trackedTouch = getEdgeSwipeTouch(te.changedTouches, edgeSwipeRef.current);
if (!trackedTouch) {
clearEdgeSwipe();
return;
}
const { swipe, touch } = trackedTouch;
const dx = touch.clientX - swipe.x;
const shouldOpen = dx >= SESSION_DRAWER_OPEN_THRESHOLD_PX;
clearEdgeSwipe();
if (shouldOpen) onOpen();
},
[clearEdgeSwipe, onOpen],
);
return {
onTouchStart,
onTouchMove,
onTouchEnd,
onTouchCancel: clearEdgeSwipe,
progress,
};
}
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: touch and pointer swipe paths share one state machine here
export function useSwipeHandlers(
view: WorkspaceView,
setView: (view: WorkspaceView) => void,
isInteractiveTarget: (target: EventTarget | null) => boolean,
getViewportWidthFn: () => number,
) {
const swipeRef = useRef<SwipeState | null>(null);
const [swipeOffsetPx, setSwipeOffsetPx] = useState(0);
const [swiping, setSwiping] = useState(false);
const clearSwipe = useCallback(() => {
swipeRef.current = null;
setSwiping(false);
setSwipeOffsetPx(0);
}, []);
const onSwipeStart = useCallback(
(e: Event) => {
const pe = e as PointerEvent;
if (pe.pointerType === "touch") return;
if (pe.pointerType === "mouse" && pe.button !== 0) return;
if (isInteractiveTarget(pe.target)) return;
swipeRef.current = { pointerId: pe.pointerId, x: pe.clientX, y: pe.clientY, dragging: false };
(e.currentTarget as HTMLElement | null)?.setPointerCapture?.(pe.pointerId);
},
[isInteractiveTarget],
);
const onSwipeMove = useCallback(
(e: Event) => {
const pe = e as PointerEvent;
if (pe.pointerType === "touch") return;
const swipe = swipeRef.current;
if (!swipe || swipe.pointerId !== pe.pointerId) return;
const { dx, dy } = getSwipeDelta(swipe, pe);
if (!swipe.dragging) {
if (!hasSwipeIntent(dx, dy)) return;
if (isVerticalSwipeDominant(dx, dy)) {
clearSwipe();
return;
}
swipe.dragging = true;
setSwiping(true);
}
setSwipeOffsetPx(clampSwipeOffset(view, dx, getViewportWidthFn()));
if (pe.cancelable) pe.preventDefault();
},
[clearSwipe, getViewportWidthFn, view],
);
const finishSwipe = useCallback(
(e: Event, commit: boolean) => {
const pe = e as PointerEvent;
if (pe.pointerType === "touch") return;
const swipe = swipeRef.current;
if (!swipe || swipe.pointerId !== pe.pointerId) return;
releaseCapturedPointer(e.currentTarget, pe.pointerId);
if (commit) {
const { dx } = getSwipeDelta(swipe, pe);
const nextView = swipe.dragging
? getCommittedSwipeView(view, dx, getViewportWidthFn())
: null;
if (nextView) setView(nextView);
}
clearSwipe();
},
[clearSwipe, getViewportWidthFn, setView, view],
);
const onSwipeEnd = useCallback((e: Event) => finishSwipe(e, true), [finishSwipe]);
const onSwipeCancel = useCallback((e: Event) => finishSwipe(e, false), [finishSwipe]);
const onTouchStart = useCallback(
(e: Event) => {
const te = e as TouchEvent;
if (te.touches.length !== 1) return;
const touch = te.touches.item(0);
if (!touch) return;
if (isInteractiveTarget(te.target)) return;
swipeRef.current = {
pointerId: touch.identifier,
x: touch.clientX,
y: touch.clientY,
dragging: false,
};
},
[isInteractiveTarget],
);
const onTouchMove = useCallback(
(e: Event) => {
const te = e as TouchEvent;
const swipe = swipeRef.current;
if (!swipe) return;
const touch = findTouchById(te.touches, swipe.pointerId);
if (!touch) return;
const dx = touch.clientX - swipe.x;
const dy = touch.clientY - swipe.y;
if (!swipe.dragging) {
if (!hasSwipeIntent(dx, dy)) return;
if (isVerticalSwipeDominant(dx, dy)) {
clearSwipe();
return;
}
swipe.dragging = true;
setSwiping(true);
}
setSwipeOffsetPx(clampSwipeOffset(view, dx, getViewportWidthFn()));
if (te.cancelable) te.preventDefault();
},
[clearSwipe, getViewportWidthFn, view],
);
const onTouchEnd = useCallback(
(e: Event) => {
const te = e as TouchEvent;
const swipe = swipeRef.current;
if (!swipe) return;
const touch = findTouchById(te.changedTouches, swipe.pointerId);
if (!touch) return;
if (swipe.dragging) {
const dx = touch.clientX - swipe.x;
const nextView = getCommittedSwipeView(view, dx, getViewportWidthFn());
if (nextView) setView(nextView);
}
clearSwipe();
},
[clearSwipe, getViewportWidthFn, setView, view],
);
const onTouchCancel = useCallback(() => {
clearSwipe();
}, [clearSwipe]);
return {
onSwipeStart,
onSwipeMove,
onSwipeEnd,
onSwipeCancel,
onTouchStart,
onTouchMove,
onTouchEnd,
onTouchCancel,
swipeOffsetPx,
swiping,
};
}
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 handleToggleTextOnly = useCallback(
async (enabled: boolean) => {
rtc.setTextOnly(enabled);
if (!enabled && !rtc.connected && !rtc.connecting) await rtc.connect();
},
[rtc],
);
return { handleToggleTextOnly };
}
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) clearCardSelection(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],
);
return {
clearSelectedCardContext,
handleCardChoice,
handleSendMessage,
};
}
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,
}: {
cards: CardItem[];
selectedCardId: string | null;
setSelectedCardId: (cardId: string | null) => void;
setSelectionVersion: (updater: (current: number) => number) => void;
}) {
useEffect(() => {
if (!selectedCardId) return;
if (cards.some((card) => card.serverId === selectedCardId)) return;
setSelectedCardId(null);
}, [cards, selectedCardId, setSelectedCardId]);
useEffect(() => {
const unsubscribe = subscribeCardSelection((cardId, selection) => {
setSelectionVersion((current) => current + 1);
if (cardId && selection) setSelectedCardId(cardId);
});
return unsubscribe;
}, [setSelectedCardId, setSelectionVersion]);
}
export function AgentWorkspace({
active,
selectedCard,
selectedCardSelection,
selectedCardContextLabel,
onClearSelectedCardContext,
textOnly,
onToggleTextOnly,
sessionDrawerEdge,
logLines,
connected,
onSendMessage,
effectiveAgentState,
textStreaming,
connecting,
audioLevel,
sessionDrawer,
workbenchOverlay,
}: {
active: boolean;
selectedCard: CardItem | null;
selectedCardSelection: CardSelectionRange | null;
selectedCardContextLabel: string | null;
onClearSelectedCardContext(): void;
textOnly: boolean;
onToggleTextOnly(enabled: boolean): Promise<void>;
sessionDrawerEdge: ComponentChildren;
logLines: LogLine[];
connected: boolean;
onSendMessage(text: string): Promise<void>;
effectiveAgentState: AgentState;
textStreaming: boolean;
connecting: boolean;
audioLevel: number;
sessionDrawer: ComponentChildren;
workbenchOverlay: ComponentChildren;
}) {
return (
<section
class={`workspace-panel workspace-agent${textOnly ? " text-mode" : ""}${
textOnly && selectedCard ? " has-card-context" : ""
}`}
>
{active && <ControlBar textOnly={textOnly} onToggleTextOnly={onToggleTextOnly} />}
{active && sessionDrawerEdge}
{active && sessionDrawer}
{active && workbenchOverlay}
{active && selectedCard && !textOnly && (
<AgentCardContext
card={selectedCard}
selection={selectedCardSelection}
onClear={onClearSelectedCardContext}
textMode={textOnly}
/>
)}
{active && (
<LogPanel
lines={logLines}
disabled={false}
onSendMessage={onSendMessage}
onOpenVoiceMode={() => {
void onToggleTextOnly(false);
}}
contextLabel={selectedCardContextLabel}
onClearContext={selectedCard ? onClearSelectedCardContext : undefined}
agentState={effectiveAgentState}
textStreaming={textStreaming}
fullScreen={textOnly}
/>
)}
{!textOnly && (
<AgentIndicator
state={effectiveAgentState}
connected={connected}
connecting={connecting}
audioLevel={audioLevel}
viewActive
onPointerDown={() => {}}
onPointerUp={() => {}}
/>
)}
</section>
);
}
export 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>
);
}
export function SwipeWorkspace({
view,
trackStyle,
onSwipeStart,
onSwipeMove,
onSwipeEnd,
onSwipeCancel,
onTouchStart,
onTouchMove,
onTouchEnd,
onTouchCancel,
agentWorkspace,
feedWorkspace,
}: {
view: WorkspaceView;
trackStyle: Record<string, string>;
onSwipeStart: (event: Event) => void;
onSwipeMove: (event: Event) => void;
onSwipeEnd: (event: Event) => void;
onSwipeCancel: (event: Event) => void;
onTouchStart: (event: Event) => void;
onTouchMove: (event: Event) => void;
onTouchEnd: (event: Event) => void;
onTouchCancel: (event: Event) => void;
agentWorkspace: ComponentChildren;
feedWorkspace: ComponentChildren;
}) {
return (
<div
id="swipe-shell"
onPointerDown={onSwipeStart}
onPointerMove={onSwipeMove}
onPointerUp={onSwipeEnd}
onPointerCancel={onSwipeCancel}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchCancel}
>
<div id="swipe-track" data-view={view} style={trackStyle}>
{agentWorkspace}
{feedWorkspace}
</div>
</div>
);
}
export function useAppPresentation(rtc: WebRTCState) {
const remoteAudioLevel = useAudioMeter(rtc.remoteStream);
const audioLevel = rtc.textOnly ? 0 : remoteAudioLevel;
const [view, setView] = useState<WorkspaceView>("agent");
const [selectedCardId, setSelectedCardId] = useState<string | null>(null);
const [selectionVersion, setSelectionVersion] = useState(0);
const { selectedCard, selectedCardSelection, selectedCardMetadata } = useSelectedCardContext(
rtc.cards,
selectedCardId,
selectionVersion,
);
const selectedCardContextLabel = useMemo(
() => formatCardContextLabel(selectedCard, selectedCardSelection),
[selectedCard, selectedCardSelection],
);
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.textOnly ? async () => {} : rtc.connect,
onInterrupt: () => rtc.sendJson({ type: "command", command: "reset" }),
});
const effectiveAgentState = agentStateOverride ?? rtc.agentState;
const {
onSwipeStart,
onSwipeMove,
onSwipeEnd,
onSwipeCancel,
onTouchStart,
onTouchMove,
onTouchEnd,
onTouchCancel,
swipeOffsetPx,
swiping,
} = useSwipeHandlers(view, setView, isSwipeInteractiveTarget, getViewportWidth);
useGlobalPointerBindings({ handlePointerDown, handlePointerMove, handlePointerUp });
useCardSelectionLifecycle({
cards: rtc.cards,
selectedCardId,
setSelectedCardId,
setSelectionVersion,
});
const { handleToggleTextOnly } = useControlActions(rtc);
const { handleAskCard } = useCardActions(setView, setSelectedCardId);
const { clearSelectedCardContext, handleCardChoice, handleSendMessage } = useSelectedCardActions({
rtc,
selectedCardId,
setSelectedCardId,
selectedCardMetadata: useCallback(() => {
const metadata = selectedCardMetadata();
if (!metadata) return metadata;
return {
...metadata,
context_label: selectedCardContextLabel ?? undefined,
};
}, [selectedCardContextLabel, selectedCardMetadata]),
});
const trackStyle = useSwipeTrackStyle(view, swipeOffsetPx, swiping);
return {
audioLevel,
view,
selectedCard,
selectedCardSelection,
selectedCardContextLabel,
effectiveAgentState,
handleToggleTextOnly,
handleAskCard,
clearSelectedCardContext,
handleCardChoice,
handleSendMessage,
onSwipeStart,
onSwipeMove,
onSwipeEnd,
onSwipeCancel,
onTouchStart,
onTouchMove,
onTouchEnd,
onTouchCancel,
trackStyle,
};
}

View file

@ -0,0 +1,363 @@
import { streamSseResponse } from "../lib/sse";
import type { JsonValue, WorkbenchItem } from "../types";
export interface ManualToolResult {
tool_name: string;
content: string;
parsed: JsonValue | null;
is_json: boolean;
}
export interface ManualToolDefinition {
name: string;
description: string;
parameters: Record<string, unknown>;
kind?: string;
}
export interface ManualToolJob {
job_id: string;
tool_name: string;
status: "queued" | "running" | "completed" | "failed";
created_at: string;
started_at: string | null;
finished_at: string | null;
result: ManualToolResult | null;
error: string | null;
error_code: number | null;
}
export interface ManualToolAsyncOptions {
timeoutMs?: number;
}
function cloneJsonValue(value: JsonValue | undefined): JsonValue | undefined {
if (value === undefined) return undefined;
try {
return JSON.parse(JSON.stringify(value)) as JsonValue;
} catch {
return value;
}
}
export async function decodeJsonError(resp: Response): Promise<string> {
try {
const payload = (await resp.json()) as { error?: unknown };
if (payload && typeof payload === "object" && typeof payload.error === "string") {
return payload.error;
}
} catch {
// Fall back to status code.
}
return `request failed (${resp.status})`;
}
function normalizeManualToolResult(
payload: Partial<ManualToolResult> | null | undefined,
fallbackName: string,
): ManualToolResult {
return {
tool_name: typeof payload?.tool_name === "string" ? payload.tool_name : fallbackName,
content: typeof payload?.content === "string" ? payload.content : "",
parsed:
payload?.parsed === null || payload?.parsed === undefined
? null
: (cloneJsonValue(payload.parsed as JsonValue) ?? null),
is_json: payload?.is_json === true,
};
}
function normalizeManualToolJob(payload: unknown, fallbackName: string): ManualToolJob {
const record = payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {};
const toolName = typeof record.tool_name === "string" ? record.tool_name : fallbackName;
const statusValue = typeof record.status === "string" ? record.status : "queued";
return {
job_id: typeof record.job_id === "string" ? record.job_id : "",
tool_name: toolName,
status:
statusValue === "running" || statusValue === "completed" || statusValue === "failed"
? statusValue
: "queued",
created_at: typeof record.created_at === "string" ? record.created_at : "",
started_at: typeof record.started_at === "string" ? record.started_at : null,
finished_at: typeof record.finished_at === "string" ? record.finished_at : null,
result: normalizeManualToolJobResult(record.result, toolName),
error: typeof record.error === "string" ? record.error : null,
error_code: typeof record.error_code === "number" ? record.error_code : null,
};
}
function normalizeManualToolAsyncOptions(
options: ManualToolAsyncOptions,
): Required<ManualToolAsyncOptions> {
const timeoutMs =
typeof options.timeoutMs === "number" &&
Number.isFinite(options.timeoutMs) &&
options.timeoutMs >= 100
? Math.round(options.timeoutMs)
: 30000;
return { timeoutMs };
}
function normalizeManualToolJobResult(
payload: unknown,
fallbackName: string,
): ManualToolResult | null {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return null;
}
const record = payload as Record<string, unknown>;
const looksNormalized =
typeof record.tool_name === "string" ||
typeof record.content === "string" ||
record.parsed !== undefined ||
typeof record.is_json === "boolean";
if (looksNormalized) {
return normalizeManualToolResult(record as Partial<ManualToolResult>, fallbackName);
}
return {
tool_name: fallbackName,
content: JSON.stringify(record),
parsed: cloneJsonValue(record as JsonValue) ?? null,
is_json: true,
};
}
function completedManualToolResult(
job: ManualToolJob,
toolName: string,
): ManualToolResult | undefined {
if (job.status !== "completed") return undefined;
return job.result ?? normalizeManualToolResult(null, toolName);
}
function assertManualToolJobDidNotFail(job: ManualToolJob, toolName: string): void {
if (job.status === "failed") {
throw new Error(job.error || `${toolName} failed`);
}
}
async function streamManualToolJobUpdates(
jobId: string,
toolName: string,
timeoutMs: number,
): Promise<ManualToolJob | null> {
let current: ManualToolJob | null = null;
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
try {
const resp = await fetch(`/tools/jobs/${encodeURIComponent(jobId)}/stream`, {
cache: "no-store",
signal: controller.signal,
});
if (!resp.ok) throw new Error(await decodeJsonError(resp));
await streamSseResponse(resp, (raw) => {
let payload: unknown = null;
try {
payload = JSON.parse(raw);
} catch {
return;
}
if (!payload || typeof payload !== "object") return;
const record = payload as Record<string, unknown>;
if (record.type !== "tool.job") return;
current = normalizeManualToolJob(record.job, toolName);
});
} catch (error) {
if (controller.signal.aborted) {
throw new Error(`${toolName} timed out`);
}
throw error;
} finally {
window.clearTimeout(timeoutId);
}
return current;
}
async function waitForManualToolJob(
job: ManualToolJob,
toolName: string,
timeoutMs: number,
): Promise<ManualToolResult> {
const immediate = completedManualToolResult(job, toolName);
if (immediate) return immediate;
assertManualToolJobDidNotFail(job, toolName);
const streamed = await streamManualToolJobUpdates(job.job_id, toolName, timeoutMs);
const streamedResult = streamed ? completedManualToolResult(streamed, toolName) : undefined;
if (streamedResult) return streamedResult;
if (streamed) assertManualToolJobDidNotFail(streamed, toolName);
const current = await getManualToolJob(job.job_id);
const finalResult = completedManualToolResult(current, toolName);
if (finalResult) return finalResult;
assertManualToolJobDidNotFail(current, toolName);
throw new Error(`${toolName} ended before completion`);
}
export async function callManualTool(
toolName: string,
argumentsValue: Record<string, JsonValue> = {},
): Promise<ManualToolResult> {
const name = toolName.trim();
if (!name) throw new Error("tool name is required");
const cloned = cloneJsonValue(argumentsValue as JsonValue);
const safeArguments =
cloned && typeof cloned === "object" && !Array.isArray(cloned)
? (cloned as Record<string, JsonValue>)
: {};
const resp = await fetch("/tools/call", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tool_name: name, arguments: safeArguments }),
});
if (!resp.ok) throw new Error(await decodeJsonError(resp));
const payload = await resp.json();
if (!payload || typeof payload !== "object") {
throw new Error("invalid tool response");
}
return normalizeManualToolResult(payload as Partial<ManualToolResult>, name);
}
export async function startManualToolCall(
toolName: string,
argumentsValue: Record<string, JsonValue> = {},
): Promise<ManualToolJob> {
const name = toolName.trim();
if (!name) throw new Error("tool name is required");
const cloned = cloneJsonValue(argumentsValue as JsonValue);
const safeArguments =
cloned && typeof cloned === "object" && !Array.isArray(cloned)
? (cloned as Record<string, JsonValue>)
: {};
const resp = await fetch("/tools/call", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tool_name: name, arguments: safeArguments, async: true }),
});
if (!resp.ok) throw new Error(await decodeJsonError(resp));
const payload = await resp.json();
if (!payload || typeof payload !== "object") {
throw new Error("invalid tool job response");
}
const job = normalizeManualToolJob(payload, name);
if (!job.job_id) throw new Error("tool job id is required");
return job;
}
export async function getManualToolJob(jobId: string): Promise<ManualToolJob> {
const key = jobId.trim();
if (!key) throw new Error("tool job id is required");
const resp = await fetch(`/tools/jobs/${encodeURIComponent(key)}`, { cache: "no-store" });
if (!resp.ok) throw new Error(await decodeJsonError(resp));
const payload = await resp.json();
if (!payload || typeof payload !== "object") {
throw new Error("invalid tool job response");
}
return normalizeManualToolJob(payload, "");
}
export async function callManualToolAsync(
toolName: string,
argumentsValue: Record<string, JsonValue> = {},
options: ManualToolAsyncOptions = {},
): Promise<ManualToolResult> {
const { timeoutMs } = normalizeManualToolAsyncOptions(options);
const job = await startManualToolCall(toolName, argumentsValue);
return waitForManualToolJob(job, toolName, timeoutMs);
}
export async function listManualTools(): Promise<ManualToolDefinition[]> {
const resp = await fetch("/tools", { cache: "no-store" });
if (!resp.ok) throw new Error(await decodeJsonError(resp));
const payload = (await resp.json()) as { tools?: unknown };
const tools = Array.isArray(payload?.tools) ? payload.tools : [];
return tools
.filter((tool): tool is Record<string, unknown> => !!tool && typeof tool === "object")
.map((tool) => ({
name: typeof tool.name === "string" ? tool.name : "",
description: typeof tool.description === "string" ? tool.description : "",
parameters:
tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters)
? (tool.parameters as Record<string, unknown>)
: {},
kind: typeof tool.kind === "string" ? tool.kind : undefined,
}))
.filter((tool) => tool.name);
}
export async function updateCardTemplateState(
cardId: string,
templateState: Record<string, JsonValue>,
): Promise<void> {
const key = cardId.trim();
if (!key) throw new Error("card id is required");
const resp = await fetch(`/cards/${encodeURIComponent(key)}/state`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template_state: templateState }),
});
if (!resp.ok) throw new Error(await decodeJsonError(resp));
}
export async function updateWorkbenchTemplateState(
item: WorkbenchItem,
templateState: Record<string, JsonValue>,
): Promise<void> {
const resp = await fetch("/workbench", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: item.id,
chat_id: item.chatId,
kind: item.kind,
title: item.title,
content: item.content,
question: item.question || "",
choices: item.choices || [],
response_value: item.responseValue || "",
slot: item.slot || "",
template_key: item.templateKey || "",
template_state: templateState,
context_summary: item.contextSummary || "",
promotable: item.promotable,
source_card_id: item.sourceCardId || "",
}),
});
if (!resp.ok) throw new Error(await decodeJsonError(resp));
}
export async function copyTextToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "true");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
document.execCommand("copy");
} finally {
textarea.remove();
}
}

View file

@ -0,0 +1,81 @@
import type { JsonValue } from "../types";
import type { RuntimeItem, RuntimeModule } from "./runtimeTypes";
import { runtimeItemId } from "./runtimeUtils";
const runtimeAssetCache = new Map<
string,
Promise<{ html: string | null; module: RuntimeModule } | null>
>();
function runtimeTemplateUrl(templateKey: string, filename: string): string {
return `/card-templates/${encodeURIComponent(templateKey)}/${filename}`;
}
function escapeAttribute(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function jsonScriptText(payload: Record<string, JsonValue>): string {
return JSON.stringify(payload, null, 0).replace(/<\//g, "<\\/");
}
export function materializeRuntimeHtml(
templateHtml: string,
item: RuntimeItem,
state: Record<string, JsonValue>,
): string {
const cardId = escapeAttribute(runtimeItemId(item));
const templateKey = escapeAttribute(item.templateKey?.trim() || "");
return [
`<div data-nanobot-card-root data-card-id="${cardId}" data-template-key="${templateKey}">`,
`<script type="application/json" data-card-state>${jsonScriptText(state)}</script>`,
templateHtml,
"</div>",
].join("");
}
export function syncRuntimeStateScript(
root: HTMLDivElement | null,
state: Record<string, JsonValue>,
): void {
const stateEl = root?.querySelector('script[data-card-state][type="application/json"]');
if (!(stateEl instanceof HTMLScriptElement)) return;
stateEl.textContent = jsonScriptText(state);
}
export async function loadRuntimeAssets(
templateKey: string,
): Promise<{ html: string | null; module: RuntimeModule } | null> {
const cached = runtimeAssetCache.get(templateKey);
if (cached) return cached;
const loader = (async () => {
const moduleUrl = runtimeTemplateUrl(templateKey, "card.js");
const moduleProbe = await fetch(moduleUrl, { cache: "no-store" });
if (!moduleProbe.ok) return null;
const templatePromise = fetch(runtimeTemplateUrl(templateKey, "template.html"), {
cache: "no-store",
})
.then(async (response) => (response.ok ? response.text() : null))
.catch(() => null);
const namespace = (await import(/* @vite-ignore */ `${moduleUrl}?runtime=1`)) as
| RuntimeModule
| { default?: RuntimeModule };
const runtimeModule = ("default" in namespace ? namespace.default : namespace) as RuntimeModule;
if (!runtimeModule || typeof runtimeModule.mount !== "function") return null;
return {
html: await templatePromise,
module: runtimeModule,
};
})();
runtimeAssetCache.set(templateKey, loader);
return loader;
}

View file

@ -0,0 +1,98 @@
import { marked } from "marked";
import { useRef } from "preact/hooks";
import type { JsonValue } from "../types";
import {
callManualTool,
callManualToolAsync,
copyTextToClipboard,
getManualToolJob,
listManualTools,
startManualToolCall,
updateCardTemplateState,
updateWorkbenchTemplateState,
} from "./api";
import type { RuntimeHost, RuntimeItem, RuntimeSurface } from "./runtimeTypes";
import { normalizeTemplateState, runtimeItemId } from "./runtimeUtils";
import {
clearCardSelection,
getCardLiveContent,
getCardSelection,
requestCardFeedRefresh,
runCardRefresh,
setCardLiveContent,
setCardRefreshHandler,
setCardSelection,
} from "./store";
export function useRuntimeHost(
surface: RuntimeSurface,
item: RuntimeItem,
stateRef: { current: Record<string, JsonValue> },
setState: (value: Record<string, JsonValue>) => void,
) {
const itemRef = useRef(item);
itemRef.current = item;
const hostRef = useRef<RuntimeHost | null>(null);
if (!hostRef.current) {
hostRef.current = {
surface,
item,
getState: () => normalizeTemplateState(stateRef.current),
replaceState: async (nextState) => {
const normalized = normalizeTemplateState(nextState);
stateRef.current = normalized;
setState(normalized);
const currentItem = itemRef.current;
if ("serverId" in currentItem) {
if (!currentItem.serverId) throw new Error("card id is required");
await updateCardTemplateState(currentItem.serverId, normalized);
} else if ("chatId" in currentItem) {
await updateWorkbenchTemplateState(currentItem, normalized);
}
return normalizeTemplateState(normalized);
},
patchState: async (patch) => {
const nextState = { ...stateRef.current, ...normalizeTemplateState(patch) };
return hostRef.current?.replaceState(nextState) ?? normalizeTemplateState(nextState);
},
setLiveContent: (snapshot) => {
setCardLiveContent(runtimeItemId(itemRef.current), snapshot);
},
getLiveContent: () => getCardLiveContent(runtimeItemId(itemRef.current)),
setSelection: (selection) => {
setCardSelection(runtimeItemId(itemRef.current), selection);
},
getSelection: () => getCardSelection(runtimeItemId(itemRef.current)),
clearSelection: () => {
clearCardSelection(runtimeItemId(itemRef.current));
},
setRefreshHandler: (handler) => {
setCardRefreshHandler(runtimeItemId(itemRef.current), handler);
},
runRefresh: () => runCardRefresh(runtimeItemId(itemRef.current)),
requestFeedRefresh: () => {
requestCardFeedRefresh();
},
callTool: callManualTool,
startToolCall: startManualToolCall,
getToolJob: getManualToolJob,
callToolAsync: callManualToolAsync,
listTools: listManualTools,
renderMarkdown: (markdown, options = {}) =>
options.inline
? (marked.parseInline(markdown) as string)
: (marked.parse(markdown) as string),
copyText: copyTextToClipboard,
getThemeName: () => document.documentElement.dataset.theme || "clay",
getThemeValue: (tokenName) => {
const normalized = tokenName.startsWith("--") ? tokenName : `--${tokenName}`;
return getComputedStyle(document.documentElement).getPropertyValue(normalized).trim();
},
};
}
hostRef.current.surface = surface;
hostRef.current.item = item;
return hostRef.current;
}

View file

@ -0,0 +1,58 @@
import type { CardItem, JsonValue, WorkbenchItem } from "../types";
import type {
ManualToolAsyncOptions,
ManualToolDefinition,
ManualToolJob,
ManualToolResult,
} from "./api";
export type RuntimeSurface = "feed" | "workbench";
export type RuntimeItem = CardItem | WorkbenchItem;
export interface RuntimeHost {
surface: RuntimeSurface;
item: RuntimeItem;
getState(): Record<string, JsonValue>;
replaceState(nextState: Record<string, JsonValue>): Promise<Record<string, JsonValue>>;
patchState(patch: Record<string, JsonValue>): Promise<Record<string, JsonValue>>;
setLiveContent(snapshot: JsonValue | null | undefined): void;
getLiveContent(): JsonValue | undefined;
setSelection(selection: JsonValue | null | undefined): void;
getSelection(): JsonValue | undefined;
clearSelection(): void;
setRefreshHandler(handler: (() => void) | null | undefined): void;
runRefresh(): boolean;
requestFeedRefresh(): void;
callTool(toolName: string, argumentsValue?: Record<string, JsonValue>): Promise<ManualToolResult>;
startToolCall(
toolName: string,
argumentsValue?: Record<string, JsonValue>,
): Promise<ManualToolJob>;
getToolJob(jobId: string): Promise<ManualToolJob>;
callToolAsync(
toolName: string,
argumentsValue?: Record<string, JsonValue>,
options?: ManualToolAsyncOptions,
): Promise<ManualToolResult>;
listTools(): Promise<ManualToolDefinition[]>;
renderMarkdown(markdown: string, options?: { inline?: boolean }): string;
copyText(text: string): Promise<void>;
getThemeName(): string;
getThemeValue(tokenName: string): string;
}
export interface RuntimeContext {
root: HTMLElement;
item: RuntimeItem;
state: Record<string, JsonValue>;
host: RuntimeHost;
}
export interface MountedRuntimeCard {
update?(context: RuntimeContext): void;
destroy?(): void;
}
export interface RuntimeModule {
mount(context: RuntimeContext): MountedRuntimeCard | undefined;
}

View file

@ -0,0 +1,35 @@
import type { CardItem, JsonValue } from "../types";
import type { RuntimeItem } from "./runtimeTypes";
export function cloneJsonValue<T extends JsonValue>(value: T | null | undefined): T | undefined {
if (value === null || value === undefined) return undefined;
try {
return JSON.parse(JSON.stringify(value)) as T;
} catch {
return value ?? undefined;
}
}
export function normalizeTemplateState(
value: Record<string, JsonValue> | undefined,
): Record<string, JsonValue> {
const cloned = cloneJsonValue((value || {}) as JsonValue);
return cloned && typeof cloned === "object" && !Array.isArray(cloned)
? (cloned as Record<string, JsonValue>)
: {};
}
export function runtimeItemId(item: RuntimeItem): string {
if (isCardItem(item)) {
return `card:${item.serverId || item.id}`;
}
return `workbench:${item.chatId}:${item.id}`;
}
function isCardItem(item: RuntimeItem): item is CardItem {
return "lane" in item;
}
export function looksLikeHtml(content: string): boolean {
return /^\s*<[a-zA-Z]/.test(content);
}

View file

@ -0,0 +1,192 @@
import type { JsonValue } from "../types";
type LiveContentListener = (cardId: string, snapshot: JsonValue | undefined) => void;
type SelectionListener = (cardId: string, selection: JsonValue | undefined) => void;
class CardRuntimeRegistry {
private readonly liveContentStore = new Map<string, JsonValue>();
private readonly selectionStore = new Map<string, JsonValue>();
private readonly refreshHandlers = new Map<string, () => void>();
private readonly liveContentListeners = new Set<LiveContentListener>();
private readonly selectionListeners = new Set<SelectionListener>();
private emitLiveContent(cardId: string, snapshot: JsonValue | undefined): void {
for (const listener of this.liveContentListeners) listener(cardId, cloneJsonValue(snapshot));
}
private emitSelection(cardId: string, selection: JsonValue | undefined): void {
for (const listener of this.selectionListeners) listener(cardId, cloneJsonValue(selection));
}
setCardLiveContent(
cardId: string | null | undefined,
snapshot: JsonValue | null | undefined,
): void {
const key = String(cardId || "").trim();
if (!key) return;
const cloned = cloneJsonValue(snapshot ?? undefined);
if (cloned === undefined) {
this.liveContentStore.delete(key);
this.emitLiveContent(key, undefined);
return;
}
this.liveContentStore.set(key, cloned);
this.emitLiveContent(key, cloned);
}
getCardLiveContent(cardId: string | null | undefined): JsonValue | undefined {
const key = String(cardId || "").trim();
if (!key) return undefined;
return cloneJsonValue(this.liveContentStore.get(key));
}
subscribeCardLiveContent(listener: LiveContentListener): () => void {
this.liveContentListeners.add(listener);
return () => {
this.liveContentListeners.delete(listener);
};
}
setCardSelection(
cardId: string | null | undefined,
selection: JsonValue | null | undefined,
): void {
const key = String(cardId || "").trim();
if (!key) return;
const cloned = cloneJsonValue(selection ?? undefined);
if (cloned === undefined) {
this.selectionStore.delete(key);
this.emitSelection(key, undefined);
return;
}
this.selectionStore.set(key, cloned);
this.emitSelection(key, cloned);
}
getCardSelection(cardId: string | null | undefined): JsonValue | undefined {
const key = String(cardId || "").trim();
if (!key) return undefined;
return cloneJsonValue(this.selectionStore.get(key));
}
clearCardSelection(cardId: string | null | undefined): void {
const key = String(cardId || "").trim();
if (!key) return;
this.selectionStore.delete(key);
this.emitSelection(key, undefined);
}
subscribeCardSelection(listener: SelectionListener): () => void {
this.selectionListeners.add(listener);
return () => {
this.selectionListeners.delete(listener);
};
}
setCardRefreshHandler(
cardId: string | null | undefined,
handler: (() => void) | null | undefined,
): void {
const key = String(cardId || "").trim();
if (!key) return;
if (typeof handler !== "function") {
this.refreshHandlers.delete(key);
return;
}
this.refreshHandlers.set(key, handler);
}
hasCardRefreshHandler(cardId: string | null | undefined): boolean {
const key = String(cardId || "").trim();
if (!key) return false;
return this.refreshHandlers.has(key);
}
runCardRefresh(cardId: string | null | undefined): boolean {
const key = String(cardId || "").trim();
if (!key) return false;
const handler = this.refreshHandlers.get(key);
if (!handler) return false;
handler();
return true;
}
disposeCardRuntimeEntry(cardId: string | null | undefined): void {
const key = String(cardId || "").trim();
if (!key) return;
this.liveContentStore.delete(key);
this.selectionStore.delete(key);
this.refreshHandlers.delete(key);
this.emitLiveContent(key, undefined);
this.emitSelection(key, undefined);
}
}
const registry = new CardRuntimeRegistry();
function cloneJsonValue(value: JsonValue | undefined): JsonValue | undefined {
if (value === undefined) return undefined;
try {
return JSON.parse(JSON.stringify(value)) as JsonValue;
} catch {
return value;
}
}
export function setCardLiveContent(
cardId: string | null | undefined,
snapshot: JsonValue | null | undefined,
): void {
registry.setCardLiveContent(cardId, snapshot);
}
export function getCardLiveContent(cardId: string | null | undefined): JsonValue | undefined {
return registry.getCardLiveContent(cardId);
}
export function subscribeCardLiveContent(listener: LiveContentListener): () => void {
return registry.subscribeCardLiveContent(listener);
}
export function setCardSelection(
cardId: string | null | undefined,
selection: JsonValue | null | undefined,
): void {
registry.setCardSelection(cardId, selection);
}
export function getCardSelection(cardId: string | null | undefined): JsonValue | undefined {
return registry.getCardSelection(cardId);
}
export function clearCardSelection(cardId: string | null | undefined): void {
registry.clearCardSelection(cardId);
}
export function subscribeCardSelection(listener: SelectionListener): () => void {
return registry.subscribeCardSelection(listener);
}
export function setCardRefreshHandler(
cardId: string | null | undefined,
handler: (() => void) | null | undefined,
): void {
registry.setCardRefreshHandler(cardId, handler);
}
export function hasCardRefreshHandler(cardId: string | null | undefined): boolean {
return registry.hasCardRefreshHandler(cardId);
}
export function runCardRefresh(cardId: string | null | undefined): boolean {
return registry.runCardRefresh(cardId);
}
export function disposeCardRuntimeEntry(cardId: string | null | undefined): void {
registry.disposeCardRuntimeEntry(cardId);
}
export function requestCardFeedRefresh(): void {
if (typeof window === "undefined") return;
window.dispatchEvent(new Event("nanobot:cards-refresh"));
}

View file

@ -0,0 +1,124 @@
import { marked } from "marked";
import { useEffect, useRef, useState } from "preact/hooks";
import {
loadRuntimeAssets,
materializeRuntimeHtml,
syncRuntimeStateScript,
} from "../cardRuntime/runtimeAssets";
import { useRuntimeHost } from "../cardRuntime/runtimeHost";
import type { MountedRuntimeCard, RuntimeItem, RuntimeSurface } from "../cardRuntime/runtimeTypes";
import { looksLikeHtml, normalizeTemplateState, runtimeItemId } from "../cardRuntime/runtimeUtils";
import { disposeCardRuntimeEntry } from "../cardRuntime/store";
import type { JsonValue } from "../types";
function StaticCardTextBody({
content,
bodyClass = "card-body",
}: {
content: string;
bodyClass?: string;
}) {
const html = looksLikeHtml(content) ? content : (marked.parse(content) as string);
return <div class={bodyClass} dangerouslySetInnerHTML={{ __html: html }} />;
}
export function DynamicCardBody({
item,
surface,
bodyClass = "card-body",
}: {
item: RuntimeItem;
surface: RuntimeSurface;
bodyClass?: string;
}) {
const templateKey = item.templateKey?.trim() || "";
const identity = runtimeItemId(item);
const rootRef = useRef<HTMLDivElement>(null);
const mountedRef = useRef<MountedRuntimeCard | null>(null);
const [runtimeAvailable, setRuntimeAvailable] = useState<boolean | null>(
templateKey ? null : false,
);
const [runtimeState, setRuntimeState] = useState<Record<string, JsonValue>>(
normalizeTemplateState(item.templateState),
);
const runtimeStateRef = useRef(runtimeState);
runtimeStateRef.current = runtimeState;
const host = useRuntimeHost(surface, item, runtimeStateRef, setRuntimeState);
useEffect(() => {
const nextState = normalizeTemplateState(item.templateState);
const activeElement = document.activeElement;
const editingInside =
activeElement instanceof Node && !!rootRef.current?.contains(activeElement);
if (editingInside) return;
const currentJson = JSON.stringify(runtimeStateRef.current);
const nextJson = JSON.stringify(nextState);
if (currentJson === nextJson) return;
runtimeStateRef.current = nextState;
setRuntimeState(nextState);
}, [item.updatedAt, item.templateState]);
useEffect(() => {
let cancelled = false;
const mountRuntime = async () => {
if (!templateKey) {
setRuntimeAvailable(false);
return;
}
setRuntimeAvailable(null);
const assets = await loadRuntimeAssets(templateKey);
if (cancelled) return;
if (!assets || !rootRef.current) {
setRuntimeAvailable(false);
return;
}
mountedRef.current?.destroy?.();
rootRef.current.innerHTML = materializeRuntimeHtml(
assets.html || "",
item,
runtimeStateRef.current,
);
mountedRef.current =
assets.module.mount({
root: rootRef.current,
item,
state: runtimeStateRef.current,
host,
}) || null;
setRuntimeAvailable(true);
};
void mountRuntime();
return () => {
cancelled = true;
mountedRef.current?.destroy?.();
mountedRef.current = null;
host.setRefreshHandler(null);
host.setLiveContent(undefined);
host.clearSelection();
disposeCardRuntimeEntry(identity);
};
}, [host, identity, templateKey]);
useEffect(() => {
if (!runtimeAvailable || !rootRef.current) return;
syncRuntimeStateScript(rootRef.current, runtimeState);
if (!mountedRef.current?.update) return;
mountedRef.current.update({
root: rootRef.current,
item,
state: runtimeState,
host,
});
}, [host, item, runtimeAvailable, runtimeState]);
if (!templateKey || runtimeAvailable === false) {
return <StaticCardTextBody content={item.content} bodyClass={bodyClass} />;
}
return <div ref={rootRef} class={bodyClass} />;
}

File diff suppressed because it is too large Load diff

View file

@ -3,28 +3,6 @@ interface VoiceStatusProps {
visible: boolean;
}
function SpeakerIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 9v6h4l5 4V5L9 9H5Z" fill="currentColor" />
<path
d="M17 9.5a4 4 0 0 1 0 5"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-width="1.8"
/>
<path
d="M18.8 7a7 7 0 0 1 0 10"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-width="1.8"
/>
</svg>
);
}
function TextBubbleIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
@ -38,28 +16,6 @@ function TextBubbleIcon() {
);
}
function ResetIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M6.5 8A7 7 0 1 1 5 12"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-width="1.9"
/>
<path
d="M6.5 4.5V8H10"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.9"
/>
</svg>
);
}
export function VoiceStatus({ text, visible }: VoiceStatusProps) {
return (
<div id="voiceStatus" class={visible ? "visible" : ""}>
@ -69,54 +25,37 @@ export function VoiceStatus({ text, visible }: VoiceStatusProps) {
}
interface ControlBarProps {
onReset(): void;
textOnly: boolean;
onToggleTextOnly(enabled: boolean): void;
}
export function ControlBar({ onReset, textOnly, onToggleTextOnly }: ControlBarProps) {
const toggleLabel = textOnly ? "Text-only mode on" : "Voice mode on";
export function ControlBar({ textOnly, onToggleTextOnly }: ControlBarProps) {
const voiceActive = !textOnly;
const toggleLabel = voiceActive ? "Exit voice mode" : "Open voice mode";
return (
<div id="controls">
<button
id="textOnlyToggleBtn"
class={`control-switch${textOnly ? " active" : ""}`}
type="button"
role="switch"
aria-checked={textOnly}
aria-label={toggleLabel}
title={toggleLabel}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggleTextOnly(!textOnly);
}}
>
<span class="control-switch-shell" aria-hidden="true">
<span class="control-switch-icon control-switch-icon-speaker">
<SpeakerIcon />
</span>
<span class="control-switch-icon control-switch-icon-text">
<TextBubbleIcon />
</span>
<span class="control-switch-thumb" />
</span>
</button>
<button
id="resetSessionBtn"
class="control-icon-btn"
type="button"
aria-label="Reset context"
title="Reset context"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onReset();
}}
>
<ResetIcon />
</button>
<div class="control-group">
{voiceActive ? (
<button
id="voiceModeBtn"
class="control-mode-btn active"
type="button"
aria-pressed={voiceActive}
aria-label={toggleLabel}
title={toggleLabel}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggleTextOnly(!textOnly);
}}
>
<span class="control-mode-btn-icon" aria-hidden="true">
<TextBubbleIcon />
</span>
</button>
) : null}
</div>
</div>
);
}

View file

@ -1,11 +1,17 @@
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import type { LogLine } from "../types";
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import type { AgentState, LogLine } from "../types";
interface Props {
lines: LogLine[];
disabled: boolean;
onSendMessage(text: string): Promise<void>;
onOpenVoiceMode?(): Promise<void> | void;
onExpandChange?(expanded: boolean): void;
contextLabel?: string | null;
onClearContext?(): void;
agentState: AgentState;
textStreaming: boolean;
fullScreen?: boolean;
}
interface LogViewProps {
@ -13,6 +19,14 @@ interface LogViewProps {
scrollRef: { current: HTMLElement | null };
}
type ChatRole = "user" | "assistant" | "system" | "tool";
interface ActivityStatus {
tone: "waiting" | "thinking" | "tool" | "replying";
label: string;
detail?: string;
}
function SendIcon() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
@ -21,6 +35,28 @@ function SendIcon() {
);
}
function SpeakerIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 9v6h4l5 4V5L9 9H5Z" fill="currentColor" />
<path
d="M17 9.5a4 4 0 0 1 0 5"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-width="1.8"
/>
<path
d="M18.8 7a7 7 0 0 1 0 10"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-width="1.8"
/>
</svg>
);
}
function CloseIcon() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
@ -30,15 +66,72 @@ function CloseIcon() {
}
function formatLine(line: LogLine): string {
const time = line.timestamp ? new Date(line.timestamp).toLocaleTimeString() : "";
const role = line.role.trim().toLowerCase();
if (role === "nanobot") {
return `[${time}] ${line.text.replace(/^(?:nanobot|napbot)\b\s*[:>-]?\s*/i, "")}`;
if (role === "nanobot" || role === "nanobot-progress") {
return line.text.replace(/^(?:nanobot|napbot)\b\s*[:>-]?\s*/i, "");
}
if (role === "user") {
return line.text;
}
if (role === "system" || role === "wisper") {
return line.text;
}
if (role === "tool") {
return `[${time}] tool: ${line.text}`;
return `tool: ${line.text}`;
}
return `[${time}] ${line.role}: ${line.text}`;
return `${line.role}: ${line.text}`;
}
function toChatRole(line: LogLine): ChatRole {
const role = line.role.trim().toLowerCase();
if (role === "user") return "user";
if (role === "nanobot" || role === "nanobot-progress") return "assistant";
if (role === "tool") return "tool";
return "system";
}
function isEphemeralLine(line: LogLine): boolean {
const role = line.role.trim().toLowerCase();
if (role !== "system") return false;
return /^(connected|disconnected)\.?$/i.test(line.text.trim());
}
function deriveActivityStatus(
lines: LogLine[],
agentState: AgentState,
textStreaming: boolean,
sending: boolean,
): ActivityStatus | null {
if (!textStreaming && !sending && agentState === "idle") return null;
const latestUserId = [...lines].reverse().find((line) => line.role === "user")?.id ?? -1;
const latestAssistantProgress = [...lines]
.reverse()
.find((line) => line.id > latestUserId && line.role === "nanobot-progress");
const latestTool = [...lines]
.reverse()
.find((line) => line.id > latestUserId && line.role === "tool");
if (latestAssistantProgress) {
return { tone: "replying", label: "Nanobot is replying" };
}
if (latestTool && (textStreaming || sending)) {
return {
tone: "tool",
label: "Nanobot is using a tool",
detail: latestTool.text.trim() || undefined,
};
}
if (agentState === "thinking") {
return { tone: "thinking", label: "Nanobot is thinking" };
}
if (agentState === "speaking") {
return { tone: "replying", label: "Nanobot is replying" };
}
if (textStreaming || sending) {
return { tone: "waiting", label: "Waiting for Nanobot" };
}
return null;
}
function LogCompose({
@ -48,6 +141,11 @@ function LogCompose({
setText,
onClose,
onSend,
onOpenVoiceMode,
showClose,
contextLabel,
onClearContext,
activityStatus,
}: {
disabled: boolean;
sending: boolean;
@ -55,6 +153,11 @@ function LogCompose({
setText(value: string): void;
onClose(): void;
onSend(): void;
onOpenVoiceMode?: () => void;
showClose: boolean;
contextLabel?: string | null;
onClearContext?: () => void;
activityStatus?: ActivityStatus | null;
}) {
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
@ -72,27 +175,69 @@ function LogCompose({
return (
<div id="log-compose">
<textarea
id="log-compose-input"
placeholder="Type a message to nanobot..."
disabled={disabled || sending}
value={text}
onInput={(e) => setText((e.target as HTMLTextAreaElement).value)}
onKeyDown={onKeyDown}
/>
<div id="log-compose-actions">
<button id="log-close-btn" type="button" aria-label="Close" onClick={onClose}>
<CloseIcon />
</button>
<button
id="log-send-btn"
type="button"
aria-label="Send message"
disabled={disabled || sending || text.trim().length === 0}
onClick={onSend}
>
<SendIcon />
</button>
{showClose ? (
<div id="log-compose-toolbar">
<button id="log-close-btn" type="button" aria-label="Close" onClick={onClose}>
<CloseIcon />
</button>
</div>
) : null}
<div id="log-compose-field">
{activityStatus ? (
<div id="log-compose-status" data-tone={activityStatus.tone} aria-live="polite">
<span id="log-compose-status-dots" aria-hidden="true">
<span class="log-compose-status-dot" />
<span class="log-compose-status-dot" />
<span class="log-compose-status-dot" />
</span>
<span id="log-compose-status-label">{activityStatus.label}</span>
{activityStatus.detail ? (
<span id="log-compose-status-detail">{activityStatus.detail}</span>
) : null}
</div>
) : null}
{contextLabel ? (
<div id="log-compose-context">
<button
id="log-compose-context-chip"
type="button"
onClick={() => onClearContext?.()}
disabled={disabled || sending}
>
[{contextLabel}]
</button>
</div>
) : null}
<textarea
id="log-compose-input"
placeholder="Type a message to nanobot..."
disabled={disabled || sending}
value={text}
onInput={(e) => setText((e.target as HTMLTextAreaElement).value)}
onKeyDown={onKeyDown}
/>
<div id="log-compose-actions">
{onOpenVoiceMode ? (
<button
id="log-voice-btn"
type="button"
aria-label="Open voice mode"
disabled={disabled || sending}
onClick={onOpenVoiceMode}
>
<SpeakerIcon />
</button>
) : null}
<button
id="log-send-btn"
type="button"
aria-label="Send message"
disabled={disabled || sending || text.trim().length === 0}
onClick={onSend}
>
<SendIcon />
</button>
</div>
</div>
</div>
);
@ -117,6 +262,45 @@ function ExpandedLogView({ lines, scrollRef }: LogViewProps) {
);
}
function ChatLogView({ lines, scrollRef }: LogViewProps) {
const visibleLines = lines.filter((line) => !isEphemeralLine(line));
return (
<div
id="chat-scroll"
ref={(node) => {
scrollRef.current = node;
}}
>
{visibleLines.length === 0 ? (
<div id="chat-empty">
<div id="chat-empty-eyebrow">Nanobot</div>
<h1 id="chat-empty-title">Start a conversation</h1>
<p id="chat-empty-copy">
Ask a question, plan something, or switch to voice when you want the live interface.
</p>
</div>
) : (
<div id="chat-inner">
{visibleLines.map((line) => {
const chatRole = toChatRole(line);
return (
<div key={line.id} class={`chat-row ${chatRole}`}>
<div class={`chat-bubble ${chatRole}`}>
{chatRole === "user" && line.contextLabel ? (
<span class="chat-context-inline">[{line.contextLabel}] </span>
) : null}
{formatLine(line)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
function CollapsedLogView({ lines, scrollRef, onExpand }: LogViewProps & { onExpand(): void }) {
return (
<button
@ -139,23 +323,46 @@ function CollapsedLogView({ lines, scrollRef, onExpand }: LogViewProps & { onExp
);
}
export function LogPanel({ lines, disabled, onSendMessage, onExpandChange }: Props) {
export function LogPanel({
lines,
disabled,
onSendMessage,
onOpenVoiceMode,
onExpandChange,
contextLabel,
onClearContext,
agentState,
textStreaming,
fullScreen = false,
}: Props) {
const [expanded, setExpanded] = useState(false);
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
const scrollRef = useRef<HTMLElement>(null);
const isExpanded = fullScreen || expanded;
const activityStatus = useMemo(
() => (fullScreen ? deriveActivityStatus(lines, agentState, textStreaming, sending) : null),
[agentState, fullScreen, lines, sending, textStreaming],
);
useEffect(() => onExpandChange?.(expanded), [expanded, onExpandChange]);
useEffect(
() => onExpandChange?.(fullScreen ? false : isExpanded),
[fullScreen, isExpanded, onExpandChange],
);
useEffect(() => () => onExpandChange?.(false), [onExpandChange]);
useEffect(() => {
if (fullScreen) setExpanded(true);
}, [fullScreen]);
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [lines, expanded]);
}, [lines, isExpanded]);
const collapse = useCallback(() => {
if (fullScreen) return;
setExpanded(false);
setText("");
}, []);
}, [fullScreen]);
const expand = useCallback(() => {
if (!expanded) setExpanded(true);
@ -176,13 +383,19 @@ export function LogPanel({ lines, disabled, onSendMessage, onExpandChange }: Pro
}, [disabled, onSendMessage, sending, text]);
return (
<div id="log" class={expanded ? "expanded" : ""} data-no-swipe="1">
{expanded ? (
<div
id="log"
class={`${isExpanded ? "expanded" : ""}${fullScreen ? " chat-mode" : ""}`}
data-no-swipe={fullScreen ? undefined : "1"}
>
{fullScreen ? (
<ChatLogView lines={lines} scrollRef={scrollRef} />
) : isExpanded ? (
<ExpandedLogView lines={lines} scrollRef={scrollRef} />
) : (
<CollapsedLogView lines={lines} scrollRef={scrollRef} onExpand={expand} />
)}
{expanded && (
{isExpanded && (
<LogCompose
disabled={disabled}
sending={sending}
@ -192,6 +405,11 @@ export function LogPanel({ lines, disabled, onSendMessage, onExpandChange }: Pro
onSend={() => {
void send();
}}
onOpenVoiceMode={fullScreen ? () => void onOpenVoiceMode?.() : undefined}
showClose={!fullScreen}
activityStatus={activityStatus}
contextLabel={contextLabel}
onClearContext={onClearContext}
/>
)}
</div>

View file

@ -0,0 +1,514 @@
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import type { ThemeName, ThemeOption } from "../theme/themes";
import type { SessionSummary } from "../types";
const DRAWER_SWIPE_INTENT_PX = 12;
const DRAWER_SWIPE_CLOSE_THRESHOLD_PX = 52;
const DRAWER_SWIPE_DIRECTION_RATIO = 1.15;
const SESSION_DRAWER_MAX_WIDTH_PX = 336;
const SESSION_DRAWER_GUTTER_PX = 28;
const SESSION_LONG_PRESS_MS = 460;
const SESSION_LONG_PRESS_MOVE_PX = 14;
interface DrawerSwipeState {
identifier: number;
x: number;
y: number;
}
function CloseIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M6 6L18 18M18 6L6 18"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-width="1.9"
/>
</svg>
);
}
function formatUpdatedAt(timestamp: string): string {
const parsed = new Date(timestamp);
if (Number.isNaN(parsed.getTime())) return "";
return parsed.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
function findTouchById(touches: TouchList, identifier: number): Touch | null {
for (let i = 0; i < touches.length; i += 1) {
const touch = touches.item(i);
if (touch && touch.identifier === identifier) return touch;
}
return null;
}
function hasSwipeIntent(dx: number, dy: number): boolean {
return Math.abs(dx) >= DRAWER_SWIPE_INTENT_PX || Math.abs(dy) >= DRAWER_SWIPE_INTENT_PX;
}
function isVerticalSwipeDominant(dx: number, dy: number): boolean {
return Math.abs(dx) < Math.abs(dy) * DRAWER_SWIPE_DIRECTION_RATIO;
}
function getDrawerWidth(): number {
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 1;
return Math.max(
1,
Math.min(SESSION_DRAWER_MAX_WIDTH_PX, viewportWidth - SESSION_DRAWER_GUTTER_PX),
);
}
function useSessionDrawerCloseSwipe(open: boolean, onClose: () => void) {
const [swipe, setSwipe] = useState<DrawerSwipeState | null>(null);
const [closeProgress, setCloseProgress] = useState(0);
const clearSwipe = useCallback(() => {
setSwipe(null);
setCloseProgress(0);
}, []);
useEffect(() => {
if (!open) clearSwipe();
}, [clearSwipe, open]);
const onTouchStart = useCallback(
(e: Event) => {
if (!open) return;
const te = e as TouchEvent;
if (te.touches.length !== 1) return;
const touch = te.touches.item(0);
if (!touch) return;
setSwipe({
identifier: touch.identifier,
x: touch.clientX,
y: touch.clientY,
});
},
[open],
);
const onTouchMove = useCallback(
(e: Event) => {
if (!open || !swipe) return;
const te = e as TouchEvent;
const touch = findTouchById(te.touches, swipe.identifier);
if (!touch) return;
const dx = swipe.x - touch.clientX;
const dy = touch.clientY - swipe.y;
if (!hasSwipeIntent(dx, dy)) return;
if (isVerticalSwipeDominant(dx, dy) || dx <= 0) {
clearSwipe();
return;
}
if (te.cancelable) te.preventDefault();
setCloseProgress(Math.max(0, Math.min(1, dx / getDrawerWidth())));
},
[clearSwipe, open, swipe],
);
const onTouchEnd = useCallback(
(e: Event) => {
if (!swipe) {
clearSwipe();
return;
}
const te = e as TouchEvent;
const touch = findTouchById(te.changedTouches, swipe.identifier);
if (!touch) {
clearSwipe();
return;
}
const dx = swipe.x - touch.clientX;
const shouldClose = dx >= DRAWER_SWIPE_CLOSE_THRESHOLD_PX;
clearSwipe();
if (shouldClose) onClose();
},
[clearSwipe, onClose, swipe],
);
return {
closeProgress,
dragging: closeProgress > 0,
onTouchStart,
onTouchMove,
onTouchEnd,
onTouchCancel: clearSwipe,
};
}
interface SessionDrawerProps {
open: boolean;
progress: number;
dragging: boolean;
sessions: SessionSummary[];
activeSessionId: string;
busy: boolean;
onClose(): void;
onCreate(): Promise<void> | void;
onSelect(chatId: string): Promise<void> | void;
onRename(chatId: string, title: string): Promise<void> | void;
onDelete(chatId: string): Promise<void> | void;
activeTheme: ThemeName;
themeOptions: ThemeOption[];
onSelectTheme(themeName: ThemeName): void;
}
function useMetaOverflow() {
const ref = useRef<HTMLDivElement | null>(null);
const [compact, setCompact] = useState(false);
useEffect(() => {
const node = ref.current;
if (!node) return;
let frame = 0;
const measure = () => {
frame = 0;
setCompact((current) => {
const tooWide = node.scrollWidth > node.clientWidth + 1;
if (!current) return tooWide;
// Hysteresis: once compact, require real spare room before switching back.
const hasSpareRoom = node.scrollWidth <= node.clientWidth - 12;
return !hasSpareRoom;
});
};
const scheduleMeasure = () => {
if (frame) cancelAnimationFrame(frame);
frame = requestAnimationFrame(measure);
};
scheduleMeasure();
const observer = new ResizeObserver(scheduleMeasure);
observer.observe(node);
return () => {
if (frame) cancelAnimationFrame(frame);
observer.disconnect();
};
}, []);
return { ref, compact };
}
function SessionDrawerItem({
session,
active,
busy,
onSelect,
onOpenMenu,
}: {
session: SessionSummary;
active: boolean;
busy: boolean;
onSelect(chatId: string): Promise<void> | void;
onOpenMenu(session: SessionSummary): void;
}) {
const meta = useMetaOverflow();
const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const holdStartRef = useRef<{ x: number; y: number } | null>(null);
const longPressTriggeredRef = useRef(false);
const clearHoldTimer = useCallback(() => {
if (holdTimerRef.current) {
clearTimeout(holdTimerRef.current);
holdTimerRef.current = null;
}
holdStartRef.current = null;
}, []);
const scheduleLongPress = useCallback(
(x: number, y: number) => {
if (busy) return;
longPressTriggeredRef.current = false;
holdStartRef.current = { x, y };
if (holdTimerRef.current) clearTimeout(holdTimerRef.current);
holdTimerRef.current = setTimeout(() => {
holdTimerRef.current = null;
longPressTriggeredRef.current = true;
onOpenMenu(session);
}, SESSION_LONG_PRESS_MS);
},
[busy, onOpenMenu, session],
);
useEffect(() => clearHoldTimer, [clearHoldTimer]);
return (
<button
class={`session-drawer-item${active ? " active" : ""}`}
type="button"
aria-disabled={busy ? "true" : undefined}
onClick={(e) => {
if (longPressTriggeredRef.current) {
e.preventDefault();
e.stopPropagation();
longPressTriggeredRef.current = false;
return;
}
if (busy || active) return;
void onSelect(session.chat_id);
}}
onContextMenu={(e) => {
e.preventDefault();
if (busy) return;
longPressTriggeredRef.current = true;
onOpenMenu(session);
}}
onTouchStart={(e) => {
if (e.touches.length !== 1) return;
const touch = e.touches.item(0);
if (!touch) return;
scheduleLongPress(touch.clientX, touch.clientY);
}}
onTouchMove={(e) => {
const touch = e.touches.item(0);
const start = holdStartRef.current;
if (!touch || !start) return;
const dx = touch.clientX - start.x;
const dy = touch.clientY - start.y;
if (Math.hypot(dx, dy) > SESSION_LONG_PRESS_MOVE_PX) clearHoldTimer();
}}
onTouchEnd={() => {
clearHoldTimer();
}}
onTouchCancel={() => {
clearHoldTimer();
}}
>
<div class="session-drawer-item-title">{session.title || "New conversation"}</div>
{session.preview ? <div class="session-drawer-item-preview">{session.preview}</div> : null}
<div ref={meta.ref} class={`session-drawer-item-meta${meta.compact ? " compact" : ""}`}>
<span>{formatUpdatedAt(session.updated_at)}</span>
<span>{session.message_count} msgs</span>
</div>
</button>
);
}
function SessionActionMenu({
session,
busy,
onClose,
onRename,
onDelete,
}: {
session: SessionSummary;
busy: boolean;
onClose(): void;
onRename(chatId: string, title: string): Promise<void> | void;
onDelete(chatId: string): Promise<void> | void;
}) {
return (
<>
<button
id="session-action-backdrop"
type="button"
aria-label="Close conversation actions"
onClick={onClose}
/>
<div id="session-action-sheet" data-no-swipe="1">
<div id="session-action-title">{session.title || "New conversation"}</div>
<button
class="session-action-btn"
type="button"
disabled={busy}
onClick={() => {
const nextTitle = window.prompt("Rename conversation", session.title || "");
if (nextTitle === null) return;
void onRename(session.chat_id, nextTitle.trim());
onClose();
}}
>
Rename
</button>
<button
class="session-action-btn destructive"
type="button"
disabled={busy}
onClick={() => {
const confirmed = window.confirm(`Delete "${session.title || "New conversation"}"?`);
if (!confirmed) return;
void onDelete(session.chat_id);
onClose();
}}
>
Delete
</button>
<button class="session-action-btn" type="button" onClick={onClose}>
Cancel
</button>
</div>
</>
);
}
function SessionThemePicker({
activeTheme,
themeOptions,
onSelectTheme,
}: {
activeTheme: ThemeName;
themeOptions: ThemeOption[];
onSelectTheme(themeName: ThemeName): void;
}) {
return (
<div id="session-drawer-theme">
<div id="session-drawer-theme-label">Theme</div>
<div id="session-drawer-theme-options">
{themeOptions.map((theme) => (
<button
key={theme.name}
type="button"
class={`session-drawer-theme-option${theme.name === activeTheme ? " active" : ""}`}
aria-pressed={theme.name === activeTheme}
onClick={() => onSelectTheme(theme.name)}
>
<span
class="session-drawer-theme-swatch"
aria-hidden="true"
style={{ background: theme.swatch }}
/>
<span>{theme.label}</span>
</button>
))}
</div>
</div>
);
}
export function SessionDrawer({
open,
progress,
dragging,
sessions,
activeSessionId,
busy,
onClose,
onCreate,
onSelect,
onRename,
onDelete,
activeTheme,
themeOptions,
onSelectTheme,
}: SessionDrawerProps) {
const closeSwipe = useSessionDrawerCloseSwipe(open, onClose);
const [actionSession, setActionSession] = useState<SessionSummary | null>(null);
useEffect(() => {
if (!open) setActionSession(null);
}, [open]);
useEffect(() => {
setActionSession((current) => {
if (!current) return current;
return current.chat_id === activeSessionId ? current : null;
});
}, [activeSessionId]);
const visible = open || progress > 0 || closeSwipe.closeProgress > 0;
if (!visible) return null;
const drawerProgress = open ? 1 - closeSwipe.closeProgress : progress;
const inMotion = dragging || closeSwipe.dragging;
const backdropStyle = {
opacity: String(drawerProgress),
transition: inMotion ? "none" : "opacity 0.2s ease",
pointerEvents: open ? "auto" : "none",
};
const drawerStyle = {
transform: `translateX(${(drawerProgress - 1) * 100}%)`,
transition: inMotion ? "none" : "transform 0.2s ease",
};
const actionMenuOpen = actionSession !== null;
return (
<div id="session-drawer-root" data-no-swipe="1">
<button
id="session-drawer-backdrop"
type="button"
aria-label="Close conversations"
style={backdropStyle}
onClick={onClose}
/>
<aside
id="session-drawer"
aria-label="Conversations"
style={drawerStyle}
onTouchStart={closeSwipe.onTouchStart}
onTouchMove={closeSwipe.onTouchMove}
onTouchEnd={closeSwipe.onTouchEnd}
onTouchCancel={closeSwipe.onTouchCancel}
>
<div id="session-drawer-header">
<div>
<div id="session-drawer-eyebrow">Nanobot</div>
<h2 id="session-drawer-title">Conversations</h2>
</div>
<button
id="session-drawer-close"
type="button"
aria-label="Close conversations"
onClick={onClose}
>
<CloseIcon />
</button>
</div>
<button
id="session-drawer-new"
type="button"
disabled={busy}
onClick={() => {
void onCreate();
}}
>
New conversation
</button>
<SessionThemePicker
activeTheme={activeTheme}
themeOptions={themeOptions}
onSelectTheme={onSelectTheme}
/>
<div id="session-drawer-list">
{sessions.map((session) => {
const active = session.chat_id === activeSessionId;
return (
<SessionDrawerItem
key={session.chat_id}
session={session}
active={active}
busy={busy}
onSelect={onSelect}
onOpenMenu={setActionSession}
/>
);
})}
</div>
</aside>
{actionMenuOpen ? (
<SessionActionMenu
session={actionSession}
busy={busy}
onClose={() => setActionSession(null)}
onRename={onRename}
onDelete={onDelete}
/>
) : null}
</div>
);
}

View file

@ -0,0 +1,194 @@
import { useEffect, useMemo, useState } from "preact/hooks";
import type { WorkbenchItem } from "../types";
import { DynamicCardBody } from "./CardBodyRenderer";
interface WorkbenchOverlayProps {
items: WorkbenchItem[];
onDismiss(id: string): Promise<void>;
onPromote(id: string): Promise<void>;
}
function DismissIcon() {
return (
<svg viewBox="0 0 20 20" aria-hidden="true">
<path
d="M14.5 5.5L5.5 14.5M5.5 5.5L14.5 14.5"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-width="1.8"
/>
</svg>
);
}
function PromoteIcon() {
return (
<svg viewBox="0 0 20 20" aria-hidden="true">
<path d="M10 4l4 4h-2.4v5H8.4V8H6l4-4Z" fill="currentColor" />
<path
d="M5.5 15.5h9"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-width="1.8"
/>
</svg>
);
}
function workbenchTabLabel(item: WorkbenchItem): string {
const title = item.title.trim();
if (!title) return "Workbench";
return title.length > 22 ? `${title.slice(0, 22).trimEnd()}...` : title;
}
function WorkbenchQuestionBody({ item }: { item: WorkbenchItem }) {
return (
<div class="workbench-overlay-question">
{item.content.trim() ? (
<div class="workbench-overlay-question-copy">{item.content.trim()}</div>
) : null}
{item.question?.trim() ? (
<div class="workbench-overlay-question-prompt">{item.question.trim()}</div>
) : null}
{item.choices?.length ? (
<div class="workbench-overlay-question-choices">
{item.choices.map((choice) => (
<span key={choice} class="workbench-overlay-question-choice">
{choice}
</span>
))}
</div>
) : null}
</div>
);
}
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: this overlay owns item selection and async promote/dismiss controls together
export function WorkbenchOverlay({ items, onDismiss, onPromote }: WorkbenchOverlayProps) {
const [activeId, setActiveId] = useState<string | null>(items[0]?.id ?? null);
const [busyId, setBusyId] = useState<string | null>(null);
const [errorText, setErrorText] = useState("");
useEffect(() => {
if (!items.length) {
setActiveId(null);
return;
}
if (activeId && items.some((item) => item.id === activeId)) return;
setActiveId(items[0].id);
}, [activeId, items]);
const activeItem = useMemo(
() =>
activeId
? (items.find((item) => item.id === activeId) ?? items[0] ?? null)
: (items[0] ?? null),
[activeId, items],
);
if (!activeItem) return null;
const busy = busyId === activeItem.id;
const handleDismiss = async () => {
if (!activeItem || busy) return;
setBusyId(activeItem.id);
setErrorText("");
try {
await onDismiss(activeItem.id);
} catch (error) {
setErrorText(error instanceof Error ? error.message : String(error));
} finally {
setBusyId(null);
}
};
const handlePromote = async () => {
if (!activeItem || busy || !activeItem.promotable) return;
setBusyId(activeItem.id);
setErrorText("");
try {
await onPromote(activeItem.id);
} catch (error) {
setErrorText(error instanceof Error ? error.message : String(error));
} finally {
setBusyId(null);
}
};
return (
<div id="workbench-overlay-root" data-no-swipe="1">
<section id="workbench-overlay" aria-label="Workbench">
<div id="workbench-overlay-head">
<div id="workbench-overlay-eyebrow">Workbench</div>
<div id="workbench-overlay-actions">
{activeItem.promotable ? (
<button
class="workbench-overlay-icon-btn"
type="button"
aria-label="Add this workbench item to the feed"
title="Add to feed"
disabled={busy}
onClick={handlePromote}
>
<PromoteIcon />
</button>
) : null}
<button
class="workbench-overlay-icon-btn"
type="button"
aria-label="Dismiss this workbench item"
title="Dismiss"
disabled={busy}
onClick={handleDismiss}
>
<DismissIcon />
</button>
</div>
</div>
{items.length > 1 ? (
<div id="workbench-overlay-tabs">
{items.map((item) => (
<button
key={item.id}
class={`workbench-overlay-tab${item.id === activeItem.id ? " active" : ""}`}
type="button"
disabled={busyId !== null && busyId !== item.id}
onClick={() => {
setActiveId(item.id);
setErrorText("");
}}
>
{workbenchTabLabel(item)}
</button>
))}
</div>
) : null}
<div id="workbench-overlay-title">{activeItem.title || "Untitled workbench"}</div>
{activeItem.contextSummary?.trim() ? (
<div id="workbench-overlay-blurb">{activeItem.contextSummary.trim()}</div>
) : null}
<div id="workbench-overlay-body">
{activeItem.kind === "question" ? (
<WorkbenchQuestionBody item={activeItem} />
) : (
<DynamicCardBody item={activeItem} surface="workbench" />
)}
</div>
<div id="workbench-overlay-footer">
{busy ? (
<div class="workbench-overlay-status">Saving to workbench...</div>
) : errorText ? (
<div class="workbench-overlay-status error">{errorText}</div>
) : activeItem.promotable ? (
<div class="workbench-overlay-status">Add to feed when it is worth keeping.</div>
) : (
<div class="workbench-overlay-status">Temporary session artifact.</div>
)}
</div>
</section>
</div>
);
}

View file

@ -0,0 +1,399 @@
import { useEffect, useState } from "preact/hooks";
import { decodeJsonError } from "../../cardRuntime/api";
import { requestCardFeedRefresh } from "../../cardRuntime/store";
const INBOX_REFRESH_EVENT = "nanobot:inbox-refresh";
interface InboxItem {
path: string;
title: string;
kind: string;
status: string;
source: string;
confidence: number | null;
captured: string;
updated: string;
suggestedDue: string;
tags: string[];
body: string;
}
function normalizeTaskTag(raw: string): string {
const trimmed = raw.trim().replace(/^#+/, "").replace(/\s+/g, "-");
return trimmed ? `#${trimmed}` : "";
}
function normalizeTaskTags(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
const seen = new Set<string>();
const tags: string[] = [];
for (const value of raw) {
const tag = normalizeTaskTag(String(value || ""));
const key = tag.toLowerCase();
if (!tag || seen.has(key)) continue;
seen.add(key);
tags.push(tag);
}
return tags;
}
function normalizeInboxItem(raw: unknown): InboxItem | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const record = raw as Record<string, unknown>;
const path = typeof record.path === "string" ? record.path.trim() : "";
if (!path) return null;
return {
path,
title:
typeof record.title === "string" && record.title.trim()
? record.title.trim()
: "Untitled capture",
kind: typeof record.kind === "string" ? record.kind.trim() : "unknown",
status: typeof record.status === "string" ? record.status.trim() : "new",
source: typeof record.source === "string" ? record.source.trim() : "unknown",
confidence:
typeof record.confidence === "number" && Number.isFinite(record.confidence)
? record.confidence
: null,
captured: typeof record.captured === "string" ? record.captured.trim() : "",
updated: typeof record.updated === "string" ? record.updated.trim() : "",
suggestedDue: typeof record.suggested_due === "string" ? record.suggested_due.trim() : "",
tags: normalizeTaskTags(record.tags),
body: typeof record.body === "string" ? record.body : "",
};
}
async function fetchInboxItems(limit = 4): Promise<InboxItem[]> {
const params = new URLSearchParams({ limit: String(limit) });
const resp = await fetch(`/inbox?${params.toString()}`, { cache: "no-store" });
if (!resp.ok) throw new Error(await decodeJsonError(resp));
const payload = (await resp.json()) as { items?: unknown };
const items = Array.isArray(payload.items) ? payload.items : [];
return items.map(normalizeInboxItem).filter((item): item is InboxItem => item !== null);
}
function requestInboxRefresh(): void {
window.dispatchEvent(new Event(INBOX_REFRESH_EVENT));
}
function extractCaptureTags(text: string): string[] {
const matches = text.match(/(^|\s)#([A-Za-z0-9_-]+)/g) ?? [];
return Array.from(
new Set(
matches
.map((match) => match.trim())
.filter(Boolean)
.map((tag) => normalizeTaskTag(tag)),
),
);
}
export async function captureInboxItem(text: string): Promise<InboxItem> {
const trimmed = text.trim();
if (!trimmed) throw new Error("capture text is required");
const resp = await fetch("/inbox/capture", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: trimmed,
tags: extractCaptureTags(trimmed),
source: "web-ui",
}),
});
if (!resp.ok) throw new Error(await decodeJsonError(resp));
const payload = (await resp.json()) as { item?: unknown };
const item = normalizeInboxItem(payload.item);
if (!item) throw new Error("invalid inbox response");
requestInboxRefresh();
return item;
}
async function dismissInboxItem(itemPath: string): Promise<void> {
const resp = await fetch("/inbox/dismiss", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ item: itemPath }),
});
if (!resp.ok) throw new Error(await decodeJsonError(resp));
requestInboxRefresh();
}
async function acceptInboxItemAsTask(itemPath: string): Promise<void> {
const resp = await fetch("/inbox/accept-task", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ item: itemPath, lane: "backlog" }),
});
if (!resp.ok) throw new Error(await decodeJsonError(resp));
requestInboxRefresh();
requestCardFeedRefresh();
}
function formatInboxUpdatedAt(timestamp: string): string {
const parsed = new Date(timestamp);
if (Number.isNaN(parsed.getTime())) return "";
return parsed.toLocaleString([], {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
function summarizeInboxBody(body: string): string {
return body.replace(/\r\n?/g, "\n").split("\n## Raw Capture")[0].replace(/\s+/g, " ").trim();
}
function computeInboxScore(items: InboxItem[]): number {
if (!items.length) return 0;
const newest = items[0];
const newestMs = newest.updated ? new Date(newest.updated).getTime() : Number.NaN;
const ageHours = Number.isFinite(newestMs) ? (Date.now() - newestMs) / (60 * 60 * 1000) : 999;
if (ageHours <= 1) return 91;
if (ageHours <= 6) return 86;
if (ageHours <= 24) return 82;
return 78;
}
export function InboxQuickAdd({
onSubmit,
visible,
}: {
onSubmit(text: string): Promise<void>;
visible: boolean;
}) {
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!visible) {
setOpen(false);
setError("");
}
}, [visible]);
const close = () => {
if (busy) return;
setOpen(false);
setError("");
};
const submit = async () => {
const trimmed = value.trim();
if (!trimmed) return;
setBusy(true);
setError("");
try {
await onSubmit(trimmed);
setValue("");
setOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
if (!visible) return null;
return (
<>
<button
id="inbox-quick-add-btn"
type="button"
data-no-swipe="1"
aria-label="Quick add to inbox"
onClick={() => setOpen(true)}
>
+
</button>
{open ? (
<>
<button
id="inbox-quick-add-backdrop"
type="button"
data-no-swipe="1"
aria-label="Close inbox quick add"
onClick={close}
/>
<div id="inbox-quick-add-sheet" data-no-swipe="1">
<div class="inbox-quick-add-sheet__title">Quick Add</div>
<textarea
class="inbox-quick-add-sheet__input"
rows={3}
placeholder="Something to remember..."
value={value}
disabled={busy}
onInput={(event) => setValue(event.currentTarget.value)}
/>
{error ? <div class="inbox-quick-add-sheet__error">{error}</div> : null}
<div class="inbox-quick-add-sheet__actions">
<button
class="inbox-quick-add-sheet__btn"
type="button"
disabled={busy}
onClick={close}
>
Cancel
</button>
<button
class="inbox-quick-add-sheet__btn primary"
type="button"
disabled={busy || !value.trim()}
onClick={() => {
void submit();
}}
>
Add
</button>
</div>
</div>
</>
) : null}
</>
);
}
export function InboxReviewCard() {
const [items, setItems] = useState<InboxItem[]>([]);
const [loading, setLoading] = useState(true);
const [busyItem, setBusyItem] = useState<{ path: string; action: "accept" | "dismiss" } | null>(
null,
);
const [errorText, setErrorText] = useState("");
const loadInbox = async () => {
setLoading(true);
try {
const nextItems = await fetchInboxItems(4);
setItems(nextItems);
setErrorText("");
} catch (error) {
console.error("Inbox load failed", error);
setErrorText(error instanceof Error ? error.message : String(error));
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadInbox();
const onRefresh = (event?: Event) => {
const customEvent = event as CustomEvent<{ items?: unknown[] }> | undefined;
const nextItems = customEvent?.detail?.items;
if (Array.isArray(nextItems)) {
setItems(
nextItems.map(normalizeInboxItem).filter((item): item is InboxItem => item !== null),
);
setLoading(false);
setErrorText("");
return;
}
void loadInbox();
};
window.addEventListener(INBOX_REFRESH_EVENT, onRefresh);
window.addEventListener("focus", onRefresh);
return () => {
window.removeEventListener(INBOX_REFRESH_EVENT, onRefresh);
window.removeEventListener("focus", onRefresh);
};
}, []);
const withBusyItem = async (
itemPath: string,
actionKind: "accept" | "dismiss",
action: () => Promise<void>,
) => {
setBusyItem({ path: itemPath, action: actionKind });
setErrorText("");
try {
await action();
await loadInbox();
} catch (error) {
console.error("Inbox action failed", error);
setErrorText(error instanceof Error ? error.message : String(error));
} finally {
setBusyItem(null);
}
};
const score = computeInboxScore(items);
if (!loading && !errorText && items.length === 0) {
return null;
}
return (
<article class="card kind-text state-active inbox-review-card">
<header class="card-header">
<div class="card-title-wrap">
<div class="card-title-line">
<span class="card-title">Inbox</span>
</div>
<div class="card-meta">
<span class="card-state state-active">
{loading ? "Loading" : `${items.length} open`}
</span>
</div>
</div>
</header>
<div class="inbox-review-card__body">
<div class="inbox-review-card__summary">
Capture first, organize later.
{score ? <span class="inbox-review-card__score">Priority {score}</span> : null}
</div>
{errorText ? <div class="inbox-review-card__error">{errorText}</div> : null}
{items.map((item) => {
const preview = summarizeInboxBody(item.body);
const itemBusy = busyItem?.path === item.path;
const accepting = busyItem?.path === item.path && busyItem.action === "accept";
return (
<div key={item.path} class="inbox-review-card__item">
<div class="inbox-review-card__item-topline">
<span class="inbox-review-card__item-kind">{item.kind}</span>
<span class={`inbox-review-card__item-updated${accepting ? " is-active" : ""}`}>
{accepting ? "Nanobot..." : formatInboxUpdatedAt(item.updated || item.captured)}
</span>
</div>
<div class="inbox-review-card__item-title">{item.title}</div>
{preview ? <div class="inbox-review-card__item-preview">{preview}</div> : null}
{item.tags.length ? (
<div class="inbox-review-card__tags">
{item.tags.map((tag) => (
<span key={tag} class="inbox-review-card__tag">
{tag}
</span>
))}
</div>
) : null}
<div class="inbox-review-card__actions">
<button
class="inbox-review-card__action"
type="button"
disabled={itemBusy}
onClick={() => {
void withBusyItem(item.path, "accept", () => acceptInboxItemAsTask(item.path));
}}
>
{accepting ? "Nanobot..." : "Ask Nanobot"}
</button>
<button
class="inbox-review-card__action ghost"
type="button"
disabled={itemBusy}
onClick={() => {
void withBusyItem(item.path, "dismiss", () => dismissInboxItem(item.path));
}}
>
Dismiss
</button>
</div>
</div>
);
})}
</div>
</article>
);
}

View file

@ -0,0 +1,19 @@
import { decodeJsonError } from "../../cardRuntime/api";
function nextLocalMidnightIso(): string {
const tomorrow = new Date();
tomorrow.setHours(24, 0, 0, 0);
return tomorrow.toISOString();
}
export async function snoozeCardUntilTomorrow(cardId: string | null | undefined): Promise<void> {
const key = (cardId || "").trim();
if (!key) throw new Error("card id is required");
const resp = await fetch(`/cards/${encodeURIComponent(key)}/snooze`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ until: nextLocalMidnightIso() }),
});
if (!resp.ok) throw new Error(await decodeJsonError(resp));
window.dispatchEvent(new Event("nanobot:cards-refresh"));
}

View file

@ -1,735 +1,254 @@
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import type {
AgentState,
CardItem,
CardMessageMetadata,
CardState,
ClientMessage,
LogLine,
ServerMessage,
} from "../types";
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? "";
const WEBRTC_STUN_URL = import.meta.env.VITE_WEBRTC_STUN_URL?.trim() ?? "";
const LOCAL_ICE_GATHER_TIMEOUT_MS = 350;
const CARD_LIVE_CONTENT_EVENT = "nanobot:card-live-content-change";
let cardIdCounter = 0;
let logIdCounter = 0;
const STATE_RANK: Record<CardState, number> = {
active: 0,
stale: 1,
resolved: 2,
superseded: 3,
archived: 4,
};
interface WebRTCState {
connected: boolean;
connecting: boolean;
textOnly: boolean;
agentState: AgentState;
logLines: LogLine[];
cards: CardItem[];
voiceStatus: string;
statusVisible: boolean;
remoteAudioEl: HTMLAudioElement | null;
remoteStream: MediaStream | null;
sendJson(msg: ClientMessage): void;
sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise<void>;
dismissCard(id: number): void;
setTextOnly(enabled: boolean): void;
connect(): Promise<void>;
}
type AppendLine = (role: string, text: string, timestamp: string) => void;
type UpsertCard = (item: Omit<CardItem, "id">) => void;
type SetAgentState = (updater: (prev: AgentState) => AgentState) => void;
type RawPersistedCard =
| Extract<ServerMessage, { type: "card" }>
| (Omit<Extract<ServerMessage, { type: "card" }>, "type"> & { type?: "card" });
interface IdleFallbackControls {
clear(): void;
schedule(delayMs?: number): void;
}
interface RTCRefs {
pcRef: { current: RTCPeerConnection | null };
dcRef: { current: RTCDataChannel | null };
remoteAudioRef: { current: HTMLAudioElement | null };
micSendersRef: { current: RTCRtpSender[] };
localTracksRef: { current: MediaStreamTrack[] };
}
interface RTCCallbacks {
setConnected: (v: boolean) => void;
setConnecting: (v: boolean) => void;
setRemoteStream: (s: MediaStream | null) => void;
showStatus: (text: string, persistMs?: number) => void;
appendLine: AppendLine;
onDcMessage: (raw: string) => void;
onDcOpen: () => void;
closePC: () => void;
}
function readCardScore(card: Pick<CardItem, "priority" | "serverId">): number {
if (!card.serverId) return card.priority;
const liveContent = window.__nanobotGetCardLiveContent?.(card.serverId);
if (!liveContent || typeof liveContent !== "object" || Array.isArray(liveContent)) {
return card.priority;
}
const score = (liveContent as Record<string, unknown>).score;
return typeof score === "number" && Number.isFinite(score) ? score : card.priority;
}
function compareCards(a: CardItem, b: CardItem): number {
const stateDiff = STATE_RANK[a.state] - STATE_RANK[b.state];
if (stateDiff !== 0) return stateDiff;
const scoreDiff = readCardScore(b) - readCardScore(a);
if (scoreDiff !== 0) return scoreDiff;
if (a.priority !== b.priority) return b.priority - a.priority;
const updatedDiff = b.updatedAt.localeCompare(a.updatedAt);
if (updatedDiff !== 0) return updatedDiff;
return b.createdAt.localeCompare(a.createdAt);
}
function sortCards(items: CardItem[]): CardItem[] {
return [...items].sort(compareCards);
}
function stopMediaTracks(tracks: Iterable<MediaStreamTrack | null | undefined>): void {
const seen = new Set<MediaStreamTrack>();
for (const track of tracks) {
if (!track || seen.has(track)) continue;
seen.add(track);
track.stop();
}
}
function toCardItem(msg: Extract<ServerMessage, { type: "card" }>): Omit<CardItem, "id"> {
return {
serverId: msg.id,
kind: msg.kind,
content: msg.content,
title: msg.title,
question: msg.question || undefined,
choices: msg.choices.length > 0 ? msg.choices : undefined,
responseValue: msg.response_value || undefined,
slot: msg.slot || undefined,
lane: msg.lane,
priority: msg.priority,
state: msg.state,
templateKey: msg.template_key || undefined,
templateState:
msg.template_state && typeof msg.template_state === "object" ? msg.template_state : undefined,
contextSummary: msg.context_summary || undefined,
createdAt: msg.created_at || new Date().toISOString(),
updatedAt: msg.updated_at || new Date().toISOString(),
};
}
function appendLogLineEntry(
prev: LogLine[],
role: string,
text: string,
timestamp: string,
): LogLine[] {
const next = [
...prev,
{ id: logIdCounter++, role, text, timestamp: timestamp || new Date().toISOString() },
];
return next.length > 250 ? next.slice(next.length - 250) : next;
}
function mergeCardItem(prev: CardItem[], item: Omit<CardItem, "id">): CardItem[] {
const existingIndex = item.serverId
? prev.findIndex((card) => card.serverId === item.serverId)
: -1;
if (existingIndex >= 0) {
const next = [...prev];
next[existingIndex] = { ...next[existingIndex], ...item };
return sortCards(next);
}
return sortCards([...prev, { ...item, id: cardIdCounter++ }]);
}
function normalizePersistedCard(raw: RawPersistedCard): Omit<CardItem, "id"> {
return toCardItem({
type: "card",
...(raw as Omit<Extract<ServerMessage, { type: "card" }>, "type">),
});
}
function reconcilePersistedCards(prev: CardItem[], rawCards: RawPersistedCard[]): CardItem[] {
const byServerId = new Map(
prev.filter((card) => card.serverId).map((card) => [card.serverId as string, card.id]),
);
const next = rawCards.map((raw) => {
const card = normalizePersistedCard(raw);
return {
...card,
id:
card.serverId && byServerId.has(card.serverId)
? (byServerId.get(card.serverId) as number)
: cardIdCounter++,
};
});
return sortCards(next);
}
function handleTypedMessage(
msg: Extract<ServerMessage, { type: string }>,
setAgentState: SetAgentState,
appendLine: AppendLine,
upsertCard: UpsertCard,
idleFallback: IdleFallbackControls,
): void {
if (msg.type === "agent_state") {
idleFallback.clear();
setAgentState(() => msg.state);
return;
}
if (msg.type === "message") {
if (msg.is_tool_hint) {
appendLine("tool", msg.content, msg.timestamp);
return;
}
if (!msg.is_progress) {
appendLine(msg.role, msg.content, msg.timestamp);
idleFallback.schedule();
}
return;
}
if (msg.type === "card") {
upsertCard(toCardItem(msg));
idleFallback.schedule();
return;
}
if (msg.type === "error") {
appendLine("system", msg.error, "");
idleFallback.schedule();
}
}
async function acquireMicStream(): Promise<MediaStream> {
try {
return await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
sampleRate: 48000,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: false,
},
video: false,
});
} catch {
return navigator.mediaDevices.getUserMedia({ audio: true, video: false });
}
}
function waitForIceComplete(pc: RTCPeerConnection): Promise<void> {
return new Promise<void>((resolve) => {
if (pc.iceGatheringState === "complete") {
resolve();
return;
}
const check = () => {
if (pc.iceGatheringState === "complete") {
pc.removeEventListener("icegatheringstatechange", check);
resolve();
}
};
pc.addEventListener("icegatheringstatechange", check);
setTimeout(() => {
pc.removeEventListener("icegatheringstatechange", check);
resolve();
}, LOCAL_ICE_GATHER_TIMEOUT_MS);
});
}
async function exchangeSdp(
localDesc: RTCSessionDescription,
): Promise<{ sdp: string; rtcType: string }> {
const rtcUrl = BACKEND_URL ? `${BACKEND_URL}/rtc/offer` : "/rtc/offer";
const resp = await fetch(rtcUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sdp: localDesc.sdp, rtcType: localDesc.type }),
});
if (!resp.ok) throw new Error(`/rtc/offer returned ${resp.status}`);
return resp.json() as Promise<{ sdp: string; rtcType: string }>;
}
async function runConnect(
refs: RTCRefs,
cbs: RTCCallbacks,
opts: { textOnly: boolean },
): Promise<void> {
if (refs.pcRef.current) return;
if (!window.RTCPeerConnection) {
cbs.showStatus("WebRTC unavailable in this browser.", 4000);
return;
}
cbs.setConnecting(true);
cbs.showStatus("Connecting...");
let micStream: MediaStream | null = null;
try {
refs.localTracksRef.current = [];
if (!opts.textOnly) {
micStream = await acquireMicStream();
const audioTracks = micStream.getAudioTracks();
audioTracks.forEach((track) => {
track.enabled = false;
});
refs.localTracksRef.current = audioTracks;
}
// Local-only deployments do not need a public STUN server; host candidates are enough
// and avoiding external ICE gathering removes several seconds from startup latency.
const pc = new RTCPeerConnection(
WEBRTC_STUN_URL ? { iceServers: [{ urls: WEBRTC_STUN_URL }] } : undefined,
);
refs.pcRef.current = pc;
const newRemoteStream = new MediaStream();
cbs.setRemoteStream(newRemoteStream);
if (refs.remoteAudioRef.current) {
refs.remoteAudioRef.current.srcObject = newRemoteStream;
refs.remoteAudioRef.current.play().catch(() => {});
}
pc.ontrack = (event) => {
if (event.track.kind !== "audio") return;
newRemoteStream.addTrack(event.track);
refs.remoteAudioRef.current?.play().catch(() => {});
};
const dc = pc.createDataChannel("app", { ordered: true });
refs.dcRef.current = dc;
dc.onopen = () => {
cbs.setConnected(true);
cbs.setConnecting(false);
cbs.showStatus(opts.textOnly ? "Text-only mode enabled" : "Hold anywhere to talk", 2500);
cbs.appendLine("system", "Connected.", new Date().toISOString());
cbs.onDcOpen();
};
dc.onclose = () => {
cbs.appendLine("system", "Disconnected.", new Date().toISOString());
cbs.closePC();
};
dc.onmessage = (e) => cbs.onDcMessage(e.data as string);
refs.micSendersRef.current = [];
if (micStream) {
micStream.getAudioTracks().forEach((track) => {
pc.addTrack(track, micStream as MediaStream);
});
refs.micSendersRef.current = pc.getSenders().filter((s) => s.track?.kind === "audio");
}
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await waitForIceComplete(pc);
const localDesc = pc.localDescription;
if (!localDesc) throw new Error("No local description after ICE gathering");
const answer = await exchangeSdp(localDesc);
await pc.setRemoteDescription({ type: answer.rtcType as RTCSdpType, sdp: answer.sdp });
} catch (err) {
cbs.appendLine("system", `Connection failed: ${err}`, new Date().toISOString());
cbs.showStatus("Connection failed.", 3000);
cbs.closePC();
}
}
function useBackendActions() {
const sendTextMessage = useCallback(async (text: string, metadata?: CardMessageMetadata) => {
const message = text.trim();
if (!message) return;
const url = BACKEND_URL ? `${BACKEND_URL}/message` : "/message";
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: message, metadata: metadata ?? {} }),
});
if (!resp.ok) throw new Error(`Send failed (${resp.status})`);
}, []);
return { sendTextMessage };
}
function useCardPolling(loadPersistedCards: () => Promise<void>) {
useEffect(() => {
loadPersistedCards().catch(() => {});
const pollId = window.setInterval(() => {
loadPersistedCards().catch(() => {});
}, 10000);
const onVisible = () => {
if (document.visibilityState === "visible") loadPersistedCards().catch(() => {});
};
const onCardsRefresh = () => {
loadPersistedCards().catch(() => {});
};
window.addEventListener("focus", onVisible);
window.addEventListener("nanobot:cards-refresh", onCardsRefresh);
document.addEventListener("visibilitychange", onVisible);
return () => {
window.clearInterval(pollId);
window.removeEventListener("focus", onVisible);
window.removeEventListener("nanobot:cards-refresh", onCardsRefresh);
document.removeEventListener("visibilitychange", onVisible);
};
}, [loadPersistedCards]);
}
function useRemoteAudioBindings({
textOnly,
connected,
showStatus,
remoteAudioRef,
micSendersRef,
dcRef,
textOnlyRef,
}: {
textOnly: boolean;
connected: boolean;
showStatus: (text: string, persistMs?: number) => void;
remoteAudioRef: { current: HTMLAudioElement | null };
micSendersRef: { current: RTCRtpSender[] };
dcRef: { current: RTCDataChannel | null };
textOnlyRef: { current: boolean };
}) {
useEffect(() => {
textOnlyRef.current = textOnly;
}, [textOnly, textOnlyRef]);
useEffect(() => {
const audio = new Audio();
audio.autoplay = true;
(audio as HTMLAudioElement & { playsInline: boolean }).playsInline = true;
audio.muted = textOnlyRef.current;
remoteAudioRef.current = audio;
return () => {
audio.srcObject = null;
};
}, [remoteAudioRef, textOnlyRef]);
useEffect(() => {
const handler = (e: Event) => {
const enabled = (e as CustomEvent<{ enabled: boolean }>).detail?.enabled ?? false;
micSendersRef.current.forEach((sender) => {
if (sender.track) sender.track.enabled = enabled && !textOnlyRef.current;
});
};
window.addEventListener("nanobot-mic-enable", handler);
return () => window.removeEventListener("nanobot-mic-enable", handler);
}, [micSendersRef, textOnlyRef]);
useEffect(() => {
if (remoteAudioRef.current) {
remoteAudioRef.current.muted = textOnly;
if (textOnly) remoteAudioRef.current.pause();
else remoteAudioRef.current.play().catch(() => {});
}
micSendersRef.current.forEach((sender) => {
if (sender.track) sender.track.enabled = false;
});
if (textOnly) {
const dc = dcRef.current;
if (dc?.readyState === "open") {
dc.send(JSON.stringify({ type: "voice-ptt", pressed: false } satisfies ClientMessage));
}
}
if (connected) showStatus(textOnly ? "Text-only mode enabled" : "Hold anywhere to talk", 2000);
}, [connected, dcRef, micSendersRef, remoteAudioRef, showStatus, textOnly]);
}
function useIdleFallback(setAgentState: SetAgentState): IdleFallbackControls {
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clear = useCallback(() => {
if (!idleTimerRef.current) return;
clearTimeout(idleTimerRef.current);
idleTimerRef.current = null;
}, []);
const schedule = useCallback(
(delayMs = 450) => {
clear();
idleTimerRef.current = setTimeout(() => {
idleTimerRef.current = null;
setAgentState((prev) => {
if (prev === "listening" || prev === "speaking") return prev;
return "idle";
});
}, delayMs);
},
[clear, setAgentState],
);
useEffect(() => clear, [clear]);
return { clear, schedule };
}
function useLogState() {
const [logLines, setLogLines] = useState<LogLine[]>([]);
const appendLine = useCallback((role: string, text: string, timestamp: string) => {
setLogLines((prev) => appendLogLineEntry(prev, role, text, timestamp));
}, []);
return { logLines, appendLine };
}
async function fetchPersistedCardsFromBackend(): Promise<RawPersistedCard[] | null> {
const url = BACKEND_URL ? `${BACKEND_URL}/cards` : "/cards";
const resp = await fetch(url, { cache: "no-store" });
if (!resp.ok) {
console.warn(`[cards] /cards returned ${resp.status}`);
return null;
}
return (await resp.json()) as RawPersistedCard[];
}
function useCardsState() {
const [cards, setCards] = useState<CardItem[]>([]);
const upsertCard = useCallback((item: Omit<CardItem, "id">) => {
setCards((prev) => mergeCardItem(prev, item));
}, []);
const dismissCard = useCallback((id: number) => {
setCards((prev) => {
const card = prev.find((entry) => entry.id === id);
if (card?.serverId) {
const url = BACKEND_URL
? `${BACKEND_URL}/cards/${card.serverId}`
: `/cards/${card.serverId}`;
fetch(url, { method: "DELETE" }).catch(() => {});
}
return prev.filter((entry) => entry.id !== id);
});
}, []);
const loadPersistedCards = useCallback(async () => {
try {
const rawCards = await fetchPersistedCardsFromBackend();
if (!rawCards) return;
setCards((prev) => reconcilePersistedCards(prev, rawCards));
} catch (err) {
console.warn("[cards] failed to load persisted cards", err);
}
}, []);
useEffect(() => {
const onCardLiveContentChange = (event: Event) => {
const customEvent = event as CustomEvent<{ cardId?: unknown }>;
const cardId =
customEvent.detail && typeof customEvent.detail.cardId === "string"
? customEvent.detail.cardId
: "";
setCards((prev) => {
if (prev.length === 0) return prev;
if (cardId && !prev.some((card) => card.serverId === cardId)) return prev;
return sortCards([...prev]);
});
};
window.addEventListener(CARD_LIVE_CONTENT_EVENT, onCardLiveContentChange);
return () => {
window.removeEventListener(CARD_LIVE_CONTENT_EVENT, onCardLiveContentChange);
};
}, []);
return { cards, upsertCard, dismissCard, loadPersistedCards };
}
function parseServerMessage(raw: string): Extract<ServerMessage, { type: string }> | null {
let msg: ServerMessage;
try {
msg = JSON.parse(raw);
} catch {
return null;
}
if (typeof msg !== "object" || msg === null || !("type" in msg)) return null;
return msg as Extract<ServerMessage, { type: string }>;
}
function useDataChannelMessages({
setAgentState,
appendLine,
upsertCard,
idleFallback,
}: {
setAgentState: SetAgentState;
appendLine: AppendLine;
upsertCard: UpsertCard;
idleFallback: IdleFallbackControls;
}) {
return useCallback(
(raw: string) => {
const msg = parseServerMessage(raw);
if (!msg) return;
handleTypedMessage(msg, setAgentState, appendLine, upsertCard, idleFallback);
},
[appendLine, idleFallback, setAgentState, upsertCard],
);
}
function useMessageState() {
const [agentState, setAgentState] = useState<AgentState>("idle");
const { logLines, appendLine } = useLogState();
const { cards, upsertCard, dismissCard, loadPersistedCards } = useCardsState();
const idleFallback = useIdleFallback(setAgentState);
const onDcMessage = useDataChannelMessages({
setAgentState,
appendLine,
upsertCard,
idleFallback,
});
return { agentState, logLines, cards, appendLine, dismissCard, loadPersistedCards, onDcMessage };
}
function usePeerConnectionControls({
textOnly,
connected,
appendLine,
onDcMessage,
loadPersistedCards,
showStatus,
refs,
setConnected,
setConnecting,
setRemoteStream,
textOnlyRef,
}: {
textOnly: boolean;
connected: boolean;
appendLine: AppendLine;
onDcMessage: (raw: string) => void;
loadPersistedCards: () => Promise<void>;
showStatus: (text: string, persistMs?: number) => void;
refs: RTCRefs;
setConnected: (value: boolean) => void;
setConnecting: (value: boolean) => void;
setRemoteStream: (stream: MediaStream | null) => void;
textOnlyRef: { current: boolean };
}) {
const closePC = useCallback(() => {
const dc = refs.dcRef.current;
const pc = refs.pcRef.current;
const localTracks = refs.localTracksRef.current;
const senderTracks = refs.micSendersRef.current.map((sender) => sender.track);
refs.dcRef.current = null;
refs.pcRef.current = null;
refs.micSendersRef.current = [];
refs.localTracksRef.current = [];
stopMediaTracks([...senderTracks, ...localTracks]);
dc?.close();
pc?.close();
setConnected(false);
setConnecting(false);
if (refs.remoteAudioRef.current) refs.remoteAudioRef.current.srcObject = null;
setRemoteStream(null);
}, [refs, setConnected, setConnecting, setRemoteStream]);
const closePCRef = useRef(closePC);
useEffect(() => {
closePCRef.current = closePC;
}, [closePC]);
const connect = useCallback(async () => {
await runConnect(
refs,
{
setConnected,
setConnecting,
setRemoteStream,
showStatus,
appendLine,
onDcMessage,
onDcOpen: () => {
loadPersistedCards().catch(() => {});
},
closePC,
},
{ textOnly: textOnlyRef.current },
);
}, [
appendLine,
closePC,
loadPersistedCards,
onDcMessage,
refs,
setConnected,
setConnecting,
setRemoteStream,
showStatus,
textOnlyRef,
]);
useEffect(() => {
if (textOnly || !connected || refs.micSendersRef.current.length > 0) return;
closePC();
connect().catch(() => {});
}, [closePC, connect, connected, refs.micSendersRef, textOnly]);
useEffect(() => {
return () => {
closePCRef.current();
};
}, []);
return { closePC, connect };
}
import type { CardMessageMetadata, ClientMessage, SessionSummary } from "../types";
import {
createSessionInBackend,
deleteSessionInBackend,
fetchSessionDetailFromBackend,
fetchSessionsFromBackend,
renameSessionInBackend,
useBackendActions,
} from "./webrtc/backend";
import { useSessionSurfaceEvents } from "./webrtc/cards";
import { useMessageState } from "./webrtc/messages";
import {
usePeerConnectionControls,
useRemoteAudioBindings,
useRtcRefs,
} from "./webrtc/rtcTransport";
import type { WebRTCState } from "./webrtc/types";
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: this hook owns the app's transport/session state wiring
export function useWebRTC(): WebRTCState {
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(false);
const [textOnly, setTextOnlyState] = useState(false);
const [textOnly, setTextOnlyState] = useState(true);
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [activeSessionId, setActiveSessionId] = useState("web");
const [sessionLoading, setSessionLoading] = useState(false);
const [textStreaming, setTextStreaming] = useState(false);
const [voiceStatus, setVoiceStatus] = useState("");
const [statusVisible, setStatusVisible] = useState(false);
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
const refs: RTCRefs = {
pcRef: useRef<RTCPeerConnection | null>(null),
dcRef: useRef<RTCDataChannel | null>(null),
remoteAudioRef: useRef<HTMLAudioElement | null>(null),
micSendersRef: useRef<RTCRtpSender[]>([]),
localTracksRef: useRef<MediaStreamTrack[]>([]),
};
const refs = useRtcRefs();
const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const textOnlyRef = useRef(false);
const { sendTextMessage } = useBackendActions();
const { agentState, logLines, cards, appendLine, dismissCard, loadPersistedCards, onDcMessage } =
useMessageState();
const textOnlyRef = useRef(true);
const activeSessionIdRef = useRef("web");
const {
agentState,
setAgentState,
logLines,
cards,
workbenchItems,
appendLine,
replaceLines,
clearLines,
clearCards,
clearWorkbench,
dismissCard,
dismissWorkbenchItem,
promoteWorkbenchItem,
loadPersistedCards,
loadWorkbench,
onDcMessage,
onIncomingMessage,
suppressRtcTextRef,
} = useMessageState(activeSessionIdRef);
const { sendTextMessage: sendTextMessageRaw } = useBackendActions({
onMessage: onIncomingMessage,
suppressRtcTextRef,
getActiveSessionId: () => activeSessionIdRef.current,
setTextStreaming,
});
const activeSession = sessions.find((session) => session.chat_id === activeSessionId) ?? null;
useEffect(() => {
activeSessionIdRef.current = activeSessionId;
}, [activeSessionId]);
const refreshSessions = useCallback(async () => {
const nextSessions = await fetchSessionsFromBackend();
if (nextSessions) setSessions(nextSessions);
return nextSessions;
}, []);
const setTextOnly = useCallback((enabled: boolean) => {
textOnlyRef.current = enabled;
setTextOnlyState(enabled);
}, []);
const showStatus = useCallback((text: string, persistMs = 0) => {
setVoiceStatus(text);
setStatusVisible(true);
if (statusTimerRef.current) clearTimeout(statusTimerRef.current);
if (persistMs > 0)
if (persistMs > 0) {
statusTimerRef.current = setTimeout(() => setStatusVisible(false), persistMs);
}
}, []);
const sendJson = useCallback(
(msg: ClientMessage) => {
const dc = refs.dcRef.current;
if (dc?.readyState === "open") dc.send(JSON.stringify(msg));
if (dc?.readyState !== "open") return;
if (msg.type === "command" || msg.type === "voice-ptt") {
dc.send(JSON.stringify({ ...msg, chat_id: activeSessionIdRef.current }));
return;
}
dc.send(JSON.stringify(msg));
},
[refs.dcRef],
);
useCardPolling(loadPersistedCards);
const sendTextMessage = useCallback(
async (text: string, metadata?: CardMessageMetadata) => {
await sendTextMessageRaw(text, metadata);
await refreshSessions();
},
[refreshSessions, sendTextMessageRaw],
);
const switchSession = useCallback(async (chatId: string) => {
if (!chatId || chatId === activeSessionIdRef.current) return;
activeSessionIdRef.current = chatId;
setActiveSessionId(chatId);
}, []);
const createSession = useCallback(
async (title = "") => {
setSessionLoading(true);
try {
const created = await createSessionInBackend(title);
const nextSessions = await refreshSessions();
if (!created) return;
const nextSessionId = created.chat_id;
activeSessionIdRef.current = nextSessionId;
setActiveSessionId(nextSessionId);
if (nextSessions && !nextSessions.some((session) => session.chat_id === nextSessionId)) {
setSessions((prev) => [created, ...prev]);
}
} finally {
setSessionLoading(false);
}
},
[refreshSessions],
);
const renameSession = useCallback(
async (chatId: string, title: string) => {
if (!chatId) return;
setSessionLoading(true);
try {
const updated = await renameSessionInBackend(chatId, title);
const nextSessions = await refreshSessions();
if (!updated) return;
if (nextSessions && !nextSessions.some((session) => session.chat_id === updated.chat_id)) {
setSessions((prev) => [
updated,
...prev.filter((session) => session.chat_id !== updated.chat_id),
]);
}
} finally {
setSessionLoading(false);
}
},
[refreshSessions],
);
const deleteSession = useCallback(
async (chatId: string) => {
if (!chatId) return;
setSessionLoading(true);
try {
const deleted = await deleteSessionInBackend(chatId);
const nextSessions = await refreshSessions();
if (!deleted) return;
if (chatId !== activeSessionIdRef.current) return;
const fallbackSessionId =
nextSessions?.find((session) => session.chat_id !== chatId)?.chat_id || "web";
activeSessionIdRef.current = fallbackSessionId;
setActiveSessionId(fallbackSessionId);
} finally {
setSessionLoading(false);
}
},
[refreshSessions],
);
useEffect(() => {
refreshSessions().then((nextSessions) => {
if (
!nextSessions ||
nextSessions.some((session) => session.chat_id === activeSessionIdRef.current)
) {
return;
}
if (nextSessions[0]?.chat_id) {
activeSessionIdRef.current = nextSessions[0].chat_id;
setActiveSessionId(nextSessions[0].chat_id);
}
});
}, [refreshSessions]);
useEffect(() => {
let cancelled = false;
const loadSession = async () => {
setSessionLoading(true);
setAgentState(() => "idle");
clearLines();
clearCards();
clearWorkbench();
try {
const detail = await fetchSessionDetailFromBackend(activeSessionId);
if (cancelled || activeSessionIdRef.current !== activeSessionId) return;
if (detail) {
replaceLines(detail.messages);
setSessions((prev) => {
const filtered = prev.filter((session) => session.chat_id !== detail.session.chat_id);
return [detail.session, ...filtered].sort((a, b) =>
b.updated_at.localeCompare(a.updated_at),
);
});
}
await loadPersistedCards();
await loadWorkbench();
} finally {
if (!cancelled && activeSessionIdRef.current === activeSessionId) {
setSessionLoading(false);
}
}
};
loadSession().catch((err) => {
console.warn("[sessions] failed to load session", err);
if (!cancelled && activeSessionIdRef.current === activeSessionId) {
setSessionLoading(false);
}
});
return () => {
cancelled = true;
};
}, [
activeSessionId,
clearCards,
clearLines,
clearWorkbench,
loadPersistedCards,
loadWorkbench,
replaceLines,
setAgentState,
]);
useSessionSurfaceEvents({
activeSessionId,
loadPersistedCards,
loadWorkbench,
refreshSessions,
setSessions,
});
useRemoteAudioBindings({
textOnly,
connected,
@ -745,6 +264,7 @@ export function useWebRTC(): WebRTCState {
appendLine,
onDcMessage,
loadPersistedCards,
loadWorkbench,
showStatus,
refs,
setConnected,
@ -760,6 +280,12 @@ export function useWebRTC(): WebRTCState {
agentState,
logLines,
cards,
workbenchItems,
sessions,
activeSessionId,
activeSession,
sessionLoading,
textStreaming,
voiceStatus,
statusVisible,
remoteAudioEl: refs.remoteAudioRef.current,
@ -767,7 +293,13 @@ export function useWebRTC(): WebRTCState {
sendJson,
sendTextMessage,
dismissCard,
dismissWorkbenchItem,
promoteWorkbenchItem,
setTextOnly,
switchSession,
createSession,
renameSession,
deleteSession,
connect,
};
}

View file

@ -0,0 +1,140 @@
import { useCallback } from "preact/hooks";
import { streamSseResponse } from "../../lib/sse";
import type { CardMessageMetadata, SessionSummary } from "../../types";
import type { RawPersistedCard, RawWorkbenchItem, SessionDetailResponse } from "./types";
export const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? "";
export function useBackendActions({
onMessage,
suppressRtcTextRef,
getActiveSessionId,
setTextStreaming,
}: {
onMessage: (raw: string) => void;
suppressRtcTextRef: { current: number };
getActiveSessionId: () => string;
setTextStreaming: (value: boolean) => void;
}) {
const sendTextMessage = useCallback(
async (text: string, metadata?: CardMessageMetadata) => {
const message = text.trim();
if (!message) return;
suppressRtcTextRef.current += 1;
setTextStreaming(true);
try {
const url = BACKEND_URL ? `${BACKEND_URL}/message/stream` : "/message/stream";
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: message,
metadata: metadata ?? {},
chat_id: getActiveSessionId(),
}),
});
if (!resp.ok) throw new Error(`Send failed (${resp.status})`);
await streamSseResponse(resp, onMessage);
} finally {
setTextStreaming(false);
suppressRtcTextRef.current = Math.max(0, suppressRtcTextRef.current - 1);
}
},
[getActiveSessionId, onMessage, setTextStreaming, suppressRtcTextRef],
);
return { sendTextMessage };
}
export async function fetchPersistedCardsFromBackend(
chatId: string,
): Promise<RawPersistedCard[] | null> {
const params = new URLSearchParams({ chat_id: chatId });
const url = BACKEND_URL ? `${BACKEND_URL}/cards?${params}` : `/cards?${params}`;
const resp = await fetch(url, { cache: "no-store" });
if (!resp.ok) {
console.warn(`[cards] /cards returned ${resp.status}`);
return null;
}
return (await resp.json()) as RawPersistedCard[];
}
export async function fetchWorkbenchFromBackend(
chatId: string,
): Promise<RawWorkbenchItem[] | null> {
const params = new URLSearchParams({ chat_id: chatId });
const url = BACKEND_URL ? `${BACKEND_URL}/workbench?${params}` : `/workbench?${params}`;
const resp = await fetch(url, { cache: "no-store" });
if (!resp.ok) {
console.warn(`[workbench] /workbench returned ${resp.status}`);
return null;
}
const payload = (await resp.json()) as { items?: RawWorkbenchItem[] };
return Array.isArray(payload.items) ? payload.items : null;
}
export async function fetchSessionsFromBackend(): Promise<SessionSummary[] | null> {
const url = BACKEND_URL ? `${BACKEND_URL}/sessions` : "/sessions";
const resp = await fetch(url, { cache: "no-store" });
if (!resp.ok) {
console.warn(`[sessions] /sessions returned ${resp.status}`);
return null;
}
const payload = (await resp.json()) as { sessions?: SessionSummary[] };
return Array.isArray(payload.sessions) ? payload.sessions : null;
}
export async function fetchSessionDetailFromBackend(
chatId: string,
): Promise<SessionDetailResponse | null> {
const url = BACKEND_URL ? `${BACKEND_URL}/sessions/${chatId}` : `/sessions/${chatId}`;
const resp = await fetch(url, { cache: "no-store" });
if (!resp.ok) {
console.warn(`[sessions] /sessions/${chatId} returned ${resp.status}`);
return null;
}
return (await resp.json()) as SessionDetailResponse;
}
export async function createSessionInBackend(title = ""): Promise<SessionSummary | null> {
const url = BACKEND_URL ? `${BACKEND_URL}/sessions` : "/sessions";
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
if (!resp.ok) {
console.warn(`[sessions] POST /sessions returned ${resp.status}`);
return null;
}
const payload = (await resp.json()) as { session?: SessionSummary };
return payload.session ?? null;
}
export async function renameSessionInBackend(
chatId: string,
title: string,
): Promise<SessionSummary | null> {
const url = BACKEND_URL ? `${BACKEND_URL}/sessions/${chatId}` : `/sessions/${chatId}`;
const resp = await fetch(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
if (!resp.ok) {
console.warn(`[sessions] PATCH /sessions/${chatId} returned ${resp.status}`);
return null;
}
const payload = (await resp.json()) as { session?: SessionSummary };
return payload.session ?? null;
}
export async function deleteSessionInBackend(chatId: string): Promise<boolean> {
const url = BACKEND_URL ? `${BACKEND_URL}/sessions/${chatId}` : `/sessions/${chatId}`;
const resp = await fetch(url, { method: "DELETE" });
if (!resp.ok) {
console.warn(`[sessions] DELETE /sessions/${chatId} returned ${resp.status}`);
return false;
}
return true;
}

View file

@ -0,0 +1,543 @@
import { useCallback, useEffect, useState } from "preact/hooks";
import { getCardLiveContent } from "../../cardRuntime/store";
import type { CardItem, CardState, JsonValue, SessionSummary, WorkbenchItem } from "../../types";
import { BACKEND_URL, fetchPersistedCardsFromBackend, fetchWorkbenchFromBackend } from "./backend";
import type { RawPersistedCard, RawWorkbenchItem } from "./types";
const CARD_LIVE_CONTENT_EVENT = "nanobot:card-live-content-change";
const INBOX_REFRESH_EVENT = "nanobot:inbox-refresh";
let cardIdCounter = 0;
const STATE_RANK: Record<CardState, number> = {
active: 0,
stale: 1,
resolved: 2,
superseded: 3,
archived: 4,
};
function readCardScore(card: Pick<CardItem, "priority" | "serverId">): number {
if (!card.serverId) return card.priority;
const liveContent = getCardLiveContent(card.serverId);
if (!liveContent || typeof liveContent !== "object" || Array.isArray(liveContent)) {
return card.priority;
}
const score = (liveContent as Record<string, unknown>).score;
return typeof score === "number" && Number.isFinite(score) ? score : card.priority;
}
function compareCards(a: CardItem, b: CardItem): number {
const stateDiff = STATE_RANK[a.state] - STATE_RANK[b.state];
if (stateDiff !== 0) return stateDiff;
const scoreDiff = readCardScore(b) - readCardScore(a);
if (scoreDiff !== 0) return scoreDiff;
if (a.priority !== b.priority) return b.priority - a.priority;
const createdDiff = b.createdAt.localeCompare(a.createdAt);
if (createdDiff !== 0) return createdDiff;
return a.id - b.id;
}
function readTaskGroupKey(
card: Pick<CardItem, "serverId" | "slot" | "templateKey" | "templateState">,
): string {
const raw = card.templateState;
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
const taskKey = raw.task_key;
if (typeof taskKey === "string" && taskKey.trim()) return taskKey.trim();
}
return "";
}
function isTaskParentCard(card: Pick<CardItem, "templateKey" | "templateState">): boolean {
return card.templateKey === "todo-item-live" && !!readTaskGroupKey(card);
}
function isLinkedHelperCard(card: Pick<CardItem, "serverId" | "slot" | "templateState">): boolean {
const taskGroupKey = readTaskGroupKey(card);
if (!taskGroupKey) return false;
const raw = card.templateState;
const helperKind =
raw && typeof raw === "object" && !Array.isArray(raw) ? raw.helper_kind : undefined;
return (
(typeof helperKind === "string" && !!helperKind.trim()) ||
(card.serverId ?? "").startsWith("task-helper-") ||
(card.slot ?? "").startsWith("taskhelper:")
);
}
interface TaskLinkedCardIndex {
parentGroups: Set<string>;
helpersByGroup: Map<string, CardItem[]>;
}
function indexTaskLinkedCards(base: CardItem[]): TaskLinkedCardIndex {
const parentGroups = new Set<string>();
const helpersByGroup = new Map<string, CardItem[]>();
for (const card of base) {
const groupKey = readTaskGroupKey(card);
if (!groupKey) continue;
if (isTaskParentCard(card)) {
parentGroups.add(groupKey);
continue;
}
if (!isLinkedHelperCard(card)) continue;
const current = helpersByGroup.get(groupKey);
if (current) current.push(card);
else helpersByGroup.set(groupKey, [card]);
}
return { parentGroups, helpersByGroup };
}
function shouldDeferLinkedHelper(card: CardItem, index: TaskLinkedCardIndex): boolean {
const groupKey = readTaskGroupKey(card);
return !!groupKey && isLinkedHelperCard(card) && index.parentGroups.has(groupKey);
}
function appendGroupedTaskHelpers(
grouped: CardItem[],
emitted: Set<number>,
card: CardItem,
index: TaskLinkedCardIndex,
): void {
const groupKey = readTaskGroupKey(card);
if (!groupKey || !isTaskParentCard(card)) return;
const helpers = index.helpersByGroup.get(groupKey) ?? [];
for (const helper of helpers) {
if (emitted.has(helper.id)) continue;
grouped.push(helper);
emitted.add(helper.id);
}
}
function sortCards(items: CardItem[]): CardItem[] {
const base = [...items].sort(compareCards);
const linkedIndex = indexTaskLinkedCards(base);
const emitted = new Set<number>();
const grouped: CardItem[] = [];
for (const card of base) {
if (emitted.has(card.id)) continue;
if (shouldDeferLinkedHelper(card, linkedIndex)) continue;
grouped.push(card);
emitted.add(card.id);
appendGroupedTaskHelpers(grouped, emitted, card, linkedIndex);
}
return grouped;
}
function toCardItem(msg: Extract<RawPersistedCard, { type?: "card" }>): Omit<CardItem, "id"> {
return {
serverId: msg.id,
kind: msg.kind,
content: msg.content,
title: msg.title,
question: msg.question || undefined,
choices: msg.choices.length > 0 ? msg.choices : undefined,
responseValue: msg.response_value || undefined,
slot: msg.slot || undefined,
lane: msg.lane,
priority: msg.priority,
state: msg.state,
templateKey: msg.template_key || undefined,
templateState:
msg.template_state && typeof msg.template_state === "object" ? msg.template_state : undefined,
contextSummary: msg.context_summary || undefined,
createdAt: msg.created_at || new Date().toISOString(),
updatedAt: msg.updated_at || new Date().toISOString(),
};
}
function mergeCardItem(prev: CardItem[], item: Omit<CardItem, "id">): CardItem[] {
const existingIndex = item.serverId
? prev.findIndex((card) => card.serverId === item.serverId)
: -1;
if (existingIndex >= 0) {
const next = [...prev];
next[existingIndex] = { ...next[existingIndex], ...item };
return sortCards(next);
}
return sortCards([...prev, { ...item, id: cardIdCounter++ }]);
}
function normalizePersistedCard(raw: RawPersistedCard): Omit<CardItem, "id"> {
return toCardItem({
type: "card",
...(raw as Omit<Extract<RawPersistedCard, { type?: "card" }>, "type">),
});
}
function areEquivalentCardItems(
existing: CardItem,
next: Omit<CardItem, "id"> & { id: number },
): boolean {
return (
existing.serverId === next.serverId &&
existing.kind === next.kind &&
existing.content === next.content &&
existing.title === next.title &&
existing.question === next.question &&
existing.responseValue === next.responseValue &&
existing.slot === next.slot &&
existing.lane === next.lane &&
existing.priority === next.priority &&
existing.state === next.state &&
existing.templateKey === next.templateKey &&
existing.contextSummary === next.contextSummary &&
existing.createdAt === next.createdAt &&
areStringArraysEqual(existing.choices, next.choices) &&
areJsonRecordsEqual(existing.templateState, next.templateState)
);
}
function areStringArraysEqual(left: string[] | undefined, right: string[] | undefined): boolean {
if (left === right) return true;
if (!left || !right) return !left && !right;
if (left.length !== right.length) return false;
return left.every((value, index) => value === right[index]);
}
function areJsonRecordsEqual(
left: Record<string, JsonValue> | undefined,
right: Record<string, JsonValue> | undefined,
): boolean {
if (left === right) return true;
if (!left || !right) return !left && !right;
try {
return JSON.stringify(left) === JSON.stringify(right);
} catch {
return false;
}
}
function reconcilePersistedCards(prev: CardItem[], rawCards: RawPersistedCard[]): CardItem[] {
const byServerId = new Map(
prev.filter((card) => card.serverId).map((card) => [card.serverId as string, card]),
);
const next = rawCards.map((raw) => {
const card = normalizePersistedCard(raw);
const existing = card.serverId ? byServerId.get(card.serverId) : undefined;
return {
...card,
id: existing?.id ?? cardIdCounter++,
};
});
const reused = next.map((card) => {
const existing = card.serverId ? byServerId.get(card.serverId) : undefined;
if (!existing) return card;
return areEquivalentCardItems(existing, card) ? existing : card;
});
const sorted = sortCards(reused);
if (sorted.length === prev.length && sorted.every((card, index) => card === prev[index])) {
return prev;
}
return sorted;
}
function toWorkbenchItem(msg: Extract<RawWorkbenchItem, { type?: "workbench" }>): WorkbenchItem {
return {
id: msg.id,
chatId: msg.chat_id,
kind: msg.kind,
title: msg.title,
content: msg.content,
question: msg.question || undefined,
choices: msg.choices.length > 0 ? msg.choices : undefined,
responseValue: msg.response_value || undefined,
slot: msg.slot || undefined,
templateKey: msg.template_key || undefined,
templateState:
msg.template_state && typeof msg.template_state === "object" ? msg.template_state : undefined,
contextSummary: msg.context_summary || undefined,
promotable: msg.promotable !== false,
sourceCardId: msg.source_card_id || undefined,
createdAt: msg.created_at || new Date().toISOString(),
updatedAt: msg.updated_at || new Date().toISOString(),
};
}
function normalizePersistedWorkbench(raw: RawWorkbenchItem): WorkbenchItem {
return toWorkbenchItem({
type: "workbench",
...(raw as Omit<Extract<RawWorkbenchItem, { type?: "workbench" }>, "type">),
});
}
function sortWorkbenchItems(items: WorkbenchItem[]): WorkbenchItem[] {
return [...items].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
function mergeWorkbenchItem(prev: WorkbenchItem[], item: WorkbenchItem): WorkbenchItem[] {
const existingIndex = prev.findIndex((entry) => entry.id === item.id);
if (existingIndex >= 0) {
const next = [...prev];
next[existingIndex] = { ...next[existingIndex], ...item };
return sortWorkbenchItems(next);
}
return sortWorkbenchItems([item, ...prev]);
}
function reconcilePersistedWorkbench(
prev: WorkbenchItem[],
rawItems: RawWorkbenchItem[],
): WorkbenchItem[] {
const byId = new Map(prev.map((item) => [item.id, item]));
const next = rawItems.map((raw) => {
const item = normalizePersistedWorkbench(raw);
const existing = byId.get(item.id);
return existing ? { ...existing, ...item } : item;
});
return sortWorkbenchItems(next);
}
interface SessionSurfaceEventsOptions {
activeSessionId: string;
loadPersistedCards: () => Promise<void>;
loadWorkbench: () => Promise<void>;
refreshSessions: () => Promise<SessionSummary[] | null>;
setSessions: (sessions: SessionSummary[]) => void;
}
function parseUiEventPayload(raw: string): Record<string, unknown> | null {
try {
const payload = JSON.parse(raw) as Record<string, unknown>;
return payload && typeof payload === "object" ? payload : null;
} catch {
return null;
}
}
function handleSessionSurfaceEvent(
payload: Record<string, unknown>,
options: Pick<
SessionSurfaceEventsOptions,
"loadPersistedCards" | "loadWorkbench" | "refreshSessions" | "setSessions"
>,
): void {
if (typeof payload.type !== "string") return;
const { loadPersistedCards, loadWorkbench, refreshSessions, setSessions } = options;
switch (payload.type) {
case "cards.changed":
loadPersistedCards().catch(() => {});
return;
case "workbench.changed":
loadWorkbench().catch(() => {});
return;
case "sessions.changed":
if (Array.isArray(payload.sessions)) {
setSessions(payload.sessions as SessionSummary[]);
return;
}
refreshSessions().catch(() => {});
return;
case "inbox.changed":
window.dispatchEvent(
new CustomEvent(INBOX_REFRESH_EVENT, {
detail: { items: Array.isArray(payload.items) ? payload.items : undefined },
}),
);
return;
default:
return;
}
}
export function useSessionSurfaceEvents({
activeSessionId,
loadPersistedCards,
loadWorkbench,
refreshSessions,
setSessions,
}: SessionSurfaceEventsOptions) {
useEffect(() => {
loadPersistedCards().catch(() => {});
loadWorkbench().catch(() => {});
refreshSessions().catch(() => {});
let fallbackPollId: number | null = null;
let source: EventSource | null = null;
const connectEvents = () => {
const params = new URLSearchParams({ chat_id: activeSessionId });
const url = BACKEND_URL ? `${BACKEND_URL}/events?${params}` : `/events?${params}`;
source = new EventSource(url);
source.onopen = () => {
loadPersistedCards().catch(() => {});
loadWorkbench().catch(() => {});
refreshSessions().catch(() => {});
};
source.onmessage = (event) => {
const payload = parseUiEventPayload(event.data);
if (!payload) return;
handleSessionSurfaceEvent(payload, {
loadPersistedCards,
loadWorkbench,
refreshSessions,
setSessions,
});
};
};
if (typeof EventSource === "function") {
connectEvents();
} else {
fallbackPollId = window.setInterval(() => {
loadPersistedCards().catch(() => {});
loadWorkbench().catch(() => {});
refreshSessions().catch(() => {});
}, 5000);
}
const onVisible = () => {
if (document.visibilityState === "visible") {
loadPersistedCards().catch(() => {});
loadWorkbench().catch(() => {});
refreshSessions().catch(() => {});
}
};
const onCardsRefresh = () => {
loadPersistedCards().catch(() => {});
};
const onWorkbenchRefresh = () => {
loadWorkbench().catch(() => {});
};
window.addEventListener("focus", onVisible);
window.addEventListener("nanobot:cards-refresh", onCardsRefresh);
window.addEventListener("nanobot:workbench-refresh", onWorkbenchRefresh);
document.addEventListener("visibilitychange", onVisible);
return () => {
if (fallbackPollId !== null) window.clearInterval(fallbackPollId);
source?.close();
window.removeEventListener("focus", onVisible);
window.removeEventListener("nanobot:cards-refresh", onCardsRefresh);
window.removeEventListener("nanobot:workbench-refresh", onWorkbenchRefresh);
document.removeEventListener("visibilitychange", onVisible);
};
}, [activeSessionId, loadPersistedCards, loadWorkbench, refreshSessions, setSessions]);
}
export function useCardsState(activeSessionIdRef: { current: string }) {
const [cards, setCards] = useState<CardItem[]>([]);
const upsertCard = useCallback((item: Omit<CardItem, "id">) => {
setCards((prev) => mergeCardItem(prev, item));
}, []);
const dismissCard = useCallback((id: number) => {
setCards((prev) => {
const card = prev.find((entry) => entry.id === id);
if (card?.serverId) {
const url = BACKEND_URL
? `${BACKEND_URL}/cards/${card.serverId}`
: `/cards/${card.serverId}`;
fetch(url, { method: "DELETE" }).catch(() => {});
}
return prev.filter((entry) => entry.id !== id);
});
}, []);
const loadPersistedCards = useCallback(async () => {
try {
const rawCards = await fetchPersistedCardsFromBackend(activeSessionIdRef.current);
if (!rawCards) return;
setCards((prev) => reconcilePersistedCards(prev, rawCards));
} catch (err) {
console.warn("[cards] failed to load persisted cards", err);
}
}, [activeSessionIdRef]);
const clearCards = useCallback(() => {
setCards([]);
}, []);
useEffect(() => {
const onCardLiveContentChange = (event: Event) => {
const customEvent = event as CustomEvent<{ cardId?: unknown }>;
const cardId =
customEvent.detail && typeof customEvent.detail.cardId === "string"
? customEvent.detail.cardId
: "";
setCards((prev) => {
if (prev.length === 0) return prev;
if (cardId && !prev.some((card) => card.serverId === cardId)) return prev;
return sortCards([...prev]);
});
};
window.addEventListener(CARD_LIVE_CONTENT_EVENT, onCardLiveContentChange);
return () => {
window.removeEventListener(CARD_LIVE_CONTENT_EVENT, onCardLiveContentChange);
};
}, []);
return { cards, upsertCard, dismissCard, loadPersistedCards, clearCards };
}
export function useWorkbenchState(activeSessionIdRef: { current: string }) {
const [workbenchItems, setWorkbenchItems] = useState<WorkbenchItem[]>([]);
const upsertWorkbench = useCallback((item: WorkbenchItem) => {
setWorkbenchItems((prev) => mergeWorkbenchItem(prev, item));
}, []);
const dismissWorkbenchItem = useCallback(
async (id: string) => {
const key = id.trim();
if (!key) return;
const chatId = activeSessionIdRef.current;
const params = new URLSearchParams({ chat_id: chatId });
const url = BACKEND_URL
? `${BACKEND_URL}/workbench/${encodeURIComponent(key)}?${params}`
: `/workbench/${encodeURIComponent(key)}?${params}`;
await fetch(url, { method: "DELETE" }).catch(() => {});
setWorkbenchItems((prev) => prev.filter((item) => item.id !== key));
},
[activeSessionIdRef],
);
const promoteWorkbenchItem = useCallback(
async (id: string) => {
const key = id.trim();
if (!key) return;
const chatId = activeSessionIdRef.current;
const url = BACKEND_URL
? `${BACKEND_URL}/workbench/${encodeURIComponent(key)}/promote`
: `/workbench/${encodeURIComponent(key)}/promote`;
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chat_id: chatId }),
});
if (!resp.ok) throw new Error(`promote failed (${resp.status})`);
setWorkbenchItems((prev) => prev.filter((item) => item.id !== key));
window.dispatchEvent(new Event("nanobot:cards-refresh"));
},
[activeSessionIdRef],
);
const loadWorkbench = useCallback(async () => {
try {
const rawItems = await fetchWorkbenchFromBackend(activeSessionIdRef.current);
if (!rawItems) return;
setWorkbenchItems((prev) => reconcilePersistedWorkbench(prev, rawItems));
} catch (err) {
console.warn("[workbench] failed to load workbench items", err);
}
}, [activeSessionIdRef]);
const clearWorkbench = useCallback(() => {
setWorkbenchItems([]);
}, []);
return {
workbenchItems,
upsertWorkbench,
dismissWorkbenchItem,
promoteWorkbenchItem,
loadWorkbench,
clearWorkbench,
};
}

View file

@ -0,0 +1,399 @@
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import type {
AgentState,
CardItem,
LogLine,
ServerMessage,
SessionHistoryLine,
WorkbenchItem,
} from "../../types";
import { useCardsState, useWorkbenchState } from "./cards";
import type { AppendLine, IdleFallbackControls, SetAgentState, UpsertCard } from "./types";
let logIdCounter = 0;
function hydrateLogLines(entries: SessionHistoryLine[]): LogLine[] {
return entries.map((entry) => ({
id: logIdCounter++,
role: entry.role,
text: entry.text,
timestamp: entry.timestamp || new Date().toISOString(),
contextLabel: undefined,
}));
}
function appendLogLineEntry(
prev: LogLine[],
role: string,
text: string,
timestamp: string,
contextLabel?: string,
): LogLine[] {
const next = [
...prev,
{
id: logIdCounter++,
role,
text,
timestamp: timestamp || new Date().toISOString(),
contextLabel: contextLabel?.trim() || undefined,
},
];
return next.length > 250 ? next.slice(next.length - 250) : next;
}
function appendStreamingAssistantEntry(
prev: LogLine[],
text: string,
timestamp: string,
): LogLine[] {
const nextTimestamp = timestamp || new Date().toISOString();
const last = prev[prev.length - 1];
if (last?.role === "nanobot-progress") {
const next = [...prev];
next[next.length - 1] = {
...last,
text: `${last.text}${text}`,
timestamp: nextTimestamp,
};
return next;
}
return appendLogLineEntry(prev, "nanobot-progress", text, nextTimestamp);
}
function finalizeStreamingAssistantEntry(
prev: LogLine[],
text: string,
timestamp: string,
): LogLine[] {
const nextTimestamp = timestamp || new Date().toISOString();
const last = prev[prev.length - 1];
if (last?.role === "nanobot-progress") {
const next = [...prev];
next[next.length - 1] = {
...last,
role: "nanobot",
text,
timestamp: nextTimestamp,
};
return next;
}
return appendLogLineEntry(prev, "nanobot", text, nextTimestamp);
}
interface MessageHandlers {
setAgentState: SetAgentState;
appendLine: AppendLine;
appendAssistantDelta: AppendLine;
finalizeAssistantLine: AppendLine;
upsertCard: UpsertCard;
upsertWorkbench: (item: WorkbenchItem) => void;
idleFallback: IdleFallbackControls;
}
function handleAgentStateMessage(
msg: Extract<ServerMessage, { type: "agent_state" }>,
handlers: Pick<MessageHandlers, "idleFallback" | "setAgentState">,
): void {
handlers.idleFallback.clear();
handlers.setAgentState(() => msg.state);
}
function handleTextMessage(
msg: Extract<ServerMessage, { type: "message" }>,
handlers: Pick<
MessageHandlers,
"appendAssistantDelta" | "appendLine" | "finalizeAssistantLine" | "idleFallback"
>,
): void {
if (msg.is_tool_hint) {
handlers.appendLine("tool", msg.content, msg.timestamp);
return;
}
if (msg.is_progress) {
handlers.appendAssistantDelta("nanobot-progress", msg.content, msg.timestamp);
return;
}
handlers.finalizeAssistantLine(msg.role, msg.content, msg.timestamp, msg.context_label);
handlers.idleFallback.schedule();
}
function toIncomingCard(msg: Extract<ServerMessage, { type: "card" }>): Omit<CardItem, "id"> {
return {
serverId: msg.id,
kind: msg.kind,
content: msg.content,
title: msg.title,
question: msg.question || undefined,
choices: msg.choices.length > 0 ? msg.choices : undefined,
responseValue: msg.response_value || undefined,
slot: msg.slot || undefined,
lane: msg.lane,
priority: msg.priority,
state: msg.state,
templateKey: msg.template_key || undefined,
templateState:
msg.template_state && typeof msg.template_state === "object" ? msg.template_state : undefined,
contextSummary: msg.context_summary || undefined,
createdAt: msg.created_at || new Date().toISOString(),
updatedAt: msg.updated_at || new Date().toISOString(),
};
}
function toIncomingWorkbench(msg: Extract<ServerMessage, { type: "workbench" }>): WorkbenchItem {
return {
id: msg.id,
chatId: msg.chat_id,
kind: msg.kind,
title: msg.title,
content: msg.content,
question: msg.question || undefined,
choices: msg.choices.length > 0 ? msg.choices : undefined,
responseValue: msg.response_value || undefined,
slot: msg.slot || undefined,
templateKey: msg.template_key || undefined,
templateState:
msg.template_state && typeof msg.template_state === "object" ? msg.template_state : undefined,
contextSummary: msg.context_summary || undefined,
promotable: msg.promotable !== false,
sourceCardId: msg.source_card_id || undefined,
createdAt: msg.created_at || new Date().toISOString(),
updatedAt: msg.updated_at || new Date().toISOString(),
};
}
function handleTypedMessage(
msg: Extract<ServerMessage, { type: string }>,
handlers: MessageHandlers,
): void {
switch (msg.type) {
case "agent_state":
handleAgentStateMessage(msg, handlers);
return;
case "message":
handleTextMessage(msg, handlers);
return;
case "card":
handlers.upsertCard(toIncomingCard(msg));
handlers.idleFallback.schedule();
return;
case "workbench":
handlers.upsertWorkbench(toIncomingWorkbench(msg));
handlers.idleFallback.schedule();
return;
case "error":
handlers.appendLine("system", msg.error, "");
handlers.idleFallback.schedule();
return;
default:
return;
}
}
function parseServerMessage(raw: string): Extract<ServerMessage, { type: string }> | null {
let msg: ServerMessage;
try {
msg = JSON.parse(raw);
} catch {
return null;
}
if (typeof msg !== "object" || msg === null || !("type" in msg)) return null;
return msg as Extract<ServerMessage, { type: string }>;
}
function useIdleFallback(setAgentState: SetAgentState): IdleFallbackControls {
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clear = useCallback(() => {
if (!idleTimerRef.current) return;
clearTimeout(idleTimerRef.current);
idleTimerRef.current = null;
}, []);
const schedule = useCallback(
(delayMs = 450) => {
clear();
idleTimerRef.current = setTimeout(() => {
idleTimerRef.current = null;
setAgentState((prev) => {
if (prev === "listening" || prev === "speaking") return prev;
return "idle";
});
}, delayMs);
},
[clear, setAgentState],
);
useEffect(() => clear, [clear]);
return { clear, schedule };
}
function useLogState() {
const [logLines, setLogLines] = useState<LogLine[]>([]);
const appendLine = useCallback(
(role: string, text: string, timestamp: string, contextLabel?: string) => {
setLogLines((prev) => appendLogLineEntry(prev, role, text, timestamp, contextLabel));
},
[],
);
const appendAssistantDelta = useCallback((_: string, text: string, timestamp: string) => {
setLogLines((prev) => appendStreamingAssistantEntry(prev, text, timestamp));
}, []);
const finalizeAssistantLine = useCallback(
(role: string, text: string, timestamp: string, contextLabel?: string) => {
if (role !== "nanobot") {
setLogLines((prev) => appendLogLineEntry(prev, role, text, timestamp, contextLabel));
return;
}
setLogLines((prev) => finalizeStreamingAssistantEntry(prev, text, timestamp));
},
[],
);
const replaceLines = useCallback((entries: SessionHistoryLine[]) => {
setLogLines(hydrateLogLines(entries));
}, []);
const clearLines = useCallback(() => {
setLogLines([]);
}, []);
return {
logLines,
appendLine,
appendAssistantDelta,
finalizeAssistantLine,
replaceLines,
clearLines,
};
}
function useIncomingMessages({
setAgentState,
appendLine,
appendAssistantDelta,
finalizeAssistantLine,
upsertCard,
upsertWorkbench,
idleFallback,
}: {
setAgentState: SetAgentState;
appendLine: AppendLine;
appendAssistantDelta: AppendLine;
finalizeAssistantLine: AppendLine;
upsertCard: UpsertCard;
upsertWorkbench: (item: WorkbenchItem) => void;
idleFallback: IdleFallbackControls;
}) {
return useCallback(
(raw: string) => {
const msg = parseServerMessage(raw);
if (!msg) return;
handleTypedMessage(msg, {
setAgentState,
appendLine,
appendAssistantDelta,
finalizeAssistantLine,
upsertCard,
upsertWorkbench,
idleFallback,
});
},
[
appendAssistantDelta,
appendLine,
finalizeAssistantLine,
idleFallback,
setAgentState,
upsertCard,
upsertWorkbench,
],
);
}
export function useMessageState(activeSessionIdRef: { current: string }) {
const [agentState, setAgentState] = useState<AgentState>("idle");
const {
logLines,
appendLine,
appendAssistantDelta,
finalizeAssistantLine,
replaceLines,
clearLines,
} = useLogState();
const { cards, upsertCard, dismissCard, loadPersistedCards, clearCards } =
useCardsState(activeSessionIdRef);
const {
workbenchItems,
upsertWorkbench,
dismissWorkbenchItem,
promoteWorkbenchItem,
loadWorkbench,
clearWorkbench,
} = useWorkbenchState(activeSessionIdRef);
const idleFallback = useIdleFallback(setAgentState);
const suppressRtcTextRef = useRef(0);
const onIncomingMessage = useIncomingMessages({
setAgentState,
appendLine,
appendAssistantDelta,
finalizeAssistantLine,
upsertCard,
upsertWorkbench,
idleFallback,
});
const onDcMessage = useCallback(
(raw: string) => {
const msg = parseServerMessage(raw);
if (!msg) return;
if (
suppressRtcTextRef.current > 0 &&
(msg.type === "message" || msg.type === "agent_state" || msg.type === "card")
) {
return;
}
handleTypedMessage(msg, {
setAgentState,
appendLine,
appendAssistantDelta,
finalizeAssistantLine,
upsertCard,
upsertWorkbench,
idleFallback,
});
},
[
appendAssistantDelta,
appendLine,
finalizeAssistantLine,
idleFallback,
setAgentState,
upsertCard,
upsertWorkbench,
],
);
return {
agentState,
setAgentState,
logLines,
cards,
workbenchItems,
appendLine,
replaceLines,
clearLines,
clearCards,
clearWorkbench,
dismissCard,
dismissWorkbenchItem,
promoteWorkbenchItem,
loadPersistedCards,
loadWorkbench,
onDcMessage,
onIncomingMessage,
suppressRtcTextRef,
};
}

View file

@ -0,0 +1,319 @@
import { useCallback, useEffect, useRef } from "preact/hooks";
import type { ClientMessage } from "../../types";
import { BACKEND_URL } from "./backend";
import type { AppendLine, RTCCallbacks, RTCRefs } from "./types";
const WEBRTC_STUN_URL = import.meta.env.VITE_WEBRTC_STUN_URL?.trim() ?? "";
const LOCAL_ICE_GATHER_TIMEOUT_MS = 350;
function stopMediaTracks(tracks: Iterable<MediaStreamTrack | null | undefined>): void {
const seen = new Set<MediaStreamTrack>();
for (const track of tracks) {
if (!track || seen.has(track)) continue;
seen.add(track);
track.stop();
}
}
async function acquireMicStream(): Promise<MediaStream> {
try {
return await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
sampleRate: 48000,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: false,
},
video: false,
});
} catch {
return navigator.mediaDevices.getUserMedia({ audio: true, video: false });
}
}
function waitForIceComplete(pc: RTCPeerConnection): Promise<void> {
return new Promise<void>((resolve) => {
if (pc.iceGatheringState === "complete") {
resolve();
return;
}
const check = () => {
if (pc.iceGatheringState === "complete") {
pc.removeEventListener("icegatheringstatechange", check);
resolve();
}
};
pc.addEventListener("icegatheringstatechange", check);
setTimeout(() => {
pc.removeEventListener("icegatheringstatechange", check);
resolve();
}, LOCAL_ICE_GATHER_TIMEOUT_MS);
});
}
async function exchangeSdp(
localDesc: RTCSessionDescription,
): Promise<{ sdp: string; rtcType: string }> {
const rtcUrl = BACKEND_URL ? `${BACKEND_URL}/rtc/offer` : "/rtc/offer";
const resp = await fetch(rtcUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sdp: localDesc.sdp, rtcType: localDesc.type }),
});
if (!resp.ok) throw new Error(`/rtc/offer returned ${resp.status}`);
return resp.json() as Promise<{ sdp: string; rtcType: string }>;
}
async function runConnect(
refs: RTCRefs,
cbs: RTCCallbacks,
opts: { textOnly: boolean },
): Promise<void> {
if (refs.pcRef.current) return;
if (!window.RTCPeerConnection) {
cbs.showStatus("WebRTC unavailable in this browser.", 4000);
return;
}
cbs.setConnecting(true);
cbs.showStatus("Connecting...");
let micStream: MediaStream | null = null;
try {
refs.localTracksRef.current = [];
if (!opts.textOnly) {
micStream = await acquireMicStream();
const audioTracks = micStream.getAudioTracks();
audioTracks.forEach((track) => {
track.enabled = false;
});
refs.localTracksRef.current = audioTracks;
}
const pc = new RTCPeerConnection(
WEBRTC_STUN_URL ? { iceServers: [{ urls: WEBRTC_STUN_URL }] } : undefined,
);
refs.pcRef.current = pc;
const newRemoteStream = new MediaStream();
cbs.setRemoteStream(newRemoteStream);
if (refs.remoteAudioRef.current) {
refs.remoteAudioRef.current.srcObject = newRemoteStream;
refs.remoteAudioRef.current.play().catch(() => {});
}
pc.ontrack = (event) => {
if (event.track.kind !== "audio") return;
newRemoteStream.addTrack(event.track);
refs.remoteAudioRef.current?.play().catch(() => {});
};
const dc = pc.createDataChannel("app", { ordered: true });
refs.dcRef.current = dc;
dc.onopen = () => {
cbs.setConnected(true);
cbs.setConnecting(false);
cbs.showStatus(opts.textOnly ? "Text-only mode enabled" : "Hold anywhere to talk", 2500);
cbs.appendLine("system", "Connected.", new Date().toISOString());
cbs.onDcOpen();
};
dc.onclose = () => {
cbs.appendLine("system", "Disconnected.", new Date().toISOString());
cbs.closePC();
};
dc.onmessage = (e) => cbs.onDcMessage(e.data as string);
refs.micSendersRef.current = [];
if (micStream) {
micStream.getAudioTracks().forEach((track) => {
pc.addTrack(track, micStream as MediaStream);
});
refs.micSendersRef.current = pc.getSenders().filter((s) => s.track?.kind === "audio");
}
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await waitForIceComplete(pc);
const localDesc = pc.localDescription;
if (!localDesc) throw new Error("No local description after ICE gathering");
const answer = await exchangeSdp(localDesc);
await pc.setRemoteDescription({ type: answer.rtcType as RTCSdpType, sdp: answer.sdp });
} catch (err) {
cbs.appendLine("system", `Connection failed: ${err}`, new Date().toISOString());
cbs.showStatus("Connection failed.", 3000);
cbs.closePC();
}
}
export function useRtcRefs(): RTCRefs {
return {
pcRef: useRef<RTCPeerConnection | null>(null),
dcRef: useRef<RTCDataChannel | null>(null),
remoteAudioRef: useRef<HTMLAudioElement | null>(null),
micSendersRef: useRef<RTCRtpSender[]>([]),
localTracksRef: useRef<MediaStreamTrack[]>([]),
};
}
export function useRemoteAudioBindings({
textOnly,
connected,
showStatus,
remoteAudioRef,
micSendersRef,
dcRef,
textOnlyRef,
}: {
textOnly: boolean;
connected: boolean;
showStatus: (text: string, persistMs?: number) => void;
remoteAudioRef: { current: HTMLAudioElement | null };
micSendersRef: { current: RTCRtpSender[] };
dcRef: { current: RTCDataChannel | null };
textOnlyRef: { current: boolean };
}) {
useEffect(() => {
textOnlyRef.current = textOnly;
}, [textOnly, textOnlyRef]);
useEffect(() => {
const audio = new Audio();
audio.autoplay = true;
(audio as HTMLAudioElement & { playsInline: boolean }).playsInline = true;
audio.muted = textOnlyRef.current;
remoteAudioRef.current = audio;
return () => {
audio.srcObject = null;
};
}, [remoteAudioRef, textOnlyRef]);
useEffect(() => {
const handler = (e: Event) => {
const enabled = (e as CustomEvent<{ enabled: boolean }>).detail?.enabled ?? false;
micSendersRef.current.forEach((sender) => {
if (sender.track) sender.track.enabled = enabled && !textOnlyRef.current;
});
};
window.addEventListener("nanobot-mic-enable", handler);
return () => window.removeEventListener("nanobot-mic-enable", handler);
}, [micSendersRef, textOnlyRef]);
useEffect(() => {
if (remoteAudioRef.current) {
remoteAudioRef.current.muted = textOnly;
if (textOnly) remoteAudioRef.current.pause();
else remoteAudioRef.current.play().catch(() => {});
}
micSendersRef.current.forEach((sender) => {
if (sender.track) sender.track.enabled = false;
});
if (textOnly) {
const dc = dcRef.current;
if (dc?.readyState === "open") {
dc.send(JSON.stringify({ type: "voice-ptt", pressed: false } satisfies ClientMessage));
}
}
if (connected) showStatus(textOnly ? "Text-only mode enabled" : "Hold anywhere to talk", 2000);
}, [connected, dcRef, micSendersRef, remoteAudioRef, showStatus, textOnly]);
}
export function usePeerConnectionControls({
textOnly,
connected,
appendLine,
onDcMessage,
loadPersistedCards,
loadWorkbench,
showStatus,
refs,
setConnected,
setConnecting,
setRemoteStream,
textOnlyRef,
}: {
textOnly: boolean;
connected: boolean;
appendLine: AppendLine;
onDcMessage: (raw: string) => void;
loadPersistedCards: () => Promise<void>;
loadWorkbench: () => Promise<void>;
showStatus: (text: string, persistMs?: number) => void;
refs: RTCRefs;
setConnected: (value: boolean) => void;
setConnecting: (value: boolean) => void;
setRemoteStream: (stream: MediaStream | null) => void;
textOnlyRef: { current: boolean };
}) {
const closePC = useCallback(() => {
const dc = refs.dcRef.current;
const pc = refs.pcRef.current;
const localTracks = refs.localTracksRef.current;
const senderTracks = refs.micSendersRef.current.map((sender) => sender.track);
refs.dcRef.current = null;
refs.pcRef.current = null;
refs.micSendersRef.current = [];
refs.localTracksRef.current = [];
stopMediaTracks([...senderTracks, ...localTracks]);
dc?.close();
pc?.close();
setConnected(false);
setConnecting(false);
if (refs.remoteAudioRef.current) refs.remoteAudioRef.current.srcObject = null;
setRemoteStream(null);
}, [refs, setConnected, setConnecting, setRemoteStream]);
const closePCRef = useRef(closePC);
useEffect(() => {
closePCRef.current = closePC;
}, [closePC]);
const connect = useCallback(async () => {
await runConnect(
refs,
{
setConnected,
setConnecting,
setRemoteStream,
showStatus,
appendLine,
onDcMessage,
onDcOpen: () => {
loadPersistedCards().catch(() => {});
loadWorkbench().catch(() => {});
},
closePC,
},
{ textOnly: textOnlyRef.current },
);
}, [
appendLine,
closePC,
loadPersistedCards,
loadWorkbench,
onDcMessage,
refs,
setConnected,
setConnecting,
setRemoteStream,
showStatus,
textOnlyRef,
]);
useEffect(() => {
if (textOnly || !connected || refs.micSendersRef.current.length > 0) return;
closePC();
connect().catch(() => {});
}, [closePC, connect, connected, refs.micSendersRef, textOnly]);
useEffect(() => {
return () => {
closePCRef.current();
};
}, []);
return { closePC, connect };
}

View file

@ -0,0 +1,87 @@
import type {
AgentState,
CardItem,
CardMessageMetadata,
ClientMessage,
LogLine,
ServerMessage,
SessionHistoryLine,
SessionSummary,
WorkbenchItem,
} from "../../types";
export interface WebRTCState {
connected: boolean;
connecting: boolean;
textOnly: boolean;
agentState: AgentState;
logLines: LogLine[];
cards: CardItem[];
workbenchItems: WorkbenchItem[];
sessions: SessionSummary[];
activeSessionId: string;
activeSession: SessionSummary | null;
sessionLoading: boolean;
textStreaming: boolean;
voiceStatus: string;
statusVisible: boolean;
remoteAudioEl: HTMLAudioElement | null;
remoteStream: MediaStream | null;
sendJson(msg: ClientMessage): void;
sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise<void>;
dismissCard(id: number): void;
dismissWorkbenchItem(id: string): Promise<void>;
promoteWorkbenchItem(id: string): Promise<void>;
setTextOnly(enabled: boolean): void;
switchSession(chatId: string): Promise<void>;
createSession(title?: string): Promise<void>;
renameSession(chatId: string, title: string): Promise<void>;
deleteSession(chatId: string): Promise<void>;
connect(): Promise<void>;
}
export type AppendLine = (
role: string,
text: string,
timestamp: string,
contextLabel?: string,
) => void;
export type UpsertCard = (item: Omit<CardItem, "id">) => void;
export type SetAgentState = (updater: (prev: AgentState) => AgentState) => void;
export type RawPersistedCard =
| Extract<ServerMessage, { type: "card" }>
| (Omit<Extract<ServerMessage, { type: "card" }>, "type"> & { type?: "card" });
export type RawWorkbenchItem =
| Extract<ServerMessage, { type: "workbench" }>
| (Omit<Extract<ServerMessage, { type: "workbench" }>, "type"> & {
type?: "workbench";
});
export interface IdleFallbackControls {
clear(): void;
schedule(delayMs?: number): void;
}
export interface RTCRefs {
pcRef: { current: RTCPeerConnection | null };
dcRef: { current: RTCDataChannel | null };
remoteAudioRef: { current: HTMLAudioElement | null };
micSendersRef: { current: RTCRtpSender[] };
localTracksRef: { current: MediaStreamTrack[] };
}
export interface RTCCallbacks {
setConnected: (v: boolean) => void;
setConnecting: (v: boolean) => void;
setRemoteStream: (s: MediaStream | null) => void;
showStatus: (text: string, persistMs?: number) => void;
appendLine: AppendLine;
onDcMessage: (raw: string) => void;
onDcOpen: () => void;
closePC: () => void;
}
export interface SessionDetailResponse {
session: SessionSummary;
messages: SessionHistoryLine[];
}

File diff suppressed because it is too large Load diff

49
frontend/src/lib/sse.ts Normal file
View file

@ -0,0 +1,49 @@
export async function streamSseResponse(
response: Response,
onMessage: (raw: string) => void,
): Promise<void> {
const reader = response.body?.getReader();
if (!reader) return;
const decoder = new TextDecoder();
let buffer = "";
let eventData = "";
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value ?? new Uint8Array(), { stream: !done });
({ buffer, eventData } = consumeSseBuffer(buffer, eventData, onMessage));
if (done) break;
}
const tail = `${eventData}\n${buffer}`.trim();
if (tail) onMessage(tail);
}
function consumeSseBuffer(
rawBuffer: string,
rawEventData: string,
onMessage: (raw: string) => void,
): { buffer: string; eventData: string } {
let buffer = rawBuffer;
let eventData = rawEventData;
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex >= 0) {
const line = buffer.slice(0, newlineIndex).replace(/\r$/, "");
buffer = buffer.slice(newlineIndex + 1);
eventData = consumeSseLine(line, eventData, onMessage);
newlineIndex = buffer.indexOf("\n");
}
return { buffer, eventData };
}
function consumeSseLine(line: string, eventData: string, onMessage: (raw: string) => void): string {
if (!line) {
const payload = eventData.trim();
if (payload) onMessage(payload);
return "";
}
if (!line.startsWith("data:")) return eventData;
const chunk = line.slice(5).trimStart();
return eventData ? `${eventData}\n${chunk}` : chunk;
}

View file

@ -1,6 +1,8 @@
import { render } from "preact";
import { App } from "./App";
import "./index.css";
import { applyThemeToDocument, getStoredThemeName } from "./theme/themes";
const root = document.getElementById("app");
applyThemeToDocument(getStoredThemeName());
if (root) render(<App />, root);

View file

@ -0,0 +1,252 @@
export type ThemeName = "clay" | "sage" | "mist";
export interface ThemeOption {
name: ThemeName;
label: string;
swatch: string;
tokens: Record<string, string>;
}
export const THEME_STORAGE_KEY = "nanobot.theme";
export const DEFAULT_THEME: ThemeName = "clay";
const cardFonts = {
"--card-font": '"Iosevka", "SF Mono", ui-monospace, Menlo, Consolas, monospace',
"--theme-font-title": '"IBM Plex Sans Condensed", "SF Pro Display", "Arial Narrow", sans-serif',
"--theme-font-mono": '"M-1m Code", "SF Mono", ui-monospace, Menlo, Consolas, monospace',
};
export const THEME_OPTIONS: ThemeOption[] = [
{
name: "clay",
label: "Clay",
swatch: "linear-gradient(135deg, #b56c3d 0%, #d9b1b8 100%)",
tokens: {
...cardFonts,
"--theme-root-bg": "radial-gradient(circle at center, #f7d9bf 0%, #f2caa8 100%)",
"--theme-agent-bg": "#ffffff",
"--theme-agent-text-bg":
"radial-gradient(circle at top, rgba(245, 228, 210, 0.54), transparent 34%), linear-gradient(180deg, #fcf7f1 0%, #f6efe7 100%)",
"--theme-feed-bg": "#e7ddd0",
"--theme-panel-bg": "rgba(247, 239, 230, 0.9)",
"--theme-panel-bg-strong": "rgba(250, 244, 237, 0.98)",
"--theme-panel-bg-soft": "rgba(255, 255, 255, 0.94)",
"--theme-border": "rgba(141, 104, 75, 0.18)",
"--theme-border-strong": "rgba(120, 84, 52, 0.22)",
"--theme-text": "#2f2118",
"--theme-text-soft": "#5f4634",
"--theme-text-muted": "#8b654b",
"--theme-accent": "#b56c3d",
"--theme-accent-strong": "#99532a",
"--theme-accent-soft": "rgba(255, 200, 140, 0.2)",
"--theme-accent-contrast": "#ffffff",
"--theme-overlay": "rgba(0, 0, 0, 0.42)",
"--theme-shadow": "0 8px 18px rgba(48, 28, 18, 0.1)",
"--theme-shadow-strong": "0 18px 36px rgba(48, 28, 18, 0.18)",
"--theme-status-muted": "#6b7280",
"--theme-status-live": "#047857",
"--theme-status-warning": "#b45309",
"--theme-status-danger": "#b91c1c",
"--theme-card-neutral-bg": "linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)",
"--theme-card-neutral-border": "#dbe4ee",
"--theme-card-neutral-text": "#111827",
"--theme-card-neutral-muted": "#64748b",
"--theme-card-neutral-subtle": "#475569",
"--theme-card-warm-bg": "linear-gradient(180deg, #fffdfb 0%, #f7f1ea 100%)",
"--theme-card-warm-border": "rgba(168, 120, 86, 0.18)",
"--theme-card-warm-text": "#23150d",
"--theme-card-warm-muted": "#78543b",
"--theme-card-success-bg":
"linear-gradient(180deg, rgba(234, 244, 229, 0.98), rgba(218, 232, 212, 0.98))",
"--theme-card-success-border": "rgba(96, 126, 90, 0.18)",
"--theme-card-success-text": "#2f442a",
"--theme-card-success-muted": "rgba(52, 82, 45, 0.76)",
"--theme-task-bg":
"radial-gradient(circle at top right, rgba(255, 255, 255, 0.72), transparent 32%), linear-gradient(145deg, rgba(253, 245, 235, 0.98), rgba(242, 227, 211, 0.97))",
"--theme-task-border": "rgba(87, 65, 50, 0.14)",
"--theme-task-text": "#2f241e",
"--theme-task-muted": "#7e6659",
"--theme-task-shadow":
"inset 0 1px 0 rgba(255, 255, 255, 0.68), 0 18px 36px rgba(79, 56, 43, 0.12)",
"--theme-task-pattern": "rgba(122, 97, 78, 0.035)",
"--theme-list-bg":
"radial-gradient(circle at top right, rgba(255, 252, 233, 0.68), transparent 34%), linear-gradient(145deg, rgba(244, 226, 187, 0.98), rgba(226, 198, 145, 0.97))",
"--theme-list-text": "#4d392d",
"--theme-list-muted": "rgba(77, 57, 45, 0.72)",
"--theme-list-border": "rgba(92, 70, 55, 0.18)",
"--theme-list-shadow": "inset 0 1px 0 rgba(255, 250, 224, 0.62)",
"--card-surface": "linear-gradient(180deg, #b56c3d 0%, #8f4f27 100%)",
"--card-border": "rgba(255, 220, 188, 0.24)",
"--card-shadow": "0 10px 28px rgba(68, 34, 15, 0.22)",
"--card-text": "rgba(255, 245, 235, 0.9)",
"--card-muted": "rgba(255, 233, 214, 0.72)",
"--helper-card-surface": "linear-gradient(180deg, #d9b1b8 0%, #c78d9a 100%)",
"--helper-card-border": "rgba(111, 46, 63, 0.18)",
"--helper-card-shadow": "0 10px 28px rgba(92, 42, 57, 0.16)",
"--helper-card-text": "rgba(63, 23, 35, 0.94)",
"--helper-card-muted": "rgba(87, 43, 56, 0.72)",
},
},
{
name: "sage",
label: "Sage",
swatch: "linear-gradient(135deg, #6d8a5d 0%, #d7cfb4 100%)",
tokens: {
...cardFonts,
"--theme-root-bg": "radial-gradient(circle at center, #dfe8d3 0%, #c8d5bf 100%)",
"--theme-agent-bg": "#fbfcf8",
"--theme-agent-text-bg":
"radial-gradient(circle at top, rgba(232, 242, 223, 0.54), transparent 34%), linear-gradient(180deg, #fbfcf8 0%, #f1f5eb 100%)",
"--theme-feed-bg": "#dfe6d7",
"--theme-panel-bg": "rgba(241, 246, 236, 0.92)",
"--theme-panel-bg-strong": "rgba(247, 250, 243, 0.98)",
"--theme-panel-bg-soft": "rgba(255, 255, 255, 0.95)",
"--theme-border": "rgba(107, 129, 95, 0.18)",
"--theme-border-strong": "rgba(86, 110, 74, 0.24)",
"--theme-text": "#263126",
"--theme-text-soft": "#415141",
"--theme-text-muted": "#677767",
"--theme-accent": "#6d8a5d",
"--theme-accent-strong": "#547246",
"--theme-accent-soft": "rgba(137, 170, 117, 0.18)",
"--theme-accent-contrast": "#ffffff",
"--theme-overlay": "rgba(24, 34, 24, 0.34)",
"--theme-shadow": "0 8px 18px rgba(40, 60, 36, 0.12)",
"--theme-shadow-strong": "0 18px 36px rgba(40, 60, 36, 0.16)",
"--theme-status-muted": "#5f6b5f",
"--theme-status-live": "#2f6b47",
"--theme-status-warning": "#9a641d",
"--theme-status-danger": "#a13f35",
"--theme-card-neutral-bg": "linear-gradient(180deg, #fcfdf9 0%, #eff4e8 100%)",
"--theme-card-neutral-border": "rgba(122, 145, 109, 0.22)",
"--theme-card-neutral-text": "#223022",
"--theme-card-neutral-muted": "#607260",
"--theme-card-neutral-subtle": "#4b5f4b",
"--theme-card-warm-bg": "linear-gradient(180deg, #f8f7ef 0%, #ece7d8 100%)",
"--theme-card-warm-border": "rgba(150, 134, 98, 0.22)",
"--theme-card-warm-text": "#353024",
"--theme-card-warm-muted": "#776950",
"--theme-card-success-bg":
"linear-gradient(180deg, rgba(229, 241, 220, 0.98), rgba(211, 229, 201, 0.98))",
"--theme-card-success-border": "rgba(98, 129, 86, 0.2)",
"--theme-card-success-text": "#29412a",
"--theme-card-success-muted": "rgba(56, 82, 49, 0.76)",
"--theme-task-bg":
"radial-gradient(circle at top right, rgba(255, 255, 255, 0.68), transparent 32%), linear-gradient(145deg, rgba(248, 250, 240, 0.98), rgba(231, 236, 219, 0.97))",
"--theme-task-border": "rgba(95, 118, 89, 0.14)",
"--theme-task-text": "#243125",
"--theme-task-muted": "#647464",
"--theme-task-shadow":
"inset 0 1px 0 rgba(255, 255, 255, 0.72), 0 18px 36px rgba(54, 72, 48, 0.12)",
"--theme-task-pattern": "rgba(98, 118, 90, 0.03)",
"--theme-list-bg":
"radial-gradient(circle at top right, rgba(255, 249, 227, 0.68), transparent 34%), linear-gradient(145deg, rgba(238, 232, 190, 0.98), rgba(221, 213, 160, 0.97))",
"--theme-list-text": "#4f4531",
"--theme-list-muted": "rgba(79, 69, 49, 0.7)",
"--theme-list-border": "rgba(105, 93, 67, 0.18)",
"--theme-list-shadow": "inset 0 1px 0 rgba(255, 251, 232, 0.62)",
"--card-surface": "linear-gradient(180deg, #7a8f63 0%, #5d714b 100%)",
"--card-border": "rgba(231, 243, 219, 0.24)",
"--card-shadow": "0 10px 28px rgba(44, 58, 33, 0.22)",
"--card-text": "rgba(247, 250, 241, 0.92)",
"--card-muted": "rgba(226, 236, 210, 0.76)",
"--helper-card-surface": "linear-gradient(180deg, #d5c3cc 0%, #b898a8 100%)",
"--helper-card-border": "rgba(100, 72, 84, 0.18)",
"--helper-card-shadow": "0 10px 28px rgba(76, 54, 64, 0.16)",
"--helper-card-text": "rgba(49, 36, 41, 0.94)",
"--helper-card-muted": "rgba(83, 63, 71, 0.72)",
},
},
{
name: "mist",
label: "Mist",
swatch: "linear-gradient(135deg, #5f7884 0%, #c7d2dd 100%)",
tokens: {
...cardFonts,
"--theme-root-bg": "radial-gradient(circle at center, #d8e0e7 0%, #c1ccd8 100%)",
"--theme-agent-bg": "#f8fafc",
"--theme-agent-text-bg":
"radial-gradient(circle at top, rgba(225, 233, 241, 0.56), transparent 34%), linear-gradient(180deg, #fbfcfe 0%, #eef3f7 100%)",
"--theme-feed-bg": "#dce3ea",
"--theme-panel-bg": "rgba(239, 244, 248, 0.92)",
"--theme-panel-bg-strong": "rgba(247, 250, 252, 0.98)",
"--theme-panel-bg-soft": "rgba(255, 255, 255, 0.95)",
"--theme-border": "rgba(96, 117, 131, 0.18)",
"--theme-border-strong": "rgba(86, 104, 118, 0.24)",
"--theme-text": "#23303a",
"--theme-text-soft": "#42515f",
"--theme-text-muted": "#6a7b88",
"--theme-accent": "#5f7884",
"--theme-accent-strong": "#46616d",
"--theme-accent-soft": "rgba(120, 146, 160, 0.18)",
"--theme-accent-contrast": "#ffffff",
"--theme-overlay": "rgba(18, 28, 36, 0.36)",
"--theme-shadow": "0 8px 18px rgba(34, 48, 58, 0.12)",
"--theme-shadow-strong": "0 18px 36px rgba(34, 48, 58, 0.18)",
"--theme-status-muted": "#5f6b75",
"--theme-status-live": "#16616c",
"--theme-status-warning": "#9a641d",
"--theme-status-danger": "#a13f35",
"--theme-card-neutral-bg": "linear-gradient(180deg, #ffffff 0%, #eef4f8 100%)",
"--theme-card-neutral-border": "rgba(137, 160, 175, 0.22)",
"--theme-card-neutral-text": "#1e2934",
"--theme-card-neutral-muted": "#657786",
"--theme-card-neutral-subtle": "#506271",
"--theme-card-warm-bg": "linear-gradient(180deg, #fcfcfb 0%, #eef1ed 100%)",
"--theme-card-warm-border": "rgba(132, 146, 129, 0.2)",
"--theme-card-warm-text": "#283127",
"--theme-card-warm-muted": "#667262",
"--theme-card-success-bg":
"linear-gradient(180deg, rgba(231, 240, 236, 0.98), rgba(213, 227, 220, 0.98))",
"--theme-card-success-border": "rgba(102, 132, 118, 0.2)",
"--theme-card-success-text": "#274036",
"--theme-card-success-muted": "rgba(56, 82, 71, 0.76)",
"--theme-task-bg":
"radial-gradient(circle at top right, rgba(255, 255, 255, 0.72), transparent 32%), linear-gradient(145deg, rgba(244, 248, 250, 0.98), rgba(226, 233, 238, 0.97))",
"--theme-task-border": "rgba(98, 117, 130, 0.14)",
"--theme-task-text": "#23303a",
"--theme-task-muted": "#657684",
"--theme-task-shadow":
"inset 0 1px 0 rgba(255, 255, 255, 0.72), 0 18px 36px rgba(56, 72, 84, 0.12)",
"--theme-task-pattern": "rgba(102, 122, 136, 0.03)",
"--theme-list-bg":
"radial-gradient(circle at top right, rgba(249, 248, 233, 0.68), transparent 34%), linear-gradient(145deg, rgba(236, 230, 198, 0.98), rgba(221, 214, 173, 0.97))",
"--theme-list-text": "#4b4334",
"--theme-list-muted": "rgba(75, 67, 52, 0.72)",
"--theme-list-border": "rgba(100, 92, 73, 0.18)",
"--theme-list-shadow": "inset 0 1px 0 rgba(255, 250, 234, 0.62)",
"--card-surface": "linear-gradient(180deg, #6d8391 0%, #526774 100%)",
"--card-border": "rgba(228, 236, 241, 0.24)",
"--card-shadow": "0 10px 28px rgba(35, 49, 58, 0.22)",
"--card-text": "rgba(244, 248, 250, 0.92)",
"--card-muted": "rgba(220, 229, 234, 0.76)",
"--helper-card-surface": "linear-gradient(180deg, #d0bfd0 0%, #b49cb6 100%)",
"--helper-card-border": "rgba(91, 73, 96, 0.18)",
"--helper-card-shadow": "0 10px 28px rgba(71, 55, 76, 0.16)",
"--helper-card-text": "rgba(43, 35, 47, 0.94)",
"--helper-card-muted": "rgba(77, 63, 83, 0.72)",
},
},
];
export function getTheme(themeName: string | null | undefined): ThemeOption {
return THEME_OPTIONS.find((theme) => theme.name === themeName) || THEME_OPTIONS[0];
}
export function getStoredThemeName(): ThemeName {
if (typeof window === "undefined") return DEFAULT_THEME;
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
const theme = getTheme(stored);
return theme.name;
}
export function applyThemeToDocument(themeName: ThemeName): ThemeOption {
const theme = getTheme(themeName);
if (typeof document === "undefined") return theme;
const root = document.documentElement;
root.dataset.theme = theme.name;
for (const [key, value] of Object.entries(theme.tokens)) {
root.style.setProperty(key, value);
}
return theme;
}

View file

@ -0,0 +1,30 @@
import { useEffect, useMemo, useState } from "preact/hooks";
import {
applyThemeToDocument,
DEFAULT_THEME,
getStoredThemeName,
THEME_OPTIONS,
THEME_STORAGE_KEY,
type ThemeName,
} from "./themes";
export function useThemePreference() {
const [themeName, setThemeName] = useState<ThemeName>(() => {
if (typeof window === "undefined") return DEFAULT_THEME;
return getStoredThemeName();
});
useEffect(() => {
applyThemeToDocument(themeName);
window.localStorage.setItem(THEME_STORAGE_KEY, themeName);
}, [themeName]);
return useMemo(
() => ({
themeName,
themeOptions: THEME_OPTIONS,
setThemeName,
}),
[themeName],
);
}

View file

@ -34,6 +34,23 @@ export interface CardMessageMetadata {
card_selection_label?: string;
card_selection?: JsonValue;
card_live_content?: JsonValue;
context_label?: string;
}
export interface SessionSummary {
chat_id: string;
key: string;
title: string;
preview: string;
created_at: string;
updated_at: string;
message_count: number;
}
export interface SessionHistoryLine {
role: string;
text: string;
timestamp: string;
}
export type ServerMessage =
@ -45,6 +62,7 @@ export type ServerMessage =
is_progress: boolean;
is_tool_hint: boolean;
timestamp: string;
context_label?: string;
}
| {
type: "card";
@ -65,12 +83,31 @@ export type ServerMessage =
created_at: string;
updated_at: string;
}
| {
type: "workbench";
id: string;
chat_id: string;
kind: "text" | "question";
title: string;
content: string;
question: string;
choices: string[];
response_value: string;
slot: string;
template_key: string;
template_state: Record<string, JsonValue>;
context_summary: string;
promotable: boolean;
source_card_id: string;
created_at: string;
updated_at: string;
}
| { type: "error"; error: string }
| { type: "pong" };
export type ClientMessage =
| { type: "voice-ptt"; pressed: boolean; metadata?: CardMessageMetadata }
| { type: "command"; command: string }
| { type: "voice-ptt"; pressed: boolean; metadata?: CardMessageMetadata; chat_id?: string }
| { type: "command"; command: string; chat_id?: string }
| { type: "card-response"; card_id: string; value: string }
| { type: "ping" };
@ -79,6 +116,7 @@ export interface LogLine {
role: string;
text: string;
timestamp: string;
contextLabel?: string;
}
export interface CardItem {
@ -100,3 +138,22 @@ export interface CardItem {
createdAt: string;
updatedAt: string;
}
export interface WorkbenchItem {
id: string;
chatId: string;
kind: "text" | "question";
title: string;
content: string;
question?: string;
choices?: string[];
responseValue?: string;
slot?: string;
templateKey?: string;
templateState?: Record<string, JsonValue>;
contextSummary?: string;
promotable: boolean;
sourceCardId?: string;
createdAt: string;
updatedAt: string;
}