chore: snapshot current state before cleanup

This commit is contained in:
kacper 2026-03-14 12:10:39 -04:00
parent db4ce8b14f
commit 94e62c9456
14 changed files with 489 additions and 3929 deletions

View file

@ -5,6 +5,7 @@
"": {
"name": "nanobot-ui",
"dependencies": {
"@fontsource/iosevka": "^5.2.5",
"marked": "^12.0.0",
"preact": "^10.22.0",
"three": "^0.165.0",
@ -132,6 +133,8 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
"@fontsource/iosevka": ["@fontsource/iosevka@5.2.5", "", {}, "sha512-Zv/UHJodDug1LcnWv2u2+GPp3oWP3U6Xp16cJOsqqZQNsCu8sA/ttT331N0NypxBZ+7c8szlSRlYDcy9liZ8pw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>Nanobot</title>
<script type="module" crossorigin src="/assets/index-D7b7A0h0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-IlzmBIpb.css">
<script type="module" crossorigin src="/assets/index-CAK37B_S.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dz_SN8B6.css">
</head>
<body>
<div id="app"></div>

View file

@ -12,6 +12,7 @@
"check": "biome check ./src"
},
"dependencies": {
"@fontsource/iosevka": "^5.2.5",
"marked": "^12.0.0",
"preact": "^10.22.0",
"three": "^0.165.0"

View file

@ -6,10 +6,11 @@ import { LogPanel } from "./components/LogPanel";
import { useAudioMeter } from "./hooks/useAudioMeter";
import { usePTT } from "./hooks/usePTT";
import { useWebRTC } from "./hooks/useWebRTC";
import type { CardItem, CardMessageMetadata, JsonValue } from "./types";
import type { CardItem, CardMessageMetadata, CardSelectionRange, JsonValue } from "./types";
const SWIPE_THRESHOLD_PX = 64;
const SWIPE_DIRECTION_RATIO = 1.15;
const CARD_SELECTION_EVENT = "nanobot:card-selection-change";
interface AppRtcActions {
connect(): Promise<void>;
@ -25,6 +26,36 @@ interface AppRtcActions {
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,
@ -35,6 +66,11 @@ function buildCardMetadata(card: CardItem): CardMessageMetadata {
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;
@ -42,12 +78,27 @@ function buildCardMetadata(card: CardItem): CardMessageMetadata {
return metadata;
}
function AgentCardContext({ card, onClear }: { card: CardItem; onClear(): void }) {
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">Using card</div>
<div class="agent-card-context-label">{label}</div>
<div class="agent-card-context-row">
<div class="agent-card-context-title">{card.title}</div>
<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"
@ -146,6 +197,7 @@ export function App() {
const [view, setView] = useState<"agent" | "feed">("agent");
const [composing, setComposing] = useState(false);
const [selectedCardId, setSelectedCardId] = useState<string | null>(null);
const [selectionVersion, setSelectionVersion] = useState(0);
const autoOpenedFeedRef = useRef(false);
const selectedCard = useMemo(
@ -153,9 +205,13 @@ export function App() {
selectedCardId ? (rtc.cards.find((card) => card.serverId === selectedCardId) ?? null) : null,
[rtc.cards, selectedCardId],
);
const selectedCardSelection = useMemo(
() => (selectedCardId ? readCardSelection(selectedCardId) : null),
[selectedCardId, selectionVersion],
);
const selectedCardMetadata = useCallback(
() => (selectedCard ? buildCardMetadata(selectedCard) : undefined),
[selectedCard],
[selectedCard, selectionVersion],
);
const { agentStateOverride, handlePointerDown, handlePointerMove, handlePointerUp } = usePTT({
@ -202,9 +258,29 @@ export function App() {
setSelectedCardId(null);
}, [rtc.cards, selectedCardId]);
useEffect(() => {
const handleSelectionChange = (event: Event) => {
const detail = (event as CustomEvent<{ cardId?: string; selection?: JsonValue | null }>)
.detail;
setSelectionVersion((current) => current + 1);
const cardId = typeof detail?.cardId === "string" ? detail.cardId : "";
if (cardId && detail?.selection) setSelectedCardId(cardId);
};
window.addEventListener(CARD_SELECTION_EVENT, handleSelectionChange as EventListener);
return () => {
window.removeEventListener(CARD_SELECTION_EVENT, handleSelectionChange as EventListener);
};
}, []);
const { handleToggleTextOnly } = useControlActions(rtc);
const { handleAskCard } = useCardActions(setView, setSelectedCardId);
const clearSelectedCardContext = useCallback(() => {
if (selectedCardId) window.__nanobotClearCardSelection?.(selectedCardId);
setSelectedCardId(null);
}, [selectedCardId]);
const handleCardChoice = useCallback(
(cardId: string, value: string) => {
rtc.sendJson({ type: "card-response", card_id: cardId, value });
@ -220,10 +296,11 @@ export function App() {
const handleResetWithSelection = useCallback(async () => {
const confirmed = window.confirm("Clear the current conversation context and start fresh?");
if (!confirmed) return;
if (selectedCardId) window.__nanobotClearCardSelection?.(selectedCardId);
setSelectedCardId(null);
await rtc.connect();
rtc.sendJson({ type: "command", command: "reset" });
}, [rtc]);
}, [rtc, selectedCardId]);
return (
<>
@ -238,7 +315,11 @@ export function App() {
/>
)}
{view === "agent" && selectedCard && (
<AgentCardContext card={selectedCard} onClear={() => setSelectedCardId(null)} />
<AgentCardContext
card={selectedCard}
selection={selectedCardSelection}
onClear={clearSelectedCardContext}
/>
)}
{view === "agent" && (
<LogPanel

View file

@ -8,7 +8,19 @@ const EXECUTABLE_SCRIPT_TYPES = new Set([
"application/javascript",
"module",
]);
const CARD_SELECTION_EVENT = "nanobot:card-selection-change";
const cardLiveContentStore = new Map<string, JsonValue>();
const cardRefreshHandlers = new Map<string, () => void>();
const cardSelectionStore = new Map<string, JsonValue>();
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 undefined;
}
}
function readCardState(script: HTMLScriptElement | null): Record<string, unknown> {
const root = script?.closest("[data-nanobot-card-root]");
@ -38,27 +50,79 @@ function setCardLiveContent(
const root = resolveCardRoot(target);
const cardId = root?.dataset.cardId?.trim();
if (!cardId) return;
if (snapshot === null || snapshot === undefined) {
const cloned = cloneJsonValue(snapshot ?? undefined);
if (cloned === undefined) {
cardLiveContentStore.delete(cardId);
return;
}
try {
cardLiveContentStore.set(cardId, JSON.parse(JSON.stringify(snapshot)) as JsonValue);
} catch {
cardLiveContentStore.delete(cardId);
}
cardLiveContentStore.set(cardId, cloned);
}
function getCardLiveContent(cardId: string | null | undefined): JsonValue | undefined {
const key = (cardId || "").trim();
if (!key) return undefined;
const value = cardLiveContentStore.get(key);
if (value === undefined) return undefined;
try {
return JSON.parse(JSON.stringify(value)) as JsonValue;
} catch {
return undefined;
return cloneJsonValue(cardLiveContentStore.get(key));
}
function setCardRefreshHandler(
target: HTMLScriptElement | HTMLElement | null,
handler: (() => void) | null | undefined,
): void {
const root = resolveCardRoot(target);
const cardId = root?.dataset.cardId?.trim();
if (!cardId) return;
if (typeof handler !== "function") {
cardRefreshHandlers.delete(cardId);
return;
}
cardRefreshHandlers.set(cardId, handler);
}
function runCardRefresh(cardId: string | null | undefined): boolean {
const key = (cardId || "").trim();
if (!key) return false;
const handler = cardRefreshHandlers.get(key);
if (!handler) return false;
handler();
return true;
}
function dispatchCardSelectionChange(cardId: string, selection: JsonValue | undefined): void {
window.dispatchEvent(
new CustomEvent(CARD_SELECTION_EVENT, {
detail: { cardId, selection: selection ?? null },
}),
);
}
function setCardSelection(
target: HTMLScriptElement | HTMLElement | null,
selection: JsonValue | null | undefined,
): void {
const root = resolveCardRoot(target);
const cardId = root?.dataset.cardId?.trim();
if (!cardId) return;
const cloned = cloneJsonValue(selection ?? undefined);
if (cloned === undefined) {
cardSelectionStore.delete(cardId);
dispatchCardSelectionChange(cardId, undefined);
return;
}
cardSelectionStore.set(cardId, cloned);
dispatchCardSelectionChange(cardId, cloned);
}
function getCardSelection(cardId: string | null | undefined): JsonValue | undefined {
const key = (cardId || "").trim();
if (!key) return undefined;
return cloneJsonValue(cardSelectionStore.get(key));
}
function clearCardSelection(cardId: string | null | undefined): void {
const key = (cardId || "").trim();
if (!key) return;
cardSelectionStore.delete(key);
dispatchCardSelectionChange(key, undefined);
}
function ensureCardStateHelper(): void {
@ -71,6 +135,21 @@ function ensureCardStateHelper(): void {
if (!window.__nanobotGetCardLiveContent) {
window.__nanobotGetCardLiveContent = getCardLiveContent;
}
if (!window.__nanobotSetCardRefresh) {
window.__nanobotSetCardRefresh = setCardRefreshHandler;
}
if (!window.__nanobotRefreshCard) {
window.__nanobotRefreshCard = runCardRefresh;
}
if (!window.__nanobotSetCardSelection) {
window.__nanobotSetCardSelection = setCardSelection;
}
if (!window.__nanobotGetCardSelection) {
window.__nanobotGetCardSelection = getCardSelection;
}
if (!window.__nanobotClearCardSelection) {
window.__nanobotClearCardSelection = clearCardSelection;
}
}
declare global {
@ -81,6 +160,17 @@ declare global {
snapshot: JsonValue | null | undefined,
) => void;
__nanobotGetCardLiveContent?: (cardId: string | null | undefined) => JsonValue | undefined;
__nanobotSetCardRefresh?: (
target: HTMLScriptElement | HTMLElement | null,
handler: (() => void) | null | undefined,
) => void;
__nanobotRefreshCard?: (cardId: string | null | undefined) => boolean;
__nanobotSetCardSelection?: (
target: HTMLScriptElement | HTMLElement | null,
selection: JsonValue | null | undefined,
) => void;
__nanobotGetCardSelection?: (cardId: string | null | undefined) => JsonValue | undefined;
__nanobotClearCardSelection?: (cardId: string | null | undefined) => void;
}
}
@ -128,6 +218,8 @@ function CardTextBody({ card }: { card: CardItem }) {
}
return () => {
window.__nanobotSetCardLiveContent?.(bodyRef.current, null);
window.__nanobotSetCardRefresh?.(bodyRef.current, null);
window.__nanobotSetCardSelection?.(bodyRef.current, null);
};
}, [card.id, card.content]);
@ -184,6 +276,7 @@ function CardHeader({
}) {
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const canRefresh = card.kind === "text" && card.templateKey === "git-diff-live";
useEffect(() => {
if (!menuOpen) return;
@ -235,6 +328,21 @@ function CardHeader({
</button>
{menuOpen && (
<div class="card-menu" role="menu">
{canRefresh && (
<button
class="card-menu-item"
type="button"
role="menuitem"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
if (!card.serverId) return;
window.__nanobotRefreshCard?.(card.serverId);
}}
>
Refresh
</button>
)}
{card.kind === "text" && (
<button
class="card-menu-item"

View file

@ -294,11 +294,16 @@ function useCardPolling(loadPersistedCards: () => Promise<void>) {
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]);

View file

@ -4,6 +4,10 @@
-webkit-user-select: none;
}
:root {
--card-font: "Iosevka", "SF Mono", ui-monospace, Menlo, Consolas, monospace;
}
html {
font: -apple-system-body;
}
@ -29,7 +33,7 @@ body {
inset: 0;
overflow: hidden;
z-index: 1;
touch-action: pan-y;
touch-action: manipulation;
}
#swipe-track {
@ -401,14 +405,17 @@ body {
}
.agent-card-context-row {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-top: 4px;
}
.agent-card-context-title {
.agent-card-context-main {
min-width: 0;
flex: 1;
}
.agent-card-context-title {
min-width: 0;
font-size: 0.9rem;
line-height: 1.2;
font-weight: 700;
@ -417,6 +424,16 @@ body {
overflow: hidden;
text-overflow: ellipsis;
}
.agent-card-context-meta {
margin-top: 2px;
font-size: 0.68rem;
line-height: 1.2;
font-weight: 600;
color: #8b654b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-card-context-clear {
flex-shrink: 0;
border: 1px solid #e3d6ca;
@ -617,7 +634,7 @@ body {
padding: 0;
z-index: 2;
pointer-events: auto;
touch-action: pan-y;
touch-action: manipulation;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: rgba(255, 200, 140, 0.25) transparent;
@ -848,7 +865,7 @@ body {
.card-question,
.card-response,
.card-footer {
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
font-family: var(--card-font);
font-size: 0.75rem;
line-height: 1.65;
color: rgba(255, 245, 235, 0.82);
@ -968,7 +985,7 @@ body {
border: 1px solid rgba(255, 200, 140, 0.35);
border-radius: 8px;
color: rgba(255, 245, 235, 0.9);
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
font-family: var(--card-font);
font-size: 0.75rem;
padding: 6px 14px;
cursor: pointer;

View file

@ -1,5 +1,9 @@
import { render } from "preact";
import { App } from "./App";
import "@fontsource/iosevka/400.css";
import "@fontsource/iosevka/600.css";
import "@fontsource/iosevka/700.css";
import "@fontsource/iosevka/800.css";
import "./index.css";
const root = document.getElementById("app");

View file

@ -11,6 +11,18 @@ export type JsonValue =
| { [key: string]: JsonValue }
| JsonValue[];
export interface CardSelectionRange {
kind: "git_diff_range";
file_path: string;
file_label: string;
range_label: string;
label: string;
old_start: number | null;
old_end: number | null;
new_start: number | null;
new_end: number | null;
}
export interface CardMessageMetadata {
card_id?: string;
card_slot?: string;
@ -19,6 +31,8 @@ export interface CardMessageMetadata {
card_template_key?: string;
card_context_summary?: string;
card_response_value?: string;
card_selection_label?: string;
card_selection?: JsonValue;
card_live_content?: JsonValue;
}