feat(webui): add initial webui with websocket chat flow

This commit is contained in:
Xubin Ren
2026-04-18 18:51:53 +00:00
parent 6bfb75ed03
commit 9ed3031a42
76 changed files with 7088 additions and 38 deletions
+311
View File
@@ -0,0 +1,311 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { DeleteConfirm } from "@/components/DeleteConfirm";
import { Sidebar } from "@/components/Sidebar";
import { ThreadShell } from "@/components/thread/ThreadShell";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { preloadMarkdownText } from "@/components/MarkdownText";
import { useSessions } from "@/hooks/useSessions";
import { useTheme } from "@/hooks/useTheme";
import { cn } from "@/lib/utils";
import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
import { NanobotClient } from "@/lib/nanobot-client";
import { ClientProvider } from "@/providers/ClientProvider";
import type { ChatSummary } from "@/lib/types";
type BootState =
| { status: "loading" }
| { status: "error"; message: string }
| {
status: "ready";
client: NanobotClient;
token: string;
modelName: string | null;
};
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
const SIDEBAR_WIDTH = 279;
function readSidebarOpen(): boolean {
if (typeof window === "undefined") return true;
try {
const raw = window.localStorage.getItem(SIDEBAR_STORAGE_KEY);
if (raw === null) return true;
return raw === "1";
} catch {
return true;
}
}
export default function App() {
const [state, setState] = useState<BootState>({ status: "loading" });
useEffect(() => {
let cancelled = false;
(async () => {
try {
const boot = await fetchBootstrap();
if (cancelled) return;
const url = deriveWsUrl(boot.ws_path, boot.token);
const client = new NanobotClient({
url,
onReauth: async () => {
try {
const refreshed = await fetchBootstrap();
return deriveWsUrl(refreshed.ws_path, refreshed.token);
} catch {
return null;
}
},
});
client.connect();
setState({
status: "ready",
client,
token: boot.token,
modelName: boot.model_name ?? null,
});
} catch (e) {
if (cancelled) return;
setState({ status: "error", message: (e as Error).message });
}
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const warm = () => preloadMarkdownText();
const win = globalThis as typeof globalThis & {
requestIdleCallback?: (
callback: IdleRequestCallback,
options?: IdleRequestOptions,
) => number;
cancelIdleCallback?: (handle: number) => void;
};
if (typeof win.requestIdleCallback === "function") {
const id = win.requestIdleCallback(warm, { timeout: 1500 });
return () => win.cancelIdleCallback?.(id);
}
const id = globalThis.setTimeout(warm, 250);
return () => globalThis.clearTimeout(id);
}, []);
if (state.status === "loading") {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex flex-col items-center gap-3 animate-in fade-in-0 duration-300">
<img
src="/brand/nanobot_icon.png"
alt=""
className="h-10 w-10 animate-pulse select-none"
aria-hidden
draggable={false}
/>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-foreground/40" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-foreground/60" />
</span>
Connecting to nanobot
</div>
</div>
</div>
);
}
if (state.status === "error") {
return (
<div className="flex h-full w-full items-center justify-center px-4 text-center">
<div className="flex max-w-md flex-col items-center gap-3">
<img
src="/brand/nanobot_icon.png"
alt=""
className="h-10 w-10 opacity-60 grayscale select-none"
aria-hidden
draggable={false}
/>
<p className="text-lg font-semibold">Couldn't reach nanobot</p>
<p className="text-sm text-muted-foreground">{state.message}</p>
<p className="text-xs text-muted-foreground">
Make sure the gateway is running (`nanobot web`) and that this page
is open on the same machine.
</p>
</div>
</div>
);
}
return (
<ClientProvider
client={state.client}
token={state.token}
modelName={state.modelName}
>
<Shell />
</ClientProvider>
);
}
function Shell() {
const { theme, toggle } = useTheme();
const { sessions, loading, refresh, createChat, deleteChat } = useSessions();
const [activeKey, setActiveKey] = useState<string | null>(null);
const [desktopSidebarOpen, setDesktopSidebarOpen] =
useState<boolean>(readSidebarOpen);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [pendingDelete, setPendingDelete] = useState<{
key: string;
label: string;
} | null>(null);
const lastSessionsLen = useRef(0);
useEffect(() => {
try {
window.localStorage.setItem(
SIDEBAR_STORAGE_KEY,
desktopSidebarOpen ? "1" : "0",
);
} catch {
// ignore storage errors (private mode, etc.)
}
}, [desktopSidebarOpen]);
useEffect(() => {
if (activeKey) return;
if (sessions.length > 0 && lastSessionsLen.current === 0) {
setActiveKey(sessions[0].key);
}
lastSessionsLen.current = sessions.length;
}, [sessions, activeKey]);
const activeSession = useMemo<ChatSummary | null>(() => {
if (!activeKey) return null;
return sessions.find((s) => s.key === activeKey) ?? null;
}, [sessions, activeKey]);
const closeDesktopSidebar = useCallback(() => {
setDesktopSidebarOpen(false);
}, []);
const closeMobileSidebar = useCallback(() => {
setMobileSidebarOpen(false);
}, []);
const toggleSidebar = useCallback(() => {
const isDesktop =
typeof window !== "undefined" &&
window.matchMedia("(min-width: 1024px)").matches;
if (isDesktop) {
setDesktopSidebarOpen((v) => !v);
} else {
setMobileSidebarOpen((v) => !v);
}
}, []);
const onNewChat = useCallback(async () => {
try {
const chatId = await createChat();
setActiveKey(`websocket:${chatId}`);
setMobileSidebarOpen(false);
return chatId;
} catch (e) {
console.error("Failed to create chat", e);
return null;
}
}, [createChat]);
const onSelectChat = useCallback(
(key: string) => {
setActiveKey(key);
setMobileSidebarOpen(false);
},
[],
);
const onConfirmDelete = useCallback(async () => {
if (!pendingDelete) return;
const key = pendingDelete.key;
const deletingActive = activeKey === key;
const currentIndex = sessions.findIndex((s) => s.key === key);
const fallbackKey = deletingActive
? (sessions[currentIndex + 1]?.key ?? sessions[currentIndex - 1]?.key ?? null)
: activeKey;
setPendingDelete(null);
if (deletingActive) setActiveKey(fallbackKey);
try {
await deleteChat(key);
} catch (e) {
if (deletingActive) setActiveKey(key);
console.error("Failed to delete session", e);
}
}, [pendingDelete, deleteChat, activeKey, sessions]);
const headerTitle = activeSession
? activeSession.preview || `Chat ${activeSession.chatId.slice(0, 6)}`
: "nanobot";
const sidebarProps = {
sessions,
activeKey,
loading,
theme,
onToggleTheme: toggle,
onNewChat: () => {
void onNewChat();
},
onSelect: onSelectChat,
onRefresh: () => void refresh(),
onRequestDelete: (key: string, label: string) =>
setPendingDelete({ key, label }),
};
return (
<div className="relative flex h-full w-full overflow-hidden">
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */}
<aside
className={cn(
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
"transition-[width] duration-300 ease-out",
)}
style={{ width: desktopSidebarOpen ? SIDEBAR_WIDTH : 0 }}
>
<div
className={cn(
"absolute inset-y-0 left-0 h-full w-[279px] overflow-hidden bg-sidebar shadow-inner-right",
"transition-transform duration-300 ease-out",
desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
)}
>
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
</div>
</aside>
<Sheet
open={mobileSidebarOpen}
onOpenChange={(open) => setMobileSidebarOpen(open)}
>
<SheetContent side="left" className="w-[279px] p-0 sm:max-w-[279px] lg:hidden">
<Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} />
</SheetContent>
</Sheet>
<main className="flex h-full min-w-0 flex-1 flex-col">
<ThreadShell
session={activeSession}
title={headerTitle}
onToggleSidebar={toggleSidebar}
onGoHome={() => setActiveKey(null)}
onNewChat={onNewChat}
hideSidebarToggleOnDesktop={desktopSidebarOpen}
/>
</main>
<DeleteConfirm
open={!!pendingDelete}
title={pendingDelete?.label ?? ""}
onCancel={() => setPendingDelete(null)}
onConfirm={onConfirmDelete}
/>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { MoreHorizontal, Trash2 } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import { relativeTime } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { ChatSummary } from "@/lib/types";
interface ChatListProps {
sessions: ChatSummary[];
activeKey: string | null;
onSelect: (key: string) => void;
onRequestDelete: (key: string, label: string) => void;
loading?: boolean;
}
function titleFor(s: ChatSummary): string {
const p = s.preview?.trim();
if (p) return p.length > 48 ? `${p.slice(0, 45)}` : p;
return `Chat ${s.chatId.slice(0, 6)}`;
}
export function ChatList({
sessions,
activeKey,
onSelect,
onRequestDelete,
loading,
}: ChatListProps) {
if (loading && sessions.length === 0) {
return (
<div className="px-3 py-6 text-[12px] text-muted-foreground">Loading</div>
);
}
if (sessions.length === 0) {
return (
<div className="px-3 py-6 text-xs text-muted-foreground">
No sessions yet.
</div>
);
}
return (
<ScrollArea className="h-full">
<ul className="space-y-0.5 px-2 py-1">
{sessions.map((s) => {
const active = s.key === activeKey;
const title = titleFor(s);
return (
<li key={s.key}>
<div
className={cn(
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-[12.5px] transition-colors",
active
? "bg-sidebar-accent/80 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--border)/0.4)]"
: "text-sidebar-foreground/88 hover:bg-sidebar-accent/45",
)}
>
<button
type="button"
onClick={() => onSelect(s.key)}
className="flex min-w-0 flex-1 flex-col items-start text-left"
>
<span className="w-full truncate font-medium leading-5">{title}</span>
<span className="text-[10.5px] text-muted-foreground/80">
{relativeTime(s.updatedAt ?? s.createdAt) || "—"}
</span>
</button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className={cn(
"inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity",
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100",
"focus-visible:opacity-100",
active && "opacity-100",
)}
aria-label={`Chat actions for ${title}`}
>
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
onSelect={() => {
window.setTimeout(() => onRequestDelete(s.key, title), 0);
}}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</li>
);
})}
</ul>
</ScrollArea>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Composer } from "@/components/Composer";
import { MessageList } from "@/components/MessageList";
import { useClient } from "@/providers/ClientProvider";
import { useNanobotStream } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions";
import type { ChatSummary } from "@/lib/types";
interface ChatPaneProps {
session: ChatSummary | null;
/** Provision a new chat and mark it active. Returns the new chat_id or null. */
onNewChat: () => Promise<string | null>;
}
/**
* The chat surface: persisted history on top, live stream below, composer
* pinned at the bottom. When no session is active we render a centered
* welcome card with a fully-functional composer — typing a first message
* quietly provisions a new chat and routes the message through.
*/
export function ChatPane({ session, onNewChat }: ChatPaneProps) {
const chatId = session?.chatId ?? null;
const historyKey = session?.key ?? null;
const { messages: historical, loading } = useSessionHistory(historyKey);
const { client } = useClient();
const [booting, setBooting] = useState(false);
const pendingFirstRef = useRef<string | null>(null);
const initial = useMemo(() => historical, [historical]);
const { messages, isStreaming, send, setMessages } = useNanobotStream(
chatId,
initial,
);
useEffect(() => {
if (!loading && chatId) setMessages(historical);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading, chatId, historical]);
// Once a session becomes active, flush any first-message stashed from the
// welcome composer so the user's keystroke "just sends".
useEffect(() => {
if (!chatId) return;
const pending = pendingFirstRef.current;
if (!pending) return;
pendingFirstRef.current = null;
client.sendMessage(chatId, pending);
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: "user",
content: pending,
createdAt: Date.now(),
},
]);
setBooting(false);
}, [chatId, client, setMessages]);
const handleWelcomeSend = useCallback(
async (content: string) => {
if (booting) return;
setBooting(true);
pendingFirstRef.current = content;
const newId = await onNewChat();
if (!newId) {
// Creation failed — release the lock so the user can retry.
pendingFirstRef.current = null;
setBooting(false);
}
},
[booting, onNewChat],
);
if (!session) {
return (
<section className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-1 flex-col items-center justify-center gap-8 px-4 pb-6">
<div className="flex flex-col items-center gap-4 animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
<picture>
<source
srcSet="/brand/nanobot_logo.webp"
type="image/webp"
/>
<img
src="/brand/nanobot_logo.png"
alt="nanobot"
className="h-12 w-auto select-none drop-shadow-sm"
draggable={false}
/>
</picture>
<h1 className="text-xl font-medium tracking-tight text-foreground/90">
What's on your mind?
</h1>
<p className="max-w-md text-center text-sm text-muted-foreground">
Your conversations are persisted locally under the nanobot
workspace. Start typing and I'll open a new chat.
</p>
</div>
<div className="w-full animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
<Composer
compact
disabled={booting}
onSend={handleWelcomeSend}
placeholder={
booting ? "Opening a new chat…" : "Type your message…"
}
/>
</div>
</div>
</section>
);
}
return (
<section className="relative flex min-h-0 flex-1 flex-col">
<MessageList messages={messages} isStreaming={isStreaming} />
<Composer
onSend={send}
disabled={!chatId}
placeholder="Type your message…"
/>
</section>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { useState } from "react";
import { Check, Copy } from "lucide-react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import {
oneDark,
oneLight,
} from "react-syntax-highlighter/dist/esm/styles/prism";
import { cn } from "@/lib/utils";
interface CodeBlockProps {
language?: string;
code: string;
className?: string;
}
export function CodeBlock({ language, code, className }: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const onCopy = () => {
if (!navigator.clipboard) return;
navigator.clipboard.writeText(code).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1_500);
});
};
const isDark =
typeof window !== "undefined"
? document.documentElement.classList.contains("dark")
: true;
return (
<div className={cn("overflow-hidden rounded-lg", className)}>
<div className="flex items-center justify-between bg-zinc-900 px-4 py-1.5 text-xs font-medium text-zinc-200">
<span className="lowercase">{language || "code"}</span>
<button
type="button"
onClick={onCopy}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-zinc-300 transition-colors hover:bg-zinc-800 hover:text-zinc-100"
aria-label="Copy code"
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
<span>{copied ? "Copied" : "Copy"}</span>
</button>
</div>
<SyntaxHighlighter
language={language}
style={isDark ? oneDark : oneLight}
customStyle={{
margin: 0,
padding: "1rem",
background: "var(--tw-prose-pre-bg, #0a0a0a)",
fontSize: "0.8125rem",
lineHeight: 1.55,
}}
PreTag="pre"
wrapLongLines
>
{code}
</SyntaxHighlighter>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface ComposerProps {
onSend: (content: string) => void;
disabled?: boolean;
placeholder?: string;
/** Visually collapse the outer padding when embedded inside a welcome screen. */
compact?: boolean;
}
/**
* Rounded, shadowed composer with an embedded send button — modeled after the
* agent-chat-ui input: a single surface that looks like one interactive unit
* rather than a textarea + button pair.
*/
export function Composer({
onSend,
disabled,
placeholder = "Type your message…",
compact = false,
}: ComposerProps) {
const [value, setValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Autofocus on mount — coming back to a chat, switching sessions, or
// opening the welcome screen should always land the caret in the box.
useEffect(() => {
if (disabled) return;
const el = textareaRef.current;
if (!el) return;
// Defer so layout settles first (important during enter animations).
const id = requestAnimationFrame(() => el.focus());
return () => cancelAnimationFrame(id);
}, [disabled]);
const submit = useCallback(() => {
const trimmed = value.trim();
if (!trimmed || disabled) return;
onSend(trimmed);
setValue("");
requestAnimationFrame(() => {
const el = textareaRef.current;
if (el) {
el.style.height = "auto";
el.focus();
}
});
}, [disabled, onSend, value]);
const onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
submit();
}
};
const onInput: React.FormEventHandler<HTMLTextAreaElement> = (e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
submit();
}}
className={cn(
"w-full",
compact ? "px-0" : "bg-background/95 px-4 pb-4 pt-2 backdrop-blur",
)}
>
<div
className={cn(
"relative mx-auto flex w-full max-w-[64rem] flex-col overflow-hidden rounded-3xl",
"border bg-muted/60 shadow-sm transition-all duration-200",
"focus-within:bg-muted focus-within:shadow-md focus-within:ring-1 focus-within:ring-foreground/10",
disabled && "opacity-60",
)}
>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onInput={onInput}
onKeyDown={onKeyDown}
rows={1}
placeholder={placeholder}
disabled={disabled}
aria-label="Message input"
className={cn(
"min-h-[56px] w-full resize-none bg-transparent px-5 pt-4 pb-2 text-sm",
"placeholder:text-muted-foreground",
"focus:outline-none focus-visible:outline-none",
"disabled:cursor-not-allowed",
)}
/>
<div className="flex items-center justify-between gap-2 px-3 pb-2">
<span className="hidden select-none text-[11px] text-muted-foreground/70 sm:inline">
Enter to send · Shift+Enter for newline
</span>
<span className="sm:hidden" aria-hidden />
<Button
type="submit"
size="icon"
disabled={disabled || !value.trim()}
aria-label="Send message"
className={cn(
"h-9 w-9 rounded-full shadow-sm transition-transform",
value.trim() && !disabled && "hover:scale-[1.03] active:scale-95",
)}
>
<ArrowUp className="h-4 w-4" />
</Button>
</div>
</div>
</form>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
import type { ConnectionStatus } from "@/lib/types";
const COPY: Record<ConnectionStatus, { label: string; color: string }> = {
idle: { label: "Idle", color: "bg-card/40 text-muted-foreground" },
connecting: {
label: "Connecting…",
color: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
},
open: {
label: "Connected",
color: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
},
reconnecting: {
label: "Reconnecting…",
color: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
},
closed: {
label: "Disconnected",
color: "bg-card/40 text-muted-foreground",
},
error: {
label: "Connection error",
color: "bg-destructive/10 text-destructive",
},
};
export function ConnectionBadge() {
const { client } = useClient();
const [status, setStatus] = useState<ConnectionStatus>(client.status);
useEffect(() => client.onStatus(setStatus), [client]);
const meta = COPY[status];
const pulsing =
status === "connecting" ||
status === "reconnecting" ||
status === "error";
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-md border border-border/60 px-2 py-1 text-[11px] font-medium transition-colors",
meta.color,
)}
aria-live="polite"
>
<span className="relative flex h-1.5 w-1.5" aria-hidden>
{pulsing && (
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-75" />
)}
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
</span>
{meta.label}
</span>
);
}
+46
View File
@@ -0,0 +1,46 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface DeleteConfirmProps {
open: boolean;
title: string;
onCancel: () => void;
onConfirm: () => void;
}
export function DeleteConfirm({
open,
title,
onCancel,
onConfirm,
}: DeleteConfirmProps) {
return (
<AlertDialog open={open} onOpenChange={(o) => (!o ? onCancel() : undefined)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {title}?</AlertDialogTitle>
<AlertDialogDescription>
The session file will be removed from disk. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { MessageSquarePlus } from "lucide-react";
import { Button } from "@/components/ui/button";
export function EmptyState({
onNewChat,
}: {
onNewChat: () => void;
}) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 text-center">
<MessageSquarePlus
className="h-10 w-10 text-muted-foreground"
aria-hidden
/>
<div className="space-y-1">
<p className="text-lg font-medium">No chats yet</p>
<p className="max-w-sm text-sm text-muted-foreground">
Start a conversation your sessions are stored locally on the nanobot
workspace and stay available across reloads.
</p>
</div>
<Button onClick={onNewChat}>New chat</Button>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { Suspense, lazy } from "react";
import { cn } from "@/lib/utils";
interface MarkdownTextProps {
children: string;
className?: string;
}
const loadMarkdownRenderer = () => import("@/components/MarkdownTextRenderer");
const LazyMarkdownRenderer = lazy(loadMarkdownRenderer);
export function preloadMarkdownText(): void {
void loadMarkdownRenderer();
}
/**
* Lightweight markdown renderer mirroring agent-chat-ui: GFM + math via
* ``remark-math`` / ``rehype-katex``, and fenced code blocks delegated to
* ``CodeBlock`` for copy-to-clipboard and syntax highlighting.
*/
export function MarkdownText({ children, className }: MarkdownTextProps) {
return (
<Suspense
fallback={
<div
className={cn(
"whitespace-pre-wrap break-words leading-relaxed text-foreground/92",
className,
)}
>
{children}
</div>
}
>
<LazyMarkdownRenderer className={className}>{children}</LazyMarkdownRenderer>
</Suspense>
);
}
@@ -0,0 +1,86 @@
import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { CodeBlock } from "@/components/CodeBlock";
import { cn } from "@/lib/utils";
import "katex/dist/katex.min.css";
interface MarkdownTextRendererProps {
children: string;
className?: string;
}
/**
* Heavy markdown stack (GFM, math, KaTeX, syntax highlighting) kept in a
* separate chunk so the app shell can paint sooner on refresh.
*/
export default function MarkdownTextRenderer({
children,
className,
}: MarkdownTextRendererProps) {
return (
<div
className={cn(
"markdown-content prose prose-sm max-w-none dark:prose-invert",
"prose-headings:mt-4 prose-headings:mb-2 prose-headings:font-semibold",
"prose-h1:text-lg prose-h2:text-base prose-h3:text-[0.95rem] prose-h4:text-sm",
"prose-p:my-2 prose-p:leading-relaxed",
"prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5",
"prose-blockquote:my-3 prose-blockquote:border-l-2 prose-blockquote:font-normal",
"prose-blockquote:not-italic prose-blockquote:text-foreground/80",
"prose-a:text-primary prose-a:underline-offset-2 hover:prose-a:opacity-80",
"prose-hr:my-6",
"prose-pre:my-0 prose-pre:bg-transparent prose-pre:p-0",
"prose-code:before:content-none prose-code:after:content-none prose-code:font-normal",
"prose-table:my-3 prose-th:text-left prose-th:font-medium",
className,
)}
>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
code({ className: cls, children: kids, ...props }) {
const match = /language-(\w+)/.exec(cls || "");
if (!match) {
return (
<code
className={cn(
"rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]",
cls,
)}
{...props}
>
{kids}
</code>
);
}
const code = String(kids).replace(/\n$/, "");
return <CodeBlock language={match[1]} code={code} className="my-3" />;
},
pre({ children: markdownChildren }) {
return <>{markdownChildren}</>;
},
a({ href, children: markdownChildren, ...props }) {
return (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="text-primary underline underline-offset-2 hover:opacity-80"
{...props}
>
{markdownChildren}
</a>
);
},
}}
>
{children}
</ReactMarkdown>
</div>
);
}
+159
View File
@@ -0,0 +1,159 @@
import { useState } from "react";
import { ChevronRight, Wrench } from "lucide-react";
import { MarkdownText } from "@/components/MarkdownText";
import { cn } from "@/lib/utils";
import type { UIMessage } from "@/lib/types";
interface MessageBubbleProps {
message: UIMessage;
}
/**
* Render a single message. Following agent-chat-ui: user turns are a rounded
* "pill" right-aligned with a muted fill; assistant turns render as bare
* markdown so prose/code read like a document rather than a chat bubble.
* Each turn fades+slides in for a touch of motion polish.
*
* Trace rows (tool-call hints, progress breadcrumbs) render as a subdued
* collapsible group so intermediate steps never masquerade as replies.
*/
export function MessageBubble({ message }: MessageBubbleProps) {
const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300";
if (message.kind === "trace") {
return <TraceGroup message={message} animClass={baseAnim} />;
}
if (message.role === "user") {
return (
<div
className={cn(
"group ml-auto flex max-w-[min(85%,36rem)] items-center gap-2",
baseAnim,
)}
>
<p
className={cn(
"ml-auto w-fit rounded-[18px] border border-border/60 bg-secondary/70 px-4 py-2",
"text-right text-sm whitespace-pre-wrap break-words",
"shadow-[0_10px_24px_-18px_rgba(0,0,0,0.55)]",
)}
>
{message.content}
</p>
</div>
);
}
const empty = message.content.trim().length === 0;
return (
<div className={cn("w-full text-sm leading-relaxed", baseAnim)}>
{empty && message.isStreaming ? (
<TypingDots />
) : (
<>
<MarkdownText>{message.content}</MarkdownText>
{message.isStreaming && <StreamCursor />}
</>
)}
</div>
);
}
/** Blinking cursor appended at the end of streaming text. */
function StreamCursor() {
return (
<span
aria-label="streaming"
className={cn(
"ml-0.5 inline-block h-[1em] w-[3px] translate-y-[2px] align-middle",
"rounded-sm bg-foreground/70 animate-pulse",
)}
/>
);
}
/** Pre-token-arrival placeholder: three bouncing dots. */
function TypingDots() {
return (
<span
aria-label="Assistant is typing"
className="inline-flex items-center gap-1 py-1"
>
<Dot delay="0ms" />
<Dot delay="150ms" />
<Dot delay="300ms" />
</span>
);
}
function Dot({ delay }: { delay: string }) {
return (
<span
style={{ animationDelay: delay }}
className={cn(
"inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground/60",
"animate-bounce",
)}
/>
);
}
interface TraceGroupProps {
message: UIMessage;
animClass: string;
}
/**
* Collapsible group of tool-call / progress breadcrumbs. Defaults to
* expanded for discoverability; a single click on the header folds the
* group down to a one-line summary so it never dominates the thread.
*/
function TraceGroup({ message, animClass }: TraceGroupProps) {
const lines = message.traces ?? [message.content];
const count = lines.length;
const [open, setOpen] = useState(true);
return (
<div className={cn("w-full", animClass)}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={cn(
"group flex w-full items-center gap-2 rounded-md px-2 py-1.5",
"text-xs text-muted-foreground transition-colors hover:bg-muted/45",
)}
aria-expanded={open}
>
<Wrench className="h-3.5 w-3.5" aria-hidden />
<span className="font-medium">
{count === 1 ? "Using a tool" : `Used ${count} tools`}
</span>
<ChevronRight
aria-hidden
className={cn(
"ml-auto h-3.5 w-3.5 transition-transform duration-200",
open && "rotate-90",
)}
/>
</button>
{open && (
<ul
className={cn(
"mt-1 space-y-0.5 border-l border-muted-foreground/20 pl-3",
"animate-in fade-in-0 slide-in-from-top-1 duration-200",
)}
>
{lines.map((line, i) => (
<li
key={i}
className="whitespace-pre-wrap break-words font-mono text-[11.5px] leading-relaxed text-muted-foreground/90"
>
{line}
</li>
))}
</ul>
)}
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowDown } from "lucide-react";
import { MessageBubble } from "@/components/MessageBubble";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { UIMessage } from "@/lib/types";
interface MessageListProps {
messages: UIMessage[];
isStreaming: boolean;
}
const NEAR_BOTTOM_PX = 48;
/**
* Scrollable message log. Auto-sticks to the bottom as new content arrives,
* but only when the user was already at the bottom — preserving scroll
* position when they've scrolled up to read earlier turns. A floating
* "scroll to bottom" button appears whenever we're detached from the bottom.
*/
export function MessageList({ messages, isStreaming }: MessageListProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [atBottom, setAtBottom] = useState(true);
const scrollToBottom = useCallback((smooth = false) => {
const el = scrollRef.current;
if (!el) return;
el.scrollTo({
top: el.scrollHeight,
behavior: smooth ? "smooth" : "auto",
});
}, []);
// Keep the viewport pinned to the bottom as long as the user hasn't
// scrolled up. During streaming we do instant jumps (smooth scrolling each
// token fights the incoming animations); on settled updates we animate.
useEffect(() => {
if (!atBottom) return;
scrollToBottom(!isStreaming);
}, [messages, isStreaming, atBottom, scrollToBottom]);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const onScroll = () => {
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
setAtBottom(distance < NEAR_BOTTOM_PX);
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, []);
if (messages.length === 0) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Say hi to get started.
</div>
);
}
return (
<div className="relative flex min-h-0 flex-1 overflow-hidden">
<div
ref={scrollRef}
className={cn(
"h-full overflow-y-auto scroll-smooth",
"[&::-webkit-scrollbar]:w-1.5",
"[&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30",
"[&::-webkit-scrollbar-track]:bg-transparent",
)}
>
<div className="mx-auto flex w-full max-w-[64rem] flex-col gap-6 px-4 pt-4 pb-8">
{messages.map((m) => (
<MessageBubble key={m.id} message={m} />
))}
</div>
</div>
{/* Top fade so messages slide under the header gracefully. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent"
/>
{/* Bottom fade so messages fade out behind the composer. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-background to-transparent"
/>
{!atBottom && (
<Button
variant="outline"
size="icon"
onClick={() => scrollToBottom(true)}
className={cn(
"absolute bottom-2 left-1/2 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
"bg-background/90 backdrop-blur",
"animate-in fade-in-0 zoom-in-95",
)}
aria-label="Scroll to bottom"
>
<ArrowDown className="h-4 w-4" />
</Button>
)}
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { Moon, PanelLeftClose, Plus, RefreshCcw, Sun } from "lucide-react";
import { ChatList } from "@/components/ChatList";
import { ConnectionBadge } from "@/components/ConnectionBadge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import type { ChatSummary } from "@/lib/types";
interface SidebarProps {
sessions: ChatSummary[];
activeKey: string | null;
loading: boolean;
theme: "light" | "dark";
onToggleTheme: () => void;
onNewChat: () => void;
onSelect: (key: string) => void;
onRefresh: () => void;
onRequestDelete: (key: string, label: string) => void;
onCollapse: () => void;
}
export function Sidebar(props: SidebarProps) {
return (
<aside className="flex h-full w-full flex-col border-r border-sidebar-border/70 bg-sidebar text-sidebar-foreground">
<div className="flex items-center justify-between px-2 py-2">
<Button
variant="ghost"
size="icon"
aria-label="Collapse sidebar"
onClick={props.onCollapse}
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
<PanelLeftClose className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Toggle theme"
onClick={props.onToggleTheme}
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
{props.theme === "dark" ? (
<Sun className="h-3.5 w-3.5" />
) : (
<Moon className="h-3.5 w-3.5" />
)}
</Button>
</div>
<div className="px-2 pb-2.5">
<Button
onClick={props.onNewChat}
className="h-8.5 w-full justify-start gap-2 rounded-lg border border-sidebar-border/80 bg-card/25 px-3 text-[13px] font-medium text-sidebar-foreground shadow-none hover:bg-sidebar-accent/80"
variant="outline"
>
<Plus className="h-3.5 w-3.5" />
New chat
</Button>
</div>
<Separator className="bg-sidebar-border/70" />
<div className="flex items-center justify-between px-2.5 py-2 text-[11px] font-medium text-muted-foreground">
<span>Recent</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-md text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
onClick={props.onRefresh}
aria-label="Refresh sessions"
>
<RefreshCcw className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex-1 overflow-hidden">
<ChatList
sessions={props.sessions}
activeKey={props.activeKey}
loading={props.loading}
onSelect={props.onSelect}
onRequestDelete={props.onRequestDelete}
/>
</div>
<Separator className="bg-sidebar-border/70" />
<div className="flex items-center justify-between px-2.5 py-2 text-xs">
<ConnectionBadge />
</div>
</aside>
);
}
@@ -0,0 +1,144 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface ThreadComposerProps {
onSend: (content: string) => void;
disabled?: boolean;
placeholder?: string;
modelLabel?: string | null;
variant?: "thread" | "hero";
}
export function ThreadComposer({
onSend,
disabled,
placeholder = "Type your message…",
modelLabel = null,
variant = "thread",
}: ThreadComposerProps) {
const [value, setValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isHero = variant === "hero";
useEffect(() => {
if (disabled) return;
const el = textareaRef.current;
if (!el) return;
const id = requestAnimationFrame(() => el.focus());
return () => cancelAnimationFrame(id);
}, [disabled]);
const submit = useCallback(() => {
const trimmed = value.trim();
if (!trimmed || disabled) return;
onSend(trimmed);
setValue("");
requestAnimationFrame(() => {
const el = textareaRef.current;
if (el) {
el.style.height = "auto";
el.focus();
}
});
}, [disabled, onSend, value]);
const onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
submit();
}
};
const onInput: React.FormEventHandler<HTMLTextAreaElement> = (e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
submit();
}}
className={cn("w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")}
>
<div
className={cn(
"relative mx-auto flex w-full flex-col overflow-hidden transition-all duration-200",
isHero
? "max-w-[40rem] rounded-[24px] border border-border/75 bg-card/72 shadow-[0_10px_30px_rgba(0,0,0,0.10)]"
: "max-w-[49.5rem] rounded-[16px] border border-border/70 bg-card/55",
"focus-within:bg-card/70 focus-within:ring-1 focus-within:ring-foreground/8",
disabled && "opacity-60",
)}
>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onInput={onInput}
onKeyDown={onKeyDown}
rows={1}
placeholder={placeholder}
disabled={disabled}
aria-label="Message input"
className={cn(
"w-full resize-none bg-transparent",
isHero
? "min-h-[96px] px-4 pb-2 pt-4 text-[15px] leading-6"
: "min-h-[50px] px-4 pb-1.5 pt-3 text-sm",
"placeholder:text-muted-foreground",
"focus:outline-none focus-visible:outline-none",
"disabled:cursor-not-allowed",
)}
/>
<div
className={cn(
"flex items-center justify-between gap-2",
isHero ? "px-3.5 pb-3.5" : "px-3 pb-2",
)}
>
<div className="flex min-w-0 items-center gap-2">
{modelLabel ? (
<span
title={modelLabel}
className={cn(
"inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2.5 py-1",
"border-foreground/10 bg-foreground/[0.035] font-medium text-foreground/80",
isHero ? "text-[11px]" : "text-[10.5px]",
)}
>
<span
aria-hidden
className="h-1.5 w-1.5 flex-none rounded-full bg-emerald-500/80"
/>
<span className="truncate">{modelLabel}</span>
</span>
) : null}
<span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline">
Enter to send · Shift+Enter for newline
</span>
</div>
<span className="sm:hidden" aria-hidden />
<Button
type="submit"
size="icon"
disabled={disabled || !value.trim()}
aria-label="Send message"
className={cn(
"rounded-full border border-border/70 bg-secondary/85 text-secondary-foreground shadow-none transition-transform hover:bg-accent",
isHero ? "h-8.5 w-8.5" : "h-7.5 w-7.5",
value.trim() && !disabled && "hover:scale-[1.03] active:scale-95",
)}
>
<ArrowUp className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4")} />
</Button>
</div>
</div>
</form>
);
}
@@ -0,0 +1,52 @@
import { PanelLeftOpen } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface ThreadHeaderProps {
title: string;
onToggleSidebar: () => void;
onGoHome: () => void;
hideSidebarToggleOnDesktop?: boolean;
}
export function ThreadHeader({
title,
onToggleSidebar,
onGoHome,
hideSidebarToggleOnDesktop = false,
}: ThreadHeaderProps) {
return (
<div className="relative z-10 flex items-center justify-between gap-3 px-3 py-2">
<div className="relative flex min-w-0 items-center gap-2">
<Button
variant="ghost"
size="icon"
aria-label="Toggle sidebar"
onClick={onToggleSidebar}
className={cn(
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
hideSidebarToggleOnDesktop && "lg:pointer-events-none lg:opacity-0",
)}
>
<PanelLeftOpen className="h-3.5 w-3.5" />
</Button>
<button
type="button"
onClick={onGoHome}
className="flex min-w-0 items-center gap-2 rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent/35 hover:text-foreground"
>
<img
src="/brand/nanobot_icon.png"
alt=""
className="h-4 w-4 rounded-[5px] opacity-85"
aria-hidden
/>
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
</button>
</div>
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
</div>
);
}
@@ -0,0 +1,16 @@
import { MessageBubble } from "@/components/MessageBubble";
import type { UIMessage } from "@/lib/types";
interface ThreadMessagesProps {
messages: UIMessage[];
}
export function ThreadMessages({ messages }: ThreadMessagesProps) {
return (
<div className="flex w-full flex-col gap-5">
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { ThreadHeader } from "@/components/thread/ThreadHeader";
import { ThreadViewport } from "@/components/thread/ThreadViewport";
import { useNanobotStream } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions";
import type { ChatSummary, UIMessage } from "@/lib/types";
import { useClient } from "@/providers/ClientProvider";
interface ThreadShellProps {
session: ChatSummary | null;
title: string;
onToggleSidebar: () => void;
onGoHome: () => void;
onNewChat: () => Promise<string | null>;
hideSidebarToggleOnDesktop?: boolean;
}
function toModelBadgeLabel(modelName: string | null): string | null {
if (!modelName) return null;
const trimmed = modelName.trim();
if (!trimmed) return null;
const leaf = trimmed.split("/").pop() ?? trimmed;
return leaf || trimmed;
}
export function ThreadShell({
session,
title,
onToggleSidebar,
onGoHome,
onNewChat,
hideSidebarToggleOnDesktop = false,
}: ThreadShellProps) {
const chatId = session?.chatId ?? null;
const historyKey = session?.key ?? null;
const { messages: historical, loading } = useSessionHistory(historyKey);
const { client, modelName } = useClient();
const [booting, setBooting] = useState(false);
const pendingFirstRef = useRef<string | null>(null);
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
const initial = useMemo(() => {
if (!chatId) return historical;
return messageCacheRef.current.get(chatId) ?? historical;
}, [chatId, historical]);
const { messages, isStreaming, send, setMessages } = useNanobotStream(
chatId,
initial,
);
const showHeroComposer = messages.length === 0 && !loading;
useEffect(() => {
if (!chatId || loading) return;
const cached = messageCacheRef.current.get(chatId);
// When the user switches away and back, keep the local in-memory thread
// state (including not-yet-persisted messages) instead of replacing it with
// whatever the history endpoint currently knows about.
setMessages(cached && cached.length > 0 ? cached : historical);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading, chatId, historical]);
useEffect(() => {
if (chatId) return;
setMessages(historical);
}, [chatId, historical, setMessages]);
useEffect(() => {
if (!chatId) return;
messageCacheRef.current.set(chatId, messages);
}, [chatId, messages]);
useEffect(() => {
if (!chatId) return;
const pending = pendingFirstRef.current;
if (!pending) return;
pendingFirstRef.current = null;
client.sendMessage(chatId, pending);
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: "user",
content: pending,
createdAt: Date.now(),
},
]);
setBooting(false);
}, [chatId, client, setMessages]);
const handleWelcomeSend = useCallback(
async (content: string) => {
if (booting) return;
setBooting(true);
pendingFirstRef.current = content;
const newId = await onNewChat();
if (!newId) {
pendingFirstRef.current = null;
setBooting(false);
}
},
[booting, onNewChat],
);
const emptyState = loading ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading conversation
</div>
) : (
<div className="flex w-full max-w-[40rem] flex-col gap-2 text-left animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
<div className="inline-flex items-center gap-2 text-[11px] font-medium text-muted-foreground">
<img
src="/brand/nanobot_icon.png"
alt=""
aria-hidden
draggable={false}
className="h-4 w-4 rounded-sm opacity-90"
/>
<span className="text-foreground/82">nanobot</span>
</div>
<p className="max-w-[28rem] text-[13px] leading-6 text-muted-foreground">
Ask questions, continue local work, or start a new thread.
</p>
</div>
);
return (
<section className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<ThreadHeader
title={title}
onToggleSidebar={onToggleSidebar}
onGoHome={onGoHome}
hideSidebarToggleOnDesktop={hideSidebarToggleOnDesktop}
/>
<ThreadViewport
messages={messages}
isStreaming={isStreaming}
emptyState={emptyState}
composer={
session ? (
<ThreadComposer
onSend={send}
disabled={!chatId}
placeholder={showHeroComposer ? "What's on your mind?" : "Type your message…"}
modelLabel={toModelBadgeLabel(modelName)}
variant={showHeroComposer ? "hero" : "thread"}
/>
) : (
<ThreadComposer
onSend={handleWelcomeSend}
disabled={booting}
placeholder={booting ? "Opening a new chat…" : "What's on your mind?"}
modelLabel={toModelBadgeLabel(modelName)}
variant="hero"
/>
)
}
/>
</section>
);
}
@@ -0,0 +1,114 @@
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { ArrowDown } from "lucide-react";
import { ThreadMessages } from "@/components/thread/ThreadMessages";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { UIMessage } from "@/lib/types";
interface ThreadViewportProps {
messages: UIMessage[];
isStreaming: boolean;
composer: ReactNode;
emptyState?: ReactNode;
}
const NEAR_BOTTOM_PX = 48;
export function ThreadViewport({
messages,
isStreaming,
composer,
emptyState,
}: ThreadViewportProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [atBottom, setAtBottom] = useState(true);
const hasMessages = messages.length > 0;
const scrollToBottom = useCallback((smooth = false) => {
const el = scrollRef.current;
if (!el) return;
el.scrollTo({
top: el.scrollHeight,
behavior: smooth ? "smooth" : "auto",
});
}, []);
useEffect(() => {
if (!atBottom) return;
scrollToBottom(!isStreaming);
}, [messages, isStreaming, atBottom, scrollToBottom]);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const onScroll = () => {
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
setAtBottom(distance < NEAR_BOTTOM_PX);
};
onScroll();
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, []);
return (
<div className="relative flex min-h-0 flex-1 overflow-hidden">
<div
ref={scrollRef}
className={cn(
"absolute inset-0 overflow-y-auto scroll-smooth scrollbar-thin",
"[&::-webkit-scrollbar]:w-1.5",
"[&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30",
"[&::-webkit-scrollbar-track]:bg-transparent",
)}
>
{hasMessages ? (
<div className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col">
<div className="flex-1 px-4 pb-20 pt-4">
<ThreadMessages messages={messages} />
</div>
<div className="sticky bottom-0 z-10 mt-auto">
<div className="px-4 pb-3">
{composer}
</div>
</div>
</div>
) : (
<div className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col px-4">
<div className="flex w-full flex-1 justify-center pb-16 pt-14 md:pt-[3.5rem]">
<div className="flex w-full max-w-[40rem] flex-col gap-5">
{emptyState}
<div className="w-full">{composer}</div>
</div>
</div>
</div>
)}
</div>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent"
/>
{!atBottom && (
<Button
variant="outline"
size="icon"
onClick={() => scrollToBottom(true)}
className={cn(
"absolute bottom-28 left-1/2 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
"bg-background/90 backdrop-blur",
"animate-in fade-in-0 zoom-in-95",
)}
aria-label="Scroll to bottom"
>
<ArrowDown className="h-4 w-4" />
</Button>
)}
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
{...props}
/>
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className,
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-9 w-9 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted text-xs font-medium",
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarFallback, AvatarImage };
+56
View File
@@ -0,0 +1,56 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
+116
View File
@@ -0,0 +1,116 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+177
View File
@@ -0,0 +1,177 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
+109
View File
@@ -0,0 +1,109 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const Sheet = DialogPrimitive.Root;
const SheetTrigger = DialogPrimitive.Trigger;
const SheetClose = DialogPrimitive.Close;
const SheetPortal = DialogPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/40 backdrop-blur-sm",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out",
{
variants: {
side: {
top: cn(
"inset-x-0 top-0 border-b",
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
),
bottom: cn(
"inset-x-0 bottom-0 border-t",
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
),
left: cn(
"inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
),
right: cn(
"inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
),
},
},
defaultVariants: {
side: "right",
},
},
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
sheetVariants({ side }),
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"duration-300",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = DialogPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
SheetHeader.displayName = "SheetHeader";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
));
SheetTitle.displayName = DialogPrimitive.Title.displayName;
export { Sheet, SheetTrigger, SheetClose, SheetPortal, SheetOverlay, SheetContent, SheetHeader, SheetTitle };
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = "Textarea";
export { Textarea };
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95",
className,
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
+113
View File
@@ -0,0 +1,113 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Design tokens — HSL form, sourced from shadcn/ui's "neutral" palette. */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.4375rem;
--sidebar: 0 0% 98%;
--sidebar-foreground: 0 0% 3.9%;
--sidebar-accent: 0 0% 96.1%;
--sidebar-accent-foreground: 0 0% 9%;
--sidebar-border: 0 0% 89.8%;
}
.dark {
--background: 0 0% 10%;
--foreground: 0 0% 98%;
--card: 0 0% 12%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 12%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 12%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 13%;
--muted-foreground: 0 0% 60%;
--accent: 0 0% 15%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 18%;
--input: 0 0% 18%;
--ring: 0 0% 83.1%;
--sidebar: 0 0% 12%;
--sidebar-foreground: 0 0% 98%;
--sidebar-accent: 0 0% 16%;
--sidebar-accent-foreground: 0 0% 98%;
--sidebar-border: 0 0% 18%;
}
}
@layer base {
* {
@apply border-border;
}
html,
body,
#root {
@apply h-full;
}
body {
@apply bg-background text-foreground antialiased;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif,
"Apple Color Emoji", "Segoe UI Emoji";
}
::selection {
@apply bg-primary/15;
}
}
@layer utilities {
.shadow-inner-right {
box-shadow: inset -9px 0 6px -1px rgb(0 0 0 / 0.02);
}
/* Markdown body styles, ported from agent-chat-ui's markdown-styles.css. */
.markdown-content > :first-child {
@apply mt-0;
}
.markdown-content > :last-child {
@apply mb-0;
}
/* Subtle scrollbar that doesn't fight the dark background. */
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: hsl(var(--muted-foreground) / 0.4) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: hsl(var(--muted-foreground) / 0.4);
border-radius: 9999px;
}
}
+165
View File
@@ -0,0 +1,165 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useClient } from "@/providers/ClientProvider";
import type { InboundEvent, UIMessage } from "@/lib/types";
interface StreamBuffer {
/** ID of the assistant message currently receiving deltas. */
messageId: string;
/** Sequence of deltas accumulated in order. */
parts: string[];
}
/**
* Subscribe to a chat by ID. Returns the in-memory message list for the chat,
* a streaming flag, and a ``send`` function. Initial history must be seeded
* separately (e.g. via ``fetchSessionMessages``) since the server only replays
* live events.
*/
export function useNanobotStream(
chatId: string | null,
initialMessages: UIMessage[] = [],
): {
messages: UIMessage[];
isStreaming: boolean;
send: (content: string) => void;
setMessages: React.Dispatch<React.SetStateAction<UIMessage[]>>;
} {
const { client } = useClient();
const [messages, setMessages] = useState<UIMessage[]>(initialMessages);
const [isStreaming, setIsStreaming] = useState(false);
const buffer = useRef<StreamBuffer | null>(null);
// Reset local state when switching chats.
useEffect(() => {
setMessages(initialMessages);
setIsStreaming(false);
buffer.current = null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatId]);
useEffect(() => {
if (!chatId) return;
const handle = (ev: InboundEvent) => {
if (ev.event === "delta") {
const id = buffer.current?.messageId ?? crypto.randomUUID();
if (!buffer.current) {
buffer.current = { messageId: id, parts: [] };
setMessages((prev) => [
...prev,
{
id,
role: "assistant",
content: "",
isStreaming: true,
createdAt: Date.now(),
},
]);
setIsStreaming(true);
}
buffer.current.parts.push(ev.text);
const combined = buffer.current.parts.join("");
const targetId = buffer.current.messageId;
setMessages((prev) =>
prev.map((m) => (m.id === targetId ? { ...m, content: combined } : m)),
);
return;
}
if (ev.event === "stream_end") {
if (!buffer.current) {
setIsStreaming(false);
return;
}
const finalId = buffer.current.messageId;
buffer.current = null;
setIsStreaming(false);
setMessages((prev) =>
prev.map((m) =>
m.id === finalId ? { ...m, isStreaming: false } : m,
),
);
return;
}
if (ev.event === "message") {
// Intermediate agent breadcrumbs (tool-call hints, raw progress).
// Attach them to the last trace row if it was the last emitted item
// so a sequence of calls collapses into one compact trace group.
if (ev.kind === "tool_hint" || ev.kind === "progress") {
const line = ev.text;
setMessages((prev) => {
const last = prev[prev.length - 1];
if (last && last.kind === "trace" && !last.isStreaming) {
const merged: UIMessage = {
...last,
traces: [...(last.traces ?? [last.content]), line],
content: line,
};
return [...prev.slice(0, -1), merged];
}
return [
...prev,
{
id: crypto.randomUUID(),
role: "tool",
kind: "trace",
content: line,
traces: [line],
createdAt: Date.now(),
},
];
});
return;
}
// A complete (non-streamed) assistant message. If a stream was in
// flight, drop the placeholder so we don't render the text twice.
const activeId = buffer.current?.messageId;
buffer.current = null;
setIsStreaming(false);
setMessages((prev) => {
const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
return [
...filtered,
{
id: crypto.randomUUID(),
role: "assistant",
content: ev.text,
createdAt: Date.now(),
},
];
});
return;
}
// ``attached`` / ``error`` frames aren't actionable here; the client
// shell handles them separately.
};
const unsub = client.onChat(chatId, handle);
return () => {
unsub();
buffer.current = null;
};
}, [chatId, client]);
const send = useCallback(
(content: string) => {
if (!chatId || !content.trim()) return;
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: "user",
content,
createdAt: Date.now(),
},
]);
client.sendMessage(chatId, content);
},
[chatId, client],
);
return { messages, isStreaming, send, setMessages };
}
+187
View File
@@ -0,0 +1,187 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useClient } from "@/providers/ClientProvider";
import {
ApiError,
deleteSession as apiDeleteSession,
fetchSessionMessages,
listSessions,
} from "@/lib/api";
import { deriveTitle } from "@/lib/format";
import type { ChatSummary, UIMessage } from "@/lib/types";
/** Sidebar state: fetches the full session list and exposes create / delete actions. */
export function useSessions(): {
sessions: ChatSummary[];
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
createChat: () => Promise<string>;
deleteChat: (key: string) => Promise<void>;
} {
const { client, token } = useClient();
const [sessions, setSessions] = useState<ChatSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const tokenRef = useRef(token);
tokenRef.current = token;
const refresh = useCallback(async () => {
try {
setLoading(true);
const rows = await listSessions(tokenRef.current);
setSessions(rows);
setError(null);
} catch (e) {
const msg =
e instanceof ApiError ? `HTTP ${e.status}` : (e as Error).message;
setError(msg);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const createChat = useCallback(async (): Promise<string> => {
const chatId = await client.newChat();
const key = `websocket:${chatId}`;
// Optimistic insert; a subsequent refresh will replace it with the
// authoritative row once the server persists the session.
setSessions((prev) => [
{
key,
channel: "websocket",
chatId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
preview: "",
},
...prev.filter((s) => s.key !== key),
]);
return chatId;
}, [client]);
const deleteChat = useCallback(
async (key: string) => {
await apiDeleteSession(tokenRef.current, key);
setSessions((prev) => prev.filter((s) => s.key !== key));
},
[],
);
return { sessions, loading, error, refresh, createChat, deleteChat };
}
/** Lazy-load a session's on-disk messages the first time the UI displays it. */
export function useSessionHistory(key: string | null): {
messages: UIMessage[];
loading: boolean;
error: string | null;
} {
const { token } = useClient();
const [state, setState] = useState<{
key: string | null;
messages: UIMessage[];
loading: boolean;
error: string | null;
}>({
key: null,
messages: [],
loading: false,
error: null,
});
useEffect(() => {
if (!key) {
setState({
key: null,
messages: [],
loading: false,
error: null,
});
return;
}
let cancelled = false;
// Mark the new key as loading immediately so callers never see stale
// messages from the previous session during the render right after a switch.
setState({
key,
messages: [],
loading: true,
error: null,
});
(async () => {
try {
const body = await fetchSessionMessages(token, key);
if (cancelled) return;
const ui: UIMessage[] = body.messages.flatMap((m, idx) => {
if (m.role !== "user" && m.role !== "assistant") return [];
if (typeof m.content !== "string") return [];
return [
{
id: `hist-${idx}`,
role: m.role,
content: m.content,
createdAt: m.timestamp ? Date.parse(m.timestamp) : Date.now(),
},
];
});
setState({
key,
messages: ui,
loading: false,
error: null,
});
} catch (e) {
if (cancelled) return;
// A 404 just means the session hasn't been persisted yet (brand-new
// chat, first message not sent). That's a normal state, not an error.
if (e instanceof ApiError && e.status === 404) {
setState({
key,
messages: [],
loading: false,
error: null,
});
} else {
setState({
key,
messages: [],
loading: false,
error: (e as Error).message,
});
}
}
})();
return () => {
cancelled = true;
};
}, [key, token]);
if (!key) {
return { messages: [], loading: false, error: null };
}
// Even before the effect above commits its loading state, never surface the
// previous session's payload for a brand-new key.
if (state.key !== key) {
return { messages: [], loading: true, error: null };
}
return {
messages: state.messages,
loading: state.loading,
error: state.error,
};
}
/** Produce a compact display title for a session. */
export function sessionTitle(
session: ChatSummary,
firstUserMessage?: string,
): string {
return deriveTitle(firstUserMessage || session.preview, "New chat");
}
+48
View File
@@ -0,0 +1,48 @@
import { useCallback, useEffect, useState } from "react";
type Theme = "light" | "dark";
const STORAGE_KEY = "nanobot-webui.theme";
function readStored(): Theme | null {
try {
const v = localStorage.getItem(STORAGE_KEY);
return v === "light" || v === "dark" ? v : null;
} catch {
return null;
}
}
function applyTheme(theme: Theme): void {
const root = document.documentElement;
if (theme === "dark") root.classList.add("dark");
else root.classList.remove("dark");
}
export function useTheme(): { theme: Theme; toggle: () => void; setTheme: (t: Theme) => void } {
const [theme, setThemeState] = useState<Theme>(() => {
const stored = readStored();
if (stored) return stored;
if (typeof window !== "undefined" && window.matchMedia) {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
return "light";
});
useEffect(() => {
applyTheme(theme);
try {
localStorage.setItem(STORAGE_KEY, theme);
} catch {
// ignore
}
}, [theme]);
const setTheme = useCallback((t: Theme) => setThemeState(t), []);
const toggle = useCallback(
() => setThemeState((t) => (t === "dark" ? "light" : "dark")),
[],
);
return { theme, toggle, setTheme };
}
+93
View File
@@ -0,0 +1,93 @@
import type { ChatSummary } from "./types";
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.name = "ApiError";
}
}
async function request<T>(
url: string,
token: string,
init?: RequestInit,
): Promise<T> {
const res = await fetch(url, {
...(init ?? {}),
headers: {
...(init?.headers ?? {}),
Authorization: `Bearer ${token}`,
},
credentials: "same-origin",
});
if (!res.ok) {
throw new ApiError(res.status, `HTTP ${res.status}`);
}
return (await res.json()) as T;
}
function splitKey(key: string): { channel: string; chatId: string } {
const idx = key.indexOf(":");
if (idx === -1) return { channel: "", chatId: key };
return { channel: key.slice(0, idx), chatId: key.slice(idx + 1) };
}
export async function listSessions(
token: string,
base: string = "",
): Promise<ChatSummary[]> {
type Row = {
key: string;
created_at: string | null;
updated_at: string | null;
preview?: string;
};
const body = await request<{ sessions: Row[] }>(
`${base}/api/sessions`,
token,
);
return body.sessions.map((s) => ({
key: s.key,
...splitKey(s.key),
createdAt: s.created_at,
updatedAt: s.updated_at,
preview: s.preview ?? "",
}));
}
export async function fetchSessionMessages(
token: string,
key: string,
base: string = "",
): Promise<{
key: string;
created_at: string | null;
updated_at: string | null;
messages: Array<{
role: string;
content: string;
timestamp?: string;
tool_calls?: unknown;
tool_call_id?: string;
name?: string;
}>;
}> {
return request(
`${base}/api/sessions/${encodeURIComponent(key)}/messages`,
token,
);
}
export async function deleteSession(
token: string,
key: string,
base: string = "",
): Promise<boolean> {
const body = await request<{ deleted: boolean }>(
`${base}/api/sessions/${encodeURIComponent(key)}/delete`,
token,
);
return body.deleted;
}
+40
View File
@@ -0,0 +1,40 @@
import type { BootstrapResponse } from "./types";
/**
* Fetch a short-lived token + the WebSocket path from the gateway's
* ``/webui/bootstrap`` endpoint. Localhost-only on the server side.
*/
export async function fetchBootstrap(
baseUrl: string = "",
): Promise<BootstrapResponse> {
const res = await fetch(`${baseUrl}/webui/bootstrap`, {
method: "GET",
credentials: "same-origin",
});
if (!res.ok) {
throw new Error(`bootstrap failed: HTTP ${res.status}`);
}
const body = (await res.json()) as BootstrapResponse;
if (!body.token || !body.ws_path) {
throw new Error("bootstrap response missing token or ws_path");
}
return body;
}
/** Derive a WebSocket URL from the current window location and the server-provided path.
*
* Keeps the path segment exactly as the server registered it: the root ``/``
* stays ``/`` and non-root paths are not given an extra trailing slash. This
* matters because some WS servers dispatch handshakes based on the literal
* path, not a normalised form.
*/
export function deriveWsUrl(wsPath: string, token: string): string {
const path = wsPath && wsPath.startsWith("/") ? wsPath : `/${wsPath || ""}`;
const query = `?token=${encodeURIComponent(token)}`;
if (typeof window === "undefined") {
return `ws://127.0.0.1:8765${path}${query}`;
}
const scheme = window.location.protocol === "https:" ? "wss" : "ws";
const host = window.location.host;
return `${scheme}://${host}${path}${query}`;
}
+44
View File
@@ -0,0 +1,44 @@
/** Truncate the first user message into a chat title. */
export function deriveTitle(preview: string | undefined, fallback: string): string {
if (!preview) return fallback;
const oneLine = preview.replace(/\s+/g, " ").trim();
if (!oneLine) return fallback;
return oneLine.length > 60 ? `${oneLine.slice(0, 57)}` : oneLine;
}
/** Loose ISO-or-epoch parser; returns ``null`` for missing/invalid input. */
function parseDate(value: string | number | null | undefined): Date | null {
if (value === null || value === undefined || value === "") return null;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d;
}
const RELATIVE_THRESHOLDS: [number, Intl.RelativeTimeFormatUnit][] = [
[60, "second"],
[60, "minute"],
[24, "hour"],
[7, "day"],
[4.345, "week"],
[12, "month"],
[Number.POSITIVE_INFINITY, "year"],
];
const RTF = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
export function relativeTime(value: string | number | null | undefined): string {
const date = parseDate(value);
if (!date) return "";
let delta = (date.getTime() - Date.now()) / 1000;
for (const [step, unit] of RELATIVE_THRESHOLDS) {
if (Math.abs(delta) < step) {
return RTF.format(Math.round(delta), unit);
}
delta /= step;
}
return RTF.format(Math.round(delta), "year");
}
export function fmtDateTime(value: string | number | null | undefined): string {
const date = parseDate(value);
return date ? date.toLocaleString() : "";
}
+264
View File
@@ -0,0 +1,264 @@
import type { ConnectionStatus, InboundEvent, Outbound } from "./types";
/** WebSocket readyState constants, referenced by value to stay portable
* across runtimes that don't expose a global ``WebSocket`` (tests, SSR). */
const WS_OPEN = 1;
const WS_CLOSING = 2;
type Unsubscribe = () => void;
type EventHandler = (ev: InboundEvent) => void;
type StatusHandler = (status: ConnectionStatus) => void;
interface PendingNewChat {
resolve: (chatId: string) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
export interface NanobotClientOptions {
url: string;
reconnect?: boolean;
/** Called when a connection drops so the app can refresh its token. */
onReauth?: () => Promise<string | null>;
/** Inject a custom WebSocket factory (used by unit tests). */
socketFactory?: (url: string) => WebSocket;
/** Delay-cap for reconnect backoff (ms). */
maxBackoffMs?: number;
}
/**
* Singleton WebSocket client that multiplexes chat streams.
*
* One socket carries many chat_ids: the server tags every outbound event with
* ``chat_id``, and this class fans those events out to handlers registered
* per chat. Reconnects are transparent and re-attach every known chat_id.
*/
export class NanobotClient {
private socket: WebSocket | null = null;
private statusHandlers = new Set<StatusHandler>();
// chat_id -> handlers listening on it
private chatHandlers = new Map<string, Set<EventHandler>>();
// chat_ids we've attached to since connect; re-attached after reconnects
private knownChats = new Set<string>();
private pendingNewChat: PendingNewChat | null = null;
// Frames queued while the socket is not yet OPEN
private sendQueue: Outbound[] = [];
private reconnectAttempts = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private readonly shouldReconnect: boolean;
private readonly maxBackoffMs: number;
private readonly socketFactory: (url: string) => WebSocket;
private currentUrl: string;
private status_: ConnectionStatus = "idle";
private readyChatId: string | null = null;
// Set by ``close()`` so the onclose handler knows the drop was intentional
// and must not schedule a reconnect or flip status back to "reconnecting".
private intentionallyClosed = false;
constructor(private options: NanobotClientOptions) {
this.shouldReconnect = options.reconnect ?? true;
this.maxBackoffMs = options.maxBackoffMs ?? 15_000;
this.socketFactory =
options.socketFactory ?? ((url) => new WebSocket(url));
this.currentUrl = options.url;
}
get status(): ConnectionStatus {
return this.status_;
}
get defaultChatId(): string | null {
return this.readyChatId;
}
/** Swap the URL (e.g. after fetching a fresh token) then reconnect. */
updateUrl(url: string): void {
this.currentUrl = url;
}
onStatus(handler: StatusHandler): Unsubscribe {
this.statusHandlers.add(handler);
handler(this.status_);
return () => {
this.statusHandlers.delete(handler);
};
}
/** Subscribe to events for a given chat_id. Auto-attaches on the next open. */
onChat(chatId: string, handler: EventHandler): Unsubscribe {
let handlers = this.chatHandlers.get(chatId);
if (!handlers) {
handlers = new Set();
this.chatHandlers.set(chatId, handlers);
}
handlers.add(handler);
this.attach(chatId);
return () => {
const current = this.chatHandlers.get(chatId);
if (!current) return;
current.delete(handler);
if (current.size === 0) this.chatHandlers.delete(chatId);
};
}
connect(): void {
if (this.socket && this.socket.readyState < WS_CLOSING) return;
this.intentionallyClosed = false;
this.setStatus("connecting");
const sock = this.socketFactory(this.currentUrl);
this.socket = sock;
sock.onopen = () => this.handleOpen();
sock.onmessage = (ev) => this.handleMessage(ev);
sock.onerror = () => this.setStatus("error");
sock.onclose = () => this.handleClose();
}
close(): void {
this.intentionallyClosed = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
const sock = this.socket;
this.socket = null;
try {
sock?.close();
} catch {
// ignore
}
this.setStatus("closed");
}
/** Ask the server to provision a new chat_id; resolves with the assigned id. */
newChat(timeoutMs: number = 5_000): Promise<string> {
if (this.pendingNewChat) {
return Promise.reject(new Error("newChat already in flight"));
}
return new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingNewChat = null;
reject(new Error("newChat timed out"));
}, timeoutMs);
this.pendingNewChat = { resolve, reject, timer };
this.queueSend({ type: "new_chat" });
});
}
attach(chatId: string): void {
this.knownChats.add(chatId);
if (this.socket?.readyState === WS_OPEN) {
this.queueSend({ type: "attach", chat_id: chatId });
}
}
sendMessage(chatId: string, content: string): void {
this.knownChats.add(chatId);
this.queueSend({ type: "message", chat_id: chatId, content });
}
// -- internals ---------------------------------------------------------
private setStatus(status: ConnectionStatus): void {
if (this.status_ === status) return;
this.status_ = status;
for (const handler of this.statusHandlers) handler(status);
}
private handleOpen(): void {
this.setStatus("open");
this.reconnectAttempts = 0;
// Re-attach every known chat_id so deliveries continue routing after a drop.
for (const chatId of this.knownChats) {
this.rawSend({ type: "attach", chat_id: chatId });
}
// Flush anything queued during reconnect.
const queued = this.sendQueue.splice(0);
for (const frame of queued) this.rawSend(frame);
}
private handleMessage(ev: MessageEvent): void {
let parsed: InboundEvent;
try {
parsed = JSON.parse(typeof ev.data === "string" ? ev.data : "") as InboundEvent;
} catch {
return;
}
if (parsed.event === "ready") {
this.readyChatId = parsed.chat_id;
this.knownChats.add(parsed.chat_id);
return;
}
if (parsed.event === "attached") {
this.knownChats.add(parsed.chat_id);
if (this.pendingNewChat) {
clearTimeout(this.pendingNewChat.timer);
this.pendingNewChat.resolve(parsed.chat_id);
this.pendingNewChat = null;
}
this.dispatch(parsed.chat_id, parsed);
return;
}
const chatId = (parsed as { chat_id?: string }).chat_id;
if (chatId) this.dispatch(chatId, parsed);
}
private dispatch(chatId: string, ev: InboundEvent): void {
const handlers = this.chatHandlers.get(chatId);
if (!handlers) return;
for (const h of handlers) h(ev);
}
private handleClose(): void {
this.socket = null;
if (this.pendingNewChat) {
clearTimeout(this.pendingNewChat.timer);
this.pendingNewChat.reject(new Error("socket closed"));
this.pendingNewChat = null;
}
if (this.intentionallyClosed || !this.shouldReconnect) {
this.setStatus("closed");
return;
}
this.scheduleReconnect();
}
private scheduleReconnect(): void {
this.setStatus("reconnecting");
const attempt = this.reconnectAttempts++;
// Exponential backoff: 0.5s, 1s, 2s, 4s, capped.
const delay = Math.min(500 * 2 ** attempt, this.maxBackoffMs);
this.reconnectTimer = setTimeout(async () => {
this.reconnectTimer = null;
if (this.options.onReauth) {
try {
const refreshed = await this.options.onReauth();
if (refreshed) this.currentUrl = refreshed;
} catch {
// fall through to retry with current URL
}
}
this.connect();
}, delay);
}
private queueSend(frame: Outbound): void {
if (this.socket?.readyState === WS_OPEN) {
this.rawSend(frame);
} else {
this.sendQueue.push(frame);
}
}
private rawSend(frame: Outbound): void {
if (!this.socket) return;
try {
this.socket.send(JSON.stringify(frame));
} catch {
// Send failure will materialize as a close; queue the frame for retry.
this.sendQueue.push(frame);
}
}
}
+74
View File
@@ -0,0 +1,74 @@
export type Role = "user" | "assistant" | "tool" | "system";
/** "trace" rows are intermediate agent breadcrumbs (tool-call hints,
* progress pings) that should not be rendered as conversational replies. */
export type MessageKind = "message" | "trace";
export interface UIMessage {
id: string;
role: Role;
content: string;
kind?: MessageKind;
isStreaming?: boolean;
createdAt: number;
/** For trace rows: each individual hint line, so consecutive hints can
* render as a single collapsible group. */
traces?: string[];
}
export interface ChatSummary {
/** Server-side session key, e.g. ``websocket:abcd-...``. */
key: string;
/** Local channel + chat_id parts derived from ``key`` for convenience. */
channel: string;
chatId: string;
createdAt: string | null;
updatedAt: string | null;
preview: string;
}
export interface BootstrapResponse {
token: string;
ws_path: string;
expires_in: number;
model_name?: string | null;
}
export type ConnectionStatus =
| "idle"
| "connecting"
| "open"
| "reconnecting"
| "closed"
| "error";
export type InboundEvent =
| { event: "ready"; chat_id: string; client_id: string }
| { event: "attached"; chat_id: string }
| {
event: "message";
chat_id: string;
text: string;
reply_to?: string;
media?: string[];
/** Present when the frame is an agent breadcrumb (e.g. tool hint,
* generic progress line) rather than a conversational reply. */
kind?: "tool_hint" | "progress";
}
| {
event: "delta";
chat_id: string;
text: string;
stream_id?: string;
}
| {
event: "stream_end";
chat_id: string;
stream_id?: string;
}
| { event: "error"; chat_id?: string; detail?: string };
export type Outbound =
| { type: "new_chat" }
| { type: "attach"; chat_id: string }
| { type: "message"; chat_id: string; content: string };
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
+14
View File
@@ -0,0 +1,14 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./globals.css";
const root = document.getElementById("root");
if (!root) throw new Error("root element missing");
ReactDOM.createRoot(root).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+37
View File
@@ -0,0 +1,37 @@
import { createContext, useContext, type ReactNode } from "react";
import type { NanobotClient } from "@/lib/nanobot-client";
interface ClientContextValue {
client: NanobotClient;
token: string;
modelName: string | null;
}
const ClientContext = createContext<ClientContextValue | null>(null);
export function ClientProvider({
client,
token,
modelName = null,
children,
}: {
client: NanobotClient;
token: string;
modelName?: string | null;
children: ReactNode;
}) {
return (
<ClientContext.Provider value={{ client, token, modelName }}>
{children}
</ClientContext.Provider>
);
}
export function useClient(): ClientContextValue {
const ctx = useContext(ClientContext);
if (!ctx) {
throw new Error("useClient must be used within a ClientProvider");
}
return ctx;
}
+37
View File
@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { deleteSession, fetchSessionMessages } from "@/lib/api";
describe("webui API helpers", () => {
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ deleted: true, key: "websocket:chat-1", messages: [] }),
}),
);
});
it("percent-encodes websocket keys when fetching session history", async () => {
await fetchSessionMessages("tok", "websocket:chat-1");
expect(fetch).toHaveBeenCalledWith(
"/api/sessions/websocket%3Achat-1/messages",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("percent-encodes websocket keys when deleting a session", async () => {
await deleteSession("tok", "websocket:chat-1");
expect(fetch).toHaveBeenCalledWith(
"/api/sessions/websocket%3Achat-1/delete",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
});
+148
View File
@@ -0,0 +1,148 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatSummary } from "@/lib/types";
const connectSpy = vi.fn();
const refreshSpy = vi.fn();
const createChatSpy = vi.fn().mockResolvedValue("chat-1");
const deleteChatSpy = vi.fn();
let mockSessions: ChatSummary[] = [];
vi.mock("@/hooks/useSessions", async (importOriginal) => {
const React = await import("react");
const actual = await importOriginal<typeof import("@/hooks/useSessions")>();
return {
...actual,
useSessions: () => {
const [sessions, setSessions] = React.useState(mockSessions);
return {
sessions,
loading: false,
error: null,
refresh: refreshSpy,
createChat: createChatSpy,
deleteChat: async (key: string) => {
await deleteChatSpy(key);
setSessions((prev: ChatSummary[]) => prev.filter((s) => s.key !== key));
},
};
},
};
});
vi.mock("@/hooks/useTheme", () => ({
useTheme: () => ({
theme: "light" as const,
toggle: vi.fn(),
}),
}));
vi.mock("@/lib/bootstrap", () => ({
fetchBootstrap: vi.fn().mockResolvedValue({
token: "tok",
ws_path: "/",
expires_in: 300,
}),
deriveWsUrl: vi.fn(() => "ws://test"),
}));
vi.mock("@/lib/nanobot-client", () => {
class MockClient {
status = "idle" as const;
defaultChatId: string | null = null;
connect = connectSpy;
onStatus = () => () => {};
onChat = () => () => {};
sendMessage = vi.fn();
newChat = vi.fn();
attach = vi.fn();
close = vi.fn();
updateUrl = vi.fn();
}
return { NanobotClient: MockClient };
});
import App from "@/App";
describe("App layout", () => {
beforeEach(() => {
mockSessions = [];
connectSpy.mockClear();
refreshSpy.mockReset();
createChatSpy.mockClear();
deleteChatSpy.mockReset();
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 404,
}),
);
});
it("keeps sidebar layout out of the main thread width contract", async () => {
const { container } = render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const main = container.querySelector("main");
expect(main).toBeInTheDocument();
expect(main).not.toHaveAttribute("style");
const asideClassNames = Array.from(container.querySelectorAll("aside")).map(
(el) => el.className,
);
expect(asideClassNames.some((cls) => cls.includes("lg:block"))).toBe(true);
});
it("switches to the next session when deleting the active chat", async () => {
mockSessions = [
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
preview: "First chat",
},
{
key: "websocket:chat-b",
channel: "websocket",
chatId: "chat-b",
createdAt: "2026-04-16T11:00:00Z",
updatedAt: "2026-04-16T11:00:00Z",
preview: "Second chat",
},
];
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
await waitFor(() =>
expect(screen.getByRole("button", { name: /^First chat$/ })).toBeInTheDocument(),
);
fireEvent.pointerDown(screen.getByLabelText("Chat actions for First chat"), {
button: 0,
});
fireEvent.click(await screen.findByRole("menuitem", { name: "Delete" }));
await waitFor(() =>
expect(screen.getByText('Delete “First chat”?')).toBeInTheDocument(),
);
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() =>
expect(deleteChatSpy).toHaveBeenCalledWith("websocket:chat-a"),
);
await waitFor(() =>
expect(
screen.getByRole("button", { name: /^Second chat$/ }),
).toBeInTheDocument(),
);
expect(screen.queryByText('Delete “First chat”?')).not.toBeInTheDocument();
expect(document.body.style.pointerEvents).not.toBe("none");
});
});
+43
View File
@@ -0,0 +1,43 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { MessageBubble } from "@/components/MessageBubble";
import type { UIMessage } from "@/lib/types";
describe("MessageBubble", () => {
it("renders user messages as right-aligned pills", () => {
const message: UIMessage = {
id: "u1",
role: "user",
content: "hello",
createdAt: Date.now(),
};
const { container } = render(<MessageBubble message={message} />);
const row = container.firstElementChild;
const pill = screen.getByText("hello");
expect(row).toHaveClass("ml-auto", "flex");
expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]");
});
it("renders trace messages as collapsible tool groups", () => {
const message: UIMessage = {
id: "t1",
role: "tool",
kind: "trace",
content: 'search "hk weather"',
traces: ['weather("get")', 'search "hk weather"'],
createdAt: Date.now(),
};
render(<MessageBubble message={message} />);
const toggle = screen.getByRole("button", { name: /used 2 tools/i });
expect(screen.getByText('weather("get")')).toBeInTheDocument();
expect(screen.getByText('search "hk weather"')).toBeInTheDocument();
fireEvent.click(toggle);
expect(screen.queryByText('weather("get")')).not.toBeInTheDocument();
});
});
+192
View File
@@ -0,0 +1,192 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NanobotClient } from "@/lib/nanobot-client";
/**
* Minimal fake WebSocket implementing the subset NanobotClient touches.
* Every instance is retrievable via ``FakeSocket.instances`` so tests can
* drive open/close/message lifecycles deterministically.
*/
class FakeSocket {
static instances: FakeSocket[] = [];
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
url: string;
readyState = FakeSocket.CONNECTING;
sent: string[] = [];
onopen: (() => void) | null = null;
onmessage: ((ev: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
onclose: (() => void) | null = null;
constructor(url: string) {
this.url = url;
FakeSocket.instances.push(this);
}
send(data: string) {
this.sent.push(data);
}
close() {
this.readyState = FakeSocket.CLOSED;
this.onclose?.();
}
fakeOpen() {
this.readyState = FakeSocket.OPEN;
this.onopen?.();
}
fakeMessage(payload: unknown) {
this.onmessage?.({ data: JSON.stringify(payload) } as MessageEvent);
}
}
function lastSocket(): FakeSocket {
const s = FakeSocket.instances.at(-1);
if (!s) throw new Error("no socket created yet");
return s;
}
beforeEach(() => {
FakeSocket.instances = [];
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe("NanobotClient", () => {
it("routes events to the matching chat handler", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const handler = vi.fn();
client.onChat("chat-a", handler);
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({ event: "message", chat_id: "chat-a", text: "hi" });
lastSocket().fakeMessage({ event: "message", chat_id: "chat-b", text: "no" });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0]).toMatchObject({
event: "message",
chat_id: "chat-a",
text: "hi",
});
});
it("resolves newChat() via the server-assigned chat_id", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
const promise = client.newChat(1_000);
expect(lastSocket().sent).toContain(JSON.stringify({ type: "new_chat" }));
lastSocket().fakeMessage({ event: "attached", chat_id: "fresh-id" });
await expect(promise).resolves.toBe("fresh-id");
});
it("queues sends while connecting and flushes on open", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
client.sendMessage("chat-x", "hello");
expect(lastSocket().sent).toEqual([]);
lastSocket().fakeOpen();
// Attach is sent first because sendMessage adds to knownChats, which
// handleOpen re-attaches; then the queued message follows.
expect(lastSocket().sent).toContain(
JSON.stringify({ type: "message", chat_id: "chat-x", content: "hello" }),
);
});
it("re-attaches known chats after a reconnect", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: true,
maxBackoffMs: 10,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.onChat("chat-z", () => {});
client.connect();
lastSocket().fakeOpen();
expect(lastSocket().sent).toContain(
JSON.stringify({ type: "attach", chat_id: "chat-z" }),
);
// Drop the socket.
lastSocket().close();
// Advance the backoff timer.
await vi.advanceTimersByTimeAsync(20);
const reconnected = lastSocket();
expect(reconnected).not.toBe(FakeSocket.instances[0]);
reconnected.fakeOpen();
expect(reconnected.sent).toContain(
JSON.stringify({ type: "attach", chat_id: "chat-z" }),
);
});
it("reports status transitions through onStatus", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const seen: string[] = [];
client.onStatus((s) => seen.push(s));
client.connect();
lastSocket().fakeOpen();
lastSocket().close();
expect(seen).toEqual(["idle", "connecting", "open", "closed"]);
});
it("does not schedule a reconnect when close() is called explicitly", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: true,
maxBackoffMs: 10,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const seen: string[] = [];
client.onStatus((s) => seen.push(s));
client.connect();
lastSocket().fakeOpen();
client.close();
// Advance past any possible backoff window to prove no reconnect was scheduled.
await vi.advanceTimersByTimeAsync(200);
expect(FakeSocket.instances).toHaveLength(1);
// "reconnecting" must never appear after an intentional close.
expect(seen).not.toContain("reconnecting");
expect(seen.at(-1)).toBe("closed");
});
it("surfaces 'reconnecting' only on an unexpected drop", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: true,
maxBackoffMs: 5,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const seen: string[] = [];
client.onStatus((s) => seen.push(s));
client.connect();
lastSocket().fakeOpen();
// Simulate the remote side hanging up (no client.close() call).
lastSocket().close();
await vi.advanceTimersByTimeAsync(50);
expect(seen).toContain("reconnecting");
expect(FakeSocket.instances.length).toBeGreaterThan(1);
});
});
+14
View File
@@ -0,0 +1,14 @@
import "@testing-library/jest-dom/vitest";
// happy-dom doesn't ship with ``crypto.randomUUID``; shim a tiny v4-ish helper.
if (!("randomUUID" in globalThis.crypto)) {
Object.defineProperty(globalThis.crypto, "randomUUID", {
value: () =>
"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
}),
configurable: true,
});
}
+23
View File
@@ -0,0 +1,23 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
describe("ThreadComposer", () => {
it("renders a readonly hero model composer when provided", () => {
render(
<ThreadComposer
onSend={vi.fn()}
modelLabel="claude-opus-4-5"
placeholder="What's on your mind?"
variant="hero"
/>,
);
expect(screen.getByText("claude-opus-4-5")).toBeInTheDocument();
const input = screen.getByPlaceholderText("What's on your mind?");
expect(input).toBeInTheDocument();
expect(input.className).toContain("min-h-[96px]");
expect(input.parentElement?.className).toContain("max-w-[40rem]");
});
});
+324
View File
@@ -0,0 +1,324 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ThreadShell } from "@/components/thread/ThreadShell";
import { ClientProvider } from "@/providers/ClientProvider";
function makeClient() {
return {
status: "open" as const,
defaultChatId: null as string | null,
onStatus: () => () => {},
onChat: () => () => {},
sendMessage: vi.fn(),
newChat: vi.fn(),
attach: vi.fn(),
connect: vi.fn(),
close: vi.fn(),
updateUrl: vi.fn(),
};
}
function wrap(client: ReturnType<typeof makeClient>, children: ReactNode) {
return (
<ClientProvider
client={client as unknown as import("@/lib/nanobot-client").NanobotClient}
token="tok"
>
{children}
</ClientProvider>
);
}
function session(chatId: string) {
return {
key: `websocket:${chatId}`,
channel: "websocket" as const,
chatId,
createdAt: null,
updatedAt: null,
preview: "",
};
}
function httpJson(body: unknown) {
return {
ok: true,
status: 200,
json: async () => body,
};
}
describe("ThreadShell", () => {
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 404,
json: async () => ({}),
}),
);
});
it("restores in-memory messages when switching away and back to a session", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a");
const { rerender } = render(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
/>,
),
);
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "persist me across tabs" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() =>
expect(client.sendMessage).toHaveBeenCalledWith(
"chat-a",
"persist me across tabs",
),
);
expect(screen.getByText("persist me across tabs")).toBeInTheDocument();
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("chat-b")}
title="Chat chat-b"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
/>,
),
);
});
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
/>,
),
);
});
expect(screen.getByText("persist me across tabs")).toBeInTheDocument();
});
it("clears the old thread when the active session is removed", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a");
const { rerender } = render(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
/>,
),
);
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "delete me cleanly" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() =>
expect(client.sendMessage).toHaveBeenCalledWith(
"chat-a",
"delete me cleanly",
),
);
expect(screen.getByText("delete me cleanly")).toBeInTheDocument();
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={null}
title="nanobot"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
/>,
),
);
});
await waitFor(() => {
expect(screen.queryByText("delete me cleanly")).not.toBeInTheDocument();
});
expect(screen.getByPlaceholderText("What's on your mind?")).toBeInTheDocument();
});
it("does not leak the previous thread when opening a brand-new chat", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-new");
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("websocket%3Achat-a/messages")) {
return httpJson({
key: "websocket:chat-a",
created_at: null,
updated_at: null,
messages: [
{ role: "user", content: "old question" },
{ role: "assistant", content: "old answer" },
],
});
}
return {
ok: false,
status: 404,
json: async () => ({}),
};
}),
);
const { rerender } = render(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
/>,
),
);
await waitFor(() => expect(screen.getByText("old answer")).toBeInTheDocument());
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("chat-new")}
title="Chat chat-new"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
/>,
),
);
});
expect(screen.queryByText("old answer")).not.toBeInTheDocument();
await waitFor(() =>
expect(screen.getByPlaceholderText("What's on your mind?")).toBeInTheDocument(),
);
const input = screen.getByPlaceholderText("What's on your mind?");
expect(input.className).toContain("min-h-[96px]");
expect(screen.queryByText("old answer")).not.toBeInTheDocument();
});
it("clears the previous thread immediately while the next session loads", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-b");
let resolveChatB:
| ((value: { ok: boolean; status: number; json: () => Promise<unknown> }) => void)
| null = null;
vi.stubGlobal(
"fetch",
vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("websocket%3Achat-a/messages")) {
return Promise.resolve(
httpJson({
key: "websocket:chat-a",
created_at: null,
updated_at: null,
messages: [{ role: "assistant", content: "from chat a" }],
}),
);
}
if (url.includes("websocket%3Achat-b/messages")) {
return new Promise((resolve) => {
resolveChatB = resolve;
});
}
return Promise.resolve({
ok: false,
status: 404,
json: async () => ({}),
});
}),
);
const { rerender } = render(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
/>,
),
);
await waitFor(() => expect(screen.getByText("from chat a")).toBeInTheDocument());
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("chat-b")}
title="Chat chat-b"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
/>,
),
);
});
expect(screen.queryByText("from chat a")).not.toBeInTheDocument();
expect(screen.getByText("Loading conversation…")).toBeInTheDocument();
await act(async () => {
resolveChatB?.(
httpJson({
key: "websocket:chat-b",
created_at: null,
updated_at: null,
messages: [{ role: "assistant", content: "from chat b" }],
}),
);
});
await waitFor(() => expect(screen.getByText("from chat b")).toBeInTheDocument());
expect(screen.queryByText("from chat a")).not.toBeInTheDocument();
});
});
+94
View File
@@ -0,0 +1,94 @@
import { act, renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import { useNanobotStream } from "@/hooks/useNanobotStream";
import type { InboundEvent } from "@/lib/types";
import { ClientProvider } from "@/providers/ClientProvider";
function fakeClient() {
const handlers = new Map<string, Set<(ev: InboundEvent) => void>>();
return {
client: {
status: "open" as const,
defaultChatId: null as string | null,
onStatus: () => () => {},
onChat(chatId: string, h: (ev: InboundEvent) => void) {
let set = handlers.get(chatId);
if (!set) {
set = new Set();
handlers.set(chatId, set);
}
set.add(h);
return () => set!.delete(h);
},
sendMessage: vi.fn(),
newChat: vi.fn(),
attach: vi.fn(),
connect: vi.fn(),
close: vi.fn(),
updateUrl: vi.fn(),
},
emit(chatId: string, ev: InboundEvent) {
const set = handlers.get(chatId);
set?.forEach((h) => h(ev));
},
};
}
function wrap(client: ReturnType<typeof fakeClient>["client"]) {
return function Wrapper({ children }: { children: ReactNode }) {
return (
<ClientProvider
client={client as unknown as import("@/lib/nanobot-client").NanobotClient}
token="tok"
>
{children}
</ClientProvider>
);
};
}
describe("useNanobotStream", () => {
it("collapses consecutive tool_hint frames into one trace row", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-t", []), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-t", {
event: "message",
chat_id: "chat-t",
text: 'weather("get")',
kind: "tool_hint",
});
fake.emit("chat-t", {
event: "message",
chat_id: "chat-t",
text: 'search "hk weather"',
kind: "tool_hint",
});
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].kind).toBe("trace");
expect(result.current.messages[0].role).toBe("tool");
expect(result.current.messages[0].traces).toEqual([
'weather("get")',
'search "hk weather"',
]);
act(() => {
fake.emit("chat-t", {
event: "message",
chat_id: "chat-t",
text: "## Summary",
});
});
expect(result.current.messages).toHaveLength(2);
expect(result.current.messages[1].role).toBe("assistant");
expect(result.current.messages[1].kind).toBeUndefined();
});
});
+116
View File
@@ -0,0 +1,116 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useSessions } from "@/hooks/useSessions";
import * as api from "@/lib/api";
import { ClientProvider } from "@/providers/ClientProvider";
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return {
...actual,
listSessions: vi.fn(),
deleteSession: vi.fn(),
fetchSessionMessages: vi.fn(),
};
});
function fakeClient() {
return {
status: "open" as const,
defaultChatId: null as string | null,
onStatus: () => () => {},
onChat: () => () => {},
sendMessage: vi.fn(),
newChat: vi.fn(),
attach: vi.fn(),
connect: vi.fn(),
close: vi.fn(),
updateUrl: vi.fn(),
};
}
function wrap(client: ReturnType<typeof fakeClient>) {
return function Wrapper({ children }: { children: ReactNode }) {
return (
<ClientProvider
client={client as unknown as import("@/lib/nanobot-client").NanobotClient}
token="tok"
>
{children}
</ClientProvider>
);
};
}
describe("useSessions", () => {
beforeEach(() => {
vi.mocked(api.listSessions).mockReset();
vi.mocked(api.deleteSession).mockReset();
vi.mocked(api.fetchSessionMessages).mockReset();
});
it("removes a session from the local list after delete succeeds", async () => {
vi.mocked(api.listSessions).mockResolvedValue([
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
preview: "Alpha",
},
{
key: "websocket:chat-b",
channel: "websocket",
chatId: "chat-b",
createdAt: "2026-04-16T11:00:00Z",
updatedAt: "2026-04-16T11:00:00Z",
preview: "Beta",
},
]);
vi.mocked(api.deleteSession).mockResolvedValue(true);
const { result } = renderHook(() => useSessions(), {
wrapper: wrap(fakeClient()),
});
await waitFor(() => expect(result.current.sessions).toHaveLength(2));
await act(async () => {
await result.current.deleteChat("websocket:chat-a");
});
expect(api.deleteSession).toHaveBeenCalledWith("tok", "websocket:chat-a");
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-b"]);
});
it("keeps the session in the list when delete fails", async () => {
vi.mocked(api.listSessions).mockResolvedValue([
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
preview: "Alpha",
},
]);
vi.mocked(api.deleteSession).mockRejectedValue(new Error("boom"));
const { result } = renderHook(() => useSessions(), {
wrapper: wrap(fakeClient()),
});
await waitFor(() => expect(result.current.sessions).toHaveLength(1));
await expect(
act(async () => {
await result.current.deleteChat("websocket:chat-a");
}),
).rejects.toThrow("boom");
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-a"]);
});
});