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,