From a1d4d62f688039e6e8fea3c401224678db428556 Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Thu, 6 Nov 2025 19:23:08 +0700 Subject: [PATCH] feat(frontend): show server status menu when hovering over the status indicator (#11635) --- .../conversation/server-status.test.tsx | 337 +++++------------- .../chat/components/chat-input-actions.tsx | 23 -- .../chat/components/chat-input-container.tsx | 4 - .../features/chat/custom-chat-input.tsx | 1 - .../server-status-context-menu-icon-text.tsx | 2 +- .../controls/server-status-context-menu.tsx | 54 ++- .../features/controls/server-status.tsx | 85 +---- .../conversation-name-with-status.tsx | 79 ++++ .../conversation/conversation-name.tsx | 2 +- frontend/src/routes/conversation.tsx | 4 +- frontend/src/utils/utils.ts | 64 ++++ 11 files changed, 284 insertions(+), 371 deletions(-) create mode 100644 frontend/src/components/features/conversation/conversation-name-with-status.tsx diff --git a/frontend/__tests__/components/features/conversation/server-status.test.tsx b/frontend/__tests__/components/features/conversation/server-status.test.tsx index 8780530d0e..a1807f8ad6 100644 --- a/frontend/__tests__/components/features/conversation/server-status.test.tsx +++ b/frontend/__tests__/components/features/conversation/server-status.test.tsx @@ -13,34 +13,6 @@ vi.mock("#/hooks/use-agent-state", () => ({ useAgentState: vi.fn(), })); -// Mock the custom hooks -const mockStartConversationMutate = vi.fn(); -const mockStopConversationMutate = vi.fn(); - -vi.mock("#/hooks/mutation/use-unified-start-conversation", () => ({ - useUnifiedStartConversation: () => ({ - mutate: mockStartConversationMutate, - }), -})); - -vi.mock("#/hooks/mutation/use-unified-stop-conversation", () => ({ - useUnifiedStopConversation: () => ({ - mutate: mockStopConversationMutate, - }), -})); - -vi.mock("#/hooks/use-conversation-id", () => ({ - useConversationId: () => ({ - conversationId: "test-conversation-id", - }), -})); - -vi.mock("#/hooks/use-user-providers", () => ({ - useUserProviders: () => ({ - providers: [], - }), -})); - vi.mock("#/hooks/query/use-task-polling", () => ({ useTaskPolling: () => ({ isTask: false, @@ -66,8 +38,12 @@ vi.mock("react-i18next", async () => { COMMON$SERVER_STOPPED: "Server Stopped", COMMON$ERROR: "Error", COMMON$STARTING: "Starting", + COMMON$STOPPING: "Stopping...", COMMON$STOP_RUNTIME: "Stop Runtime", COMMON$START_RUNTIME: "Start Runtime", + CONVERSATION$ERROR_STARTING_CONVERSATION: + "Error starting conversation", + CONVERSATION$READY: "Ready", }; return translations[key] || key; }, @@ -79,10 +55,6 @@ vi.mock("react-i18next", async () => { }); describe("ServerStatus", () => { - // Mock functions for handlers - const mockHandleStop = vi.fn(); - const mockHandleResumeAgent = vi.fn(); - // Helper function to mock agent state with specific state const mockAgentStore = (agentState: AgentState) => { vi.mocked(useAgentState).mockReturnValue({ @@ -94,248 +66,91 @@ describe("ServerStatus", () => { vi.clearAllMocks(); }); - it("should render server status with different conversation statuses", () => { - // Mock agent store to return RUNNING state + it("should render server status with RUNNING conversation status", () => { mockAgentStore(AgentState.RUNNING); - // Test RUNNING status - const { rerender } = renderWithProviders( - , - ); - expect(screen.getByText("Running")).toBeInTheDocument(); + renderWithProviders(); - // Test STOPPED status - rerender( - , - ); + expect(screen.getByTestId("server-status")).toBeInTheDocument(); + expect(screen.getByText("Running")).toBeInTheDocument(); + }); + + it("should render server status with STOPPED conversation status", () => { + mockAgentStore(AgentState.RUNNING); + + renderWithProviders(); + + expect(screen.getByTestId("server-status")).toBeInTheDocument(); expect(screen.getByText("Server Stopped")).toBeInTheDocument(); - - // Test STARTING status (shows "Running" due to agent state being RUNNING) - rerender( - , - ); - expect(screen.getByText("Running")).toBeInTheDocument(); - - // Test null status (shows "Running" due to agent state being RUNNING) - rerender( - , - ); - expect(screen.getByText("Running")).toBeInTheDocument(); }); - it("should show context menu when clicked with RUNNING status", async () => { - const user = userEvent.setup(); + it("should render STARTING status when agent state is LOADING", () => { + mockAgentStore(AgentState.LOADING); - // Mock agent store to return RUNNING state + renderWithProviders(); + + expect(screen.getByTestId("server-status")).toBeInTheDocument(); + expect(screen.getByText("Starting")).toBeInTheDocument(); + }); + + it("should render STARTING status when agent state is INIT", () => { + mockAgentStore(AgentState.INIT); + + renderWithProviders(); + + expect(screen.getByTestId("server-status")).toBeInTheDocument(); + expect(screen.getByText("Starting")).toBeInTheDocument(); + }); + + it("should render ERROR status when agent state is ERROR", () => { + mockAgentStore(AgentState.ERROR); + + renderWithProviders(); + + expect(screen.getByTestId("server-status")).toBeInTheDocument(); + expect(screen.getByText("Error")).toBeInTheDocument(); + }); + + it("should render STOPPING status when isPausing is true", () => { mockAgentStore(AgentState.RUNNING); renderWithProviders( - , + , ); - const statusContainer = screen.getByText("Running").closest("div"); - expect(statusContainer).toBeInTheDocument(); - - await user.click(statusContainer!); - - // Context menu should appear - expect( - screen.getByTestId("server-status-context-menu"), - ).toBeInTheDocument(); - expect(screen.getByTestId("stop-server-button")).toBeInTheDocument(); - }); - - it("should show context menu when clicked with STOPPED status", async () => { - const user = userEvent.setup(); - - // Mock agent store to return STOPPED state - mockAgentStore(AgentState.STOPPED); - - renderWithProviders( - , - ); - - const statusContainer = screen.getByText("Server Stopped").closest("div"); - expect(statusContainer).toBeInTheDocument(); - - await user.click(statusContainer!); - - // Context menu should appear - expect( - screen.getByTestId("server-status-context-menu"), - ).toBeInTheDocument(); - expect(screen.getByTestId("start-server-button")).toBeInTheDocument(); - }); - - it("should not show context menu when clicked with other statuses", async () => { - const user = userEvent.setup(); - - // Mock agent store to return RUNNING state - mockAgentStore(AgentState.RUNNING); - - renderWithProviders( - , - ); - - const statusContainer = screen.getByText("Running").closest("div"); - expect(statusContainer).toBeInTheDocument(); - - await user.click(statusContainer!); - - // Context menu should not appear - expect( - screen.queryByTestId("server-status-context-menu"), - ).not.toBeInTheDocument(); - }); - - it("should call stop conversation mutation when stop server is clicked", async () => { - const user = userEvent.setup(); - - // Clear previous calls - mockHandleStop.mockClear(); - - // Mock agent store to return RUNNING state - mockAgentStore(AgentState.RUNNING); - - renderWithProviders( - , - ); - - const statusContainer = screen.getByText("Running").closest("div"); - await user.click(statusContainer!); - - const stopButton = screen.getByTestId("stop-server-button"); - await user.click(stopButton); - - expect(mockHandleStop).toHaveBeenCalledTimes(1); - }); - - it("should call start conversation mutation when start server is clicked", async () => { - const user = userEvent.setup(); - - // Clear previous calls - mockHandleResumeAgent.mockClear(); - - // Mock agent store to return STOPPED state - mockAgentStore(AgentState.STOPPED); - - renderWithProviders( - , - ); - - const statusContainer = screen.getByText("Server Stopped").closest("div"); - await user.click(statusContainer!); - - const startButton = screen.getByTestId("start-server-button"); - await user.click(startButton); - - expect(mockHandleResumeAgent).toHaveBeenCalledTimes(1); - }); - - it("should close context menu after stop server action", async () => { - const user = userEvent.setup(); - - // Mock agent store to return RUNNING state - mockAgentStore(AgentState.RUNNING); - - renderWithProviders( - , - ); - - const statusContainer = screen.getByText("Running").closest("div"); - await user.click(statusContainer!); - - const stopButton = screen.getByTestId("stop-server-button"); - await user.click(stopButton); - - // Context menu should be closed (handled by the component) - expect(mockHandleStop).toHaveBeenCalledTimes(1); - }); - - it("should close context menu after start server action", async () => { - const user = userEvent.setup(); - - // Mock agent store to return STOPPED state - mockAgentStore(AgentState.STOPPED); - - renderWithProviders( - , - ); - - const statusContainer = screen.getByText("Server Stopped").closest("div"); - await user.click(statusContainer!); - - const startButton = screen.getByTestId("start-server-button"); - await user.click(startButton); - - // Context menu should be closed - expect( - screen.queryByTestId("server-status-context-menu"), - ).not.toBeInTheDocument(); + expect(screen.getByTestId("server-status")).toBeInTheDocument(); + expect(screen.getByText("Stopping...")).toBeInTheDocument(); }); it("should handle null conversation status", () => { - // Mock agent store to return RUNNING state + mockAgentStore(AgentState.RUNNING); + + renderWithProviders(); + + expect(screen.getByTestId("server-status")).toBeInTheDocument(); + expect(screen.getByText("Running")).toBeInTheDocument(); + }); + + it("should apply custom className", () => { mockAgentStore(AgentState.RUNNING); renderWithProviders( - , + , ); - const statusText = screen.getByText("Running"); - expect(statusText).toBeInTheDocument(); + const container = screen.getByTestId("server-status"); + expect(container).toHaveClass("custom-class"); }); }); describe("ServerStatusContextMenu", () => { + // Helper function to mock agent state with specific state + const mockAgentStore = (agentState: AgentState) => { + vi.mocked(useAgentState).mockReturnValue({ + curAgentState: agentState, + }); + }; + const defaultProps = { onClose: vi.fn(), conversationStatus: "RUNNING" as ConversationStatus, @@ -346,6 +161,8 @@ describe("ServerStatusContextMenu", () => { }); it("should render stop server button when status is RUNNING", () => { + mockAgentStore(AgentState.RUNNING); + renderWithProviders( { />, ); + expect(screen.getByTestId("server-status")).toBeInTheDocument(); expect(screen.getByTestId("stop-server-button")).toBeInTheDocument(); expect(screen.getByText("Stop Runtime")).toBeInTheDocument(); }); it("should render start server button when status is STOPPED", () => { + mockAgentStore(AgentState.RUNNING); + renderWithProviders( { />, ); + expect(screen.getByTestId("server-status")).toBeInTheDocument(); expect(screen.getByTestId("start-server-button")).toBeInTheDocument(); expect(screen.getByText("Start Runtime")).toBeInTheDocument(); }); it("should not render stop server button when onStopServer is not provided", () => { + mockAgentStore(AgentState.RUNNING); + renderWithProviders( { />, ); + expect(screen.getByTestId("server-status")).toBeInTheDocument(); expect(screen.queryByTestId("stop-server-button")).not.toBeInTheDocument(); }); it("should not render start server button when onStartServer is not provided", () => { + mockAgentStore(AgentState.RUNNING); + renderWithProviders( { />, ); + expect(screen.getByTestId("server-status")).toBeInTheDocument(); expect(screen.queryByTestId("start-server-button")).not.toBeInTheDocument(); }); it("should call onStopServer when stop button is clicked", async () => { const user = userEvent.setup(); const onStopServer = vi.fn(); + mockAgentStore(AgentState.RUNNING); renderWithProviders( { it("should call onStartServer when start button is clicked", async () => { const user = userEvent.setup(); const onStartServer = vi.fn(); + mockAgentStore(AgentState.RUNNING); renderWithProviders( { }); it("should render correct text content for stop server button", () => { + mockAgentStore(AgentState.RUNNING); + renderWithProviders( { }); it("should render correct text content for start server button", () => { + mockAgentStore(AgentState.RUNNING); + renderWithProviders( { it("should call onClose when context menu is closed", () => { const onClose = vi.fn(); + mockAgentStore(AgentState.RUNNING); renderWithProviders( { }); it("should not render any buttons for other conversation statuses", () => { + mockAgentStore(AgentState.RUNNING); + renderWithProviders( { />, ); + expect(screen.getByTestId("server-status")).toBeInTheDocument(); expect(screen.queryByTestId("stop-server-button")).not.toBeInTheDocument(); expect(screen.queryByTestId("start-server-button")).not.toBeInTheDocument(); }); diff --git a/frontend/src/components/features/chat/components/chat-input-actions.tsx b/frontend/src/components/features/chat/components/chat-input-actions.tsx index 09f4ce5643..abe226520e 100644 --- a/frontend/src/components/features/chat/components/chat-input-actions.tsx +++ b/frontend/src/components/features/chat/components/chat-input-actions.tsx @@ -1,11 +1,7 @@ -import { ConversationStatus } from "#/types/conversation-status"; -import { ServerStatus } from "#/components/features/controls/server-status"; import { AgentStatus } from "#/components/features/controls/agent-status"; import { Tools } from "../../controls/tools"; import { useUnifiedPauseConversationSandbox } from "#/hooks/mutation/use-unified-stop-conversation"; import { useConversationId } from "#/hooks/use-conversation-id"; -import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation"; -import { useUserProviders } from "#/hooks/use-user-providers"; import { useActiveConversation } from "#/hooks/query/use-active-conversation"; import { useSendMessage } from "#/hooks/use-send-message"; import { generateAgentStateChangeEvent } from "#/services/agent-state-service"; @@ -14,32 +10,23 @@ import { useV1PauseConversation } from "#/hooks/mutation/use-v1-pause-conversati import { useV1ResumeConversation } from "#/hooks/mutation/use-v1-resume-conversation"; interface ChatInputActionsProps { - conversationStatus: ConversationStatus | null; disabled: boolean; handleResumeAgent: () => void; } export function ChatInputActions({ - conversationStatus, disabled, handleResumeAgent, }: ChatInputActionsProps) { const { data: conversation } = useActiveConversation(); const pauseConversationSandboxMutation = useUnifiedPauseConversationSandbox(); - const resumeConversationSandboxMutation = - useUnifiedResumeConversationSandbox(); const v1PauseConversationMutation = useV1PauseConversation(); const v1ResumeConversationMutation = useV1ResumeConversation(); const { conversationId } = useConversationId(); - const { providers } = useUserProviders(); const { send } = useSendMessage(); const isV1Conversation = conversation?.conversation_version === "V1"; - const handleStopClick = () => { - pauseConversationSandboxMutation.mutate({ conversationId }); - }; - const handlePauseAgent = () => { if (isV1Conversation) { // V1: Pause the conversation (agent execution) @@ -62,10 +49,6 @@ export function ChatInputActions({ handleResumeAgent(); }; - const handleStartClick = () => { - resumeConversationSandboxMutation.mutate({ conversationId, providers }); - }; - const isPausing = pauseConversationSandboxMutation.isPending || v1PauseConversationMutation.isPending; @@ -74,12 +57,6 @@ export function ChatInputActions({
-
; handleFileIconClick: (isDisabled: boolean) => void; handleSubmit: () => void; @@ -32,7 +30,6 @@ export function ChatInputContainer({ disabled, showButton, buttonClassName, - conversationStatus, chatInputRef, handleFileIconClick, handleSubmit, @@ -74,7 +71,6 @@ export function ChatInputContainer({ /> diff --git a/frontend/src/components/features/chat/custom-chat-input.tsx b/frontend/src/components/features/chat/custom-chat-input.tsx index 9c12beefca..92ec264a34 100644 --- a/frontend/src/components/features/chat/custom-chat-input.tsx +++ b/frontend/src/components/features/chat/custom-chat-input.tsx @@ -137,7 +137,6 @@ export function CustomChatInput({ disabled={isDisabled} showButton={showButton} buttonClassName={buttonClassName} - conversationStatus={conversationStatus} chatInputRef={chatInputRef} handleFileIconClick={handleFileIconClick} handleSubmit={handleSubmit} diff --git a/frontend/src/components/features/controls/server-status-context-menu-icon-text.tsx b/frontend/src/components/features/controls/server-status-context-menu-icon-text.tsx index e622acd687..85164d99aa 100644 --- a/frontend/src/components/features/controls/server-status-context-menu-icon-text.tsx +++ b/frontend/src/components/features/controls/server-status-context-menu-icon-text.tsx @@ -13,7 +13,7 @@ export function ServerStatusContextMenuIconText({ }: ServerStatusContextMenuIconTextProps) { return (