diff --git a/frontend/__tests__/components/chat-status-indicator.test.tsx b/frontend/__tests__/components/chat-status-indicator.test.tsx new file mode 100644 index 0000000000..06eaa86e74 --- /dev/null +++ b/frontend/__tests__/components/chat-status-indicator.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import ChatStatusIndicator from "#/components/features/chat/chat-status-indicator"; + +vi.mock("#/icons/debug-stackframe-dot.svg?react", () => ({ + default: (props: any) => ( + + ), +})); + +describe("ChatStatusIndicator", () => { + it("renders the status indicator with status text", () => { + render( + + ); + + expect( + screen.getByTestId("chat-status-indicator"), + ).toBeInTheDocument(); + expect(screen.getByText("Waiting for sandbox")).toBeInTheDocument(); + }); + + it("passes the statusColor to the DebugStackframeDot icon", () => { + render( + + ); + + const icon = screen.getByTestId("debug-stackframe-dot"); + expect(icon).toHaveAttribute("color", "#FF684E"); + }); + + it("renders the DebugStackframeDot icon", () => { + render( + + ); + + expect(screen.getByTestId("debug-stackframe-dot")).toBeInTheDocument(); + }); +}); diff --git a/frontend/__tests__/components/chat/chat-interface.test.tsx b/frontend/__tests__/components/chat/chat-interface.test.tsx index 43da7cfae7..7e8f0b8d2b 100644 --- a/frontend/__tests__/components/chat/chat-interface.test.tsx +++ b/frontend/__tests__/components/chat/chat-interface.test.tsx @@ -24,6 +24,8 @@ import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory"; import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files"; import { OpenHandsAction } from "#/types/core/actions"; import { useEventStore } from "#/stores/use-event-store"; +import { useAgentState } from "#/hooks/use-agent-state"; +import { AgentState } from "#/types/agent-state"; vi.mock("#/context/ws-client-provider"); vi.mock("#/hooks/query/use-config"); @@ -59,6 +61,12 @@ vi.mock("#/hooks/use-conversation-name-context-menu", () => ({ }), })); +vi.mock("#/hooks/use-agent-state", () => ({ + useAgentState: vi.fn(() => ({ + curAgentState: AgentState.AWAITING_USER_INPUT, + })), +})); + // Helper function to render with Router context const renderChatInterfaceWithRouter = () => renderWithProviders( @@ -344,6 +352,28 @@ describe("ChatInterface - Empty state", () => { ); }); +describe('ChatInterface - Status Indicator', () => { + it("should render ChatStatusIndicator when agent is not awaiting user input / conversation is NOT ready", () => { + vi.mocked(useAgentState).mockReturnValue({ + curAgentState: AgentState.LOADING, + }); + + renderChatInterfaceWithRouter(); + + expect(screen.getByTestId("chat-status-indicator")).toBeInTheDocument(); + }); + + it("should NOT render ChatStatusIndicator when agent is awaiting user input / conversation is ready", () => { + vi.mocked(useAgentState).mockReturnValue({ + curAgentState: AgentState.AWAITING_USER_INPUT, + }); + + renderChatInterfaceWithRouter(); + + expect(screen.queryByTestId("chat-status-indicator")).not.toBeInTheDocument(); + }); +}); + describe.skip("ChatInterface - General functionality", () => { beforeAll(() => { // mock useScrollToBottom hook diff --git a/frontend/__tests__/utils/utils.test.ts b/frontend/__tests__/utils/utils.test.ts index 7dc9df0254..91e9ba031b 100644 --- a/frontend/__tests__/utils/utils.test.ts +++ b/frontend/__tests__/utils/utils.test.ts @@ -1,9 +1,26 @@ -import { test, expect } from "vitest"; +import { describe, it, expect, vi, test } from "vitest"; import { formatTimestamp, getExtension, removeApiKey, } from "../../src/utils/utils"; +import { getStatusText } from "#/utils/utils"; +import { AgentState } from "#/types/agent-state"; +import { I18nKey } from "#/i18n/declaration"; + +// Mock translations +const t = (key: string) => { + const translations: { [key: string]: string } = { + COMMON$WAITING_FOR_SANDBOX: "Waiting For Sandbox", + COMMON$STOPPING: "Stopping", + COMMON$STARTING: "Starting", + COMMON$SERVER_STOPPED: "Server stopped", + COMMON$RUNNING: "Running", + CONVERSATION$READY: "Ready", + CONVERSATION$ERROR_STARTING_CONVERSATION: "Error starting conversation", + }; + return translations[key] || key; +}; test("removeApiKey", () => { const data = [{ args: { LLM_API_KEY: "key", LANGUAGE: "en" } }]; @@ -23,3 +40,143 @@ test("formatTimestamp", () => { const eveningDate = new Date("2021-10-10T22:10:10.000").toISOString(); expect(formatTimestamp(eveningDate)).toBe("10/10/2021, 22:10:10"); }); + +describe("getStatusText", () => { + it("returns STOPPING when pausing", () => { + const result = getStatusText({ + isPausing: true, + isTask: false, + taskStatus: null, + taskDetail: null, + isStartingStatus: false, + isStopStatus: false, + curAgentState: AgentState.RUNNING, + t, + }); + + expect(result).toBe(t(I18nKey.COMMON$STOPPING)); + }); + + it("formats task status when polling a task", () => { + const result = getStatusText({ + isPausing: false, + isTask: true, + taskStatus: "WAITING_FOR_SANDBOX", + taskDetail: null, + isStartingStatus: false, + isStopStatus: false, + curAgentState: AgentState.RUNNING, + t, + }); + + expect(result).toBe(t(I18nKey.COMMON$WAITING_FOR_SANDBOX)); + }); + + it("returns task detail when task status is ERROR and detail exists", () => { + const result = getStatusText({ + isPausing: false, + isTask: true, + taskStatus: "ERROR", + taskDetail: "Sandbox failed", + isStartingStatus: false, + isStopStatus: false, + curAgentState: AgentState.RUNNING, + t, + }); + + expect(result).toBe("Sandbox failed"); + }); + + it("returns translated error when task status is ERROR and no detail", () => { + const result = getStatusText({ + isPausing: false, + isTask: true, + taskStatus: "ERROR", + taskDetail: null, + isStartingStatus: false, + isStopStatus: false, + curAgentState: AgentState.RUNNING, + t, + }); + + expect(result).toBe( + t(I18nKey.CONVERSATION$ERROR_STARTING_CONVERSATION), + ); + }); + + it("returns READY translation when task is ready", () => { + const result = getStatusText({ + isPausing: false, + isTask: true, + taskStatus: "READY", + taskDetail: null, + isStartingStatus: false, + isStopStatus: false, + curAgentState: AgentState.RUNNING, + t, + }); + + expect(result).toBe(t(I18nKey.CONVERSATION$READY)); + }); + + it("returns STARTING when starting status is true", () => { + const result = getStatusText({ + isPausing: false, + isTask: false, + taskStatus: null, + taskDetail: null, + isStartingStatus: true, + isStopStatus: false, + curAgentState: AgentState.INIT, + t, + }); + + expect(result).toBe(t(I18nKey.COMMON$STARTING)); + }); + + it("returns SERVER_STOPPED when stop status is true", () => { + const result = getStatusText({ + isPausing: false, + isTask: false, + taskStatus: null, + taskDetail: null, + isStartingStatus: false, + isStopStatus: true, + curAgentState: AgentState.STOPPED, + t, + }); + + expect(result).toBe(t(I18nKey.COMMON$SERVER_STOPPED)); + }); + + it("returns errorMessage when agent state is ERROR", () => { + const result = getStatusText({ + isPausing: false, + isTask: false, + taskStatus: null, + taskDetail: null, + isStartingStatus: false, + isStopStatus: false, + curAgentState: AgentState.ERROR, + errorMessage: "Something broke", + t, + }); + + expect(result).toBe("Something broke"); + }); + + it("returns default RUNNING status", () => { + const result = getStatusText({ + isPausing: false, + isTask: false, + taskStatus: null, + taskDetail: null, + isStartingStatus: false, + isStopStatus: false, + curAgentState: AgentState.RUNNING, + t, + }); + + expect(result).toBe(t(I18nKey.COMMON$RUNNING)); + }); +}); diff --git a/frontend/src/components/features/chat/chat-interface.tsx b/frontend/src/components/features/chat/chat-interface.tsx index 84c269dac3..1a3b169198 100644 --- a/frontend/src/components/features/chat/chat-interface.tsx +++ b/frontend/src/components/features/chat/chat-interface.tsx @@ -49,6 +49,8 @@ import { import { useActiveConversation } from "#/hooks/query/use-active-conversation"; import { useTaskPolling } from "#/hooks/query/use-task-polling"; import { useConversationWebSocket } from "#/contexts/conversation-websocket-context"; +import ChatStatusIndicator from "./chat-status-indicator"; +import { getStatusColor, getStatusText } from "#/utils/utils"; function getEntryPoint( hasRepository: boolean | null, @@ -65,7 +67,7 @@ export function ChatInterface() { const { data: conversation } = useActiveConversation(); const { errorMessage } = useErrorMessageStore(); const { isLoadingMessages } = useWsClient(); - const { isTask } = useTaskPolling(); + const { isTask, taskStatus, taskDetail } = useTaskPolling(); const conversationWebSocket = useConversationWebSocket(); const { send } = useSendMessage(); const storeEvents = useEventStore((state) => state.events); @@ -235,6 +237,31 @@ export function ChatInterface() { const v1UserEventsExist = hasV1UserEvent(v1FullEvents); const userEventsExist = v0UserEventsExist || v1UserEventsExist; + // Get server status indicator props + const isStartingStatus = + curAgentState === AgentState.LOADING || curAgentState === AgentState.INIT; + const isStopStatus = curAgentState === AgentState.STOPPED; + const isPausing = curAgentState === AgentState.PAUSED; + const serverStatusColor = getStatusColor({ + isPausing, + isTask, + taskStatus, + isStartingStatus, + isStopStatus, + curAgentState, + }); + const serverStatusText = getStatusText({ + isPausing, + isTask, + taskStatus, + taskDetail, + isStartingStatus, + isStopStatus, + curAgentState, + errorMessage, + t, + }); + return (
@@ -282,8 +309,14 @@ export function ChatInterface() {
-
+
+ {isStartingStatus && ( + + )} {totalEvents > 0 && !isV1Conversation && ( diff --git a/frontend/src/components/features/chat/chat-status-indicator.tsx b/frontend/src/components/features/chat/chat-status-indicator.tsx new file mode 100644 index 0000000000..de23b980fe --- /dev/null +++ b/frontend/src/components/features/chat/chat-status-indicator.tsx @@ -0,0 +1,53 @@ +import { cn } from "@heroui/react"; +import { motion, AnimatePresence } from "framer-motion"; +import DebugStackframeDot from "#/icons/debug-stackframe-dot.svg?react"; + +interface ChatStatusIndicatorProps { + status: string; + statusColor: string; +} + +function ChatStatusIndicator({ + status, + statusColor, +}: ChatStatusIndicatorProps) { + return ( +
+ + {/* Dot */} + + + + + {/* Text */} + + {status} + + +
+ ); +} + +export default ChatStatusIndicator; diff --git a/frontend/src/components/features/controls/server-status.tsx b/frontend/src/components/features/controls/server-status.tsx index e79d4215ea..eec5ed565b 100644 --- a/frontend/src/components/features/controls/server-status.tsx +++ b/frontend/src/components/features/controls/server-status.tsx @@ -1,11 +1,10 @@ import { useTranslation } from "react-i18next"; 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 { useAgentState } from "#/hooks/use-agent-state"; import { useTaskPolling } from "#/hooks/query/use-task-polling"; -import { getStatusColor } from "#/utils/utils"; +import { getStatusColor, getStatusText } from "#/utils/utils"; import { useErrorMessageStore } from "#/stores/error-message-store"; export interface ServerStatusProps { @@ -20,13 +19,12 @@ export function ServerStatus({ isPausing = false, }: ServerStatusProps) { const { curAgentState } = useAgentState(); - const { t } = useTranslation(); const { isTask, taskStatus, taskDetail } = useTaskPolling(); + const { t } = useTranslation(); const { errorMessage } = useErrorMessageStore(); const isStartingStatus = curAgentState === AgentState.LOADING || curAgentState === AgentState.INIT; - const isStopStatus = conversationStatus === "STOPPED"; const statusColor = getStatusColor({ @@ -38,45 +36,17 @@ export function ServerStatus({ curAgentState, }); - const getStatusText = (): string => { - // Show pausing status - if (isPausing) { - return t(I18nKey.COMMON$STOPPING); - } - - // Show task status if we're polling a task - if (isTask && taskStatus) { - if (taskStatus === "ERROR") { - return ( - taskDetail || t(I18nKey.CONVERSATION$ERROR_STARTING_CONVERSATION) - ); - } - if (taskStatus === "READY") { - return t(I18nKey.CONVERSATION$READY); - } - // Format status text: "WAITING_FOR_SANDBOX" -> "Waiting for sandbox" - return ( - taskDetail || - taskStatus - .toLowerCase() - .replace(/_/g, " ") - .replace(/\b\w/g, (c) => c.toUpperCase()) - ); - } - - if (isStartingStatus) { - return t(I18nKey.COMMON$STARTING); - } - if (isStopStatus) { - return t(I18nKey.COMMON$SERVER_STOPPED); - } - if (curAgentState === AgentState.ERROR) { - return errorMessage || t(I18nKey.COMMON$ERROR); - } - return t(I18nKey.COMMON$RUNNING); - }; - - const statusText = getStatusText(); + const statusText = getStatusText({ + isPausing, + isTask, + taskStatus, + taskDetail, + isStartingStatus, + isStopStatus, + curAgentState, + errorMessage, + t, + }); return (
diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 30645cb4f7..4499695392 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -929,6 +929,7 @@ export enum I18nKey { COMMON$RECENT_PROJECTS = "COMMON$RECENT_PROJECTS", COMMON$RUN = "COMMON$RUN", COMMON$RUNNING = "COMMON$RUNNING", + COMMON$WAITING_FOR_SANDBOX = "COMMON$WAITING_FOR_SANDBOX", COMMON$SELECT_GIT_PROVIDER = "COMMON$SELECT_GIT_PROVIDER", COMMON$SERVER_STATUS = "COMMON$SERVER_STATUS", COMMON$SERVER_STOPPED = "COMMON$SERVER_STOPPED", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 5c1f36af07..a602b90777 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -14863,6 +14863,22 @@ "de": "Läuft", "uk": "Працює" }, + "COMMON$WAITING_FOR_SANDBOX": { + "en": "Waiting for sandbox", + "ja": "サンドボックスを待機中", + "zh-CN": "等待沙盒", + "zh-TW": "等待沙盒", + "ko-KR": "샌드박스를 기다리는 중", + "no": "Venter på sandkasse", + "it": "In attesa del sandbox", + "pt": "Aguardando sandbox", + "es": "Esperando el sandbox", + "ar": "في انتظار البيئة المعزولة", + "fr": "En attente du bac à sable", + "tr": "Sandbox bekleniyor", + "de": "Warten auf Sandbox", + "uk": "Очікування пісочниці" + }, "COMMON$SELECT_GIT_PROVIDER": { "en": "Select Git provider", "ja": "Gitプロバイダーを選択", diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts index c3d6a900c4..3c7e58f398 100644 --- a/frontend/src/utils/utils.ts +++ b/frontend/src/utils/utils.ts @@ -7,6 +7,7 @@ import { GitRepository } from "#/types/git"; import { sanitizeQuery } from "#/utils/sanitize-query"; import { PRODUCT_URL } from "#/utils/constants"; import { AgentState } from "#/types/agent-state"; +import { I18nKey } from "#/i18n/declaration"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -746,3 +747,91 @@ export const getStatusColor = (options: { } return "#BCFF8C"; }; + +interface GetStatusTextArgs { + isPausing: boolean; + isTask: boolean; + taskStatus?: string | null; + taskDetail?: string | null; + isStartingStatus: boolean; + isStopStatus: boolean; + curAgentState: AgentState; + errorMessage?: string | null; + t: (t: string) => string; +} + +/** + * Get the server status text based on agent and task state + * + * @param options Configuration object for status text 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.taskDetail Optional task-specific detail text + * @param options.isStartingStatus Whether the conversation is in STARTING state + * @param options.isStopStatus Whether the conversation is STOPPED + * @param options.curAgentState The current agent state + * @param options.errorMessage Optional agent error message + * @returns Localized human-readable status text + * + * @example + * getStatusText({ + * isPausing: false, + * isTask: true, + * taskStatus: "WAITING_FOR_SANDBOX", + * taskDetail: null, + * isStartingStatus: false, + * isStopStatus: false, + * curAgentState: AgentState.RUNNING + * }) // Returns "Waiting For Sandbox" + */ +export function getStatusText({ + isPausing = false, + isTask, + taskStatus, + taskDetail, + isStartingStatus, + isStopStatus, + curAgentState, + errorMessage, + t, +}: GetStatusTextArgs): string { + // Show pausing status + if (isPausing) { + return t(I18nKey.COMMON$STOPPING); + } + + // Show task status if we're polling a task + if (isTask && taskStatus) { + if (taskStatus === "ERROR") { + return taskDetail || t(I18nKey.CONVERSATION$ERROR_STARTING_CONVERSATION); + } + + if (taskStatus === "READY") { + return t(I18nKey.CONVERSATION$READY); + } + + // Format status text: "WAITING_FOR_SANDBOX" -> "Waiting for sandbox" + return ( + taskDetail || + taskStatus + .toLowerCase() + .replace(/_/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()) + ); + } + + if (isStartingStatus) { + return t(I18nKey.COMMON$STARTING); + } + + if (isStopStatus) { + return t(I18nKey.COMMON$SERVER_STOPPED); + } + + if (curAgentState === AgentState.ERROR) { + return errorMessage || t(I18nKey.COMMON$ERROR); + } + + return t(I18nKey.COMMON$RUNNING); +}