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:
committed by
Xubin Ren
co-authored by
Cursor
parent
d8fd4c80bf
commit
790a03ec28
@@ -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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user