feat(webui): polish chat layout and titles

Align the WebUI sidebar and chat chrome with the updated design, and generate WebUI session titles asynchronously without blocking turns.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xubin Ren
2026-05-06 22:20:35 +08:00
committed by Xubin Ren
co-authored by Cursor
parent d8fd4c80bf
commit 790a03ec28
33 changed files with 1270 additions and 312 deletions
+29 -31
View File
@@ -25,7 +25,7 @@ type BootState =
};
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
const SIDEBAR_WIDTH = 279;
const SIDEBAR_WIDTH = 272;
type ShellView = "chat" | "settings";
function readSidebarOpen(): boolean {
@@ -99,13 +99,6 @@ export default function App() {
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" />
@@ -121,13 +114,6 @@ export default function App() {
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">{t("app.error.title")}</p>
<p className="text-sm text-muted-foreground">{state.message}</p>
<p className="text-xs text-muted-foreground">
@@ -213,7 +199,7 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
}
}, []);
const onNewChat = useCallback(async () => {
const onCreateChat = useCallback(async () => {
try {
const chatId = await createChat();
setActiveKey(`websocket:${chatId}`);
@@ -226,6 +212,12 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
}
}, [createChat]);
const onNewChat = useCallback(() => {
setActiveKey(null);
setView("chat");
setMobileSidebarOpen(false);
}, []);
const onSelectChat = useCallback(
(key: string) => {
setActiveKey(key);
@@ -235,6 +227,15 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
[],
);
const onOpenSettings = useCallback(() => {
setView("settings");
setMobileSidebarOpen(false);
}, []);
const onTurnEnd = useCallback(() => {
void refresh();
}, [refresh]);
const onConfirmDelete = useCallback(async () => {
if (!pendingDelete) return;
const key = pendingDelete.key;
@@ -254,7 +255,8 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
}, [pendingDelete, deleteChat, activeKey, sessions]);
const headerTitle = activeSession
? activeSession.preview ||
? activeSession.title ||
activeSession.preview ||
t("chat.fallbackTitle", { id: activeSession.chatId.slice(0, 6) })
: t("app.brand");
@@ -268,20 +270,10 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
sessions,
activeKey,
loading,
theme,
onToggleTheme: toggle,
onNewChat: () => {
void onNewChat();
},
onNewChat,
onSelect: onSelectChat,
onRefresh: () => void refresh(),
onRequestDelete: (key: string, label: string) =>
setPendingDelete({ key, label }),
activeView: view,
onOpenSettings: () => {
setView("settings" as const);
setMobileSidebarOpen(false);
},
};
return (
@@ -296,10 +288,11 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
>
<div
className={cn(
"absolute inset-y-0 left-0 h-full w-[279px] overflow-hidden bg-sidebar shadow-inner-right",
"absolute inset-y-0 left-0 h-full overflow-hidden bg-sidebar shadow-inner-right",
"transition-transform duration-300 ease-out",
desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
)}
style={{ width: SIDEBAR_WIDTH }}
>
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
</div>
@@ -312,7 +305,8 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
<SheetContent
side="left"
showCloseButton={false}
className="w-[279px] p-0 sm:max-w-[279px] lg:hidden"
className="p-0 lg:hidden"
style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }}
>
<Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} />
</SheetContent>
@@ -331,8 +325,12 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
session={activeSession}
title={headerTitle}
onToggleSidebar={toggleSidebar}
onGoHome={() => setActiveKey(null)}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
onTurnEnd={onTurnEnd}
theme={theme}
onToggleTheme={toggle}
onOpenSettings={onOpenSettings}
hideSidebarToggleOnDesktop={desktopSidebarOpen}
/>
)}
+101 -62
View File
@@ -8,7 +8,6 @@ import {
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";
@@ -18,10 +17,11 @@ interface ChatListProps {
onSelect: (key: string) => void;
onRequestDelete: (key: string, label: string) => void;
loading?: boolean;
emptyLabel?: string;
}
function titleFor(s: ChatSummary, fallbackTitle: string): string {
const p = s.preview?.trim();
const p = (s.title || s.preview)?.trim();
if (p) return p.length > 48 ? `${p.slice(0, 45)}` : p;
return fallbackTitle;
}
@@ -32,6 +32,7 @@ export function ChatList({
onSelect,
onRequestDelete,
loading,
emptyLabel,
}: ChatListProps) {
const { t } = useTranslation();
if (loading && sessions.length === 0) {
@@ -44,73 +45,111 @@ export function ChatList({
if (sessions.length === 0) {
return (
<div className="px-3 py-6 text-xs text-muted-foreground">
{t("chat.noSessions")}
<div className="px-3 py-6 text-[12px] leading-5 text-muted-foreground/80">
{emptyLabel ?? t("chat.noSessions")}
</div>
);
}
const groups = groupSessions(sessions, {
today: t("chat.groups.today"),
yesterday: t("chat.groups.yesterday"),
earlier: t("chat.groups.earlier"),
});
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,
t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }),
);
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={t("chat.actions", { 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"
<div className="space-y-3 px-2 py-1.5">
{groups.map((group) => (
<section key={group.label} aria-label={group.label}>
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
{group.label}
</div>
<ul className="space-y-0.5">
{group.sessions.map((s) => {
const active = s.key === activeKey;
const title = titleFor(
s,
t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }),
);
return (
<li key={s.key}>
<div
className={cn(
"group flex min-h-8 items-center gap-2 rounded-xl px-2 text-[13px] transition-colors",
active
? "bg-sidebar-accent/70 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.28)]"
: "text-sidebar-foreground/82 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
)}
>
<Trash2 className="mr-2 h-4 w-4" />
{t("chat.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</li>
);
})}
</ul>
<button
type="button"
onClick={() => onSelect(s.key)}
className="min-w-0 flex-1 py-1.5 text-left"
>
<span className="block w-full truncate font-medium leading-5">{title}</span>
</button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className={cn(
"inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground/75 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={t("chat.actions", { title })}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</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" />
{t("chat.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</li>
);
})}
</ul>
</section>
))}
</div>
</ScrollArea>
);
}
function groupSessions(
sessions: ChatSummary[],
labels: { today: string; yesterday: string; earlier: string },
): Array<{ label: string; sessions: ChatSummary[] }> {
const now = new Date();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
const buckets = new Map<string, ChatSummary[]>();
for (const session of sessions) {
const timestamp = Date.parse(session.updatedAt ?? session.createdAt ?? "");
const label = Number.isFinite(timestamp) && timestamp >= startOfToday
? labels.today
: Number.isFinite(timestamp) && timestamp >= startOfYesterday
? labels.yesterday
: labels.earlier;
const bucket = buckets.get(label) ?? [];
bucket.push(session);
buckets.set(label, bucket);
}
return [labels.today, labels.yesterday, labels.earlier]
.map((label) => ({ label, sessions: buckets.get(label) ?? [] }))
.filter((group) => group.sessions.length > 0);
}
+2 -14
View File
@@ -79,20 +79,8 @@ export function ChatPane({ session, onNewChat }: ChatPaneProps) {
<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?
What can I do for you?
</h1>
<p className="max-w-md text-center text-sm text-muted-foreground">
Your conversations are persisted locally under the nanobot
@@ -105,7 +93,7 @@ export function ChatPane({ session, onNewChat }: ChatPaneProps) {
disabled={booting}
onSend={handleWelcomeSend}
placeholder={
booting ? "Opening a new chat…" : "Type your message…"
booting ? "Opening a new chat…" : "Ask anything..."
}
/>
</div>
+7 -7
View File
@@ -6,21 +6,21 @@ import { useClient } from "@/providers/ClientProvider";
import type { ConnectionStatus } from "@/lib/types";
const COPY: Record<ConnectionStatus, { color: string }> = {
idle: { color: "bg-card/40 text-muted-foreground" },
idle: { color: "text-muted-foreground" },
connecting: {
color: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
color: "text-amber-700 dark:text-amber-300",
},
open: {
color: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
color: "text-emerald-700 dark:text-emerald-400",
},
reconnecting: {
color: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
color: "text-amber-700 dark:text-amber-300",
},
closed: {
color: "bg-card/40 text-muted-foreground",
color: "text-muted-foreground",
},
error: {
color: "bg-destructive/10 text-destructive",
color: "text-destructive",
},
};
@@ -39,7 +39,7 @@ export function ConnectionBadge() {
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",
"inline-flex min-w-0 items-center gap-1.5 rounded-md px-1.5 py-1 text-[11px] font-medium transition-colors",
meta.color,
)}
aria-live="polite"
+49 -2
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { ChevronRight, FileIcon, ImageIcon, PlaySquare, Wrench } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Wrench } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ImageLightbox } from "@/components/ImageLightbox";
@@ -21,8 +21,33 @@ interface MessageBubbleProps {
* collapsible group so intermediate steps never masquerade as replies.
*/
export function MessageBubble({ message }: MessageBubbleProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copyResetRef = useRef<number | null>(null);
const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300";
useEffect(() => {
return () => {
if (copyResetRef.current !== null) {
window.clearTimeout(copyResetRef.current);
}
};
}, []);
const onCopyAssistantReply = useCallback(() => {
if (!navigator.clipboard) return;
void navigator.clipboard.writeText(message.content).then(() => {
setCopied(true);
if (copyResetRef.current !== null) {
window.clearTimeout(copyResetRef.current);
}
copyResetRef.current = window.setTimeout(() => {
setCopied(false);
copyResetRef.current = null;
}, 1_500);
});
}, [message.content]);
if (message.kind === "trace") {
return <TraceGroup message={message} animClass={baseAnim} />;
}
@@ -60,6 +85,7 @@ export function MessageBubble({ message }: MessageBubbleProps) {
const empty = message.content.trim().length === 0;
const media = message.media ?? [];
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
return (
<div className={cn("w-full text-sm", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
{empty && message.isStreaming ? (
@@ -69,6 +95,27 @@ export function MessageBubble({ message }: MessageBubbleProps) {
<MarkdownText>{message.content}</MarkdownText>
{message.isStreaming && <StreamCursor />}
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
{showAssistantActions ? (
<div className="mt-2 flex items-center gap-1 text-muted-foreground">
<button
type="button"
onClick={onCopyAssistantReply}
aria-label={copied ? t("message.copiedReply") : t("message.copyReply")}
title={copied ? t("message.copiedReply") : t("message.copyReply")}
className={cn(
"inline-flex h-8 w-8 items-center justify-center rounded-full",
"transition-colors hover:bg-muted/55 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
{copied ? (
<Check className="h-4 w-4" aria-hidden />
) : (
<Copy className="h-4 w-4" aria-hidden />
)}
</button>
</div>
) : null}
</>
)}
</div>
+70 -58
View File
@@ -1,109 +1,121 @@
import { Moon, PanelLeftClose, RefreshCcw, Settings, SquarePen, Sun } from "lucide-react";
import { useMemo, useState } from "react";
import {
PanelLeftClose,
Search,
SquarePen,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { ChatList } from "@/components/ChatList";
import { ConnectionBadge } from "@/components/ConnectionBadge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
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;
activeView?: "chat" | "settings";
onOpenSettings: () => void;
}
export function Sidebar(props: SidebarProps) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLowerCase();
const filteredSessions = useMemo(() => {
if (!normalizedQuery) return props.sessions;
return props.sessions.filter((session) => {
const haystack = [
session.preview,
session.chatId,
session.channel,
session.key,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(normalizedQuery);
});
}, [normalizedQuery, props.sessions]);
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-3 pb-2 pt-3">
<nav
aria-label={t("sidebar.navigation")}
className="flex h-full w-full flex-col border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground"
>
<div className="flex items-center justify-between px-3 pb-2.5 pt-3">
<picture className="block min-w-0">
<source srcSet="/brand/nanobot_logo.webp" type="image/webp" />
<img
src="/brand/nanobot_logo.png"
alt="nanobot"
className="h-7 w-auto select-none object-contain"
className="h-6 w-auto select-none object-contain opacity-95"
draggable={false}
/>
</picture>
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
aria-label={t("sidebar.toggleTheme")}
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>
<Button
variant="ghost"
size="icon"
aria-label={t("sidebar.collapse")}
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>
</div>
<Button
variant="ghost"
size="icon"
aria-label={t("sidebar.collapse")}
onClick={props.onCollapse}
className="h-7 w-7 rounded-lg text-muted-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
>
<PanelLeftClose className="h-3.5 w-3.5" />
</Button>
</div>
<div className="px-2 pb-2">
<div className="space-y-1.5 px-2 pb-2">
<label className="relative block">
<span className="sr-only">{t("sidebar.searchAria")}</span>
<Search
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/70"
aria-hidden
/>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("sidebar.searchPlaceholder")}
aria-label={t("sidebar.searchAria")}
className={cn(
"h-8 w-full rounded-full border border-transparent bg-sidebar-accent/45",
"pl-8 pr-3 text-[12.5px] text-sidebar-foreground outline-none",
"placeholder:text-muted-foreground/75",
"transition-colors hover:bg-sidebar-accent/65",
"focus:border-sidebar-border/80 focus:bg-sidebar-accent/70",
"focus:ring-1 focus:ring-sidebar-border/70",
)}
/>
</label>
<Button
onClick={props.onNewChat}
className="h-9 w-full justify-start gap-2 rounded-full px-3 text-[13px] font-medium text-sidebar-foreground/90 hover:bg-sidebar-accent hover:text-sidebar-foreground"
className="h-8 w-full justify-start gap-2 rounded-full px-3 text-[12.5px] font-medium text-sidebar-foreground/92 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
variant="ghost"
>
<SquarePen className="h-3.5 w-3.5" />
{t("sidebar.newChat")}
</Button>
</div>
<div className="flex items-center justify-between px-3 pb-1.5 pt-2.5 text-[11px] font-medium text-muted-foreground">
<span>{t("sidebar.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={t("sidebar.refreshSessions")}
>
<RefreshCcw className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex-1 overflow-hidden">
<ChatList
sessions={props.sessions}
sessions={filteredSessions}
activeKey={props.activeKey}
loading={props.loading}
emptyLabel={
normalizedQuery ? t("sidebar.noSearchResults") : t("chat.noSessions")
}
onSelect={props.onSelect}
onRequestDelete={props.onRequestDelete}
/>
</div>
<Separator className="bg-sidebar-border/50" />
<div className="flex items-center justify-between gap-2 px-2.5 py-2 text-xs">
<div className="flex items-center px-2.5 py-2.5 text-xs">
<ConnectionBadge />
<Button
onClick={props.onOpenSettings}
className="h-7 gap-1.5 rounded-md px-2 text-[11px] text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
variant={props.activeView === "settings" ? "secondary" : "ghost"}
>
<Settings className="h-3.5 w-3.5" />
Settings
</Button>
</div>
</aside>
</nav>
);
}
+23 -15
View File
@@ -10,7 +10,7 @@ import {
ArrowUp,
ImageIcon,
Loader2,
Paperclip,
Plus,
X,
} from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -219,8 +219,8 @@ export function ThreadComposer({
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 shadow-[0_10px_30px_rgba(0,0,0,0.10)]"
: "max-w-[49.5rem] rounded-[16px] border border-border/70 bg-card",
? "max-w-[58rem] rounded-[28px] border border-black/[0.035] bg-card shadow-[0_20px_55px_rgba(15,23,42,0.08)] dark:border-white/[0.06] dark:shadow-[0_24px_55px_rgba(0,0,0,0.34)]"
: "max-w-[49.5rem] rounded-[22px] border border-black/[0.035] bg-card shadow-[0_12px_30px_rgba(15,23,42,0.07)] dark:border-white/[0.06] dark:shadow-[0_16px_34px_rgba(0,0,0,0.28)]",
"focus-within:ring-1 focus-within:ring-foreground/8",
disabled && "opacity-60",
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
@@ -268,9 +268,9 @@ export function ThreadComposer({
className={cn(
"w-full resize-none bg-transparent",
isHero
? "min-h-[96px] px-4 pb-2 pt-4 text-[15px] leading-6"
? "min-h-[78px] px-5 pb-2 pt-5 text-[16px] leading-6"
: "min-h-[50px] px-4 pb-1.5 pt-3 text-sm",
"placeholder:text-muted-foreground",
"placeholder:text-muted-foreground/70",
"focus:outline-none focus-visible:outline-none",
"disabled:cursor-not-allowed",
)}
@@ -289,7 +289,7 @@ export function ThreadComposer({
<div
className={cn(
"flex items-center justify-between gap-2",
isHero ? "px-3.5 pb-3.5" : "px-3 pb-2",
isHero ? "px-4 pb-4" : "px-3 pb-2",
)}
>
<div className="flex min-w-0 items-center gap-2">
@@ -310,10 +310,12 @@ export function ThreadComposer({
onClick={() => fileInputRef.current?.click()}
className={cn(
"rounded-full text-muted-foreground hover:text-foreground",
isHero ? "h-8.5 w-8.5" : "h-7.5 w-7.5",
isHero
? "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card"
: "h-7.5 w-7.5 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
)}
>
<Paperclip className={cn(isHero ? "h-4 w-4" : "h-3.5 w-3.5")} />
<Plus className={cn(isHero ? "h-5 w-5" : "h-4 w-4")} />
</Button>
{modelLabel ? (
<span
@@ -321,7 +323,9 @@ export function ThreadComposer({
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]",
isHero
? "max-w-[13rem] text-[12px] shadow-[0_2px_8px_rgba(15,23,42,0.04)]"
: "max-w-[10rem] text-[10.5px] shadow-[0_2px_8px_rgba(15,23,42,0.035)]",
)}
>
<span
@@ -331,19 +335,23 @@ export function ThreadComposer({
<span className="truncate">{modelLabel}</span>
</span>
) : null}
<span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline">
{t("thread.composer.sendHint")}
</span>
{!isHero ? (
<span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline">
{t("thread.composer.sendHint")}
</span>
) : null}
</div>
<span className="sm:hidden" aria-hidden />
<span className={cn(isHero ? "hidden" : "sm:hidden")} aria-hidden />
<Button
type="submit"
size="icon"
disabled={!canSend}
aria-label={t("thread.composer.send")}
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",
isHero
? "h-9 w-9 rounded-full border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80"
: "rounded-full border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] transition-transform hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80",
isHero ? "" : "h-7.5 w-7.5",
canSend && "hover:scale-[1.03] active:scale-95",
)}
>
+79 -15
View File
@@ -1,4 +1,4 @@
import { PanelLeftOpen } from "lucide-react";
import { Menu, Moon, PanelLeftOpen, Settings, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -7,17 +7,66 @@ import { cn } from "@/lib/utils";
interface ThreadHeaderProps {
title: string;
onToggleSidebar: () => void;
onGoHome: () => void;
theme: "light" | "dark";
onToggleTheme: () => void;
onOpenSettings: () => void;
hideSidebarToggleOnDesktop?: boolean;
minimal?: boolean;
}
export function ThreadHeader({
title,
onToggleSidebar,
onGoHome,
theme,
onToggleTheme,
onOpenSettings,
hideSidebarToggleOnDesktop = false,
minimal = false,
}: ThreadHeaderProps) {
const { t } = useTranslation();
if (minimal) {
return (
<div className="relative z-10 flex h-11 items-center justify-between gap-3 px-3 py-2">
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleSidebar")}
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",
)}
>
<Menu className="h-3.5 w-3.5" />
</Button>
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleTheme")}
onClick={onToggleTheme}
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.settings")}
onClick={onOpenSettings}
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
<Settings className="h-4 w-4" />
</Button>
</div>
</div>
);
}
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">
@@ -33,19 +82,34 @@ export function ThreadHeader({
>
<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
/>
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
</button>
</div>
</div>
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleTheme")}
onClick={onToggleTheme}
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.settings")}
onClick={onOpenSettings}
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
<Settings className="h-4 w-4" />
</Button>
</div>
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
+123 -66
View File
@@ -1,4 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
BarChart3,
BookOpen,
ChevronRight,
Code2,
LayoutGrid,
Lightbulb,
MoreHorizontal,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { AskUserPrompt } from "@/components/thread/AskUserPrompt";
@@ -15,8 +24,13 @@ interface ThreadShellProps {
session: ChatSummary | null;
title: string;
onToggleSidebar: () => void;
onGoHome: () => void;
onNewChat: () => Promise<string | null>;
onGoHome?: () => void;
onNewChat?: () => void;
onCreateChat?: () => Promise<string | null>;
onTurnEnd?: () => void;
theme?: "light" | "dark";
onToggleTheme?: () => void;
onOpenSettings?: () => void;
hideSidebarToggleOnDesktop?: boolean;
}
@@ -28,12 +42,24 @@ function toModelBadgeLabel(modelName: string | null): string | null {
return leaf || trimmed;
}
const QUICK_ACTION_KEYS = [
{ key: "plan", icon: LayoutGrid, tone: "text-[#f25b8f]" },
{ key: "analyze", icon: BarChart3, tone: "text-[#4f9de8]" },
{ key: "brainstorm", icon: Lightbulb, tone: "text-[#53c59d]" },
{ key: "code", icon: Code2, tone: "text-[#eba45d]" },
{ key: "summarize", icon: BookOpen, tone: "text-[#a877e7]" },
{ key: "more", icon: MoreHorizontal, tone: "text-muted-foreground/65" },
] as const;
export function ThreadShell({
session,
title,
onToggleSidebar,
onGoHome,
onNewChat,
onCreateChat,
onTurnEnd,
theme = "light",
onToggleTheme = () => {},
onOpenSettings = () => {},
hideSidebarToggleOnDesktop = false,
}: ThreadShellProps) {
const { t } = useTranslation();
@@ -57,7 +83,7 @@ export function ThreadShell({
setMessages,
streamError,
dismissStreamError,
} = useNanobotStream(chatId, initial, hasPendingToolCalls);
} = useNanobotStream(chatId, initial, hasPendingToolCalls, onTurnEnd);
const showHeroComposer = messages.length === 0 && !loading;
const pendingAsk = useMemo(() => {
for (let index = messages.length - 1; index >= 0; index -= 1) {
@@ -125,13 +151,94 @@ export function ThreadShell({
if (booting) return;
setBooting(true);
pendingFirstRef.current = content;
const newId = await onNewChat();
const newId = await onCreateChat?.();
if (!newId) {
pendingFirstRef.current = null;
setBooting(false);
}
},
[booting, onNewChat],
[booting, onCreateChat],
);
const handleQuickAction = useCallback(
(prompt: string) => {
if (session) {
send(prompt);
return;
}
void handleWelcomeSend(prompt);
},
[handleWelcomeSend, send, session],
);
const quickActions = (
<div className="mx-auto grid w-full max-w-[58rem] grid-cols-2 gap-3 pt-4 sm:grid-cols-3 lg:grid-cols-6 lg:gap-4">
{QUICK_ACTION_KEYS.map(({ key, icon: Icon, tone }) => {
const title = t(`thread.empty.quickActions.${key}.title`);
const prompt = t(`thread.empty.quickActions.${key}.prompt`);
return (
<button
key={key}
type="button"
onClick={() => handleQuickAction(prompt)}
disabled={booting || isStreaming}
className="group flex min-h-[136px] flex-col justify-between rounded-[20px] border border-black/[0.035] bg-card px-5 py-5 text-left shadow-[0_14px_34px_rgba(15,23,42,0.07)] transition-all hover:-translate-y-0.5 hover:shadow-[0_18px_42px_rgba(15,23,42,0.10)] disabled:pointer-events-none disabled:opacity-60 dark:border-white/[0.06] dark:shadow-[0_16px_34px_rgba(0,0,0,0.28)]"
>
<Icon className={`h-[18px] w-[18px] ${tone}`} strokeWidth={2} />
<span className="max-w-[7.5rem] text-[15px] font-medium leading-[1.28] tracking-[-0.01em] text-foreground/82">
{title}
</span>
<ChevronRight className="h-4 w-4 self-end text-muted-foreground/45 transition-colors group-hover:text-muted-foreground" />
</button>
);
})}
</div>
);
const composer = (
<>
{streamError ? (
<StreamErrorNotice
error={streamError}
onDismiss={dismissStreamError}
/>
) : null}
{pendingAsk ? (
<AskUserPrompt
question={pendingAsk.question}
buttons={pendingAsk.buttons}
onAnswer={send}
/>
) : null}
{session ? (
<ThreadComposer
onSend={send}
disabled={!chatId}
isStreaming={isStreaming}
placeholder={
showHeroComposer
? t("thread.composer.placeholderHero")
: t("thread.composer.placeholderThread")
}
modelLabel={toModelBadgeLabel(modelName)}
variant={showHeroComposer ? "hero" : "thread"}
/>
) : (
<ThreadComposer
onSend={handleWelcomeSend}
disabled={booting}
isStreaming={isStreaming}
placeholder={
booting
? t("thread.composer.placeholderOpening")
: t("thread.composer.placeholderHero")
}
modelLabel={toModelBadgeLabel(modelName)}
variant="hero"
/>
)}
{showHeroComposer ? quickActions : null}
</>
);
const emptyState = loading ? (
@@ -139,20 +246,10 @@ export function ThreadShell({
{t("thread.loadingConversation")}
</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">
{t("thread.empty.description")}
</p>
<div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
<h1 className="text-balance text-[40px] font-normal leading-tight tracking-[-0.045em] text-foreground sm:text-[48px]">
{t("thread.empty.greeting")}
</h1>
</div>
);
@@ -161,57 +258,17 @@ export function ThreadShell({
<ThreadHeader
title={title}
onToggleSidebar={onToggleSidebar}
onGoHome={onGoHome}
theme={theme}
onToggleTheme={onToggleTheme}
onOpenSettings={onOpenSettings}
hideSidebarToggleOnDesktop={hideSidebarToggleOnDesktop}
minimal={!session && !loading}
/>
<ThreadViewport
messages={messages}
isStreaming={isStreaming}
emptyState={emptyState}
composer={
<>
{streamError ? (
<StreamErrorNotice
error={streamError}
onDismiss={dismissStreamError}
/>
) : null}
{pendingAsk ? (
<AskUserPrompt
question={pendingAsk.question}
buttons={pendingAsk.buttons}
onAnswer={send}
/>
) : null}
{session ? (
<ThreadComposer
onSend={send}
disabled={!chatId}
isStreaming={isStreaming}
placeholder={
showHeroComposer
? t("thread.composer.placeholderHero")
: t("thread.composer.placeholderThread")
}
modelLabel={toModelBadgeLabel(modelName)}
variant={showHeroComposer ? "hero" : "thread"}
/>
) : (
<ThreadComposer
onSend={handleWelcomeSend}
disabled={booting}
isStreaming={isStreaming}
placeholder={
booting
? t("thread.composer.placeholderOpening")
: t("thread.composer.placeholderHero")
}
modelLabel={toModelBadgeLabel(modelName)}
variant="hero"
/>
)}
</>
}
composer={composer}
/>
</section>
);
@@ -82,9 +82,9 @@ export function ThreadViewport({
</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">
<div className="mx-auto flex min-h-full w-full max-w-[72rem] flex-col px-4">
<div className="flex w-full flex-1 items-center justify-center pb-[7vh] pt-8">
<div className="flex w-full max-w-[58rem] flex-col gap-6">
{emptyState}
<div className="w-full">{composer}</div>
</div>
+4 -4
View File
@@ -25,9 +25,9 @@
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.4375rem;
--sidebar: 0 0% 98%;
--sidebar: 0 0% 98.5%;
--sidebar-foreground: 0 0% 3.9%;
--sidebar-accent: 0 0% 96.1%;
--sidebar-accent: 0 0% 95.8%;
--sidebar-accent-foreground: 0 0% 9%;
--sidebar-border: 0 0% 89.8%;
}
@@ -52,9 +52,9 @@
--border: 0 0% 18%;
--input: 0 0% 18%;
--ring: 0 0% 83.1%;
--sidebar: 0 0% 12%;
--sidebar: 0 0% 11.5%;
--sidebar-foreground: 0 0% 98%;
--sidebar-accent: 0 0% 16%;
--sidebar-accent: 0 0% 15.5%;
--sidebar-accent-foreground: 0 0% 98%;
--sidebar-border: 0 0% 18%;
}
+8 -1
View File
@@ -38,6 +38,7 @@ export function useNanobotStream(
chatId: string | null,
initialMessages: UIMessage[] = [],
hasPendingToolCalls = false,
onTurnEnd?: () => void,
): {
messages: UIMessage[];
isStreaming: boolean;
@@ -159,6 +160,12 @@ export function useNanobotStream(
setMessages((prev) =>
prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)),
);
onTurnEnd?.();
return;
}
if (ev.event === "session_updated") {
onTurnEnd?.();
return;
}
@@ -233,7 +240,7 @@ export function useNanobotStream(
streamEndTimerRef.current = null;
}
};
}, [chatId, client]);
}, [chatId, client, onTurnEnd]);
const send = useCallback(
(content: string, images?: SendImage[]) => {
+2 -1
View File
@@ -61,6 +61,7 @@ export function useSessions(): {
chatId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
title: "",
preview: "",
},
...prev.filter((s) => s.key !== key),
@@ -221,7 +222,7 @@ export function sessionTitle(
firstUserMessage?: string,
): string {
return deriveTitle(
firstUserMessage || session.preview,
session.title || firstUserMessage || session.preview,
i18n.t("chat.newChat"),
);
}
+55 -5
View File
@@ -18,11 +18,19 @@
}
},
"sidebar": {
"navigation": "Sidebar navigation",
"globalActions": "Global actions",
"collapse": "Collapse sidebar",
"toggleTheme": "Toggle theme",
"home": "Home",
"newChat": "New chat",
"searchAria": "Search chats",
"searchPlaceholder": "Search chats",
"searchResults": "Results",
"noSearchResults": "No matching chats.",
"recent": "Recent",
"refreshSessions": "Refresh sessions",
"settings": "Settings",
"language": {
"label": "Language",
"ariaLabel": "Change language"
@@ -34,7 +42,12 @@
"noSessions": "No sessions yet.",
"actions": "Chat actions for {{title}}",
"delete": "Delete",
"newChat": "New chat"
"newChat": "New chat",
"groups": {
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier"
}
},
"deleteConfirm": {
"title": "Delete “{{title}}”?",
@@ -53,20 +66,55 @@
"thread": {
"loadingConversation": "Loading conversation…",
"empty": {
"description": "Ask questions, continue local work, or start a new thread."
"greeting": "What can I do for you?",
"quickActions": {
"plan": {
"title": "Create a project plan",
"prompt": "Create a concise project plan for what I should build next."
},
"analyze": {
"title": "Analyze this data",
"prompt": "Help me analyze this data and call out the most important patterns."
},
"brainstorm": {
"title": "Brainstorm ideas",
"prompt": "Brainstorm a few practical ideas and tradeoffs for this problem."
},
"code": {
"title": "Write code",
"prompt": "Help me write the code for this task, starting with the smallest useful change."
},
"summarize": {
"title": "Summarize this document",
"prompt": "Summarize this document and list the key takeaways."
},
"more": {
"title": "More",
"prompt": "Show me a few useful ways you can help in this workspace."
}
}
},
"header": {
"toggleSidebar": "Toggle sidebar"
"toggleSidebar": "Toggle sidebar",
"newChat": "Start a new chat",
"toggleTheme": "Toggle theme from header",
"settings": "Open settings"
},
"composer": {
"placeholderThread": "Type your message…",
"placeholderHero": "What's on your mind?",
"placeholderHero": "Ask anything...",
"placeholderOpening": "Opening a new chat…",
"placeholderStreaming": "Model is responding…",
"inputAria": "Message input",
"sendHint": "Enter to send · Shift+Enter for newline",
"send": "Send message",
"attachImage": "Attach image",
"tools": {
"search": "Search",
"reason": "Reason",
"deepResearch": "Deep research",
"voice": "Voice input"
},
"encoding": "Encoding…",
"remove": "Remove attachment",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
@@ -86,7 +134,9 @@
"assistantTyping": "Assistant is typing",
"toolSingle": "Using a tool",
"toolMany": "Used {{count}} tools",
"imageAttachment": "Image attachment"
"imageAttachment": "Image attachment",
"copyReply": "Copy reply",
"copiedReply": "Copied reply"
},
"lightbox": {
"title": "Image preview",
+55 -5
View File
@@ -18,11 +18,19 @@
}
},
"sidebar": {
"navigation": "侧边栏导航",
"globalActions": "全局操作",
"collapse": "收起侧边栏",
"toggleTheme": "切换主题",
"home": "首页",
"newChat": "新建对话",
"searchAria": "搜索会话",
"searchPlaceholder": "搜索会话",
"searchResults": "搜索结果",
"noSearchResults": "没有匹配的会话。",
"recent": "最近对话",
"refreshSessions": "刷新会话",
"settings": "设置",
"language": {
"label": "语言",
"ariaLabel": "切换语言"
@@ -34,7 +42,12 @@
"noSessions": "还没有会话。",
"actions": "“{{title}}” 的会话操作",
"delete": "删除",
"newChat": "新建对话"
"newChat": "新建对话",
"groups": {
"today": "今天",
"yesterday": "昨天",
"earlier": "更早"
}
},
"deleteConfirm": {
"title": "删除“{{title}}”?",
@@ -53,20 +66,55 @@
"thread": {
"loadingConversation": "正在加载对话…",
"empty": {
"description": "可以提问、继续本地工作,或者开启一个新线程。"
"greeting": "可以帮你做什么?",
"quickActions": {
"plan": {
"title": "创建项目计划",
"prompt": "帮我为接下来要做的事情写一份简洁的项目计划。"
},
"analyze": {
"title": "分析这些数据",
"prompt": "帮我分析这些数据,并指出最重要的模式。"
},
"brainstorm": {
"title": "头脑风暴想法",
"prompt": "围绕这个问题头脑风暴几个实用方案,并说明取舍。"
},
"code": {
"title": "编写代码",
"prompt": "帮我为这个任务写代码,先从最小可用改动开始。"
},
"summarize": {
"title": "总结这份文档",
"prompt": "帮我总结这份文档,并列出关键要点。"
},
"more": {
"title": "更多",
"prompt": "展示几个你在这个工作区里可以帮我的实用方式。"
}
}
},
"header": {
"toggleSidebar": "切换侧边栏"
"toggleSidebar": "切换侧边栏",
"newChat": "从顶部新建对话",
"toggleTheme": "从顶部切换主题",
"settings": "打开设置"
},
"composer": {
"placeholderThread": "输入消息…",
"placeholderHero": "你在想什么?",
"placeholderHero": "问任何问题...",
"placeholderOpening": "正在打开新对话…",
"placeholderStreaming": "模型正在回复…",
"inputAria": "消息输入框",
"sendHint": "Enter 发送 · Shift+Enter 换行",
"send": "发送消息",
"attachImage": "添加图片",
"tools": {
"search": "搜索",
"reason": "推理",
"deepResearch": "深度研究",
"voice": "语音输入"
},
"encoding": "处理中…",
"remove": "移除附件",
"normalizedSizeHint": "{{orig}} → {{current}}(已自动压缩)",
@@ -86,7 +134,9 @@
"assistantTyping": "助手正在输入",
"toolSingle": "正在使用工具",
"toolMany": "已使用 {{count}} 个工具",
"imageAttachment": "图片附件"
"imageAttachment": "图片附件",
"copyReply": "复制回复",
"copiedReply": "已复制回复"
},
"lightbox": {
"title": "图片预览",
+2
View File
@@ -42,6 +42,7 @@ export async function listSessions(
key: string;
created_at: string | null;
updated_at: string | null;
title?: string;
preview?: string;
};
const body = await request<{ sessions: Row[] }>(
@@ -53,6 +54,7 @@ export async function listSessions(
...splitKey(s.key),
createdAt: s.created_at,
updatedAt: s.updated_at,
title: s.title ?? "",
preview: s.preview ?? "",
}));
}
+2 -2
View File
@@ -185,8 +185,8 @@ export class NanobotClient {
this.knownChats.add(chatId);
const frame: Outbound =
media && media.length > 0
? { type: "message", chat_id: chatId, content, media }
: { type: "message", chat_id: chatId, content };
? { type: "message", chat_id: chatId, content, media, webui: true }
: { type: "message", chat_id: chatId, content, webui: true };
this.queueSend(frame);
}
+5
View File
@@ -56,6 +56,7 @@ export interface ChatSummary {
chatId: string;
createdAt: string | null;
updatedAt: string | null;
title?: string;
preview: string;
}
@@ -125,6 +126,7 @@ export type InboundEvent =
stream_id?: string;
}
| { event: "turn_end"; chat_id: string }
| { event: "session_updated"; chat_id: string }
| { event: "error"; chat_id?: string; detail?: string };
/** Base64-encoded image attached to an outbound ``message`` envelope.
@@ -148,4 +150,7 @@ export type Outbound =
chat_id: string;
content: string;
media?: OutboundMedia[];
/** Marks messages sent by the embedded WebUI, without changing the
* generic websocket protocol for other clients. */
webui?: true;
};
+25 -1
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { deleteSession, fetchSessionMessages, updateSettings } from "@/lib/api";
import { deleteSession, fetchSessionMessages, listSessions, updateSettings } from "@/lib/api";
describe("webui API helpers", () => {
beforeEach(() => {
@@ -48,4 +48,28 @@ describe("webui API helpers", () => {
}),
);
});
it("maps generated session titles from the sessions list", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
sessions: [
{
key: "websocket:chat-1",
created_at: "2026-05-01T10:00:00",
updated_at: "2026-05-01T10:01:00",
title: "优化 WebUI 标题",
},
],
}),
} as Response);
await expect(listSessions("tok")).resolves.toMatchObject([
{
key: "websocket:chat-1",
title: "优化 WebUI 标题",
preview: "",
},
]);
});
});
+106 -6
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatSummary } from "@/lib/types";
@@ -7,6 +7,7 @@ const connectSpy = vi.fn();
const refreshSpy = vi.fn();
const createChatSpy = vi.fn().mockResolvedValue("chat-1");
const deleteChatSpy = vi.fn();
const toggleThemeSpy = vi.fn();
let mockSessions: ChatSummary[] = [];
vi.mock("@/hooks/useSessions", async (importOriginal) => {
@@ -34,7 +35,7 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
vi.mock("@/hooks/useTheme", () => ({
useTheme: () => ({
theme: "light" as const,
toggle: vi.fn(),
toggle: toggleThemeSpy,
}),
}));
@@ -74,6 +75,7 @@ describe("App layout", () => {
refreshSpy.mockReset();
createChatSpy.mockClear();
deleteChatSpy.mockReset();
toggleThemeSpy.mockReset();
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
@@ -121,8 +123,11 @@ describe("App layout", () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
await waitFor(() =>
expect(screen.getByRole("button", { name: /^First chat$/ })).toBeInTheDocument(),
expect(
within(sidebar).getByRole("button", { name: /^First chat$/ }),
).toBeInTheDocument(),
);
fireEvent.pointerDown(screen.getByLabelText("Chat actions for First chat"), {
@@ -140,14 +145,24 @@ describe("App layout", () => {
);
await waitFor(() =>
expect(
screen.getByRole("button", { name: /^Second chat$/ }),
within(sidebar).getByRole("button", { name: /^Second chat$/ }),
).toBeInTheDocument(),
);
expect(screen.queryByText('Delete “First chat”?')).not.toBeInTheDocument();
expect(document.body.style.pointerEvents).not.toBe("none");
}, 15_000);
it("opens the Cursor-style settings view from the sidebar", async () => {
it("opens the Cursor-style settings view from the header", async () => {
mockSessions = [
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
preview: "Existing chat",
},
];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
@@ -180,10 +195,95 @@ describe("App layout", () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
fireEvent.click(screen.getByRole("button", { name: "Settings" }));
fireEvent.click(screen.getByRole("button", { name: "Open settings" }));
expect(await screen.findByRole("heading", { name: "General" })).toBeInTheDocument();
expect(screen.getByText("AI")).toBeInTheDocument();
expect(screen.getByDisplayValue("openai/gpt-4o")).toBeInTheDocument();
});
it("filters sidebar sessions through the lightweight search row", async () => {
mockSessions = [
{
key: "websocket:chat-alpha",
channel: "websocket",
chatId: "chat-alpha",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
preview: "Project planning notes",
},
{
key: "websocket:chat-beta",
channel: "websocket",
chatId: "chat-beta",
createdAt: "2026-04-15T10:00:00Z",
updatedAt: "2026-04-15T10:00:00Z",
preview: "Travel ideas",
},
];
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
expect(within(sidebar).getByText("Project planning notes")).toBeInTheDocument();
expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument();
fireEvent.change(screen.getByRole("textbox", { name: "Search chats" }), {
target: { value: "travel" },
});
expect(within(sidebar).queryByText("Project planning notes")).not.toBeInTheDocument();
expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument();
});
it("opens a blank start page without creating an empty 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: "Existing chat",
},
];
const matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query.includes("1024px"),
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
vi.stubGlobal("matchMedia", matchMedia);
const { container } = render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
fireEvent.click(screen.getByRole("button", { name: "Toggle theme from header" }));
expect(toggleThemeSpy).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" }));
const desktopAside = container.querySelector("aside.lg\\:block") as HTMLElement;
await waitFor(() => expect(desktopAside.style.width).toBe("0px"));
expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Toggle sidebar" }));
await waitFor(() => expect(desktopAside.style.width).toBe("272px"));
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "New chat" }));
expect(createChatSpy).not.toHaveBeenCalled();
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Toggle theme from header" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Open settings" })).toBeInTheDocument();
expect(within(sidebar).getByText("Existing chat")).toBeInTheDocument();
});
});
+40 -2
View File
@@ -1,5 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MessageBubble } from "@/components/MessageBubble";
import type { UIMessage } from "@/lib/types";
@@ -19,6 +19,44 @@ describe("MessageBubble", () => {
expect(row).toHaveClass("ml-auto", "flex");
expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]");
expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument();
});
it("copies completed assistant replies from the action row", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
const message: UIMessage = {
id: "a-copy",
role: "assistant",
content: "I can help with the next step.",
createdAt: Date.now(),
};
render(<MessageBubble message={message} />);
fireEvent.click(screen.getByRole("button", { name: "Copy reply" }));
expect(writeText).toHaveBeenCalledWith("I can help with the next step.");
await waitFor(() =>
expect(screen.getByRole("button", { name: "Copied reply" })).toBeInTheDocument(),
);
});
it("does not show copy actions for streaming placeholders", () => {
const message: UIMessage = {
id: "a-streaming",
role: "assistant",
content: "",
isStreaming: true,
createdAt: Date.now(),
};
render(<MessageBubble message={message} />);
expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument();
});
it("renders trace messages as collapsible tool groups", () => {
+3 -1
View File
@@ -116,7 +116,7 @@ describe("NanobotClient", () => {
// 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" }),
JSON.stringify({ type: "message", chat_id: "chat-x", content: "hello", webui: true }),
);
});
@@ -196,6 +196,7 @@ describe("NanobotClient", () => {
chat_id: "chat-x",
content: "look",
media: [{ data_url: "data:image/png;base64,AAAA", name: "shot.png" }],
webui: true,
});
});
@@ -214,6 +215,7 @@ describe("NanobotClient", () => {
type: "message",
chat_id: "chat-x",
content: "hello",
webui: true,
});
});
+27 -4
View File
@@ -9,15 +9,38 @@ describe("ThreadComposer", () => {
<ThreadComposer
onSend={vi.fn()}
modelLabel="claude-opus-4-5"
placeholder="What's on your mind?"
placeholder="Ask anything..."
variant="hero"
/>,
);
expect(screen.getByText("claude-opus-4-5")).toBeInTheDocument();
const input = screen.getByPlaceholderText("What's on your mind?");
expect(screen.queryByRole("button", { name: "Search" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reason" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Deep research" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Voice input" })).not.toBeInTheDocument();
const input = screen.getByPlaceholderText("Ask anything...");
expect(input).toBeInTheDocument();
expect(input.className).toContain("min-h-[96px]");
expect(input.parentElement?.className).toContain("max-w-[40rem]");
expect(input.className).toContain("min-h-[78px]");
expect(input.parentElement?.className).toContain("max-w-[58rem]");
});
it("keeps the thread composer compact while matching the hero style", () => {
render(
<ThreadComposer
onSend={vi.fn()}
modelLabel="gpt-4o"
placeholder="Type your message..."
/>,
);
expect(screen.getByText("gpt-4o")).toBeInTheDocument();
const input = screen.getByPlaceholderText("Type your message...");
expect(input.className).toContain("min-h-[50px]");
expect(input.parentElement?.className).toContain("max-w-[49.5rem]");
expect(input.parentElement?.className).toContain("rounded-[22px]");
expect(input.parentElement?.className).toContain("shadow-[0_12px_30px_rgba(15,23,42,0.07)]");
expect(screen.getByRole("button", { name: "Attach image" }).className).toContain("bg-card");
expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground");
});
});
+84 -4
View File
@@ -86,6 +86,26 @@ describe("ThreadShell", () => {
);
});
it("does not navigate away when clicking the chat title", async () => {
const client = makeClient();
const onGoHome = vi.fn();
render(wrap(
client,
<ThreadShell
session={session("chat-title")}
title="Important conversation"
onToggleSidebar={() => {}}
onGoHome={onGoHome}
onNewChat={() => {}}
/>,
));
await waitFor(() => expect(screen.getByText("Important conversation")).toBeInTheDocument());
fireEvent.click(screen.getByText("Important conversation"));
expect(onGoHome).not.toHaveBeenCalled();
});
it("restores in-memory messages when switching away and back to a session", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a");
@@ -199,7 +219,67 @@ describe("ThreadShell", () => {
await waitFor(() => {
expect(screen.queryByText("delete me cleanly")).not.toBeInTheDocument();
});
expect(screen.getByPlaceholderText("What's on your mind?")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Ask anything...")).toBeInTheDocument();
});
it("creates a chat only when the blank landing sends a first message", async () => {
const client = makeClient();
const onNewChat = vi.fn();
const onCreateChat = vi.fn().mockResolvedValue("chat-new");
render(
wrap(
client,
<ThreadShell
session={null}
title="nanobot"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
/>,
),
);
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "start for real" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(onCreateChat).toHaveBeenCalledTimes(1));
expect(onNewChat).not.toHaveBeenCalled();
});
it("sends quick action prompts from the empty thread landing", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a");
render(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
/>,
),
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Write code" })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Write code" }));
await waitFor(() =>
expect(client.sendMessage).toHaveBeenCalledWith(
"chat-a",
"Help me write the code for this task, starting with the smallest useful change.",
undefined,
),
);
});
it("does not leak the previous thread when opening a brand-new chat", async () => {
@@ -260,10 +340,10 @@ describe("ThreadShell", () => {
expect(screen.queryByText("old answer")).not.toBeInTheDocument();
await waitFor(() =>
expect(screen.getByPlaceholderText("What's on your mind?")).toBeInTheDocument(),
expect(screen.getByPlaceholderText("Ask anything...")).toBeInTheDocument(),
);
const input = screen.getByPlaceholderText("What's on your mind?");
expect(input.className).toContain("min-h-[96px]");
const input = screen.getByPlaceholderText("Ask anything...");
expect(input.className).toContain("min-h-[78px]");
expect(screen.queryByText("old answer")).not.toBeInTheDocument();
});
+20 -1
View File
@@ -159,7 +159,8 @@ describe("useNanobotStream", () => {
it("keeps streaming alive across stream_end and completes on turn_end", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES), {
const onTurnEnd = vi.fn();
const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES, false, onTurnEnd), {
wrapper: wrap(fake.client),
});
@@ -211,5 +212,23 @@ describe("useNanobotStream", () => {
expect(result.current.isStreaming).toBe(false);
expect(result.current.messages.every((message) => !message.isStreaming)).toBe(true);
expect(onTurnEnd).toHaveBeenCalledTimes(1);
});
it("refreshes session metadata when the server reports a session update", () => {
const fake = fakeClient();
const onTurnEnd = vi.fn();
renderHook(() => useNanobotStream("chat-title", EMPTY_MESSAGES, false, onTurnEnd), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-title", {
event: "session_updated",
chat_id: "chat-title",
});
});
expect(onTurnEnd).toHaveBeenCalledTimes(1);
});
});