fix(chat): preserve agent UI across close

This commit is contained in:
2026-07-10 14:38:25 +08:00
parent adb53d9a13
commit c6e6e24aab
2 changed files with 148 additions and 24 deletions
+124
View File
@@ -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: () => <div>Agent header</div>,
}));
jest.mock("./AgentHistoryPanel", () => ({
AgentHistoryPanel: () => <div>History</div>,
}));
jest.mock("./AgentWorkspace", () => ({
AgentWorkspace: () => <div data-testid="agent-workspace">Workspace</div>,
}));
jest.mock("./AgentComposer", () => ({
AgentComposer: React.forwardRef(function MockAgentComposer() {
return <div>Composer</div>;
}),
}));
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(<GlobalChatbox open onClose={jest.fn()} />);
act(() => jest.runOnlyPendingTimers());
expect(createSession).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("agent-workspace")).toBeInTheDocument();
rerender(<GlobalChatbox open={false} onClose={jest.fn()} />);
act(() => jest.advanceTimersByTime(300));
expect(screen.getByTestId("agent-workspace")).toBeInTheDocument();
rerender(<GlobalChatbox open onClose={jest.fn()} />);
act(() => jest.runOnlyPendingTimers());
expect(createSession).toHaveBeenCalledTimes(1);
mockCurrentProjectId = "project-2";
rerender(<GlobalChatbox open onClose={jest.fn()} />);
act(() => jest.runOnlyPendingTimers());
expect(createSession).toHaveBeenCalledTimes(2);
});
});
+24 -24
View File
@@ -42,7 +42,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const isNearBottomRef = useRef(true); const isNearBottomRef = useRef(true);
const streamingScrollFrameRef = useRef<number | null>(null); const streamingScrollFrameRef = useRef<number | null>(null);
const composerRef = useRef<AgentComposerHandle | null>(null); const composerRef = useRef<AgentComposerHandle | null>(null);
const hasResetForOpenRef = useRef(false); const initializedProjectIdRef = useRef<string | null | undefined>(undefined);
const theme = useTheme(); const theme = useTheme();
const { open: openNotification } = useNotification(); const { open: openNotification } = useNotification();
const currentProjectId = useProjectStore((state) => state.currentProjectId); const currentProjectId = useProjectStore((state) => state.currentProjectId);
@@ -154,6 +154,17 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
isNearBottomRef.current = isNearBottom; 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(() => { useEffect(() => {
if (isStreaming) { if (isStreaming) {
if (!isNearBottomRef.current) return; if (!isNearBottomRef.current) return;
@@ -178,24 +189,18 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
); );
useEffect(() => { useEffect(() => {
if (!open) { if (
hasResetForOpenRef.current = false; !open ||
isHydrating ||
initializedProjectIdRef.current === currentProjectId
) {
return; return;
} }
if (hasResetForOpenRef.current || isHydrating) return;
hasResetForOpenRef.current = true;
const timer = window.setTimeout(() => { initializedProjectIdRef.current = currentProjectId;
createSession(); createSession();
composerRef.current?.clear(); resetConversationView();
setIsHistoryOpen(false); }, [createSession, currentProjectId, isHydrating, open, resetConversationView]);
composerRef.current?.focus();
isNearBottomRef.current = true;
cancelStreamingScroll();
scrollToBottom("auto");
}, 0);
return () => window.clearTimeout(timer);
}, [cancelStreamingScroll, createSession, isHydrating, open, scrollToBottom]);
const handleSend = useCallback(async (prompt: string) => { const handleSend = useCallback(async (prompt: string) => {
if (isStreaming || isCheckingAuth) return; if (isStreaming || isCheckingAuth) return;
@@ -230,14 +235,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
handleStopSpeech(); handleStopSpeech();
stopListening(); stopListening();
createSession(); createSession();
composerRef.current?.clear(); resetConversationView();
window.setTimeout(() => { }, [createSession, handleStopSpeech, resetConversationView, stopListening]);
composerRef.current?.focus();
isNearBottomRef.current = true;
cancelStreamingScroll();
scrollToBottom("auto");
}, 0);
}, [cancelStreamingScroll, createSession, handleStopSpeech, scrollToBottom, stopListening]);
const handleHistoryToggle = useCallback(() => { const handleHistoryToggle = useCallback(() => {
setIsHistoryOpen((prev) => !prev); setIsHistoryOpen((prev) => !prev);
@@ -311,6 +310,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
hideBackdrop hideBackdrop
disableScrollLock disableScrollLock
disableEnforceFocus disableEnforceFocus
ModalProps={{ keepMounted: true }}
sx={{ sx={{
zIndex: (muiTheme) => muiTheme.zIndex.modal + 100, zIndex: (muiTheme) => muiTheme.zIndex.modal + 100,
pointerEvents: "none", pointerEvents: "none",