diff --git a/src/components/chat/AgentHistoryPanel.test.tsx b/src/components/chat/AgentHistoryPanel.test.tsx index 8dc4227..d85f9f1 100644 --- a/src/components/chat/AgentHistoryPanel.test.tsx +++ b/src/components/chat/AgentHistoryPanel.test.tsx @@ -8,6 +8,56 @@ const renderWithTheme = (ui: React.ReactElement) => render({ui}); describe("AgentHistoryPanel", () => { + it("shows skeleton rows while history sessions are loading", () => { + renderWithTheme( + , + ); + + expect(screen.getByLabelText("正在加载历史会话")).toBeInTheDocument(); + expect(screen.queryByText("暂无历史会话")).not.toBeInTheDocument(); + }); + + it("disables the loading history session item", () => { + const onSelectSession = jest.fn(); + const onRenameSession = jest.fn(); + const onDeleteSession = jest.fn(); + + renderWithTheme( + , + ); + + expect(screen.queryByText("正在加载的会话")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "修改会话标题" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "删除会话" })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText("正在加载会话 正在加载的会话")); + + expect(onSelectSession).not.toHaveBeenCalled(); + expect(onRenameSession).not.toHaveBeenCalled(); + expect(onDeleteSession).not.toHaveBeenCalled(); + }); + it("renames a history session from the list", () => { const onRenameSession = jest.fn(); diff --git a/src/components/chat/AgentHistoryPanel.tsx b/src/components/chat/AgentHistoryPanel.tsx index 668c5a2..cfc9b9a 100644 --- a/src/components/chat/AgentHistoryPanel.tsx +++ b/src/components/chat/AgentHistoryPanel.tsx @@ -13,6 +13,7 @@ import { Divider, IconButton, Paper, + Skeleton, Stack, TextField, Tooltip, @@ -34,9 +35,11 @@ type AgentHistoryPanelProps = { sessions: ChatSessionSummary[]; activeSessionId?: string; isHydrating?: boolean; + isLoadingSessions?: boolean; + loadingSessionId?: string; onNewSession: () => void; onRenameSession: (sessionId: string, title: string) => void; - onSelectSession: (sessionId: string) => void; + onSelectSession: (sessionId: string, title: string) => void; onDeleteSession: (sessionId: string) => void; }; @@ -76,6 +79,8 @@ export const AgentHistoryPanel = ({ sessions, activeSessionId, isHydrating = false, + isLoadingSessions = false, + loadingSessionId, onNewSession, onRenameSession, onSelectSession, @@ -127,6 +132,30 @@ export const AgentHistoryPanel = ({ (session) => session.id === pendingDeleteSessionId, ); + const renderSessionListSkeleton = () => ( + + {Array.from({ length: 6 }, (_, index) => ( + + + + + + + ))} + + ); + const handleStartRename = (sessionId: string, title: string) => { setEditingSessionId(sessionId); setDraftTitle(title); @@ -215,7 +244,9 @@ export const AgentHistoryPanel = ({ - {sessions.length === 0 ? ( + {isLoadingSessions ? ( + renderSessionListSkeleton() + ) : sessions.length === 0 ? ( {groupSessions.map((session) => { const isActive = session.id === activeSessionId; + const isLoading = session.id === loadingSessionId; return ( { - if (editingSessionId === session.id) return; - onSelectSession(session.id); + if (editingSessionId === session.id || isLoading) return; + onSelectSession(session.id, session.title); }} sx={{ px: 1.25, py: 1, borderRadius: 3, - cursor: isHydrating ? "default" : "pointer", - bgcolor: isActive ? alpha("#00acc1", 0.12) : alpha("#fff", 0.56), - border: `1px solid ${isActive ? alpha("#00acc1", 0.25) : alpha("#fff", 0.72)}`, - boxShadow: isActive ? `0 8px 20px ${alpha("#00acc1", 0.12)}` : `0 4px 12px ${alpha("#000", 0.03)}`, + cursor: isHydrating || isLoading ? "default" : "pointer", + bgcolor: + isActive || isLoading + ? alpha("#00acc1", 0.12) + : alpha("#fff", 0.56), + border: `1px solid ${ + isActive || isLoading + ? alpha("#00acc1", 0.25) + : alpha("#fff", 0.72) + }`, + boxShadow: + isActive || isLoading + ? `0 8px 20px ${alpha("#00acc1", 0.12)}` + : `0 4px 12px ${alpha("#000", 0.03)}`, transition: "all 0.2s ease", - pointerEvents: isHydrating ? "none" : "auto", + pointerEvents: isHydrating || isLoading ? "none" : "auto", "&:hover": { - bgcolor: isActive ? alpha("#00acc1", 0.14) : alpha("#fff", 0.86), + bgcolor: + isActive || isLoading + ? alpha("#00acc1", 0.14) + : alpha("#fff", 0.86), borderColor: alpha("#00acc1", 0.2), }, }} @@ -382,6 +428,11 @@ export const AgentHistoryPanel = ({ + ) : isLoading ? ( + + + + ) : pendingDeleteSessionId === session.id ? ( - {!(editingSessionId === session.id || pendingDeleteSessionId === session.id) && ( + {!(editingSessionId === session.id || pendingDeleteSessionId === session.id || isLoading) && ( diff --git a/src/components/chat/AgentWorkspace.test.tsx b/src/components/chat/AgentWorkspace.test.tsx index d63f033..237d903 100644 --- a/src/components/chat/AgentWorkspace.test.tsx +++ b/src/components/chat/AgentWorkspace.test.tsx @@ -1,7 +1,7 @@ /* eslint-disable @next/next/no-img-element */ import "@testing-library/jest-dom"; import React from "react"; -import { render } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import { AgentWorkspace } from "./AgentWorkspace"; import type { Message } from "./GlobalChatbox.types"; @@ -51,6 +51,20 @@ describe("AgentWorkspace", () => { renderCounts.clear(); }); + it("shows a loading skeleton instead of the empty state while switching history sessions", () => { + render( + , + ); + + expect(screen.getByLabelText("正在加载历史记录")).toBeInTheDocument(); + expect(screen.queryByText("我已就绪,请描述任务")).not.toBeInTheDocument(); + }); + it("keeps stable history turns from re-rendering while the last assistant message streams", () => { const userMessage: Message = { id: "user-1", diff --git a/src/components/chat/AgentWorkspace.tsx b/src/components/chat/AgentWorkspace.tsx index ed841de..e1ef132 100644 --- a/src/components/chat/AgentWorkspace.tsx +++ b/src/components/chat/AgentWorkspace.tsx @@ -3,7 +3,7 @@ import Image from "next/image"; import React from "react"; import { AnimatePresence, motion } from "framer-motion"; -import { Box, Paper, Stack, Typography, alpha, useTheme, Grid } from "@mui/material"; +import { Box, Paper, Skeleton, Stack, Typography, alpha, useTheme, Grid } from "@mui/material"; import WaterDropRounded from "@mui/icons-material/WaterDropRounded"; import SensorsRounded from "@mui/icons-material/SensorsRounded"; import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded"; @@ -20,6 +20,7 @@ import type { type AgentWorkspaceProps = { messages: Message[]; isStreaming: boolean; + isLoadingSession?: boolean; bottomRef: React.RefObject; speakingMessageId: string | null; speechState: SpeechState; @@ -226,9 +227,72 @@ const EmptyState = () => { ); }; +const SessionLoadingSkeleton = () => ( + + {Array.from({ length: 2 }, (_, turnIndex) => ( + + + + + + + + + + + + + + + + + + + + + + + + ))} + +); + export const AgentWorkspace = ({ messages, isStreaming, + isLoadingSession = false, bottomRef, speakingMessageId, speechState, @@ -270,49 +334,55 @@ export const AgentWorkspace = ({ zIndex: 5, }} > - - {messages.length === 0 ? : null} - + {isLoadingSession ? ( + + ) : ( + <> + + {messages.length === 0 ? : null} + - {messages.length > 0 ? ( - - + {messages.length > 0 ? ( + + - {streamingMessage ? ( - + {streamingMessage ? ( + + ) : null} + ) : null} - - ) : null} + + )} - {showTypingIndicator ? ( + {!isLoadingSession && showTypingIndicator ? ( = ({ open, onClose }) => { chatSessions, activeSessionId, isHydrating, + loadingSessionId, isStreaming, sessionTitle, sendPrompt, @@ -159,9 +160,9 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { }, []); const handleSelectSession = useCallback( - (sessionId: string) => { + (sessionId: string, title: string) => { composerRef.current?.clear(); - void switchSession(sessionId); + void switchSession(sessionId, title); }, [switchSession], ); @@ -326,12 +327,14 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { sessions={chatSessions} activeSessionId={activeSessionId} isHydrating={isHydrating} + isLoadingSessions={isHydrating && chatSessions.length === 0} + loadingSessionId={loadingSessionId} onNewSession={() => { handleNewConversation(); setIsHistoryOpen(false); }} - onSelectSession={(id) => { - handleSelectSession(id); + onSelectSession={(id, title) => { + handleSelectSession(id, title); setIsHistoryOpen(false); }} onRenameSession={handleRenameSession} @@ -343,6 +346,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { ([]); const [isStreaming, setIsStreaming] = useState(false); const [isHydrating, setIsHydrating] = useState(true); + const [loadingSessionId, setLoadingSessionId] = useState(undefined); const abortRef = useRef(null); const sessionIdRef = useRef(undefined); const messagesRef = useRef([]); @@ -756,12 +757,17 @@ export const useAgentChatSession = ({ }, [isHydrating, isStreaming]); const switchSession = useCallback( - async (nextSessionId: string) => { + async (nextSessionId: string, optimisticTitle?: string) => { if (isHydrating || isStreaming || sessionIdRef.current === nextSessionId) { return; } setIsHydrating(true); + setLoadingSessionId(nextSessionId); + const nextTitle = optimisticTitle?.trim(); + if (nextTitle) { + setSessionTitle(nextTitle); + } try { const [nextState, sessions] = await Promise.all([ loadChatSessionById(nextSessionId), @@ -785,6 +791,7 @@ export const useAgentChatSession = ({ } catch (error) { console.error("[GlobalChatbox] Failed to switch chat session:", error); } finally { + setLoadingSessionId(undefined); setIsHydrating(false); } }, @@ -932,6 +939,7 @@ export const useAgentChatSession = ({ chatSessions, activeSessionId: sessionIdRef.current, isHydrating, + loadingSessionId, isStreaming, sessionTitle, sessionId,