openclaw/extensions/boltbot/dashboard/src/App.tsx
duy 5657dad29d fix(boltbot): address UI skills review — 16 violations
- min-h-screen → min-h-dvh for dynamic viewport
- arbitrary z-[100] → z-50 (fixed scale)
- removed tracking-tight/tracking-wide (letter-spacing not requested)
- replaced 3 URL-sync useEffects with inline wrapper functions
- initialParams moved to useRef (prevent re-computation per render)
- removed backdrop-blur-sm on full-screen overlay (perf)
- added text-balance on headings
- added min-w-0 on SessionView tool name truncate
- empty states now include guidance text with clear next action
2026-01-29 15:39:40 -08:00

186 lines
6.6 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from "react";
import type { ActionReceipt } from "./types";
import { useStats, useReceipts } from "./hooks";
import { cn } from "./utils";
import Header from "./components/Header";
import Sidebar from "./components/Sidebar";
import StatsCards from "./components/StatsCards";
import FilterControls from "./components/FilterControls";
import ReceiptList from "./components/ReceiptList";
import ReceiptDetail from "./components/ReceiptDetail";
import SessionView from "./components/SessionView";
const LIMIT = 50;
function setSearchParams(params: Record<string, string>) {
const url = new URL(window.location.href);
for (const [k, v] of Object.entries(params)) {
if (v) url.searchParams.set(k, v);
else url.searchParams.delete(k);
}
window.history.replaceState({}, "", url.toString());
}
export default function App() {
const initialParams = useRef(new URLSearchParams(window.location.search)).current;
const [selectedTiers, setSelectedTiers] = useState<string[]>(() => {
const tiers = initialParams.get("tiers");
return tiers ? tiers.split(",").filter(Boolean) : [];
});
const [anomalyOnly, setAnomalyOnly] = useState(() => initialParams.get("anomalies") === "1");
const [allReceipts, setAllReceipts] = useState<ActionReceipt[]>([]);
const [selectedReceipt, setSelectedReceipt] = useState<ActionReceipt | null>(null);
const [viewMode, setViewMode] = useState<"list" | "sessions">(() => {
const view = initialParams.get("view");
return view === "sessions" ? "sessions" : "list";
});
const [hasMore, setHasMore] = useState(false);
const [loadedCount, setLoadedCount] = useState(0);
const [loadingMore, setLoadingMore] = useState(false);
const [loadMoreError, setLoadMoreError] = useState<string | null>(null);
const initialLoadDone = useRef(false);
const { stats, isLoading: statsLoading, error: statsError } = useStats();
const { receipts, isLoading: receiptsLoading, error: receiptsError } = useReceipts(LIMIT, 0);
function updateViewMode(v: "list" | "sessions") {
setViewMode(v);
setSearchParams({ view: v === "list" ? "" : v });
}
function updateSelectedTiers(tiers: string[]) {
setSelectedTiers(tiers);
setSearchParams({ tiers: tiers.join(",") });
}
function updateAnomalyOnly(v: boolean) {
setAnomalyOnly(v);
setSearchParams({ anomalies: v ? "1" : "" });
}
// Polling: merge new receipts at the top, do NOT touch loadedCount or hasMore
useEffect(() => {
if (receipts) {
setAllReceipts((prev) => {
const ids = new Set(prev.map((r) => r.id));
const newOnes = receipts.filter((r) => !ids.has(r.id));
if (newOnes.length === 0) return prev;
return [...newOnes, ...prev];
});
}
}, [receipts]);
// Initial load: set hasMore and loadedCount once
useEffect(() => {
if (receipts && !initialLoadDone.current) {
initialLoadDone.current = true;
setLoadedCount(receipts.length);
setHasMore(receipts.length === LIMIT);
}
}, [receipts]);
const handleLoadMore = useCallback(() => {
if (loadingMore) return;
setLoadingMore(true);
setLoadMoreError(null);
fetch(`/boltbot/receipts?limit=${LIMIT}&offset=${loadedCount}`)
.then((r) => {
if (!r.ok) throw new Error(r.statusText);
return r.json();
})
.then((data: { receipts: ActionReceipt[] }) => {
setAllReceipts((prev) => {
const ids = new Set(prev.map((r) => r.id));
const newOnes = data.receipts.filter((r) => !ids.has(r.id));
return [...prev, ...newOnes];
});
setHasMore(data.receipts.length === LIMIT);
setLoadedCount((prev) => prev + data.receipts.length);
})
.catch((err) => {
setLoadMoreError(err instanceof Error ? err.message : "Failed to load more");
})
.finally(() => {
setLoadingMore(false);
});
}, [loadedCount, loadingMore]);
const filtered = allReceipts.filter((r) => {
if (selectedTiers.length > 0 && !selectedTiers.includes(r.tier)) return false;
if (anomalyOnly && r.anomalies.length === 0) return false;
return true;
});
return (
<div className="min-h-dvh bg-neutral-950 text-neutral-100">
<a href="#main-content" className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:bg-neutral-800 focus:text-white focus:px-4 focus:py-2 focus:rounded-lg">
Skip to main content
</a>
<Header />
<Sidebar />
<main id="main-content" className="pt-14 pl-0 lg:pl-56 p-6">
<div className="max-w-6xl mx-auto space-y-6 pt-6">
<StatsCards stats={stats} isLoading={statsLoading} error={statsError} />
<div className="flex items-center gap-2" role="tablist">
<button
onClick={() => updateViewMode("list")}
role="tab"
aria-selected={viewMode === "list"}
className={cn(
"px-4 py-1.5 text-sm rounded-lg transition-colors",
viewMode === "list"
? "bg-neutral-800 text-white"
: "text-neutral-400 hover:text-neutral-200",
)}
>
All Receipts
</button>
<button
onClick={() => updateViewMode("sessions")}
role="tab"
aria-selected={viewMode === "sessions"}
className={cn(
"px-4 py-1.5 text-sm rounded-lg transition-colors",
viewMode === "sessions"
? "bg-neutral-800 text-white"
: "text-neutral-400 hover:text-neutral-200",
)}
>
By Session
</button>
</div>
<FilterControls
selectedTiers={selectedTiers}
onTiersChange={updateSelectedTiers}
anomalyOnly={anomalyOnly}
onAnomalyOnlyChange={updateAnomalyOnly}
/>
{viewMode === "list" ? (
<ReceiptList
receipts={filtered}
isLoading={receiptsLoading}
error={receiptsError}
onSelect={setSelectedReceipt}
selectedId={selectedReceipt?.id ?? null}
hasMore={hasMore}
onLoadMore={handleLoadMore}
loadingMore={loadingMore}
/>
) : (
<SessionView
receipts={filtered}
onSelectReceipt={setSelectedReceipt}
/>
)}
</div>
</main>
<ReceiptDetail
receipt={selectedReceipt}
onClose={() => setSelectedReceipt(null)}
/>
</div>
);
}