feat(frontend): adding status indicator and unit test (#12111)

Co-authored-by: Chloe <chloe@openhands.com>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
This commit is contained in:
HeyItsChloe
2026-01-06 05:01:27 -08:00
committed by GitHub
parent acc0e893e3
commit d053a3d363
9 changed files with 443 additions and 46 deletions

View File

@@ -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) => (
<svg data-testid="debug-stackframe-dot" {...props} />
),
}));
describe("ChatStatusIndicator", () => {
it("renders the status indicator with status text", () => {
render(
<ChatStatusIndicator
status="Waiting for sandbox"
statusColor="#FFD600"
/>
);
expect(
screen.getByTestId("chat-status-indicator"),
).toBeInTheDocument();
expect(screen.getByText("Waiting for sandbox")).toBeInTheDocument();
});
it("passes the statusColor to the DebugStackframeDot icon", () => {
render(
<ChatStatusIndicator
status="Error"
statusColor="#FF684E"
/>
);
const icon = screen.getByTestId("debug-stackframe-dot");
expect(icon).toHaveAttribute("color", "#FF684E");
});
it("renders the DebugStackframeDot icon", () => {
render(
<ChatStatusIndicator
status="Loading"
statusColor="#FFD600"
/>
);
expect(screen.getByTestId("debug-stackframe-dot")).toBeInTheDocument();
});
});

View File

@@ -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

View File

@@ -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));
});
});

View File

@@ -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 (
<ScrollProvider value={scrollProviderValue}>
<div className="h-full flex flex-col justify-between pr-0 md:pr-4 relative">
@@ -282,8 +309,14 @@ export function ChatInterface() {
<div className="flex flex-col gap-[6px]">
<div className="flex justify-between relative">
<div className="flex items-center gap-1">
<div className="flex items-end gap-1">
<ConfirmationModeEnabled />
{isStartingStatus && (
<ChatStatusIndicator
statusColor={serverStatusColor}
status={serverStatusText}
/>
)}
{totalEvents > 0 && !isV1Conversation && (
<TrajectoryActions
onPositiveFeedback={() =>

View File

@@ -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 (
<div
data-testid="chat-status-indicator"
className={cn(
"h-[31px] w-fit rounded-[100px] pt-[20px] pr-[16px] pb-[20px] pl-[5px] bg-[#25272D] flex items-center gap-2",
)}
>
<AnimatePresence mode="wait">
{/* Dot */}
<motion.span
key={`dot-${status}`}
className="animate-[pulse_1.2s_ease-in-out_infinite]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
<DebugStackframeDot
className="w-6 h-6 shrink-0"
color={statusColor}
/>
</motion.span>
{/* Text */}
<motion.span
key={`text-${status}`}
initial={{ opacity: 0, y: -2 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 2 }}
transition={{ duration: 0.3 }}
className="font-normal text-[11px] leading-[20px] normal-case"
>
{status}
</motion.span>
</AnimatePresence>
</div>
);
}
export default ChatStatusIndicator;

View File

@@ -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 (
<div className={className} data-testid="server-status">

View File

@@ -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",

View File

@@ -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プロバイダーを選択",

View File

@@ -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);
}