feat: add ChatEmptyState component and integrate it into ChatMessageList for improved user experience
This commit is contained in:
48
src/components/chat/ChatEmptyState.tsx
Normal file
48
src/components/chat/ChatEmptyState.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useI18n } from '../../i18n';
|
||||||
|
|
||||||
|
export default function ChatEmptyState() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const suggestions = [
|
||||||
|
{
|
||||||
|
key: 'task',
|
||||||
|
title: t('conversation.emptyState.suggestions.task.title'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'continuous',
|
||||||
|
title: t('conversation.emptyState.suggestions.continuous.title'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'parallel',
|
||||||
|
title: t('conversation.emptyState.suggestions.parallel.title'),
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full items-center justify-center px-4 py-6">
|
||||||
|
<div className="flex w-full max-w-4xl flex-col items-center justify-center text-center">
|
||||||
|
<div className="h-[clamp(280px,56vh,520px)] w-full px-6 py-10 dark:bg-[radial-gradient(circle_at_top,rgba(49,73,109,0.24)_0%,rgba(24,24,27,0)_60%)]">
|
||||||
|
<div className="mx-auto flex h-full max-w-3xl flex-col items-center justify-center">
|
||||||
|
<h2
|
||||||
|
className="text-[clamp(40px,7vw,72px)] font-medium leading-[1.1] tracking-[-0.05em] text-[#2F3542] dark:text-[#E5E7EB]"
|
||||||
|
style={{ fontFamily: 'Georgia, "Songti SC", "STSong", serif' }}
|
||||||
|
>
|
||||||
|
{t('conversation.emptyState.title')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="mt-9 flex max-w-3xl flex-wrap items-center justify-center gap-3">
|
||||||
|
{suggestions.map((suggestion) => (
|
||||||
|
<div
|
||||||
|
key={suggestion.key}
|
||||||
|
className="rounded-full border border-[#D8DEE8] bg-white/78 px-6 py-3 text-[15px] font-semibold text-[#4B5563] shadow-[0_8px_20px_rgba(15,23,42,0.04)] dark:border-[#3a3a40] dark:bg-[#202024] dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{suggestion.title}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
import { memo, useEffect, useRef } from 'react';
|
import { memo, useEffect, useRef } from 'react';
|
||||||
import type { ChatMessageItem } from './types';
|
import type { ChatMessageItem } from './types';
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
|
import ChatEmptyState from './ChatEmptyState';
|
||||||
|
|
||||||
type ChatMessageListProps = {
|
type ChatMessageListProps = {
|
||||||
messages: ChatMessageItem[];
|
messages: ChatMessageItem[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
showWelcomeState?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ChatMessageList({ messages, loading }: ChatMessageListProps) {
|
function ChatMessageList({ messages, loading, showWelcomeState }: ChatMessageListProps) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const shouldShowWelcomeState = !loading && (showWelcomeState || messages.length === 0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
@@ -25,11 +28,7 @@ function ChatMessageList({ messages, loading }: ChatMessageListProps) {
|
|||||||
{t('conversation.messageList.loading')}
|
{t('conversation.messageList.loading')}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{!loading && messages.length === 0 ? (
|
{shouldShowWelcomeState ? <ChatEmptyState /> : null}
|
||||||
<div className="rounded-[18px] border border-dashed border-[#BEDBFF] bg-[#EFF6FF] px-4 py-6 text-sm leading-7 text-[#525866] dark:border-[#2a2a2d] dark:bg-[#1f1f22] dark:text-gray-400">
|
|
||||||
{t('conversation.messageList.emptyHint')}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
<article
|
<article
|
||||||
key={message.id}
|
key={message.id}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { default as ChatComposer } from './ChatComposer';
|
export { default as ChatComposer } from './ChatComposer';
|
||||||
|
export { default as ChatEmptyState } from './ChatEmptyState';
|
||||||
export { default as ChatHistoryPanel } from './ChatHistoryPanel';
|
export { default as ChatHistoryPanel } from './ChatHistoryPanel';
|
||||||
export { default as ChatMessageList } from './ChatMessageList';
|
export { default as ChatMessageList } from './ChatMessageList';
|
||||||
export { default as TaskBoard } from './TaskBoard';
|
export { default as TaskBoard } from './TaskBoard';
|
||||||
|
|||||||
@@ -23,6 +23,25 @@
|
|||||||
"assistantBadge": "AI",
|
"assistantBadge": "AI",
|
||||||
"userBadge": "Me"
|
"userBadge": "Me"
|
||||||
},
|
},
|
||||||
|
"emptyState": {
|
||||||
|
"title": "What can I help you with?",
|
||||||
|
"subtitle": "Ask a question, shape an idea, or bring in notes and files to get started.",
|
||||||
|
"tipLabel": "Try one of these",
|
||||||
|
"suggestions": {
|
||||||
|
"task": {
|
||||||
|
"title": "Handle tasks",
|
||||||
|
"description": "Work through a concrete goal, break it down, and move the task forward step by step."
|
||||||
|
},
|
||||||
|
"continuous": {
|
||||||
|
"title": "Continuous execution",
|
||||||
|
"description": "Keep context across steps, continue execution, and follow through on each stage of the work."
|
||||||
|
},
|
||||||
|
"parallel": {
|
||||||
|
"title": "Multi-agent parallelism",
|
||||||
|
"description": "Split independent subtasks across multiple agents to increase collaboration and execution speed."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"historyPanel": {
|
"historyPanel": {
|
||||||
"expandSidebar": "Expand sidebar",
|
"expandSidebar": "Expand sidebar",
|
||||||
"collapseSidebar": "Collapse sidebar",
|
"collapseSidebar": "Collapse sidebar",
|
||||||
|
|||||||
@@ -23,6 +23,25 @@
|
|||||||
"assistantBadge": "AI",
|
"assistantBadge": "AI",
|
||||||
"userBadge": "ฉัน"
|
"userBadge": "ฉัน"
|
||||||
},
|
},
|
||||||
|
"emptyState": {
|
||||||
|
"title": "มีอะไรให้ฉันช่วยคุณได้บ้าง",
|
||||||
|
"subtitle": "เริ่มได้ด้วยการถามคำถาม จัดระเบียบไอเดีย หรือแนบบันทึกและไฟล์ที่ต้องการ",
|
||||||
|
"tipLabel": "ลองเริ่มจากสิ่งเหล่านี้",
|
||||||
|
"suggestions": {
|
||||||
|
"task": {
|
||||||
|
"title": "จัดการงาน",
|
||||||
|
"description": "ขับเคลื่อนงานตามเป้าหมายที่ชัดเจน แยกขั้นตอน และผลักดันงานให้เดินหน้าต่อ"
|
||||||
|
},
|
||||||
|
"continuous": {
|
||||||
|
"title": "ดำเนินการต่อเนื่อง",
|
||||||
|
"description": "รักษาบริบทเดิมไว้ ทำงานต่อเป็นลำดับ และติดตามผลในแต่ละช่วงของงาน"
|
||||||
|
},
|
||||||
|
"parallel": {
|
||||||
|
"title": "หลายเอเจนต์ทำงานขนานกัน",
|
||||||
|
"description": "แบ่งงานย่อยที่เป็นอิสระให้หลายเอเจนต์ทำพร้อมกัน เพื่อเพิ่มประสิทธิภาพการทำงานร่วมกัน"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"historyPanel": {
|
"historyPanel": {
|
||||||
"expandSidebar": "ขยายแถบด้านข้าง",
|
"expandSidebar": "ขยายแถบด้านข้าง",
|
||||||
"collapseSidebar": "ยุบแถบด้านข้าง",
|
"collapseSidebar": "ยุบแถบด้านข้าง",
|
||||||
|
|||||||
@@ -23,6 +23,25 @@
|
|||||||
"assistantBadge": "AI",
|
"assistantBadge": "AI",
|
||||||
"userBadge": "我"
|
"userBadge": "我"
|
||||||
},
|
},
|
||||||
|
"emptyState": {
|
||||||
|
"title": "我能为你做什么?",
|
||||||
|
"subtitle": "你可以提问、整理想法,或带上笔记和文件开始。",
|
||||||
|
"tipLabel": "不妨试试这些",
|
||||||
|
"suggestions": {
|
||||||
|
"task": {
|
||||||
|
"title": "处理任务",
|
||||||
|
"description": "围绕具体目标安排步骤、拆解工作,并推进当前任务落地。"
|
||||||
|
},
|
||||||
|
"continuous": {
|
||||||
|
"title": "持续执行",
|
||||||
|
"description": "保持上下文连续推进,按阶段执行并跟进每一步结果。"
|
||||||
|
},
|
||||||
|
"parallel": {
|
||||||
|
"title": "多智能体并行",
|
||||||
|
"description": "将不同子任务并行分工处理,提升整体协作和执行效率。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"historyPanel": {
|
"historyPanel": {
|
||||||
"expandSidebar": "展开侧栏",
|
"expandSidebar": "展开侧栏",
|
||||||
"collapseSidebar": "收起侧栏",
|
"collapseSidebar": "收起侧栏",
|
||||||
|
|||||||
@@ -178,40 +178,37 @@ function handleRefreshConversationData(): void {
|
|||||||
void chatStore.loadHistory();
|
void chatStore.loadHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
function HomeChatComposerSection() {
|
type HomeChatComposerSectionProps = {
|
||||||
|
value: string;
|
||||||
|
attachments: StagedAttachment[];
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onSend: () => void;
|
||||||
|
onAttach: (files: File[]) => void | Promise<void>;
|
||||||
|
onRemoveAttachment: (index: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function HomeChatComposerSection({
|
||||||
|
value,
|
||||||
|
attachments,
|
||||||
|
onChange,
|
||||||
|
onSend,
|
||||||
|
onAttach,
|
||||||
|
onRemoveAttachment,
|
||||||
|
}: HomeChatComposerSectionProps) {
|
||||||
const error = useChatStore((state) => state.error);
|
const error = useChatStore((state) => state.error);
|
||||||
const isSending = useChatStore((state) => state.sending);
|
const isSending = useChatStore((state) => state.sending);
|
||||||
const [inputMessage, setInputMessage] = useState('');
|
|
||||||
const [attachments, setAttachments] = useState<StagedAttachment[]>([]);
|
|
||||||
|
|
||||||
async function handleSendMessage(): Promise<void> {
|
|
||||||
const sent = await chatStore.sendMessage(inputMessage, attachments);
|
|
||||||
if (sent) {
|
|
||||||
setInputMessage('');
|
|
||||||
setAttachments([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAttach(files: File[]): Promise<void> {
|
|
||||||
const stagedFiles = await chatStore.stageAttachmentFiles(files);
|
|
||||||
setAttachments((currentAttachments) => [...currentAttachments, ...stagedFiles]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChatComposer
|
<ChatComposer
|
||||||
attachments={attachments}
|
attachments={attachments}
|
||||||
error={error}
|
error={error}
|
||||||
isSending={isSending}
|
isSending={isSending}
|
||||||
value={inputMessage}
|
value={value}
|
||||||
onAttach={handleAttach}
|
onAttach={onAttach}
|
||||||
onChange={setInputMessage}
|
onChange={onChange}
|
||||||
onDismissError={chatStore.clearError}
|
onDismissError={chatStore.clearError}
|
||||||
onRemoveAttachment={(index) => {
|
onRemoveAttachment={onRemoveAttachment}
|
||||||
setAttachments((currentAttachments) => currentAttachments.filter((_, currentIndex) => currentIndex !== index));
|
onSend={onSend}
|
||||||
}}
|
|
||||||
onSend={() => {
|
|
||||||
void handleSendMessage();
|
|
||||||
}}
|
|
||||||
onStop={() => {
|
onStop={() => {
|
||||||
void chatStore.abortRun();
|
void chatStore.abortRun();
|
||||||
}}
|
}}
|
||||||
@@ -252,6 +249,8 @@ export default function HomePage() {
|
|||||||
title: string;
|
title: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [deletingConversation, setDeletingConversation] = useState(false);
|
const [deletingConversation, setDeletingConversation] = useState(false);
|
||||||
|
const [inputMessage, setInputMessage] = useState('');
|
||||||
|
const [attachments, setAttachments] = useState<StagedAttachment[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void agentsStore.init();
|
void agentsStore.init();
|
||||||
@@ -289,6 +288,11 @@ export default function HomePage() {
|
|||||||
return buckets;
|
return buckets;
|
||||||
}, [chatSessionLabels, chatSessionLastActivity, chatSessions, locale, t]);
|
}, [chatSessionLabels, chatSessionLastActivity, chatSessions, locale, t]);
|
||||||
|
|
||||||
|
const hasConversationHistory = useMemo(
|
||||||
|
() => historyBuckets.some((bucket) => bucket.sessions.length > 0),
|
||||||
|
[historyBuckets],
|
||||||
|
);
|
||||||
|
|
||||||
const visibleMessages = useMemo(
|
const visibleMessages = useMemo(
|
||||||
() => mapMessages(chatMessages, chatStreamingMessage, locale, t),
|
() => mapMessages(chatMessages, chatStreamingMessage, locale, t),
|
||||||
[chatMessages, chatStreamingMessage, locale, t],
|
[chatMessages, chatStreamingMessage, locale, t],
|
||||||
@@ -381,6 +385,19 @@ export default function HomePage() {
|
|||||||
setTaskCenterNotice(result.error || t('task.taskCenter.notices.retryFailed'));
|
setTaskCenterNotice(result.error || t('task.taskCenter.notices.retryFailed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSendMessage(): Promise<void> {
|
||||||
|
const sent = await chatStore.sendMessage(inputMessage, attachments);
|
||||||
|
if (sent) {
|
||||||
|
setInputMessage('');
|
||||||
|
setAttachments([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAttach(files: File[]): Promise<void> {
|
||||||
|
const stagedFiles = await chatStore.stageAttachmentFiles(files);
|
||||||
|
setAttachments((currentAttachments) => [...currentAttachments, ...stagedFiles]);
|
||||||
|
}
|
||||||
|
|
||||||
function handleRenameConversation(conversationId: string): void {
|
function handleRenameConversation(conversationId: string): void {
|
||||||
const nextTitle = chatSessionLabels[conversationId]
|
const nextTitle = chatSessionLabels[conversationId]
|
||||||
|| chatSessions.find((session) => session.key === conversationId)?.displayName
|
|| chatSessions.find((session) => session.key === conversationId)?.displayName
|
||||||
@@ -484,8 +501,23 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<ChatMessageList loading={chatLoading} messages={visibleMessages} />
|
<ChatMessageList
|
||||||
<HomeChatComposerSection />
|
loading={chatLoading}
|
||||||
|
messages={visibleMessages}
|
||||||
|
showWelcomeState={!hasConversationHistory}
|
||||||
|
/>
|
||||||
|
<HomeChatComposerSection
|
||||||
|
value={inputMessage}
|
||||||
|
attachments={attachments}
|
||||||
|
onChange={setInputMessage}
|
||||||
|
onAttach={handleAttach}
|
||||||
|
onRemoveAttachment={(index) => {
|
||||||
|
setAttachments((currentAttachments) => currentAttachments.filter((_, currentIndex) => currentIndex !== index));
|
||||||
|
}}
|
||||||
|
onSend={() => {
|
||||||
|
void handleSendMessage();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* <div className="border-t border-[#edf2f7] dark:border-[#2a2a2d]">
|
{/* <div className="border-t border-[#edf2f7] dark:border-[#2a2a2d]">
|
||||||
|
|||||||
@@ -675,6 +675,7 @@ async function deleteSession(sessionKey: string): Promise<void> {
|
|||||||
|
|
||||||
if (state.currentSessionKey === sessionKey) {
|
if (state.currentSessionKey === sessionKey) {
|
||||||
const nextSession = remaining[0]?.key ?? getDefaultMainSessionKey();
|
const nextSession = remaining[0]?.key ?? getDefaultMainSessionKey();
|
||||||
|
const hasRemainingSessions = remaining.length > 0;
|
||||||
patchState({
|
patchState({
|
||||||
...basePatch,
|
...basePatch,
|
||||||
currentSessionKey: nextSession,
|
currentSessionKey: nextSession,
|
||||||
@@ -688,7 +689,7 @@ async function deleteSession(sessionKey: string): Promise<void> {
|
|||||||
lastUserMessageAt: null,
|
lastUserMessageAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (nextSession) {
|
if (hasRemainingSessions && nextSession) {
|
||||||
await loadHistory(nextSession);
|
await loadHistory(nextSession);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user