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
+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>