fix(frontend): localize conversation status labels (#1010)

* fix: localize conversation status labels

* refactor: update the code based on feedback

* refactor: update the code based on feedback
This commit is contained in:
Hiep Le
2026-06-02 19:11:18 +00:00
committed by GitHub
parent 56545a78c5
commit ce1401d3cd
6 changed files with 117 additions and 34 deletions
@@ -0,0 +1,31 @@
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { renderWithProviders } from "test-utils";
import { StartTaskStatusBadge } from "#/components/features/conversation-panel/start-task-card/start-task-status-badge";
import { I18nKey } from "#/i18n/declaration";
describe("StartTaskStatusBadge", () => {
// react-i18next is globally mocked so `t` echoes the key; asserting the key
// proves the label is resolved through i18n rather than a raw enum transform.
it.each([
["STARTING_CONVERSATION", I18nKey.CONVERSATION$STARTING_CONVERSATION],
["READY", I18nKey.CONVERSATION$READY],
["ERROR", I18nKey.COMMON$ERROR],
] as const)(
"localizes the label for the %s status",
(taskStatus, expectedKey) => {
renderWithProviders(<StartTaskStatusBadge taskStatus={taskStatus} />);
expect(screen.getByText(expectedKey)).toBeInTheDocument();
},
);
it("renders nothing for the WORKING status", () => {
const { container } = renderWithProviders(
<StartTaskStatusBadge taskStatus="WORKING" />,
);
expect(container).toBeEmptyDOMElement();
});
});
+22 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { getStatusCode } from "#/utils/status";
import { getStatusCode, getTaskStatusI18nKey } from "#/utils/status";
import { I18nKey } from "#/i18n/declaration";
import { ExecutionStatus } from "#/types/agent-server/core";
import type { AppConversationStartTaskStatus } from "#/api/conversation-service/agent-server-conversation-service.types";
@@ -47,3 +47,24 @@ describe("getStatusCode", () => {
expect(result).toBe(I18nKey.CONVERSATION$STARTING_CONVERSATION);
});
});
describe("getTaskStatusI18nKey", () => {
// Exhaustive coverage of the shared mapper over every AppConversationStartTaskStatus
// member: the dedicated keys (WAITING_FOR_SANDBOX + the two setup keys), the
// terminal READY/ERROR states (which now resolve to their own localized keys
// instead of silently falling back to STARTING_CONVERSATION), and the group
// that collapses to the generic "Starting" label (WORKING/PREPARING_REPOSITORY/
// RUNNING_SETUP_SCRIPT). `as const` keeps the inputs typed as the union.
it.each([
["WAITING_FOR_SANDBOX", I18nKey.COMMON$WAITING_FOR_SANDBOX],
["SETTING_UP_GIT_HOOKS", I18nKey.STATUS$SETTING_UP_GIT_HOOKS],
["SETTING_UP_SKILLS", I18nKey.STATUS$SETTING_UP_SKILLS],
["READY", I18nKey.CONVERSATION$READY],
["ERROR", I18nKey.COMMON$ERROR],
["WORKING", I18nKey.CONVERSATION$STARTING_CONVERSATION],
["PREPARING_REPOSITORY", I18nKey.CONVERSATION$STARTING_CONVERSATION],
["RUNNING_SETUP_SCRIPT", I18nKey.CONVERSATION$STARTING_CONVERSATION],
] as const)("maps %s to its i18n key", (taskStatus, expectedKey) => {
expect(getTaskStatusI18nKey(taskStatus)).toBe(expectedKey);
});
});
+18 -2
View File
@@ -10,6 +10,7 @@ const t = (key: string) => {
COMMON$SERVER_STOPPED: "Server stopped",
COMMON$RUNNING: "Running",
CONVERSATION$READY: "Ready",
CONVERSATION$STARTING_CONVERSATION: "Starting",
CONVERSATION$ERROR_STARTING_CONVERSATION: "Error starting conversation",
};
return translations[key] || key;
@@ -31,7 +32,7 @@ describe("getStatusText", () => {
expect(result).toBe(t(I18nKey.COMMON$STOPPING));
});
it("formats task status when polling a task", () => {
it("localizes task status when polling a task", () => {
const result = getStatusText({
isPausing: false,
isTask: true,
@@ -43,7 +44,22 @@ describe("getStatusText", () => {
t,
});
expect(result).toBe("Starting conversation");
expect(result).toBe(t(I18nKey.CONVERSATION$STARTING_CONVERSATION));
});
it("prefers task detail over the localized status while polling", () => {
const result = getStatusText({
isPausing: false,
isTask: true,
taskStatus: "STARTING_CONVERSATION",
taskDetail: "Cloning repository",
isStartingStatus: false,
isStopStatus: false,
curAgentState: AgentState.RUNNING,
t,
});
expect(result).toBe("Cloning repository");
});
it("returns task detail when task status is ERROR and detail exists", () => {
@@ -1,5 +1,7 @@
import { useTranslation } from "react-i18next";
import type { AppConversationStartTaskStatus } from "#/api/conversation-service/agent-server-conversation-service.types";
import { cn } from "#/utils/utils";
import { getTaskStatusI18nKey } from "#/utils/status";
interface StartTaskStatusBadgeProps {
taskStatus: AppConversationStartTaskStatus;
@@ -8,17 +10,16 @@ interface StartTaskStatusBadgeProps {
export function StartTaskStatusBadge({
taskStatus,
}: StartTaskStatusBadgeProps) {
const { t } = useTranslation("openhands");
// Don't show badge for WORKING status (most common, clutters UI)
if (taskStatus === "WORKING") {
return null;
}
// Format status for display
const formatStatus = (status: string) =>
status
.toLowerCase()
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
// Localized status label — getTaskStatusI18nKey maps every status (including
// the terminal READY/ERROR states) to its localized key.
const getStatusLabel = () => t(getTaskStatusI18nKey(taskStatus));
// Get status color
const getStatusStyle = () => {
@@ -39,7 +40,7 @@ export function StartTaskStatusBadge({
getStatusStyle(),
)}
>
{formatStatus(taskStatus)}
{getStatusLabel()}
</span>
);
}
+34 -16
View File
@@ -28,6 +28,39 @@ export function isExecutionErrored(
return status === ExecutionStatus.ERROR || status === ExecutionStatus.STUCK;
}
export function getTaskStatusI18nKey(
taskStatus: AppConversationStartTaskStatus,
): I18nKey {
switch (taskStatus) {
case "WAITING_FOR_SANDBOX":
return I18nKey.COMMON$WAITING_FOR_SANDBOX;
case "SETTING_UP_GIT_HOOKS":
return I18nKey.STATUS$SETTING_UP_GIT_HOOKS;
case "SETTING_UP_SKILLS":
return I18nKey.STATUS$SETTING_UP_SKILLS;
// Terminal states map to their own localized keys so any caller that
// delegates here (now or in the future) gets a correct label instead of
// silently falling through to STARTING_CONVERSATION. Callers that need a
// context-specific terminal label (e.g. getStatusCode's
// AGENT_STATUS$ERROR_OCCURRED, or getStatusText's taskDetail precedence)
// still handle these states before delegating.
case "READY":
return I18nKey.CONVERSATION$READY;
case "ERROR":
return I18nKey.COMMON$ERROR;
// These collapse to the generic "Starting" label. `default` is unreachable
// for the typed union but is kept as a runtime safety net: the start-task
// API may report a new status before this enum is updated, in which case we
// degrade to "Starting" rather than throwing (see FUTURE_STATUS_FROM_CLOUD).
case "STARTING_CONVERSATION":
case "WORKING":
case "PREPARING_REPOSITORY":
case "RUNNING_SETUP_SCRIPT":
default:
return I18nKey.CONVERSATION$STARTING_CONVERSATION;
}
}
export function getStatusCode(
webSocketConnectionState: WebSocketConnectionState,
executionStatus: ExecutionStatus | null,
@@ -43,22 +76,7 @@ export function getStatusCode(
}
if (taskStatus && taskStatus !== "READY") {
switch (taskStatus) {
case "WAITING_FOR_SANDBOX":
return I18nKey.COMMON$WAITING_FOR_SANDBOX;
case "SETTING_UP_GIT_HOOKS":
return I18nKey.STATUS$SETTING_UP_GIT_HOOKS;
case "SETTING_UP_SKILLS":
return I18nKey.STATUS$SETTING_UP_SKILLS;
case "STARTING_CONVERSATION":
return I18nKey.CONVERSATION$STARTING_CONVERSATION;
case "WORKING":
case "PREPARING_REPOSITORY":
case "RUNNING_SETUP_SCRIPT":
return I18nKey.CONVERSATION$STARTING_CONVERSATION;
default:
return I18nKey.CONVERSATION$STARTING_CONVERSATION;
}
return getTaskStatusI18nKey(taskStatus);
}
if (executionStatus === ExecutionStatus.PAUSED) {
+4 -8
View File
@@ -8,6 +8,8 @@ import { sanitizeQuery } from "#/utils/sanitize-query";
import { PRODUCT_URL } from "#/utils/constants";
import { AgentState } from "#/types/agent-state";
import { I18nKey } from "#/i18n/declaration";
import { getTaskStatusI18nKey } from "#/utils/status";
import type { AppConversationStartTaskStatus } from "#/api/conversation-service/agent-server-conversation-service.types";
import {
OH_STATUS_ERROR_COLOR,
OH_STATUS_SUCCESS_COLOR,
@@ -778,7 +780,7 @@ export const getStatusColor = (options: {
interface GetStatusTextArgs {
isPausing: boolean;
isTask: boolean;
taskStatus?: string | null;
taskStatus?: AppConversationStartTaskStatus | null;
taskDetail?: string | null;
isStartingStatus: boolean;
isStopStatus: boolean;
@@ -838,13 +840,7 @@ export function getStatusText({
return t(I18nKey.CONVERSATION$READY);
}
return (
taskDetail ||
taskStatus
.toLowerCase()
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase())
);
return taskDetail || t(getTaskStatusI18nKey(taskStatus));
}
if (isStartingStatus) {