mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
feat(frontend): show server status menu when hovering over the status indicator (#11635)
This commit is contained in:
@@ -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(
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
|
||||
|
||||
// Test STOPPED status
|
||||
rerender(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render server status with STOPPED conversation status", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
|
||||
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.getByText("Server Stopped")).toBeInTheDocument();
|
||||
|
||||
// Test STARTING status (shows "Running" due to agent state being RUNNING)
|
||||
rerender(
|
||||
<ServerStatus
|
||||
conversationStatus="STARTING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
|
||||
// Test null status (shows "Running" due to agent state being RUNNING)
|
||||
rerender(
|
||||
<ServerStatus
|
||||
conversationStatus={null}
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
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(<ServerStatus conversationStatus="STARTING" />);
|
||||
|
||||
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(<ServerStatus conversationStatus="STARTING" />);
|
||||
|
||||
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(<ServerStatus conversationStatus="RUNNING" />);
|
||||
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.getByText("Error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render STOPPING status when isPausing is true", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
<ServerStatus conversationStatus="RUNNING" isPausing={true} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ServerStatus
|
||||
conversationStatus="STARTING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<ServerStatus conversationStatus={null} />);
|
||||
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should apply custom className", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus={null}
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
<ServerStatus conversationStatus="RUNNING" className="custom-class" />,
|
||||
);
|
||||
|
||||
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(
|
||||
<ServerStatusContextMenu
|
||||
{...defaultProps}
|
||||
@@ -354,11 +171,14 @@ describe("ServerStatusContextMenu", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ServerStatusContextMenu
|
||||
{...defaultProps}
|
||||
@@ -367,11 +187,14 @@ describe("ServerStatusContextMenu", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ServerStatusContextMenu
|
||||
{...defaultProps}
|
||||
@@ -379,10 +202,13 @@ describe("ServerStatusContextMenu", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ServerStatusContextMenu
|
||||
{...defaultProps}
|
||||
@@ -390,12 +216,14 @@ describe("ServerStatusContextMenu", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ServerStatusContextMenu
|
||||
@@ -414,6 +242,7 @@ describe("ServerStatusContextMenu", () => {
|
||||
it("should call onStartServer when start button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onStartServer = vi.fn();
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatusContextMenu
|
||||
@@ -430,6 +259,8 @@ describe("ServerStatusContextMenu", () => {
|
||||
});
|
||||
|
||||
it("should render correct text content for stop server button", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatusContextMenu
|
||||
{...defaultProps}
|
||||
@@ -444,6 +275,8 @@ describe("ServerStatusContextMenu", () => {
|
||||
});
|
||||
|
||||
it("should render correct text content for start server button", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatusContextMenu
|
||||
{...defaultProps}
|
||||
@@ -459,6 +292,7 @@ describe("ServerStatusContextMenu", () => {
|
||||
|
||||
it("should call onClose when context menu is closed", () => {
|
||||
const onClose = vi.fn();
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatusContextMenu
|
||||
@@ -475,6 +309,8 @@ describe("ServerStatusContextMenu", () => {
|
||||
});
|
||||
|
||||
it("should not render any buttons for other conversation statuses", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatusContextMenu
|
||||
{...defaultProps}
|
||||
@@ -482,6 +318,7 @@ describe("ServerStatusContextMenu", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("stop-server-button")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("start-server-button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Tools />
|
||||
<ServerStatus
|
||||
conversationStatus={conversationStatus}
|
||||
isPausing={isPausing}
|
||||
handleStop={handleStopClick}
|
||||
handleResumeAgent={handleStartClick}
|
||||
/>
|
||||
</div>
|
||||
<AgentStatus
|
||||
className="ml-2 md:ml-3"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from "react";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { DragOver } from "../drag-over";
|
||||
import { UploadedFiles } from "../uploaded-files";
|
||||
import { ChatInputRow } from "./chat-input-row";
|
||||
@@ -11,7 +10,6 @@ interface ChatInputContainerProps {
|
||||
disabled: boolean;
|
||||
showButton: boolean;
|
||||
buttonClassName: string;
|
||||
conversationStatus: ConversationStatus | null;
|
||||
chatInputRef: React.RefObject<HTMLDivElement | null>;
|
||||
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({
|
||||
/>
|
||||
|
||||
<ChatInputActions
|
||||
conversationStatus={conversationStatus}
|
||||
disabled={disabled}
|
||||
handleResumeAgent={handleResumeAgent}
|
||||
/>
|
||||
|
||||
@@ -137,7 +137,6 @@ export function CustomChatInput({
|
||||
disabled={isDisabled}
|
||||
showButton={showButton}
|
||||
buttonClassName={buttonClassName}
|
||||
conversationStatus={conversationStatus}
|
||||
chatInputRef={chatInputRef}
|
||||
handleFileIconClick={handleFileIconClick}
|
||||
handleSubmit={handleSubmit}
|
||||
|
||||
@@ -13,7 +13,7 @@ export function ServerStatusContextMenuIconText({
|
||||
}: ServerStatusContextMenuIconTextProps) {
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded text-sm text-white font-normal leading-5 cursor-pointer w-full"
|
||||
className="flex items-center justify-between p-2 hover:bg-[#5C5D62] rounded text-sm text-white font-normal leading-5 cursor-pointer w-full"
|
||||
onClick={onClick}
|
||||
data-testid={testId}
|
||||
type="button"
|
||||
|
||||
@@ -6,6 +6,9 @@ import { ConversationStatus } from "#/types/conversation-status";
|
||||
import StopCircleIcon from "#/icons/stop-circle.svg?react";
|
||||
import PlayCircleIcon from "#/icons/play-circle.svg?react";
|
||||
import { ServerStatusContextMenuIconText } from "./server-status-context-menu-icon-text";
|
||||
import { ServerStatus } from "./server-status";
|
||||
import { Divider } from "#/ui/divider";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ServerStatusContextMenuProps {
|
||||
onClose: () => void;
|
||||
@@ -13,6 +16,8 @@ interface ServerStatusContextMenuProps {
|
||||
onStartServer?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
conversationStatus: ConversationStatus | null;
|
||||
position?: "top" | "bottom";
|
||||
className?: string;
|
||||
isPausing?: boolean;
|
||||
}
|
||||
|
||||
export function ServerStatusContextMenu({
|
||||
@@ -21,10 +26,15 @@ export function ServerStatusContextMenu({
|
||||
onStartServer,
|
||||
conversationStatus,
|
||||
position = "top",
|
||||
className = "",
|
||||
isPausing = false,
|
||||
}: ServerStatusContextMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
|
||||
const shouldActionShown =
|
||||
conversationStatus === "RUNNING" || conversationStatus === "STOPPED";
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
ref={ref}
|
||||
@@ -32,24 +42,36 @@ export function ServerStatusContextMenu({
|
||||
position={position}
|
||||
alignment="left"
|
||||
size="default"
|
||||
className="left-2 w-fit min-w-max"
|
||||
className={cn("left-2 w-fit min-w-42", className)}
|
||||
>
|
||||
{conversationStatus === "RUNNING" && onStopServer && (
|
||||
<ServerStatusContextMenuIconText
|
||||
icon={<StopCircleIcon width={18} height={18} />}
|
||||
text={t(I18nKey.COMMON$STOP_RUNTIME)}
|
||||
onClick={onStopServer}
|
||||
testId="stop-server-button"
|
||||
/>
|
||||
)}
|
||||
<ServerStatus
|
||||
conversationStatus={conversationStatus}
|
||||
isPausing={isPausing}
|
||||
className="py-1"
|
||||
/>
|
||||
|
||||
{conversationStatus === "STOPPED" && onStartServer && (
|
||||
<ServerStatusContextMenuIconText
|
||||
icon={<PlayCircleIcon width={18} height={18} />}
|
||||
text={t(I18nKey.COMMON$START_RUNTIME)}
|
||||
onClick={onStartServer}
|
||||
testId="start-server-button"
|
||||
/>
|
||||
{shouldActionShown && (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
{conversationStatus === "RUNNING" && onStopServer && (
|
||||
<ServerStatusContextMenuIconText
|
||||
icon={<StopCircleIcon width={18} height={18} />}
|
||||
text={t(I18nKey.COMMON$STOP_RUNTIME)}
|
||||
onClick={onStopServer}
|
||||
testId="stop-server-button"
|
||||
/>
|
||||
)}
|
||||
|
||||
{conversationStatus === "STOPPED" && onStartServer && (
|
||||
<ServerStatusContextMenuIconText
|
||||
icon={<PlayCircleIcon width={18} height={18} />}
|
||||
text={t(I18nKey.COMMON$START_RUNTIME)}
|
||||
onClick={onStartServer}
|
||||
testId="start-server-button"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import DebugStackframeDot from "#/icons/debug-stackframe-dot.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { ServerStatusContextMenu } from "./server-status-context-menu";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useTaskPolling } from "#/hooks/query/use-task-polling";
|
||||
import { getStatusColor } from "#/utils/utils";
|
||||
|
||||
export interface ServerStatusProps {
|
||||
className?: string;
|
||||
conversationStatus: ConversationStatus | null;
|
||||
isPausing?: boolean;
|
||||
handleStop: () => void;
|
||||
handleResumeAgent: () => void;
|
||||
}
|
||||
|
||||
export function ServerStatus({
|
||||
className = "",
|
||||
conversationStatus,
|
||||
isPausing = false,
|
||||
handleStop,
|
||||
handleResumeAgent,
|
||||
}: ServerStatusProps) {
|
||||
const [showContextMenu, setShowContextMenu] = useState(false);
|
||||
|
||||
const { curAgentState } = useAgentState();
|
||||
const { t } = useTranslation();
|
||||
const { isTask, taskStatus, taskDetail } = useTaskPolling();
|
||||
@@ -34,34 +27,15 @@ export function ServerStatus({
|
||||
|
||||
const isStopStatus = conversationStatus === "STOPPED";
|
||||
|
||||
// Get the appropriate color based on agent status
|
||||
const getStatusColor = (): string => {
|
||||
// Show pausing status
|
||||
if (isPausing) {
|
||||
return "#FFD600";
|
||||
}
|
||||
const statusColor = getStatusColor({
|
||||
isPausing,
|
||||
isTask,
|
||||
taskStatus,
|
||||
isStartingStatus,
|
||||
isStopStatus,
|
||||
curAgentState,
|
||||
});
|
||||
|
||||
// Show task status if we're polling a task
|
||||
if (isTask && taskStatus) {
|
||||
if (taskStatus === "ERROR") {
|
||||
return "#FF684E";
|
||||
}
|
||||
return "#FFD600";
|
||||
}
|
||||
|
||||
if (isStartingStatus) {
|
||||
return "#FFD600";
|
||||
}
|
||||
if (isStopStatus) {
|
||||
return "#ffffff";
|
||||
}
|
||||
if (curAgentState === AgentState.ERROR) {
|
||||
return "#FF684E";
|
||||
}
|
||||
return "#BCFF8C";
|
||||
};
|
||||
|
||||
// Get the appropriate status text based on agent status
|
||||
const getStatusText = (): string => {
|
||||
// Show pausing status
|
||||
if (isPausing) {
|
||||
@@ -100,49 +74,14 @@ export function ServerStatus({
|
||||
return t(I18nKey.COMMON$RUNNING);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (conversationStatus === "RUNNING" || conversationStatus === "STOPPED") {
|
||||
setShowContextMenu(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseContextMenu = () => {
|
||||
setShowContextMenu(false);
|
||||
};
|
||||
|
||||
const handleStopServer = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
handleStop();
|
||||
setShowContextMenu(false);
|
||||
};
|
||||
|
||||
const handleStartServer = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
handleResumeAgent();
|
||||
setShowContextMenu(false);
|
||||
};
|
||||
|
||||
const statusColor = getStatusColor();
|
||||
const statusText = getStatusText();
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<div className="flex items-center cursor-pointer" onClick={handleClick}>
|
||||
<div className={className} data-testid="server-status">
|
||||
<div className="flex items-center">
|
||||
<DebugStackframeDot className="w-6 h-6" color={statusColor} />
|
||||
<span className="text-[11px] text-white font-normal leading-5">
|
||||
{statusText}
|
||||
</span>
|
||||
<span className="text-[13px] text-white font-normal">{statusText}</span>
|
||||
</div>
|
||||
|
||||
{showContextMenu && (
|
||||
<ServerStatusContextMenu
|
||||
onClose={handleCloseContextMenu}
|
||||
onStopServer={handleStopServer}
|
||||
onStartServer={handleStartServer}
|
||||
conversationStatus={conversationStatus}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useTaskPolling } from "#/hooks/query/use-task-polling";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useUnifiedPauseConversationSandbox } from "#/hooks/mutation/use-unified-stop-conversation";
|
||||
import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { getStatusColor } from "#/utils/utils";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import DebugStackframeDot from "#/icons/debug-stackframe-dot.svg?react";
|
||||
import { ServerStatusContextMenu } from "../controls/server-status-context-menu";
|
||||
import { ConversationName } from "./conversation-name";
|
||||
|
||||
export function ConversationNameWithStatus() {
|
||||
const { conversationId } = useParams<{ conversationId: string }>();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { curAgentState } = useAgentState();
|
||||
const { isTask, taskStatus } = useTaskPolling();
|
||||
const { mutate: pauseConversationSandbox } =
|
||||
useUnifiedPauseConversationSandbox();
|
||||
const { mutate: resumeConversationSandbox } =
|
||||
useUnifiedResumeConversationSandbox();
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
const isStartingStatus =
|
||||
curAgentState === AgentState.LOADING || curAgentState === AgentState.INIT;
|
||||
const isStopStatus = conversation?.status === "STOPPED";
|
||||
|
||||
const statusColor = getStatusColor({
|
||||
isPausing: false,
|
||||
isTask,
|
||||
taskStatus,
|
||||
isStartingStatus,
|
||||
isStopStatus,
|
||||
curAgentState,
|
||||
});
|
||||
|
||||
const handleStopServer = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (conversationId) {
|
||||
pauseConversationSandbox({ conversationId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartServer = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (conversationId) {
|
||||
resumeConversationSandbox({ conversationId, providers });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="group relative">
|
||||
<DebugStackframeDot
|
||||
className="ml-[3.5px] w-6 h-6 cursor-pointer"
|
||||
color={statusColor}
|
||||
/>
|
||||
<ServerStatusContextMenu
|
||||
onClose={() => {}}
|
||||
onStopServer={
|
||||
conversation?.status === "RUNNING" ? handleStopServer : undefined
|
||||
}
|
||||
onStartServer={
|
||||
conversation?.status === "STOPPED" ? handleStartServer : undefined
|
||||
}
|
||||
conversationStatus={conversation?.status ?? null}
|
||||
position="bottom"
|
||||
className="opacity-0 invisible pointer-events-none group-hover:opacity-100 group-hover:visible group-hover:pointer-events-auto bottom-full left-0 mt-0 min-h-fit"
|
||||
isPausing={false}
|
||||
/>
|
||||
</div>
|
||||
<ConversationName />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -124,7 +124,7 @@ export function ConversationName() {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex items-center gap-2 h-[22px] text-base font-normal text-left pl-0 lg:pl-3.5"
|
||||
className="flex items-center gap-2 h-[22px] text-base font-normal text-left pl-0 lg:pl-1"
|
||||
data-testid="conversation-name"
|
||||
>
|
||||
{titleMode === "edit" ? (
|
||||
|
||||
@@ -22,7 +22,7 @@ import { ConversationSubscriptionsProvider } from "#/context/conversation-subscr
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
|
||||
import { ConversationMain } from "#/components/features/conversation/conversation-main/conversation-main";
|
||||
import { ConversationName } from "#/components/features/conversation/conversation-name";
|
||||
import { ConversationNameWithStatus } from "#/components/features/conversation/conversation-name-with-status";
|
||||
|
||||
import { ConversationTabs } from "#/components/features/conversation/conversation-tabs/conversation-tabs";
|
||||
import { WebSocketProviderWrapper } from "#/contexts/websocket-provider-wrapper";
|
||||
@@ -160,7 +160,7 @@ function AppContent() {
|
||||
className="p-3 md:p-0 flex flex-col h-full gap-3"
|
||||
>
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4.5 pt-2 lg:pt-0">
|
||||
<ConversationName />
|
||||
<ConversationNameWithStatus />
|
||||
<ConversationTabs />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { sanitizeQuery } from "#/utils/sanitize-query";
|
||||
import { PRODUCT_URL } from "#/utils/constants";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
@@ -609,3 +610,66 @@ export const buildSessionHeaders = (
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the appropriate color based on agent status
|
||||
* @param options Configuration object for status color calculation
|
||||
* @param options.isPausing Whether the agent is currently pausing
|
||||
* @param options.isTask Whether we're polling a task
|
||||
* @param options.taskStatus The task status string (e.g., "ERROR", "READY")
|
||||
* @param options.isStartingStatus Whether the agent is in a starting state (LOADING or INIT)
|
||||
* @param options.isStopStatus Whether the conversation status is STOPPED
|
||||
* @param options.curAgentState The current agent state
|
||||
* @returns The hex color code for the status
|
||||
*
|
||||
* @example
|
||||
* getStatusColor({
|
||||
* isPausing: false,
|
||||
* isTask: false,
|
||||
* taskStatus: undefined,
|
||||
* isStartingStatus: false,
|
||||
* isStopStatus: false,
|
||||
* curAgentState: AgentState.RUNNING
|
||||
* }) // Returns "#BCFF8C"
|
||||
*/
|
||||
export const getStatusColor = (options: {
|
||||
isPausing: boolean;
|
||||
isTask: boolean;
|
||||
taskStatus?: string | null;
|
||||
isStartingStatus: boolean;
|
||||
isStopStatus: boolean;
|
||||
curAgentState: AgentState;
|
||||
}): string => {
|
||||
const {
|
||||
isPausing,
|
||||
isTask,
|
||||
taskStatus,
|
||||
isStartingStatus,
|
||||
isStopStatus,
|
||||
curAgentState,
|
||||
} = options;
|
||||
|
||||
// Show pausing status
|
||||
if (isPausing) {
|
||||
return "#FFD600";
|
||||
}
|
||||
|
||||
// Show task status if we're polling a task
|
||||
if (isTask && taskStatus) {
|
||||
if (taskStatus === "ERROR") {
|
||||
return "#FF684E";
|
||||
}
|
||||
return "#FFD600";
|
||||
}
|
||||
|
||||
if (isStartingStatus) {
|
||||
return "#FFD600";
|
||||
}
|
||||
if (isStopStatus) {
|
||||
return "#ffffff";
|
||||
}
|
||||
if (curAgentState === AgentState.ERROR) {
|
||||
return "#FF684E";
|
||||
}
|
||||
return "#BCFF8C";
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user