diff --git a/src/components/chat/GlobalChatbox.test.tsx b/src/components/chat/GlobalChatbox.test.tsx
new file mode 100644
index 0000000..235eac9
--- /dev/null
+++ b/src/components/chat/GlobalChatbox.test.tsx
@@ -0,0 +1,124 @@
+import "@testing-library/jest-dom";
+import React from "react";
+import { act, render, screen } from "@testing-library/react";
+
+import { GlobalChatbox } from "./GlobalChatbox";
+
+const createSession = jest.fn();
+let mockCurrentProjectId = "project-1";
+
+jest.mock("@refinedev/core", () => ({
+ useNotification: () => ({ open: jest.fn() }),
+}));
+
+jest.mock("@/lib/chatModels", () => ({
+ fetchAgentModels: jest.fn(() => new Promise(() => {})),
+}));
+
+jest.mock("@/store/projectStore", () => ({
+ useProjectStore: (selector: (state: { currentProjectId: string }) => unknown) =>
+ selector({ currentProjectId: mockCurrentProjectId }),
+}));
+
+jest.mock("./globalChatboxVoice", () => ({
+ useSpeechSynthesis: () => ({
+ speechState: "idle",
+ speakingMessageId: null,
+ speak: jest.fn(),
+ pause: jest.fn(),
+ resume: jest.fn(),
+ stop: jest.fn(),
+ isSupported: true,
+ }),
+ useSpeechRecognition: () => ({
+ isListening: false,
+ start: jest.fn(),
+ stop: jest.fn(),
+ isSupported: true,
+ }),
+}));
+
+jest.mock("./hooks/useAgentToolActions", () => ({
+ useAgentToolActions: () => jest.fn(),
+}));
+
+jest.mock("./hooks/useAgentChatSession", () => ({
+ useAgentChatSession: () => ({
+ messages: [],
+ chatSessions: [],
+ activeSessionId: undefined,
+ isHydrating: false,
+ loadingSessionId: null,
+ isStreaming: false,
+ sessionTitle: "新会话",
+ sendPrompt: jest.fn(),
+ createBranch: jest.fn(),
+ abort: jest.fn(),
+ replyPermission: jest.fn(),
+ replyQuestion: jest.fn(),
+ rejectQuestion: jest.fn(),
+ createSession,
+ renameSession: jest.fn(),
+ removeSession: jest.fn(),
+ switchSession: jest.fn(),
+ }),
+}));
+
+jest.mock("./AgentHeader", () => ({
+ AgentHeader: () =>
Agent header
,
+}));
+
+jest.mock("./AgentHistoryPanel", () => ({
+ AgentHistoryPanel: () => History
,
+}));
+
+jest.mock("./AgentWorkspace", () => ({
+ AgentWorkspace: () => Workspace
,
+}));
+
+jest.mock("./AgentComposer", () => ({
+ AgentComposer: React.forwardRef(function MockAgentComposer() {
+ return Composer
;
+ }),
+}));
+
+jest.mock("./GlobalChatboxParts", () => ({
+ Blob: () => null,
+}));
+
+describe("GlobalChatbox lifecycle", () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ createSession.mockClear();
+ mockCurrentProjectId = "project-1";
+ });
+
+ afterEach(() => {
+ jest.runOnlyPendingTimers();
+ jest.useRealTimers();
+ });
+
+ it("keeps content mounted and preserves the session across close and reopen", async () => {
+ const { rerender } = render();
+
+ act(() => jest.runOnlyPendingTimers());
+ expect(createSession).toHaveBeenCalledTimes(1);
+ expect(screen.getByTestId("agent-workspace")).toBeInTheDocument();
+
+ rerender();
+ act(() => jest.advanceTimersByTime(300));
+
+ expect(screen.getByTestId("agent-workspace")).toBeInTheDocument();
+
+ rerender();
+ act(() => jest.runOnlyPendingTimers());
+
+ expect(createSession).toHaveBeenCalledTimes(1);
+
+ mockCurrentProjectId = "project-2";
+ rerender();
+ act(() => jest.runOnlyPendingTimers());
+
+ expect(createSession).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx
index eeaae1c..6c0e51f 100644
--- a/src/components/chat/GlobalChatbox.tsx
+++ b/src/components/chat/GlobalChatbox.tsx
@@ -42,7 +42,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => {
const isNearBottomRef = useRef(true);
const streamingScrollFrameRef = useRef(null);
const composerRef = useRef(null);
- const hasResetForOpenRef = useRef(false);
+ const initializedProjectIdRef = useRef(undefined);
const theme = useTheme();
const { open: openNotification } = useNotification();
const currentProjectId = useProjectStore((state) => state.currentProjectId);
@@ -154,6 +154,17 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => {
isNearBottomRef.current = isNearBottom;
}, []);
+ const resetConversationView = useCallback(() => {
+ composerRef.current?.clear();
+ setIsHistoryOpen(false);
+ window.setTimeout(() => {
+ composerRef.current?.focus();
+ isNearBottomRef.current = true;
+ cancelStreamingScroll();
+ scrollToBottom("auto");
+ }, 0);
+ }, [cancelStreamingScroll, scrollToBottom]);
+
useEffect(() => {
if (isStreaming) {
if (!isNearBottomRef.current) return;
@@ -178,24 +189,18 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => {
);
useEffect(() => {
- if (!open) {
- hasResetForOpenRef.current = false;
+ if (
+ !open ||
+ isHydrating ||
+ initializedProjectIdRef.current === currentProjectId
+ ) {
return;
}
- if (hasResetForOpenRef.current || isHydrating) return;
- hasResetForOpenRef.current = true;
- const timer = window.setTimeout(() => {
- createSession();
- composerRef.current?.clear();
- setIsHistoryOpen(false);
- composerRef.current?.focus();
- isNearBottomRef.current = true;
- cancelStreamingScroll();
- scrollToBottom("auto");
- }, 0);
- return () => window.clearTimeout(timer);
- }, [cancelStreamingScroll, createSession, isHydrating, open, scrollToBottom]);
+ initializedProjectIdRef.current = currentProjectId;
+ createSession();
+ resetConversationView();
+ }, [createSession, currentProjectId, isHydrating, open, resetConversationView]);
const handleSend = useCallback(async (prompt: string) => {
if (isStreaming || isCheckingAuth) return;
@@ -230,14 +235,8 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => {
handleStopSpeech();
stopListening();
createSession();
- composerRef.current?.clear();
- window.setTimeout(() => {
- composerRef.current?.focus();
- isNearBottomRef.current = true;
- cancelStreamingScroll();
- scrollToBottom("auto");
- }, 0);
- }, [cancelStreamingScroll, createSession, handleStopSpeech, scrollToBottom, stopListening]);
+ resetConversationView();
+ }, [createSession, handleStopSpeech, resetConversationView, stopListening]);
const handleHistoryToggle = useCallback(() => {
setIsHistoryOpen((prev) => !prev);
@@ -311,6 +310,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => {
hideBackdrop
disableScrollLock
disableEnforceFocus
+ ModalProps={{ keepMounted: true }}
sx={{
zIndex: (muiTheme) => muiTheme.zIndex.modal + 100,
pointerEvents: "none",