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
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user