diff --git a/frontend/src/components/features/conversation/conversation-tabs/vscode-tooltip-content.tsx b/frontend/src/components/features/conversation/conversation-tabs/vscode-tooltip-content.tsx
index fe7f4f1730..400340b11e 100644
--- a/frontend/src/components/features/conversation/conversation-tabs/vscode-tooltip-content.tsx
+++ b/frontend/src/components/features/conversation/conversation-tabs/vscode-tooltip-content.tsx
@@ -5,10 +5,10 @@ import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
import { useConversationId } from "#/hooks/use-conversation-id";
import ConversationService from "#/api/conversation-service/conversation-service.api";
-import { useAgentStore } from "#/stores/agent-store";
+import { useAgentState } from "#/hooks/use-agent-state";
export function VSCodeTooltipContent() {
- const { curAgentState } = useAgentStore();
+ const { curAgentState } = useAgentState();
const { t } = useTranslation();
const { conversationId } = useConversationId();
diff --git a/frontend/src/components/features/jupyter/jupyter.tsx b/frontend/src/components/features/jupyter/jupyter.tsx
index 487e71afd7..5ff84c7f2f 100644
--- a/frontend/src/components/features/jupyter/jupyter.tsx
+++ b/frontend/src/components/features/jupyter/jupyter.tsx
@@ -7,7 +7,7 @@ import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { I18nKey } from "#/i18n/declaration";
import JupyterLargeIcon from "#/icons/jupyter-large.svg?react";
import { WaitingForRuntimeMessage } from "../chat/waiting-for-runtime-message";
-import { useAgentStore } from "#/stores/agent-store";
+import { useAgentState } from "#/hooks/use-agent-state";
import { useJupyterStore } from "#/state/jupyter-store";
interface JupyterEditorProps {
@@ -15,7 +15,7 @@ interface JupyterEditorProps {
}
export function JupyterEditor({ maxWidth }: JupyterEditorProps) {
- const { curAgentState } = useAgentStore();
+ const { curAgentState } = useAgentState();
const cells = useJupyterStore((state) => state.cells);
diff --git a/frontend/src/components/features/terminal/terminal.tsx b/frontend/src/components/features/terminal/terminal.tsx
index 6afa506177..33c884ecd3 100644
--- a/frontend/src/components/features/terminal/terminal.tsx
+++ b/frontend/src/components/features/terminal/terminal.tsx
@@ -3,10 +3,10 @@ import "@xterm/xterm/css/xterm.css";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { cn } from "#/utils/utils";
import { WaitingForRuntimeMessage } from "../chat/waiting-for-runtime-message";
-import { useAgentStore } from "#/stores/agent-store";
+import { useAgentState } from "#/hooks/use-agent-state";
function Terminal() {
- const { curAgentState } = useAgentStore();
+ const { curAgentState } = useAgentState();
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
diff --git a/frontend/src/components/shared/buttons/confirmation-buttons.tsx b/frontend/src/components/shared/buttons/confirmation-buttons.tsx
index c3fbddaa19..170269736a 100644
--- a/frontend/src/components/shared/buttons/confirmation-buttons.tsx
+++ b/frontend/src/components/shared/buttons/confirmation-buttons.tsx
@@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { AgentState } from "#/types/agent-state";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
-import { useWsClient } from "#/context/ws-client-provider";
import { ActionTooltip } from "../action-tooltip";
import { isOpenHandsAction, isActionOrObservation } from "#/types/core/guards";
import { ActionSecurityRisk } from "#/stores/security-analyzer-store";
@@ -12,6 +11,7 @@ import WarningIcon from "#/icons/u-warning.svg?react";
import { useEventMessageStore } from "#/stores/event-message-store";
import { useEventStore } from "#/stores/use-event-store";
import { isV0Event } from "#/types/v1/type-guards";
+import { useSendMessage } from "#/hooks/use-send-message";
export function ConfirmationButtons() {
const submittedEventIds = useEventMessageStore(
@@ -23,7 +23,7 @@ export function ConfirmationButtons() {
const { t } = useTranslation();
- const { send } = useWsClient();
+ const { send } = useSendMessage();
const events = useEventStore((state) => state.events);
// Find the most recent action awaiting confirmation
diff --git a/frontend/src/components/v1/chat/event-content-helpers/get-action-content.ts b/frontend/src/components/v1/chat/event-content-helpers/get-action-content.ts
new file mode 100644
index 0000000000..fe6bf842c3
--- /dev/null
+++ b/frontend/src/components/v1/chat/event-content-helpers/get-action-content.ts
@@ -0,0 +1,198 @@
+import { ActionEvent } from "#/types/v1/core";
+import { getDefaultEventContent, MAX_CONTENT_LENGTH } from "./shared";
+import i18n from "#/i18n";
+import { SecurityRisk } from "#/types/v1/core/base/common";
+import {
+ ExecuteBashAction,
+ FileEditorAction,
+ StrReplaceEditorAction,
+ MCPToolAction,
+ ThinkAction,
+ FinishAction,
+ TaskTrackerAction,
+ BrowserNavigateAction,
+ BrowserClickAction,
+ BrowserTypeAction,
+ BrowserGetStateAction,
+ BrowserGetContentAction,
+ BrowserScrollAction,
+ BrowserGoBackAction,
+ BrowserListTabsAction,
+ BrowserSwitchTabAction,
+ BrowserCloseTabAction,
+} from "#/types/v1/core/base/action";
+
+const getRiskText = (risk: SecurityRisk) => {
+ switch (risk) {
+ case SecurityRisk.LOW:
+ return i18n.t("SECURITY$LOW_RISK");
+ case SecurityRisk.MEDIUM:
+ return i18n.t("SECURITY$MEDIUM_RISK");
+ case SecurityRisk.HIGH:
+ return i18n.t("SECURITY$HIGH_RISK");
+ case SecurityRisk.UNKNOWN:
+ default:
+ return i18n.t("SECURITY$UNKNOWN_RISK");
+ }
+};
+
+const getNoContentActionContent = (): string => "";
+
+// File Editor Actions
+const getFileEditorActionContent = (
+ action: FileEditorAction | StrReplaceEditorAction,
+): string => {
+ // Early return if not a create command or no file text
+ if (action.command !== "create" || !action.file_text) {
+ return getNoContentActionContent();
+ }
+
+ // Process file text with length truncation
+ let fileText = action.file_text;
+ if (fileText.length > MAX_CONTENT_LENGTH) {
+ fileText = `${fileText.slice(0, MAX_CONTENT_LENGTH)}...`;
+ }
+
+ return `${action.path}\n${fileText}`;
+};
+
+// Command Actions
+const getExecuteBashActionContent = (
+ event: ActionEvent,
+): string => {
+ let content = `Command:\n\`${event.action.command}\``;
+
+ // Add security risk information if it's HIGH or MEDIUM
+ if (
+ event.security_risk === SecurityRisk.HIGH ||
+ event.security_risk === SecurityRisk.MEDIUM
+ ) {
+ content += `\n\n${getRiskText(event.security_risk)}`;
+ }
+
+ return content;
+};
+
+// Tool Actions
+const getMCPToolActionContent = (action: MCPToolAction): string => {
+ // For V1, the tool name is in the event's tool_name property, not in the action
+ let details = `**MCP Tool Call**\n\n`;
+ details += `**Arguments:**\n\`\`\`json\n${JSON.stringify(action.data, null, 2)}\n\`\`\``;
+ return details;
+};
+
+// Simple Actions
+const getThinkActionContent = (action: ThinkAction): string => action.thought;
+
+const getFinishActionContent = (action: FinishAction): string =>
+ action.message.trim();
+
+// Complex Actions
+const getTaskTrackerActionContent = (action: TaskTrackerAction): string => {
+ let content = `**Command:** \`${action.command}\``;
+
+ // Handle plan command with task list
+ if (action.command === "plan") {
+ if (action.task_list && action.task_list.length > 0) {
+ content += `\n\n**Task List (${action.task_list.length} ${action.task_list.length === 1 ? "item" : "items"}):**\n`;
+ action.task_list.forEach((task, index: number) => {
+ const statusMap = {
+ todo: "⏳",
+ in_progress: "🔄",
+ done: "✅",
+ };
+ const statusIcon =
+ statusMap[task.status as keyof typeof statusMap] || "❓";
+ content += `\n${index + 1}. ${statusIcon} **[${task.status.toUpperCase().replace("_", " ")}]** ${task.title}`;
+ if (task.notes) {
+ content += `\n *Notes: ${task.notes}*`;
+ }
+ });
+ } else {
+ content += "\n\n**Task List:** Empty";
+ }
+ }
+
+ return content;
+};
+
+// Browser Actions
+type BrowserAction =
+ | BrowserNavigateAction
+ | BrowserClickAction
+ | BrowserTypeAction
+ | BrowserGetStateAction
+ | BrowserGetContentAction
+ | BrowserScrollAction
+ | BrowserGoBackAction
+ | BrowserListTabsAction
+ | BrowserSwitchTabAction
+ | BrowserCloseTabAction;
+
+const getBrowserActionContent = (action: BrowserAction): string => {
+ switch (action.kind) {
+ case "BrowserNavigateAction":
+ if ("url" in action) {
+ return `Browsing ${action.url}`;
+ }
+ break;
+ case "BrowserClickAction":
+ case "BrowserTypeAction":
+ case "BrowserGetStateAction":
+ case "BrowserGetContentAction":
+ case "BrowserScrollAction":
+ case "BrowserGoBackAction":
+ case "BrowserListTabsAction":
+ case "BrowserSwitchTabAction":
+ case "BrowserCloseTabAction":
+ // These browser actions typically don't need detailed content display
+ return getNoContentActionContent();
+ default:
+ return getNoContentActionContent();
+ }
+
+ return getNoContentActionContent();
+};
+
+export const getActionContent = (event: ActionEvent): string => {
+ const { action } = event;
+ const actionType = action.kind;
+
+ switch (actionType) {
+ case "FileEditorAction":
+ case "StrReplaceEditorAction":
+ return getFileEditorActionContent(action);
+
+ case "ExecuteBashAction":
+ return getExecuteBashActionContent(
+ event as ActionEvent,
+ );
+
+ case "MCPToolAction":
+ return getMCPToolActionContent(action);
+
+ case "ThinkAction":
+ return getThinkActionContent(action);
+
+ case "FinishAction":
+ return getFinishActionContent(action);
+
+ case "TaskTrackerAction":
+ return getTaskTrackerActionContent(action);
+
+ case "BrowserNavigateAction":
+ case "BrowserClickAction":
+ case "BrowserTypeAction":
+ case "BrowserGetStateAction":
+ case "BrowserGetContentAction":
+ case "BrowserScrollAction":
+ case "BrowserGoBackAction":
+ case "BrowserListTabsAction":
+ case "BrowserSwitchTabAction":
+ case "BrowserCloseTabAction":
+ return getBrowserActionContent(action);
+
+ default:
+ return getDefaultEventContent(event);
+ }
+};
diff --git a/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx b/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx
new file mode 100644
index 0000000000..c1f36843de
--- /dev/null
+++ b/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx
@@ -0,0 +1,168 @@
+import { Trans } from "react-i18next";
+import { OpenHandsEvent } from "#/types/v1/core";
+import { isActionEvent, isObservationEvent } from "#/types/v1/type-guards";
+import { MonoComponent } from "../../../features/chat/mono-component";
+import { PathComponent } from "../../../features/chat/path-component";
+import { getActionContent } from "./get-action-content";
+import { getObservationContent } from "./get-observation-content";
+import i18n from "#/i18n";
+
+const trimText = (text: string, maxLength: number): string => {
+ if (!text) return "";
+ return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
+};
+
+// Helper function to create title from translation key
+const createTitleFromKey = (
+ key: string,
+ values: Record,
+): React.ReactNode => {
+ if (!i18n.exists(key)) {
+ return key;
+ }
+
+ return (
+ ,
+ cmd: ,
+ }}
+ />
+ );
+};
+
+// Action Event Processing
+const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => {
+ // Early return if not an action event
+ if (!isActionEvent(event)) {
+ return "";
+ }
+
+ const actionType = event.action.kind;
+ let actionKey = "";
+ let actionValues: Record = {};
+
+ switch (actionType) {
+ case "ExecuteBashAction":
+ actionKey = "ACTION_MESSAGE$RUN";
+ actionValues = {
+ command: trimText(event.action.command, 80),
+ };
+ break;
+ case "FileEditorAction":
+ case "StrReplaceEditorAction":
+ if (event.action.command === "view") {
+ actionKey = "ACTION_MESSAGE$READ";
+ } else if (event.action.command === "create") {
+ actionKey = "ACTION_MESSAGE$WRITE";
+ } else {
+ actionKey = "ACTION_MESSAGE$EDIT";
+ }
+ actionValues = {
+ path: event.action.path,
+ };
+ break;
+ case "MCPToolAction":
+ actionKey = "ACTION_MESSAGE$CALL_TOOL_MCP";
+ actionValues = {
+ mcp_tool_name: event.tool_name,
+ };
+ break;
+ case "ThinkAction":
+ actionKey = "ACTION_MESSAGE$THINK";
+ break;
+ case "FinishAction":
+ actionKey = "ACTION_MESSAGE$FINISH";
+ break;
+ case "TaskTrackerAction":
+ actionKey = "ACTION_MESSAGE$TASK_TRACKING";
+ break;
+ case "BrowserNavigateAction":
+ actionKey = "ACTION_MESSAGE$BROWSE";
+ break;
+ default:
+ // For unknown actions, use the type name
+ return actionType.replace("Action", "").toUpperCase();
+ }
+
+ if (actionKey) {
+ return createTitleFromKey(actionKey, actionValues);
+ }
+
+ return actionType;
+};
+
+// Observation Event Processing
+const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => {
+ // Early return if not an observation event
+ if (!isObservationEvent(event)) {
+ return "";
+ }
+
+ const observationType = event.observation.kind;
+ let observationKey = "";
+ let observationValues: Record = {};
+
+ switch (observationType) {
+ case "ExecuteBashObservation":
+ observationKey = "OBSERVATION_MESSAGE$RUN";
+ observationValues = {
+ command: event.observation.command
+ ? trimText(event.observation.command, 80)
+ : "",
+ };
+ break;
+ case "FileEditorObservation":
+ case "StrReplaceEditorObservation":
+ if (event.observation.command === "view") {
+ observationKey = "OBSERVATION_MESSAGE$READ";
+ } else {
+ observationKey = "OBSERVATION_MESSAGE$EDIT";
+ }
+ observationValues = {
+ path: event.observation.path || "",
+ };
+ break;
+ case "MCPToolObservation":
+ observationKey = "OBSERVATION_MESSAGE$MCP";
+ observationValues = {
+ mcp_tool_name: event.observation.tool_name,
+ };
+ break;
+ case "BrowserObservation":
+ observationKey = "OBSERVATION_MESSAGE$BROWSE";
+ break;
+ case "TaskTrackerObservation":
+ observationKey = "OBSERVATION_MESSAGE$TASK_TRACKING";
+ break;
+ default:
+ // For unknown observations, use the type name
+ return observationType.replace("Observation", "").toUpperCase();
+ }
+
+ if (observationKey) {
+ return createTitleFromKey(observationKey, observationValues);
+ }
+
+ return observationType;
+};
+
+export const getEventContent = (event: OpenHandsEvent) => {
+ let title: React.ReactNode = "";
+ let details: string = "";
+
+ if (isActionEvent(event)) {
+ title = getActionEventTitle(event);
+ details = getActionContent(event);
+ } else if (isObservationEvent(event)) {
+ title = getObservationEventTitle(event);
+ details = getObservationContent(event);
+ }
+
+ return {
+ title: title || i18n.t("EVENT$UNKNOWN_EVENT"),
+ details: details || i18n.t("EVENT$UNKNOWN_EVENT"),
+ };
+};
diff --git a/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts b/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts
new file mode 100644
index 0000000000..03e35ea9e9
--- /dev/null
+++ b/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts
@@ -0,0 +1,203 @@
+import { ObservationEvent } from "#/types/v1/core";
+import { getObservationResult } from "./get-observation-result";
+import { getDefaultEventContent, MAX_CONTENT_LENGTH } from "./shared";
+import i18n from "#/i18n";
+import {
+ MCPToolObservation,
+ FinishObservation,
+ ThinkObservation,
+ BrowserObservation,
+ ExecuteBashObservation,
+ FileEditorObservation,
+ StrReplaceEditorObservation,
+ TaskTrackerObservation,
+} from "#/types/v1/core/base/observation";
+
+// File Editor Observations
+const getFileEditorObservationContent = (
+ event: ObservationEvent,
+): string => {
+ const { observation } = event;
+
+ const successMessage = getObservationResult(event) === "success";
+
+ // For view commands or successful edits with content changes, format as code block
+ if (
+ (successMessage &&
+ "old_content" in observation &&
+ "new_content" in observation &&
+ observation.old_content &&
+ observation.new_content) ||
+ observation.command === "view"
+ ) {
+ return `\`\`\`\n${observation.output}\n\`\`\``;
+ }
+
+ // For other commands, return the output as-is
+ return observation.output;
+};
+
+// Command Observations
+const getExecuteBashObservationContent = (
+ event: ObservationEvent,
+): string => {
+ const { observation } = event;
+
+ let { output } = observation;
+
+ if (output.length > MAX_CONTENT_LENGTH) {
+ output = `${output.slice(0, MAX_CONTENT_LENGTH)}...`;
+ }
+
+ return `Output:\n\`\`\`sh\n${output.trim() || i18n.t("OBSERVATION$COMMAND_NO_OUTPUT")}\n\`\`\``;
+};
+
+// Tool Observations
+const getBrowserObservationContent = (
+ event: ObservationEvent,
+): string => {
+ const { observation } = event;
+
+ let contentDetails = "";
+
+ if ("error" in observation && observation.error) {
+ contentDetails += `**Error:**\n${observation.error}\n\n`;
+ }
+
+ contentDetails += `**Output:**\n${observation.output}`;
+
+ if (contentDetails.length > MAX_CONTENT_LENGTH) {
+ contentDetails = `${contentDetails.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`;
+ }
+
+ return contentDetails;
+};
+
+const getMCPToolObservationContent = (
+ event: ObservationEvent,
+): string => {
+ const { observation } = event;
+
+ // Extract text content from the observation
+ const textContent = observation.content
+ .filter((c) => c.type === "text")
+ .map((c) => c.text)
+ .join("\n");
+
+ let content = `**Tool:** ${observation.tool_name}\n\n`;
+
+ if (observation.is_error) {
+ content += `**Error:**\n${textContent}`;
+ } else {
+ content += `**Result:**\n${textContent}`;
+ }
+
+ if (content.length > MAX_CONTENT_LENGTH) {
+ content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
+ }
+
+ return content;
+};
+
+// Complex Observations
+const getTaskTrackerObservationContent = (
+ event: ObservationEvent,
+): string => {
+ const { observation } = event;
+
+ const { command, task_list: taskList } = observation;
+ let content = `**Command:** \`${command}\``;
+
+ if (command === "plan" && taskList.length > 0) {
+ content += `\n\n**Task List (${taskList.length} ${taskList.length === 1 ? "item" : "items"}):**\n`;
+
+ taskList.forEach((task, index: number) => {
+ const statusMap = {
+ todo: "⏳",
+ in_progress: "🔄",
+ done: "✅",
+ };
+ const statusIcon =
+ statusMap[task.status as keyof typeof statusMap] || "❓";
+
+ content += `\n${index + 1}. ${statusIcon} **[${task.status.toUpperCase().replace("_", " ")}]** ${task.title}`;
+ if (task.notes) {
+ content += `\n *Notes: ${task.notes}*`;
+ }
+ });
+ } else if (command === "plan") {
+ content += "\n\n**Task List:** Empty";
+ }
+
+ if (
+ "content" in observation &&
+ observation.content &&
+ observation.content.trim()
+ ) {
+ content += `\n\n**Result:** ${observation.content.trim()}`;
+ }
+
+ return content;
+};
+
+// Simple Observations
+const getThinkObservationContent = (
+ event: ObservationEvent,
+): string => {
+ const { observation } = event;
+ return observation.content || "";
+};
+
+const getFinishObservationContent = (
+ event: ObservationEvent,
+): string => {
+ const { observation } = event;
+ return observation.message || "";
+};
+
+export const getObservationContent = (event: ObservationEvent): string => {
+ const observationType = event.observation.kind;
+
+ switch (observationType) {
+ case "FileEditorObservation":
+ case "StrReplaceEditorObservation":
+ return getFileEditorObservationContent(
+ event as ObservationEvent<
+ FileEditorObservation | StrReplaceEditorObservation
+ >,
+ );
+
+ case "ExecuteBashObservation":
+ return getExecuteBashObservationContent(
+ event as ObservationEvent,
+ );
+
+ case "BrowserObservation":
+ return getBrowserObservationContent(
+ event as ObservationEvent,
+ );
+
+ case "MCPToolObservation":
+ return getMCPToolObservationContent(
+ event as ObservationEvent,
+ );
+
+ case "TaskTrackerObservation":
+ return getTaskTrackerObservationContent(
+ event as ObservationEvent,
+ );
+
+ case "ThinkObservation":
+ return getThinkObservationContent(
+ event as ObservationEvent,
+ );
+
+ case "FinishObservation":
+ return getFinishObservationContent(
+ event as ObservationEvent,
+ );
+
+ default:
+ return getDefaultEventContent(event);
+ }
+};
diff --git a/frontend/src/components/v1/chat/event-content-helpers/get-observation-result.ts b/frontend/src/components/v1/chat/event-content-helpers/get-observation-result.ts
new file mode 100644
index 0000000000..032e8823de
--- /dev/null
+++ b/frontend/src/components/v1/chat/event-content-helpers/get-observation-result.ts
@@ -0,0 +1,30 @@
+import { ObservationEvent } from "#/types/v1/core";
+
+export type ObservationResultStatus = "success" | "error" | "timeout";
+
+export const getObservationResult = (
+ event: ObservationEvent,
+): ObservationResultStatus => {
+ const { observation } = event;
+ const observationType = observation.kind;
+
+ switch (observationType) {
+ case "ExecuteBashObservation": {
+ const exitCode = observation.exit_code;
+
+ if (exitCode === -1) return "timeout"; // Command timed out
+ if (exitCode === 0) return "success"; // Command executed successfully
+ return "error"; // Command failed
+ }
+ case "FileEditorObservation":
+ case "StrReplaceEditorObservation":
+ // Check if there's an error
+ if (observation.error) return "error";
+ return "success";
+ case "MCPToolObservation":
+ if (observation.is_error) return "error";
+ return "success";
+ default:
+ return "success";
+ }
+};
diff --git a/frontend/src/components/v1/chat/event-content-helpers/parse-message-from-event.ts b/frontend/src/components/v1/chat/event-content-helpers/parse-message-from-event.ts
new file mode 100644
index 0000000000..17824a51c8
--- /dev/null
+++ b/frontend/src/components/v1/chat/event-content-helpers/parse-message-from-event.ts
@@ -0,0 +1,41 @@
+import { MessageEvent } from "#/types/v1/core";
+import i18n from "#/i18n";
+
+export const parseMessageFromEvent = (event: MessageEvent): string => {
+ const message = event.llm_message;
+
+ // Safety check: ensure llm_message exists and has content
+ if (!message || !message.content) {
+ return "";
+ }
+
+ // Get the text content from the message
+ let textContent = "";
+ if (message.content) {
+ if (Array.isArray(message.content)) {
+ // Handle array of content blocks
+ textContent = message.content
+ .filter((content) => content.type === "text")
+ .map((content) => content.text)
+ .join("\n");
+ } else if (typeof message.content === "string") {
+ // Handle string content
+ textContent = message.content;
+ }
+ }
+
+ // Check if there are image_urls in the message content
+ const hasImages =
+ Array.isArray(message.content) &&
+ message.content.some((content) => content.type === "image");
+
+ if (!hasImages) {
+ return textContent;
+ }
+
+ // If there are images, try to split by the augmented prompt delimiter
+ const delimiter = i18n.t("CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE");
+ const parts = textContent.split(delimiter);
+
+ return parts[0];
+};
diff --git a/frontend/src/components/v1/chat/event-content-helpers/shared.ts b/frontend/src/components/v1/chat/event-content-helpers/shared.ts
new file mode 100644
index 0000000000..717de7a391
--- /dev/null
+++ b/frontend/src/components/v1/chat/event-content-helpers/shared.ts
@@ -0,0 +1,6 @@
+import { OpenHandsEvent } from "#/types/v1/core";
+
+export const MAX_CONTENT_LENGTH = 1000;
+
+export const getDefaultEventContent = (event: OpenHandsEvent): string =>
+ `\`\`\`json\n${JSON.stringify(event, null, 2)}\n\`\`\``;
diff --git a/frontend/src/components/v1/chat/event-content-helpers/should-render-event.ts b/frontend/src/components/v1/chat/event-content-helpers/should-render-event.ts
new file mode 100644
index 0000000000..8acef2da03
--- /dev/null
+++ b/frontend/src/components/v1/chat/event-content-helpers/should-render-event.ts
@@ -0,0 +1,66 @@
+import { OpenHandsEvent } from "#/types/v1/core";
+import {
+ isActionEvent,
+ isObservationEvent,
+ isMessageEvent,
+ isAgentErrorEvent,
+ isConversationStateUpdateEvent,
+} from "#/types/v1/type-guards";
+
+// V1 events that should not be rendered
+const NO_RENDER_ACTION_TYPES = [
+ "ThinkAction",
+ // Add more action types that should not be rendered
+];
+
+const NO_RENDER_OBSERVATION_TYPES = [
+ "ThinkObservation",
+ // Add more observation types that should not be rendered
+];
+
+export const shouldRenderEvent = (event: OpenHandsEvent) => {
+ // Explicitly exclude system events that should not be rendered in chat
+ if (isConversationStateUpdateEvent(event)) {
+ return false;
+ }
+
+ // Render action events (with filtering)
+ if (isActionEvent(event)) {
+ // For V1, action is an object with kind property
+ const actionType = event.action.kind;
+
+ // Hide user commands from the chat interface
+ if (actionType === "ExecuteBashAction" && event.source === "user") {
+ return false;
+ }
+
+ return !NO_RENDER_ACTION_TYPES.includes(actionType);
+ }
+
+ // Render observation events (with filtering)
+ if (isObservationEvent(event)) {
+ // For V1, observation is an object with kind property
+ const observationType = event.observation.kind;
+
+ // Note: ObservationEvent source is always "environment", not "user"
+ // So no need to check for user source here
+
+ return !NO_RENDER_OBSERVATION_TYPES.includes(observationType);
+ }
+
+ // Render message events (user and assistant messages)
+ if (isMessageEvent(event)) {
+ return true;
+ }
+
+ // Render agent error events
+ if (isAgentErrorEvent(event)) {
+ return true;
+ }
+
+ // Don't render any other event types (system events, etc.)
+ return false;
+};
+
+export const hasUserEvent = (events: OpenHandsEvent[]) =>
+ events.some((event) => event.source === "user");
diff --git a/frontend/src/components/v1/chat/event-message-components/error-event-message.tsx b/frontend/src/components/v1/chat/event-message-components/error-event-message.tsx
new file mode 100644
index 0000000000..98ae2b23c6
--- /dev/null
+++ b/frontend/src/components/v1/chat/event-message-components/error-event-message.tsx
@@ -0,0 +1,49 @@
+import React from "react";
+import { AgentErrorEvent } from "#/types/v1/core";
+import { isAgentErrorEvent } from "#/types/v1/type-guards";
+import { ErrorMessage } from "../../../features/chat/error-message";
+import { MicroagentStatusWrapper } from "../../../features/chat/event-message-components/microagent-status-wrapper";
+// TODO: Implement V1 LikertScaleWrapper when API supports V1 event IDs
+// import { LikertScaleWrapper } from "../../../features/chat/event-message-components/likert-scale-wrapper";
+import { MicroagentStatus } from "#/types/microagent-status";
+
+interface ErrorEventMessageProps {
+ event: AgentErrorEvent;
+ microagentStatus?: MicroagentStatus | null;
+ microagentConversationId?: string;
+ microagentPRUrl?: string;
+ actions?: Array<{
+ icon: React.ReactNode;
+ onClick: () => void;
+ tooltip?: string;
+ }>;
+}
+
+export function ErrorEventMessage({
+ event,
+ microagentStatus,
+ microagentConversationId,
+ microagentPRUrl,
+ actions,
+}: ErrorEventMessageProps) {
+ if (!isAgentErrorEvent(event)) {
+ return null;
+ }
+
+ return (
+
+
+
+ {/* LikertScaleWrapper expects V0 event types, skip for now */}
+
+ );
+}
diff --git a/frontend/src/components/v1/chat/event-message-components/finish-event-message.tsx b/frontend/src/components/v1/chat/event-message-components/finish-event-message.tsx
new file mode 100644
index 0000000000..6ad385e8f0
--- /dev/null
+++ b/frontend/src/components/v1/chat/event-message-components/finish-event-message.tsx
@@ -0,0 +1,46 @@
+import React from "react";
+import { ActionEvent } from "#/types/v1/core";
+import { FinishAction } from "#/types/v1/core/base/action";
+import { ChatMessage } from "../../../features/chat/chat-message";
+import { MicroagentStatusWrapper } from "../../../features/chat/event-message-components/microagent-status-wrapper";
+// TODO: Implement V1 LikertScaleWrapper when API supports V1 event IDs
+// import { LikertScaleWrapper } from "../../../features/chat/event-message-components/likert-scale-wrapper";
+import { getEventContent } from "../event-content-helpers/get-event-content";
+import { MicroagentStatus } from "#/types/microagent-status";
+
+interface FinishEventMessageProps {
+ event: ActionEvent;
+ microagentStatus?: MicroagentStatus | null;
+ microagentConversationId?: string;
+ microagentPRUrl?: string;
+ actions?: Array<{
+ icon: React.ReactNode;
+ onClick: () => void;
+ tooltip?: string;
+ }>;
+}
+
+export function FinishEventMessage({
+ event,
+ microagentStatus,
+ microagentConversationId,
+ microagentPRUrl,
+ actions,
+}: FinishEventMessageProps) {
+ return (
+ <>
+
+
+ {/* LikertScaleWrapper expects V0 event types, skip for now */}
+ >
+ );
+}
diff --git a/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx b/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx
new file mode 100644
index 0000000000..c2ac1d9a73
--- /dev/null
+++ b/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx
@@ -0,0 +1,33 @@
+import React from "react";
+import { OpenHandsEvent } from "#/types/v1/core";
+import { GenericEventMessage } from "../../../features/chat/generic-event-message";
+import { getEventContent } from "../event-content-helpers/get-event-content";
+import { getObservationResult } from "../event-content-helpers/get-observation-result";
+import { isObservationEvent } from "#/types/v1/type-guards";
+import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
+
+interface GenericEventMessageWrapperProps {
+ event: OpenHandsEvent;
+ shouldShowConfirmationButtons: boolean;
+}
+
+export function GenericEventMessageWrapper({
+ event,
+ shouldShowConfirmationButtons,
+}: GenericEventMessageWrapperProps) {
+ const { title, details } = getEventContent(event);
+
+ return (
+
+
+ {shouldShowConfirmationButtons && }
+
+ );
+}
diff --git a/frontend/src/components/v1/chat/event-message-components/index.ts b/frontend/src/components/v1/chat/event-message-components/index.ts
new file mode 100644
index 0000000000..1f705a1f7a
--- /dev/null
+++ b/frontend/src/components/v1/chat/event-message-components/index.ts
@@ -0,0 +1,5 @@
+export { UserAssistantEventMessage } from "./user-assistant-event-message";
+export { ObservationPairEventMessage } from "./observation-pair-event-message";
+export { ErrorEventMessage } from "./error-event-message";
+export { FinishEventMessage } from "./finish-event-message";
+export { GenericEventMessageWrapper } from "./generic-event-message-wrapper";
diff --git a/frontend/src/components/v1/chat/event-message-components/observation-pair-event-message.tsx b/frontend/src/components/v1/chat/event-message-components/observation-pair-event-message.tsx
new file mode 100644
index 0000000000..aa0bbc09b4
--- /dev/null
+++ b/frontend/src/components/v1/chat/event-message-components/observation-pair-event-message.tsx
@@ -0,0 +1,59 @@
+import React from "react";
+import { ActionEvent } from "#/types/v1/core";
+import { isActionEvent } from "#/types/v1/type-guards";
+import { ChatMessage } from "../../../features/chat/chat-message";
+import { MicroagentStatusWrapper } from "../../../features/chat/event-message-components/microagent-status-wrapper";
+import { MicroagentStatus } from "#/types/microagent-status";
+
+interface ObservationPairEventMessageProps {
+ event: ActionEvent;
+ microagentStatus?: MicroagentStatus | null;
+ microagentConversationId?: string;
+ microagentPRUrl?: string;
+ actions?: Array<{
+ icon: React.ReactNode;
+ onClick: () => void;
+ tooltip?: string;
+ }>;
+}
+
+export function ObservationPairEventMessage({
+ event,
+ microagentStatus,
+ microagentConversationId,
+ microagentPRUrl,
+ actions,
+}: ObservationPairEventMessageProps) {
+ if (!isActionEvent(event)) {
+ return null;
+ }
+
+ // Check if there's thought content to display
+ const thoughtContent = event.thought
+ .filter((t) => t.type === "text")
+ .map((t) => t.text)
+ .join("\n");
+
+ if (thoughtContent && event.action.kind !== "ThinkAction") {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/v1/chat/event-message-components/user-assistant-event-message.tsx b/frontend/src/components/v1/chat/event-message-components/user-assistant-event-message.tsx
new file mode 100644
index 0000000000..260f9688ef
--- /dev/null
+++ b/frontend/src/components/v1/chat/event-message-components/user-assistant-event-message.tsx
@@ -0,0 +1,65 @@
+import React from "react";
+import { MessageEvent } from "#/types/v1/core";
+import { ChatMessage } from "../../../features/chat/chat-message";
+import { ImageCarousel } from "../../../features/images/image-carousel";
+// TODO: Implement file_urls support for V1 messages
+// import { FileList } from "../../../features/files/file-list";
+import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
+import { MicroagentStatusWrapper } from "../../../features/chat/event-message-components/microagent-status-wrapper";
+// TODO: Implement V1 LikertScaleWrapper when API supports V1 event IDs
+// import { LikertScaleWrapper } from "../../../features/chat/event-message-components/likert-scale-wrapper";
+import { parseMessageFromEvent } from "../event-content-helpers/parse-message-from-event";
+import { MicroagentStatus } from "#/types/microagent-status";
+
+interface UserAssistantEventMessageProps {
+ event: MessageEvent;
+ shouldShowConfirmationButtons: boolean;
+ microagentStatus?: MicroagentStatus | null;
+ microagentConversationId?: string;
+ microagentPRUrl?: string;
+ actions?: Array<{
+ icon: React.ReactNode;
+ onClick: () => void;
+ tooltip?: string;
+ }>;
+}
+
+export function UserAssistantEventMessage({
+ event,
+ shouldShowConfirmationButtons,
+ microagentStatus,
+ microagentConversationId,
+ microagentPRUrl,
+ actions,
+}: UserAssistantEventMessageProps) {
+ const message = parseMessageFromEvent(event);
+
+ // Extract image URLs from the message content
+ const imageUrls: string[] = [];
+ if (Array.isArray(event.llm_message.content)) {
+ event.llm_message.content.forEach((content) => {
+ if (content.type === "image") {
+ imageUrls.push(...content.image_urls);
+ }
+ });
+ }
+
+ return (
+ <>
+
+ {imageUrls.length > 0 && (
+
+ )}
+ {/* TODO: Handle file_urls if V1 messages support them */}
+ {shouldShowConfirmationButtons && }
+
+
+ {/* LikertScaleWrapper expects V0 event types, skip for now */}
+ >
+ );
+}
diff --git a/frontend/src/components/v1/chat/event-message.tsx b/frontend/src/components/v1/chat/event-message.tsx
new file mode 100644
index 0000000000..9ff9950473
--- /dev/null
+++ b/frontend/src/components/v1/chat/event-message.tsx
@@ -0,0 +1,119 @@
+import React from "react";
+import { OpenHandsEvent, MessageEvent, ActionEvent } from "#/types/v1/core";
+import { FinishAction } from "#/types/v1/core/base/action";
+import {
+ isActionEvent,
+ isObservationEvent,
+ isAgentErrorEvent,
+} from "#/types/v1/type-guards";
+import { MicroagentStatus } from "#/types/microagent-status";
+import { useConfig } from "#/hooks/query/use-config";
+// TODO: Implement V1 feedback functionality when API supports V1 event IDs
+// import { useFeedbackExists } from "#/hooks/query/use-feedback-exists";
+import {
+ ErrorEventMessage,
+ UserAssistantEventMessage,
+ FinishEventMessage,
+ ObservationPairEventMessage,
+ GenericEventMessageWrapper,
+} from "./event-message-components";
+
+interface EventMessageProps {
+ event: OpenHandsEvent;
+ hasObservationPair: boolean;
+ isAwaitingUserConfirmation: boolean;
+ isLastMessage: boolean;
+ microagentStatus?: MicroagentStatus | null;
+ microagentConversationId?: string;
+ microagentPRUrl?: string;
+ actions?: Array<{
+ icon: React.ReactNode;
+ onClick: () => void;
+ tooltip?: string;
+ }>;
+ isInLast10Actions: boolean;
+}
+
+/* eslint-disable react/jsx-props-no-spreading */
+export function EventMessage({
+ event,
+ hasObservationPair,
+ isAwaitingUserConfirmation,
+ isLastMessage,
+ microagentStatus,
+ microagentConversationId,
+ microagentPRUrl,
+ actions,
+ isInLast10Actions,
+}: EventMessageProps) {
+ const shouldShowConfirmationButtons =
+ isLastMessage && event.source === "agent" && isAwaitingUserConfirmation;
+
+ const { data: config } = useConfig();
+
+ // V1 events use string IDs, but useFeedbackExists expects number
+ // For now, we'll skip feedback functionality for V1 events
+ const feedbackData = { exists: false };
+ const isCheckingFeedback = false;
+
+ // Common props for components that need them
+ const commonProps = {
+ microagentStatus,
+ microagentConversationId,
+ microagentPRUrl,
+ actions,
+ isLastMessage,
+ isInLast10Actions,
+ config,
+ isCheckingFeedback,
+ feedbackData,
+ };
+
+ // Agent error events
+ if (isAgentErrorEvent(event)) {
+ return ;
+ }
+
+ // Observation pairs with actions
+ if (hasObservationPair && isActionEvent(event)) {
+ return (
+
+ );
+ }
+
+ // Finish actions
+ if (isActionEvent(event) && event.action.kind === "FinishAction") {
+ return (
+ }
+ {...commonProps}
+ />
+ );
+ }
+
+ // Message events (user and assistant messages)
+ if (!isActionEvent(event) && !isObservationEvent(event)) {
+ // This is a MessageEvent
+ return (
+
+ );
+ }
+
+ // Generic fallback for all other events (including observation events)
+ return (
+
+ );
+}
diff --git a/frontend/src/components/v1/chat/index.ts b/frontend/src/components/v1/chat/index.ts
new file mode 100644
index 0000000000..bce6a75795
--- /dev/null
+++ b/frontend/src/components/v1/chat/index.ts
@@ -0,0 +1,8 @@
+export { Messages } from "./messages";
+export { EventMessage } from "./event-message";
+export * from "./event-message-components";
+export { getEventContent } from "./event-content-helpers/get-event-content";
+export {
+ shouldRenderEvent,
+ hasUserEvent,
+} from "./event-content-helpers/should-render-event";
diff --git a/frontend/src/components/v1/chat/messages.tsx b/frontend/src/components/v1/chat/messages.tsx
new file mode 100644
index 0000000000..b6b7a1ca1d
--- /dev/null
+++ b/frontend/src/components/v1/chat/messages.tsx
@@ -0,0 +1,73 @@
+import React from "react";
+import { OpenHandsEvent } from "#/types/v1/core";
+import { isActionEvent, isObservationEvent } from "#/types/v1/type-guards";
+import { EventMessage } from "./event-message";
+import { ChatMessage } from "../../features/chat/chat-message";
+import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
+// TODO: Implement microagent functionality for V1 when APIs support V1 event IDs
+// import { AgentState } from "#/types/agent-state";
+// import MemoryIcon from "#/icons/memory_icon.svg?react";
+
+interface MessagesProps {
+ messages: OpenHandsEvent[];
+ isAwaitingUserConfirmation: boolean;
+}
+
+export const Messages: React.FC = React.memo(
+ ({ messages, isAwaitingUserConfirmation }) => {
+ const { getOptimisticUserMessage } = useOptimisticUserMessageStore();
+
+ const optimisticUserMessage = getOptimisticUserMessage();
+
+ const actionHasObservationPair = React.useCallback(
+ (event: OpenHandsEvent): boolean => {
+ if (isActionEvent(event)) {
+ // Check if there's a corresponding observation event
+ return !!messages.some(
+ (msg) => isObservationEvent(msg) && msg.action_id === event.id,
+ );
+ }
+
+ return false;
+ },
+ [messages],
+ );
+
+ // TODO: Implement microagent functionality for V1 if needed
+ // For now, we'll skip microagent features
+
+ return (
+ <>
+ {messages.map((message, index) => (
+
+ ))}
+
+ {optimisticUserMessage && (
+
+ )}
+ >
+ );
+ },
+ (prevProps, nextProps) => {
+ // Prevent re-renders if messages are the same length
+ if (prevProps.messages.length !== nextProps.messages.length) {
+ return false;
+ }
+
+ return true;
+ },
+);
+
+Messages.displayName = "Messages";
diff --git a/frontend/src/components/v1/index.ts b/frontend/src/components/v1/index.ts
new file mode 100644
index 0000000000..d27da0d970
--- /dev/null
+++ b/frontend/src/components/v1/index.ts
@@ -0,0 +1 @@
+export * from "./chat";
diff --git a/frontend/src/context/ws-client-provider.tsx b/frontend/src/context/ws-client-provider.tsx
index 8e0ef3830c..8f0a2829c0 100644
--- a/frontend/src/context/ws-client-provider.tsx
+++ b/frontend/src/context/ws-client-provider.tsx
@@ -28,7 +28,12 @@ import { useErrorMessageStore } from "#/stores/error-message-store";
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
import { useEventStore } from "#/stores/use-event-store";
-export type WebSocketStatus = "CONNECTING" | "CONNECTED" | "DISCONNECTED";
+/**
+ * @deprecated Use `V1_WebSocketConnectionState` from `conversation-websocket-context.tsx` instead.
+ * This type is for legacy V0 conversations only.
+ */
+// eslint-disable-next-line @typescript-eslint/naming-convention
+export type V0_WebSocketStatus = "CONNECTING" | "CONNECTED" | "DISCONNECTED";
const hasValidMessageProperty = (obj: unknown): obj is { message: string } =>
typeof obj === "object" &&
@@ -69,7 +74,7 @@ const isMessageAction = (
isUserMessage(event) || isAssistantMessage(event);
interface UseWsClient {
- webSocketStatus: WebSocketStatus;
+ webSocketStatus: V0_WebSocketStatus;
isLoadingMessages: boolean;
send: (event: Record) => void;
}
@@ -132,7 +137,7 @@ export function WsClientProvider({
const queryClient = useQueryClient();
const sioRef = React.useRef(null);
const [webSocketStatus, setWebSocketStatus] =
- React.useState("DISCONNECTED");
+ React.useState("DISCONNECTED");
const lastEventRef = React.useRef | null>(null);
const { providers } = useUserProviders();
diff --git a/frontend/src/contexts/conversation-websocket-context.tsx b/frontend/src/contexts/conversation-websocket-context.tsx
index fc851dd75c..b04f7aba9b 100644
--- a/frontend/src/contexts/conversation-websocket-context.tsx
+++ b/frontend/src/contexts/conversation-websocket-context.tsx
@@ -7,20 +7,37 @@ import React, {
useMemo,
} from "react";
import { useQueryClient } from "@tanstack/react-query";
-import { useWebSocket } from "#/hooks/use-websocket";
+import { useWebSocket, WebSocketHookOptions } from "#/hooks/use-websocket";
import { useEventStore } from "#/stores/use-event-store";
import { useErrorMessageStore } from "#/stores/error-message-store";
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
+import { useV1ConversationStateStore } from "#/stores/v1-conversation-state-store";
+import { useCommandStore } from "#/state/command-store";
import {
isV1Event,
isAgentErrorEvent,
isUserMessageEvent,
isActionEvent,
+ isConversationStateUpdateEvent,
+ isFullStateConversationStateUpdateEvent,
+ isAgentStatusConversationStateUpdateEvent,
+ isExecuteBashActionEvent,
+ isExecuteBashObservationEvent,
} from "#/types/v1/type-guards";
import { handleActionEventCacheInvalidation } from "#/utils/cache-utils";
+import { buildWebSocketUrl } from "#/utils/websocket-url";
+import type { V1SendMessageRequest } from "#/api/conversation-service/v1-conversation-service.types";
+
+// eslint-disable-next-line @typescript-eslint/naming-convention
+export type V1_WebSocketConnectionState =
+ | "CONNECTING"
+ | "OPEN"
+ | "CLOSED"
+ | "CLOSING";
interface ConversationWebSocketContextType {
- connectionState: "CONNECTING" | "OPEN" | "CLOSED" | "CLOSING";
+ connectionState: V1_WebSocketConnectionState;
+ sendMessage: (message: V1SendMessageRequest) => Promise;
}
const ConversationWebSocketContext = createContext<
@@ -30,22 +47,42 @@ const ConversationWebSocketContext = createContext<
export function ConversationWebSocketProvider({
children,
conversationId,
+ conversationUrl,
+ sessionApiKey,
}: {
children: React.ReactNode;
conversationId?: string;
+ conversationUrl?: string | null;
+ sessionApiKey?: string | null;
}) {
- const [connectionState, setConnectionState] = useState<
- "CONNECTING" | "OPEN" | "CLOSED" | "CLOSING"
- >("CONNECTING");
+ const [connectionState, setConnectionState] =
+ useState("CONNECTING");
+ // Track if we've ever successfully connected
+ // Don't show errors until after first successful connection
+ const hasConnectedRef = React.useRef(false);
const queryClient = useQueryClient();
const { addEvent } = useEventStore();
const { setErrorMessage, removeErrorMessage } = useErrorMessageStore();
const { removeOptimisticUserMessage } = useOptimisticUserMessageStore();
+ const { setAgentStatus } = useV1ConversationStateStore();
+ const { appendInput, appendOutput } = useCommandStore();
+
+ // Build WebSocket URL from props
+ const wsUrl = useMemo(
+ () => buildWebSocketUrl(conversationId, conversationUrl),
+ [conversationId, conversationUrl],
+ );
+
+ // Reset hasConnected flag when conversation changes
+ useEffect(() => {
+ hasConnectedRef.current = false;
+ }, [conversationId]);
const handleMessage = useCallback(
(messageEvent: MessageEvent) => {
try {
const event = JSON.parse(messageEvent.data);
+
// Use type guard to validate v1 event structure
if (isV1Event(event)) {
addEvent(event);
@@ -70,25 +107,68 @@ export function ConversationWebSocketProvider({
queryClient,
);
}
+
+ // Handle conversation state updates
+ // TODO: Tests
+ if (isConversationStateUpdateEvent(event)) {
+ if (isFullStateConversationStateUpdateEvent(event)) {
+ setAgentStatus(event.value.agent_status);
+ }
+ if (isAgentStatusConversationStateUpdateEvent(event)) {
+ setAgentStatus(event.value);
+ }
+ }
+
+ // Handle ExecuteBashAction events - add command as input to terminal
+ if (isExecuteBashActionEvent(event)) {
+ appendInput(event.action.command);
+ }
+
+ // Handle ExecuteBashObservation events - add output to terminal
+ if (isExecuteBashObservationEvent(event)) {
+ appendOutput(event.observation.output);
+ }
}
} catch (error) {
// eslint-disable-next-line no-console
console.warn("Failed to parse WebSocket message as JSON:", error);
}
},
- [addEvent, setErrorMessage, removeOptimisticUserMessage, queryClient],
+ [
+ addEvent,
+ setErrorMessage,
+ removeOptimisticUserMessage,
+ queryClient,
+ conversationId,
+ setAgentStatus,
+ appendInput,
+ appendOutput,
+ ],
);
- const websocketOptions = useMemo(
- () => ({
+ const websocketOptions: WebSocketHookOptions = useMemo(() => {
+ const queryParams: Record = {
+ resend_all: true,
+ };
+
+ // Add session_api_key if available
+ if (sessionApiKey) {
+ queryParams.session_api_key = sessionApiKey;
+ }
+
+ return {
+ queryParams,
+ reconnect: { enabled: true },
onOpen: () => {
setConnectionState("OPEN");
+ hasConnectedRef.current = true; // Mark that we've successfully connected
removeErrorMessage(); // Clear any previous error messages on successful connection
},
onClose: (event: CloseEvent) => {
setConnectionState("CLOSED");
- // Set error message for unexpected disconnects (not normal closure)
- if (event.code !== 1000) {
+ // Only show error message if we've previously connected successfully
+ // This prevents showing errors during initial connection attempts (e.g., when auto-starting a conversation)
+ if (event.code !== 1000 && hasConnectedRef.current) {
setErrorMessage(
`Connection lost: ${event.reason || "Unexpected disconnect"}`,
);
@@ -96,20 +176,44 @@ export function ConversationWebSocketProvider({
},
onError: () => {
setConnectionState("CLOSED");
- setErrorMessage("Failed to connect to server");
+ // Only show error message if we've previously connected successfully
+ if (hasConnectedRef.current) {
+ setErrorMessage("Failed to connect to server");
+ }
},
onMessage: handleMessage,
- }),
- [handleMessage, setErrorMessage, removeErrorMessage],
- );
+ };
+ }, [handleMessage, setErrorMessage, removeErrorMessage, sessionApiKey]);
- const { socket } = useWebSocket(
- "ws://localhost/events/socket",
- websocketOptions,
+ // Build a fallback URL to prevent hook from connecting if conversation data isn't ready
+ const websocketUrl = wsUrl || "ws://localhost/placeholder";
+ const { socket } = useWebSocket(websocketUrl, websocketOptions);
+
+ // V1 send message function via WebSocket
+ const sendMessage = useCallback(
+ async (message: V1SendMessageRequest) => {
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
+ const error = "WebSocket is not connected";
+ setErrorMessage(error);
+ throw new Error(error);
+ }
+
+ try {
+ // Send message through WebSocket as JSON
+ socket.send(JSON.stringify(message));
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : "Failed to send message";
+ setErrorMessage(errorMessage);
+ throw error;
+ }
+ },
+ [socket, setErrorMessage],
);
useEffect(() => {
- if (socket) {
+ // Only process socket updates if we have a valid URL
+ if (socket && wsUrl) {
// Update state based on socket readyState
const updateState = () => {
switch (socket.readyState) {
@@ -133,9 +237,12 @@ export function ConversationWebSocketProvider({
updateState();
}
- }, [socket]);
+ }, [socket, wsUrl]);
- const contextValue = useMemo(() => ({ connectionState }), [connectionState]);
+ const contextValue = useMemo(
+ () => ({ connectionState, sendMessage }),
+ [connectionState, sendMessage],
+ );
return (
@@ -145,12 +252,9 @@ export function ConversationWebSocketProvider({
}
export const useConversationWebSocket =
- (): ConversationWebSocketContextType => {
+ (): ConversationWebSocketContextType | null => {
const context = useContext(ConversationWebSocketContext);
- if (context === undefined) {
- throw new Error(
- "useConversationWebSocket must be used within a ConversationWebSocketProvider",
- );
- }
- return context;
+ // Return null instead of throwing when not in provider
+ // This allows the hook to be called conditionally based on conversation version
+ return context || null;
};
diff --git a/frontend/src/contexts/websocket-provider-wrapper.tsx b/frontend/src/contexts/websocket-provider-wrapper.tsx
index 59c3b925f3..bf2a28d6b0 100644
--- a/frontend/src/contexts/websocket-provider-wrapper.tsx
+++ b/frontend/src/contexts/websocket-provider-wrapper.tsx
@@ -1,6 +1,7 @@
import React from "react";
import { WsClientProvider } from "#/context/ws-client-provider";
import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context";
+import { useActiveConversation } from "#/hooks/query/use-active-conversation";
interface WebSocketProviderWrapperProps {
children: React.ReactNode;
@@ -33,6 +34,9 @@ export function WebSocketProviderWrapper({
conversationId,
version,
}: WebSocketProviderWrapperProps) {
+ // Get conversation data for V1 provider
+ const { data: conversation } = useActiveConversation();
+
if (version === 0) {
return (
@@ -43,7 +47,11 @@ export function WebSocketProviderWrapper({
if (version === 1) {
return (
-
+
{children}
);
diff --git a/frontend/src/hooks/mutation/conversation-mutation-utils.ts b/frontend/src/hooks/mutation/conversation-mutation-utils.ts
new file mode 100644
index 0000000000..0414f19fa6
--- /dev/null
+++ b/frontend/src/hooks/mutation/conversation-mutation-utils.ts
@@ -0,0 +1,122 @@
+import { QueryClient } from "@tanstack/react-query";
+import { Provider } from "#/types/settings";
+import ConversationService from "#/api/conversation-service/conversation-service.api";
+import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
+
+/**
+ * Gets the conversation version from the cache
+ */
+export const getConversationVersionFromQueryCache = (
+ queryClient: QueryClient,
+ conversationId: string,
+): "V0" | "V1" => {
+ const conversation = queryClient.getQueryData<{
+ conversation_version?: string;
+ }>(["user", "conversation", conversationId]);
+
+ return conversation?.conversation_version === "V1" ? "V1" : "V0";
+};
+
+/**
+ * Fetches a V1 conversation's sandbox_id
+ */
+const fetchV1ConversationSandboxId = async (
+ conversationId: string,
+): Promise => {
+ const conversations = await V1ConversationService.batchGetAppConversations([
+ conversationId,
+ ]);
+
+ const appConversation = conversations[0];
+ if (!appConversation) {
+ throw new Error(`V1 conversation not found: ${conversationId}`);
+ }
+
+ return appConversation.sandbox_id;
+};
+
+/**
+ * Pause a V1 conversation sandbox by fetching the sandbox_id and pausing it
+ */
+export const pauseV1ConversationSandbox = async (conversationId: string) => {
+ const sandboxId = await fetchV1ConversationSandboxId(conversationId);
+ return V1ConversationService.pauseSandbox(sandboxId);
+};
+
+/**
+ * Stops a V0 conversation using the legacy API
+ */
+export const stopV0Conversation = async (conversationId: string) =>
+ ConversationService.stopConversation(conversationId);
+
+/**
+ * Resumes a V1 conversation sandbox by fetching the sandbox_id and resuming it
+ */
+export const resumeV1ConversationSandbox = async (conversationId: string) => {
+ const sandboxId = await fetchV1ConversationSandboxId(conversationId);
+ return V1ConversationService.resumeSandbox(sandboxId);
+};
+
+/**
+ * Starts a V0 conversation using the legacy API
+ */
+export const startV0Conversation = async (
+ conversationId: string,
+ providers?: Provider[],
+) => ConversationService.startConversation(conversationId, providers);
+
+/**
+ * Optimistically updates the conversation status in the cache
+ */
+export const updateConversationStatusInCache = (
+ queryClient: QueryClient,
+ conversationId: string,
+ status: string,
+): void => {
+ // Update the individual conversation cache
+ queryClient.setQueryData<{ status: string }>(
+ ["user", "conversation", conversationId],
+ (oldData) => {
+ if (!oldData) return oldData;
+ return { ...oldData, status };
+ },
+ );
+
+ // Update the conversations list cache
+ queryClient.setQueriesData<{
+ pages: Array<{
+ results: Array<{ conversation_id: string; status: string }>;
+ }>;
+ }>({ queryKey: ["user", "conversations"] }, (oldData) => {
+ if (!oldData) return oldData;
+
+ return {
+ ...oldData,
+ pages: oldData.pages.map((page) => ({
+ ...page,
+ results: page.results.map((conv) =>
+ conv.conversation_id === conversationId ? { ...conv, status } : conv,
+ ),
+ })),
+ };
+ });
+};
+
+/**
+ * Invalidates all queries related to conversation mutations (start/stop)
+ */
+export const invalidateConversationQueries = (
+ queryClient: QueryClient,
+ conversationId: string,
+): void => {
+ // Invalidate the specific conversation query to trigger automatic refetch
+ queryClient.invalidateQueries({
+ queryKey: ["user", "conversation", conversationId],
+ });
+ // Also invalidate the conversations list for consistency
+ queryClient.invalidateQueries({ queryKey: ["user", "conversations"] });
+ // Invalidate V1 batch get queries
+ queryClient.invalidateQueries({
+ queryKey: ["v1-batch-get-app-conversations"],
+ });
+};
diff --git a/frontend/src/hooks/mutation/use-create-conversation.ts b/frontend/src/hooks/mutation/use-create-conversation.ts
index 6c97d8fba0..1d7336537a 100644
--- a/frontend/src/hooks/mutation/use-create-conversation.ts
+++ b/frontend/src/hooks/mutation/use-create-conversation.ts
@@ -1,9 +1,11 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import posthog from "posthog-js";
import ConversationService from "#/api/conversation-service/conversation-service.api";
+import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { SuggestedTask } from "#/utils/types";
import { Provider } from "#/types/settings";
-import { CreateMicroagent } from "#/api/open-hands.types";
+import { CreateMicroagent, Conversation } from "#/api/open-hands.types";
+import { USE_V1_CONVERSATION_API } from "#/utils/feature-flags";
interface CreateConversationVariables {
query?: string;
@@ -17,12 +19,24 @@ interface CreateConversationVariables {
createMicroagent?: CreateMicroagent;
}
+// Response type that combines both V1 and legacy responses
+interface CreateConversationResponse extends Partial {
+ conversation_id: string;
+ session_api_key: string | null;
+ url: string | null;
+ // V1 specific fields
+ v1_task_id?: string;
+ is_v1?: boolean;
+}
+
export const useCreateConversation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationKey: ["create-conversation"],
- mutationFn: async (variables: CreateConversationVariables) => {
+ mutationFn: async (
+ variables: CreateConversationVariables,
+ ): Promise => {
const {
query,
repository,
@@ -31,7 +45,33 @@ export const useCreateConversation = () => {
createMicroagent,
} = variables;
- return ConversationService.createConversation(
+ const useV1 = USE_V1_CONVERSATION_API();
+
+ if (useV1) {
+ // Use V1 API - creates a conversation start task
+ const startTask = await V1ConversationService.createConversation(
+ repository?.name,
+ repository?.gitProvider,
+ query,
+ repository?.branch,
+ conversationInstructions,
+ undefined, // trigger - will be set by backend
+ );
+
+ // Return a special task ID that the frontend will recognize
+ // Format: "task-{uuid}" so the conversation screen can poll the task
+ // Once the task is ready, it will navigate to the actual conversation ID
+ return {
+ conversation_id: `task-${startTask.id}`,
+ session_api_key: null,
+ url: startTask.agent_server_url,
+ v1_task_id: startTask.id,
+ is_v1: true,
+ };
+ }
+
+ // Use legacy API
+ const conversation = await ConversationService.createConversation(
repository?.name,
repository?.gitProvider,
query,
@@ -40,6 +80,11 @@ export const useCreateConversation = () => {
conversationInstructions,
createMicroagent,
);
+
+ return {
+ ...conversation,
+ is_v1: false,
+ };
},
onSuccess: async (_, { query, repository }) => {
posthog.capture("initial_query_submitted", {
diff --git a/frontend/src/hooks/mutation/use-unified-start-conversation.ts b/frontend/src/hooks/mutation/use-unified-start-conversation.ts
new file mode 100644
index 0000000000..6a65a7c305
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-unified-start-conversation.ts
@@ -0,0 +1,94 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import toast from "react-hot-toast";
+import { useTranslation } from "react-i18next";
+import { Provider } from "#/types/settings";
+import { useErrorMessageStore } from "#/stores/error-message-store";
+import { TOAST_OPTIONS } from "#/utils/custom-toast-handlers";
+import { I18nKey } from "#/i18n/declaration";
+import {
+ getConversationVersionFromQueryCache,
+ resumeV1ConversationSandbox,
+ startV0Conversation,
+ updateConversationStatusInCache,
+ invalidateConversationQueries,
+} from "./conversation-mutation-utils";
+
+/**
+ * Unified hook that automatically routes to the correct resume conversation sandbox implementation
+ * based on the conversation version (V0 or V1).
+ *
+ * This hook checks the cached conversation data to determine the version, then calls
+ * the appropriate API directly. Returns a single useMutation instance that all components share.
+ *
+ * Usage is the same as useStartConversation:
+ * const { mutate: startConversation } = useUnifiedResumeConversationSandbox();
+ * startConversation({ conversationId: "some-id", providers: [...] });
+ */
+export const useUnifiedResumeConversationSandbox = () => {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+ const removeErrorMessage = useErrorMessageStore(
+ (state) => state.removeErrorMessage,
+ );
+
+ return useMutation({
+ mutationKey: ["start-conversation"],
+ mutationFn: async (variables: {
+ conversationId: string;
+ providers?: Provider[];
+ version?: "V0" | "V1";
+ }) => {
+ // Use provided version or fallback to cache lookup
+ const version =
+ variables.version ||
+ getConversationVersionFromQueryCache(
+ queryClient,
+ variables.conversationId,
+ );
+
+ if (version === "V1") {
+ return resumeV1ConversationSandbox(variables.conversationId);
+ }
+
+ return startV0Conversation(variables.conversationId, variables.providers);
+ },
+ onMutate: async () => {
+ toast.loading(t(I18nKey.TOAST$STARTING_CONVERSATION), TOAST_OPTIONS);
+
+ await queryClient.cancelQueries({ queryKey: ["user", "conversations"] });
+ const previousConversations = queryClient.getQueryData([
+ "user",
+ "conversations",
+ ]);
+
+ return { previousConversations };
+ },
+ onError: (_, __, context) => {
+ toast.dismiss();
+ toast.error(t(I18nKey.TOAST$FAILED_TO_START_CONVERSATION), TOAST_OPTIONS);
+
+ if (context?.previousConversations) {
+ queryClient.setQueryData(
+ ["user", "conversations"],
+ context.previousConversations,
+ );
+ }
+ },
+ onSettled: (_, __, variables) => {
+ invalidateConversationQueries(queryClient, variables.conversationId);
+ },
+ onSuccess: (_, variables) => {
+ toast.dismiss();
+ toast.success(t(I18nKey.TOAST$CONVERSATION_STARTED), TOAST_OPTIONS);
+
+ // Clear error messages when starting/resuming conversation
+ removeErrorMessage();
+
+ updateConversationStatusInCache(
+ queryClient,
+ variables.conversationId,
+ "RUNNING",
+ );
+ },
+ });
+};
diff --git a/frontend/src/hooks/mutation/use-unified-stop-conversation.ts b/frontend/src/hooks/mutation/use-unified-stop-conversation.ts
new file mode 100644
index 0000000000..bb638c1522
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-unified-stop-conversation.ts
@@ -0,0 +1,93 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useNavigate, useParams } from "react-router";
+import toast from "react-hot-toast";
+import { useTranslation } from "react-i18next";
+import { TOAST_OPTIONS } from "#/utils/custom-toast-handlers";
+import { I18nKey } from "#/i18n/declaration";
+import {
+ getConversationVersionFromQueryCache,
+ pauseV1ConversationSandbox,
+ stopV0Conversation,
+ updateConversationStatusInCache,
+ invalidateConversationQueries,
+} from "./conversation-mutation-utils";
+
+/**
+ * Unified hook that automatically routes to the correct pause conversation sandbox
+ * implementation based on the conversation version (V0 or V1).
+ *
+ * This hook checks the cached conversation data to determine the version, then calls
+ * the appropriate API directly. Returns a single useMutation instance that all components share.
+ *
+ * Usage is the same as useStopConversation:
+ * const { mutate: stopConversation } = useUnifiedPauseConversationSandbox();
+ * stopConversation({ conversationId: "some-id" });
+ */
+export const useUnifiedPauseConversationSandbox = () => {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+ const navigate = useNavigate();
+ const params = useParams<{ conversationId: string }>();
+
+ return useMutation({
+ mutationKey: ["stop-conversation"],
+ mutationFn: async (variables: {
+ conversationId: string;
+ version?: "V0" | "V1";
+ }) => {
+ // Use provided version or fallback to cache lookup
+ const version =
+ variables.version ||
+ getConversationVersionFromQueryCache(
+ queryClient,
+ variables.conversationId,
+ );
+
+ if (version === "V1") {
+ return pauseV1ConversationSandbox(variables.conversationId);
+ }
+
+ return stopV0Conversation(variables.conversationId);
+ },
+ onMutate: async () => {
+ toast.loading(t(I18nKey.TOAST$STOPPING_CONVERSATION), TOAST_OPTIONS);
+
+ await queryClient.cancelQueries({ queryKey: ["user", "conversations"] });
+ const previousConversations = queryClient.getQueryData([
+ "user",
+ "conversations",
+ ]);
+
+ return { previousConversations };
+ },
+ onError: (_, __, context) => {
+ toast.dismiss();
+ toast.error(t(I18nKey.TOAST$FAILED_TO_STOP_CONVERSATION), TOAST_OPTIONS);
+
+ if (context?.previousConversations) {
+ queryClient.setQueryData(
+ ["user", "conversations"],
+ context.previousConversations,
+ );
+ }
+ },
+ onSettled: (_, __, variables) => {
+ invalidateConversationQueries(queryClient, variables.conversationId);
+ },
+ onSuccess: (_, variables) => {
+ toast.dismiss();
+ toast.success(t(I18nKey.TOAST$CONVERSATION_STOPPED), TOAST_OPTIONS);
+
+ updateConversationStatusInCache(
+ queryClient,
+ variables.conversationId,
+ "STOPPED",
+ );
+
+ // Only redirect if we're stopping the conversation we're currently viewing
+ if (params.conversationId === variables.conversationId) {
+ navigate("/");
+ }
+ },
+ });
+};
diff --git a/frontend/src/hooks/query/use-active-conversation.ts b/frontend/src/hooks/query/use-active-conversation.ts
index 452dba9261..dec6a38f3e 100644
--- a/frontend/src/hooks/query/use-active-conversation.ts
+++ b/frontend/src/hooks/query/use-active-conversation.ts
@@ -5,14 +5,23 @@ import ConversationService from "#/api/conversation-service/conversation-service
export const useActiveConversation = () => {
const { conversationId } = useConversationId();
- const userConversation = useUserConversation(conversationId, (query) => {
- if (query.state.data?.status === "STARTING") {
- return 3000; // 3 seconds
- }
- // TODO: Return conversation title as a WS event to avoid polling
- // This was changed from 5 minutes to 30 seconds to poll for updated conversation title after an auto update
- return 30000; // 30 seconds
- });
+
+ // Don't poll if this is a task ID (format: "task-{uuid}")
+ // Task polling is handled by useTaskPolling hook
+ const isTaskId = conversationId.startsWith("task-");
+ const actualConversationId = isTaskId ? null : conversationId;
+
+ const userConversation = useUserConversation(
+ actualConversationId,
+ (query) => {
+ if (query.state.data?.status === "STARTING") {
+ return 3000; // 3 seconds
+ }
+ // TODO: Return conversation title as a WS event to avoid polling
+ // This was changed from 5 minutes to 30 seconds to poll for updated conversation title after an auto update
+ return 30000; // 30 seconds
+ },
+ );
useEffect(() => {
const conversation = userConversation.data;
diff --git a/frontend/src/hooks/query/use-conversation-microagents.ts b/frontend/src/hooks/query/use-conversation-microagents.ts
index 1778a7484e..d51b2b311d 100644
--- a/frontend/src/hooks/query/use-conversation-microagents.ts
+++ b/frontend/src/hooks/query/use-conversation-microagents.ts
@@ -2,11 +2,11 @@ import { useQuery } from "@tanstack/react-query";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { useConversationId } from "../use-conversation-id";
import { AgentState } from "#/types/agent-state";
-import { useAgentStore } from "#/stores/agent-store";
+import { useAgentState } from "#/hooks/use-agent-state";
export const useConversationMicroagents = () => {
const { conversationId } = useConversationId();
- const { curAgentState } = useAgentStore();
+ const { curAgentState } = useAgentState();
return useQuery({
queryKey: ["conversation", conversationId, "microagents"],
diff --git a/frontend/src/hooks/query/use-start-tasks.ts b/frontend/src/hooks/query/use-start-tasks.ts
new file mode 100644
index 0000000000..da7baa8341
--- /dev/null
+++ b/frontend/src/hooks/query/use-start-tasks.ts
@@ -0,0 +1,25 @@
+import { useQuery } from "@tanstack/react-query";
+import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
+
+/**
+ * Hook to fetch in-progress V1 conversation start tasks
+ *
+ * Use case: Show tasks that are provisioning sandboxes, cloning repos, etc.
+ * These are conversations that started but haven't reached READY or ERROR status yet.
+ *
+ * Note: Filters out READY and ERROR status tasks client-side since backend doesn't support status filtering.
+ *
+ * @param limit Maximum number of tasks to return (max 100)
+ * @returns Query result with array of in-progress start tasks
+ */
+export const useStartTasks = (limit = 10) =>
+ useQuery({
+ queryKey: ["start-tasks", "search", limit],
+ queryFn: () => V1ConversationService.searchStartTasks(limit),
+ select: (tasks) =>
+ tasks.filter(
+ (task) => task.status !== "READY" && task.status !== "ERROR",
+ ),
+ staleTime: 1000 * 60 * 1, // 1 minute (short since these are in-progress)
+ gcTime: 1000 * 60 * 5, // 5 minutes
+ });
diff --git a/frontend/src/hooks/query/use-task-polling.ts b/frontend/src/hooks/query/use-task-polling.ts
new file mode 100644
index 0000000000..81dc3e7aa4
--- /dev/null
+++ b/frontend/src/hooks/query/use-task-polling.ts
@@ -0,0 +1,72 @@
+import { useEffect } from "react";
+import { useNavigate } from "react-router";
+import { useQuery } from "@tanstack/react-query";
+import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
+import { useConversationId } from "#/hooks/use-conversation-id";
+
+/**
+ * Hook that polls V1 conversation start tasks and navigates when ready.
+ *
+ * This hook:
+ * - Detects if the conversationId URL param is a task ID (format: "task-{uuid}")
+ * - Polls the V1 start task API every 3 seconds until status is READY or ERROR
+ * - Automatically navigates to the conversation URL when the task becomes READY
+ * - Exposes task status and details for UI components to show loading states and errors
+ *
+ * URL patterns:
+ * - /conversations/task-{uuid} → Polls start task, then navigates to /conversations/{conversation-id}
+ * - /conversations/{uuid or hex} → No polling (handled by useActiveConversation)
+ *
+ * Note: This hook does NOT fetch conversation data. It only handles task polling and navigation.
+ */
+export const useTaskPolling = () => {
+ const { conversationId } = useConversationId();
+ const navigate = useNavigate();
+
+ // Check if this is a task ID (format: "task-{uuid}")
+ const isTask = conversationId.startsWith("task-");
+ const taskId = isTask ? conversationId.replace("task-", "") : null;
+
+ // Poll the task if this is a task ID
+ const taskQuery = useQuery({
+ queryKey: ["start-task", taskId],
+ queryFn: async () => {
+ if (!taskId) return null;
+ return V1ConversationService.getStartTask(taskId);
+ },
+ enabled: !!taskId,
+ refetchInterval: (query) => {
+ const task = query.state.data;
+ if (!task) return false;
+
+ // Stop polling if ready or error
+ if (task.status === "READY" || task.status === "ERROR") {
+ return false;
+ }
+
+ // Poll every 3 seconds while task is in progress
+ return 3000;
+ },
+ retry: false,
+ });
+
+ // Navigate to conversation ID when task is ready
+ useEffect(() => {
+ const task = taskQuery.data;
+ if (task?.status === "READY" && task.app_conversation_id) {
+ // Replace the URL with the actual conversation ID
+ navigate(`/conversations/${task.app_conversation_id}`, { replace: true });
+ }
+ }, [taskQuery.data, navigate]);
+
+ return {
+ isTask,
+ taskId,
+ conversationId: isTask ? null : conversationId,
+ task: taskQuery.data,
+ taskStatus: taskQuery.data?.status,
+ taskDetail: taskQuery.data?.detail,
+ taskError: taskQuery.error,
+ isLoadingTask: taskQuery.isLoading,
+ };
+};
diff --git a/frontend/src/hooks/query/use-user-conversation.ts b/frontend/src/hooks/query/use-user-conversation.ts
index fbae346356..401087d8c0 100644
--- a/frontend/src/hooks/query/use-user-conversation.ts
+++ b/frontend/src/hooks/query/use-user-conversation.ts
@@ -6,6 +6,7 @@ import { Conversation } from "#/api/open-hands.types";
const FIVE_MINUTES = 1000 * 60 * 5;
const FIFTEEN_MINUTES = 1000 * 60 * 15;
+
type RefetchInterval = (
query: Query<
Conversation | null,
@@ -22,7 +23,11 @@ export const useUserConversation = (
useQuery({
queryKey: ["user", "conversation", cid],
queryFn: async () => {
- const conversation = await ConversationService.getConversation(cid!);
+ if (!cid) return null;
+
+ // Use the legacy GET endpoint - it handles both V0 and V1 conversations
+ // V1 conversations are automatically detected by UUID format and converted
+ const conversation = await ConversationService.getConversation(cid);
return conversation;
},
enabled: !!cid,
diff --git a/frontend/src/hooks/query/use-vscode-url.ts b/frontend/src/hooks/query/use-vscode-url.ts
index 8b0e74df27..ac3e19e553 100644
--- a/frontend/src/hooks/query/use-vscode-url.ts
+++ b/frontend/src/hooks/query/use-vscode-url.ts
@@ -1,7 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import ConversationService from "#/api/conversation-service/conversation-service.api";
+import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { useConversationId } from "#/hooks/use-conversation-id";
+import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { I18nKey } from "#/i18n/declaration";
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
@@ -15,13 +17,31 @@ interface VSCodeUrlResult {
export const useVSCodeUrl = () => {
const { t } = useTranslation();
const { conversationId } = useConversationId();
+ const { data: conversation } = useActiveConversation();
const runtimeIsReady = useRuntimeIsReady();
+ const isV1Conversation = conversation?.conversation_version === "V1";
+
return useQuery({
- queryKey: ["vscode_url", conversationId],
+ queryKey: [
+ "vscode_url",
+ conversationId,
+ isV1Conversation,
+ conversation?.url,
+ conversation?.session_api_key,
+ ],
queryFn: async () => {
if (!conversationId) throw new Error("No conversation ID");
- const data = await ConversationService.getVSCodeUrl(conversationId);
+
+ // Use appropriate API based on conversation version
+ const data = isV1Conversation
+ ? await V1ConversationService.getVSCodeUrl(
+ conversationId,
+ conversation?.url,
+ conversation?.session_api_key,
+ )
+ : await ConversationService.getVSCodeUrl(conversationId);
+
if (data.vscode_url) {
return {
url: transformVSCodeUrl(data.vscode_url),
diff --git a/frontend/src/hooks/use-agent-state.ts b/frontend/src/hooks/use-agent-state.ts
new file mode 100644
index 0000000000..e36c93153f
--- /dev/null
+++ b/frontend/src/hooks/use-agent-state.ts
@@ -0,0 +1,56 @@
+import { useMemo } from "react";
+import { useAgentStore } from "#/stores/agent-store";
+import { useV1ConversationStateStore } from "#/stores/v1-conversation-state-store";
+import { useActiveConversation } from "#/hooks/query/use-active-conversation";
+import { AgentState } from "#/types/agent-state";
+import { V1AgentStatus } from "#/types/v1/core/base/common";
+
+/**
+ * Maps V1 agent status to V0 AgentState
+ */
+function mapV1StatusToV0State(status: V1AgentStatus | null): AgentState {
+ if (!status) {
+ return AgentState.LOADING;
+ }
+
+ switch (status) {
+ case V1AgentStatus.IDLE:
+ return AgentState.AWAITING_USER_INPUT;
+ case V1AgentStatus.RUNNING:
+ return AgentState.RUNNING;
+ case V1AgentStatus.PAUSED:
+ return AgentState.PAUSED;
+ case V1AgentStatus.WAITING_FOR_CONFIRMATION:
+ return AgentState.AWAITING_USER_CONFIRMATION;
+ case V1AgentStatus.FINISHED:
+ return AgentState.FINISHED;
+ case V1AgentStatus.ERROR:
+ return AgentState.ERROR;
+ case V1AgentStatus.STUCK:
+ return AgentState.ERROR; // Map STUCK to ERROR for now
+ default:
+ return AgentState.LOADING;
+ }
+}
+
+/**
+ * Unified hook that returns the current agent state
+ * - For V0 conversations: Returns state from useAgentStore
+ * - For V1 conversations: Returns mapped state from useV1ConversationStateStore
+ */
+export function useAgentState() {
+ const { data: conversation } = useActiveConversation();
+ const v0State = useAgentStore((state) => state.curAgentState);
+ const v1Status = useV1ConversationStateStore((state) => state.agent_status);
+
+ const isV1Conversation = conversation?.conversation_version === "V1";
+
+ const curAgentState = useMemo(() => {
+ if (isV1Conversation) {
+ return mapV1StatusToV0State(v1Status);
+ }
+ return v0State;
+ }, [isV1Conversation, v1Status, v0State]);
+
+ return { curAgentState };
+}
diff --git a/frontend/src/hooks/use-conversation-name-context-menu.ts b/frontend/src/hooks/use-conversation-name-context-menu.ts
index d286410d28..017a01f478 100644
--- a/frontend/src/hooks/use-conversation-name-context-menu.ts
+++ b/frontend/src/hooks/use-conversation-name-context-menu.ts
@@ -8,7 +8,7 @@ import { isSystemMessage, isActionOrObservation } from "#/types/core/guards";
import { ConversationStatus } from "#/types/conversation-status";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { useDeleteConversation } from "./mutation/use-delete-conversation";
-import { useStopConversation } from "./mutation/use-stop-conversation";
+import { useUnifiedPauseConversationSandbox } from "./mutation/use-unified-stop-conversation";
import { useGetTrajectory } from "./mutation/use-get-trajectory";
import { downloadTrajectory } from "#/utils/download-trajectory";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
@@ -34,7 +34,7 @@ export function useConversationNameContextMenu({
const navigate = useNavigate();
const events = useEventStore((state) => state.events);
const { mutate: deleteConversation } = useDeleteConversation();
- const { mutate: stopConversation } = useStopConversation();
+ const { mutate: stopConversation } = useUnifiedPauseConversationSandbox();
const { mutate: getTrajectory } = useGetTrajectory();
const metrics = useMetricsStore();
diff --git a/frontend/src/hooks/use-effect-once.ts b/frontend/src/hooks/use-effect-once.ts
deleted file mode 100644
index 57b18a9237..0000000000
--- a/frontend/src/hooks/use-effect-once.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from "react";
-
-// Introduce this custom React hook to run any given effect
-// ONCE. In Strict mode, React will run all useEffect's twice,
-// which will trigger a WebSocket connection and then immediately
-// close it, causing the "closed before could connect" error.
-export const useEffectOnce = (callback: () => void) => {
- const isUsedRef = React.useRef(false);
-
- React.useEffect(() => {
- if (isUsedRef.current) {
- return;
- }
-
- isUsedRef.current = true;
- callback();
- }, [isUsedRef.current]);
-};
diff --git a/frontend/src/hooks/use-handle-runtime-active.ts b/frontend/src/hooks/use-handle-runtime-active.ts
index 52dad8be94..dcddac3d51 100644
--- a/frontend/src/hooks/use-handle-runtime-active.ts
+++ b/frontend/src/hooks/use-handle-runtime-active.ts
@@ -1,8 +1,8 @@
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
-import { useAgentStore } from "#/stores/agent-store";
+import { useAgentState } from "#/hooks/use-agent-state";
export const useHandleRuntimeActive = () => {
- const { curAgentState } = useAgentStore();
+ const { curAgentState } = useAgentState();
const runtimeActive = !RUNTIME_INACTIVE_STATES.includes(curAgentState);
diff --git a/frontend/src/hooks/use-handle-ws-events.ts b/frontend/src/hooks/use-handle-ws-events.ts
index 3b4b7b0bd1..2e87dc4c50 100644
--- a/frontend/src/hooks/use-handle-ws-events.ts
+++ b/frontend/src/hooks/use-handle-ws-events.ts
@@ -1,9 +1,9 @@
import React from "react";
-import { useWsClient } from "#/context/ws-client-provider";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { AgentState } from "#/types/agent-state";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { useEventStore } from "#/stores/use-event-store";
+import { useSendMessage } from "#/hooks/use-send-message";
interface ServerError {
error: boolean | string;
@@ -14,7 +14,7 @@ interface ServerError {
const isServerError = (data: object): data is ServerError => "error" in data;
export const useHandleWSEvents = () => {
- const { send } = useWsClient();
+ const { send } = useSendMessage();
const events = useEventStore((state) => state.events);
React.useEffect(() => {
diff --git a/frontend/src/hooks/use-runtime-is-ready.ts b/frontend/src/hooks/use-runtime-is-ready.ts
index 0cd61ac918..914b3624c4 100644
--- a/frontend/src/hooks/use-runtime-is-ready.ts
+++ b/frontend/src/hooks/use-runtime-is-ready.ts
@@ -1,6 +1,6 @@
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { useActiveConversation } from "./query/use-active-conversation";
-import { useAgentStore } from "#/stores/agent-store";
+import { useAgentState } from "#/hooks/use-agent-state";
/**
* Hook to determine if the runtime is ready for operations
@@ -9,7 +9,7 @@ import { useAgentStore } from "#/stores/agent-store";
*/
export const useRuntimeIsReady = (): boolean => {
const { data: conversation } = useActiveConversation();
- const { curAgentState } = useAgentStore();
+ const { curAgentState } = useAgentState();
return (
conversation?.status === "RUNNING" &&
diff --git a/frontend/src/hooks/use-send-message.ts b/frontend/src/hooks/use-send-message.ts
new file mode 100644
index 0000000000..1e1d627181
--- /dev/null
+++ b/frontend/src/hooks/use-send-message.ts
@@ -0,0 +1,73 @@
+import { useCallback } from "react";
+import { useWsClient } from "#/context/ws-client-provider";
+import { useActiveConversation } from "#/hooks/query/use-active-conversation";
+import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
+import { V1MessageContent } from "#/api/conversation-service/v1-conversation-service.types";
+
+/**
+ * Unified hook for sending messages that works with both V0 and V1 conversations
+ * - For V0 conversations: Uses Socket.IO WebSocket via useWsClient
+ * - For V1 conversations: Uses native WebSocket via ConversationWebSocketProvider
+ */
+export function useSendMessage() {
+ const { data: conversation } = useActiveConversation();
+ const { send: v0Send } = useWsClient();
+
+ // Get V1 context (will be null if not in V1 provider)
+ const v1Context = useConversationWebSocket();
+
+ const isV1Conversation = conversation?.conversation_version === "V1";
+
+ const send = useCallback(
+ async (event: Record) => {
+ if (isV1Conversation && v1Context) {
+ // V1: Convert V0 event format to V1 message format
+ const { action, args } = event as {
+ action: string;
+ args?: {
+ content?: string;
+ image_urls?: string[];
+ file_urls?: string[];
+ timestamp?: string;
+ };
+ };
+
+ if (action === "message" && args?.content) {
+ // Build V1 message content array
+ const content: Array = [
+ {
+ type: "text",
+ text: args.content,
+ },
+ ];
+
+ // Add images if present
+ if (args.image_urls && args.image_urls.length > 0) {
+ args.image_urls.forEach((url) => {
+ content.push({
+ type: "image_url",
+ image_url: { url },
+ });
+ });
+ }
+
+ // Send via V1 WebSocket context (uses correct host/port)
+ await v1Context.sendMessage({
+ role: "user",
+ content,
+ });
+ } else {
+ // For non-message events, fall back to V0 send
+ // (e.g., agent state changes, other control events)
+ v0Send(event);
+ }
+ } else {
+ // V0: Use Socket.IO
+ v0Send(event);
+ }
+ },
+ [isV1Conversation, v1Context, v0Send],
+ );
+
+ return { send };
+}
diff --git a/frontend/src/hooks/use-terminal.ts b/frontend/src/hooks/use-terminal.ts
index f5b4656ac3..1b444a8723 100644
--- a/frontend/src/hooks/use-terminal.ts
+++ b/frontend/src/hooks/use-terminal.ts
@@ -3,10 +3,10 @@ import { Terminal } from "@xterm/xterm";
import React from "react";
import { Command, useCommandStore } from "#/state/command-store";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
-import { useWsClient } from "#/context/ws-client-provider";
import { getTerminalCommand } from "#/services/terminal-service";
import { parseTerminalOutput } from "#/utils/parse-terminal-output";
-import { useAgentStore } from "#/stores/agent-store";
+import { useSendMessage } from "#/hooks/use-send-message";
+import { useAgentState } from "#/hooks/use-agent-state";
/*
NOTE: Tests for this hook are indirectly covered by the tests for the XTermTerminal component.
@@ -36,8 +36,8 @@ const renderCommand = (
const persistentLastCommandIndex = { current: 0 };
export const useTerminal = () => {
- const { send } = useWsClient();
- const { curAgentState } = useAgentStore();
+ const { send } = useSendMessage();
+ const { curAgentState } = useAgentState();
const commands = useCommandStore((state) => state.commands);
const terminal = React.useRef(null);
const fitAddon = React.useRef(null);
diff --git a/frontend/src/hooks/use-unified-websocket-status.ts b/frontend/src/hooks/use-unified-websocket-status.ts
new file mode 100644
index 0000000000..4ad6e45a43
--- /dev/null
+++ b/frontend/src/hooks/use-unified-websocket-status.ts
@@ -0,0 +1,39 @@
+import { useMemo } from "react";
+import { useWsClient, V0_WebSocketStatus } from "#/context/ws-client-provider";
+import { useActiveConversation } from "#/hooks/query/use-active-conversation";
+import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
+
+/**
+ * Unified hook that returns the current WebSocket status
+ * - For V0 conversations: Returns status from useWsClient
+ * - For V1 conversations: Returns status from ConversationWebSocketProvider
+ */
+export function useUnifiedWebSocketStatus(): V0_WebSocketStatus {
+ const { data: conversation } = useActiveConversation();
+ const v0Status = useWsClient();
+ const v1Context = useConversationWebSocket();
+
+ const isV1Conversation = conversation?.conversation_version === "V1";
+
+ const webSocketStatus = useMemo(() => {
+ if (isV1Conversation) {
+ // Map V1 connection state to WebSocketStatus
+ if (!v1Context) return "DISCONNECTED";
+
+ switch (v1Context.connectionState) {
+ case "OPEN":
+ return "CONNECTED";
+ case "CONNECTING":
+ return "CONNECTING";
+ case "CLOSED":
+ case "CLOSING":
+ return "DISCONNECTED";
+ default:
+ return "DISCONNECTED";
+ }
+ }
+ return v0Status.webSocketStatus;
+ }, [isV1Conversation, v1Context, v0Status.webSocketStatus]);
+
+ return webSocketStatus;
+}
diff --git a/frontend/src/hooks/use-websocket.ts b/frontend/src/hooks/use-websocket.ts
index 644f6f972a..34f46205fd 100644
--- a/frontend/src/hooks/use-websocket.ts
+++ b/frontend/src/hooks/use-websocket.ts
@@ -1,45 +1,78 @@
import React from "react";
+export interface WebSocketHookOptions {
+ queryParams?: Record;
+ onOpen?: (event: Event) => void;
+ onClose?: (event: CloseEvent) => void;
+ onMessage?: (event: MessageEvent) => void;
+ onError?: (event: Event) => void;
+ reconnect?: {
+ enabled?: boolean;
+ maxAttempts?: number;
+ };
+}
+
export const useWebSocket = (
url: string,
- options?: {
- queryParams?: Record;
- onOpen?: (event: Event) => void;
- onClose?: (event: CloseEvent) => void;
- onMessage?: (event: MessageEvent) => void;
- onError?: (event: Event) => void;
- },
+ options?: WebSocketHookOptions,
) => {
const [isConnected, setIsConnected] = React.useState(false);
const [lastMessage, setLastMessage] = React.useState(null);
const [messages, setMessages] = React.useState([]);
const [error, setError] = React.useState(null);
+ const [isReconnecting, setIsReconnecting] = React.useState(false);
const wsRef = React.useRef(null);
+ const attemptCountRef = React.useRef(0);
+ const reconnectTimeoutRef = React.useRef(null);
+ const shouldReconnectRef = React.useRef(true); // Only set to false by disconnect()
+ // Track which WebSocket instances are allowed to reconnect using a WeakSet
+ const allowedToReconnectRef = React.useRef>(new WeakSet());
+ // Store options in a ref to avoid reconnecting when callbacks change
+ const optionsRef = React.useRef(options);
React.useEffect(() => {
+ optionsRef.current = options;
+ }, [options]);
+
+ const connectWebSocket = React.useCallback(() => {
// Build URL with query parameters if provided
let wsUrl = url;
- if (options?.queryParams) {
- const params = new URLSearchParams(options.queryParams);
+ if (optionsRef.current?.queryParams) {
+ const stringParams = Object.entries(
+ optionsRef.current.queryParams,
+ ).reduce(
+ (acc, [key, value]) => {
+ acc[key] = String(value);
+ return acc;
+ },
+ {} as Record,
+ );
+ const params = new URLSearchParams(stringParams);
wsUrl = `${url}?${params.toString()}`;
}
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
+ // Mark this WebSocket instance as allowed to reconnect
+ allowedToReconnectRef.current.add(ws);
ws.onopen = (event) => {
setIsConnected(true);
setError(null); // Clear any previous errors
- options?.onOpen?.(event);
+ setIsReconnecting(false);
+ attemptCountRef.current = 0; // Reset attempt count on successful connection
+ optionsRef.current?.onOpen?.(event);
};
ws.onmessage = (event) => {
setLastMessage(event.data);
setMessages((prev) => [...prev, event.data]);
- options?.onMessage?.(event);
+ optionsRef.current?.onMessage?.(event);
};
ws.onclose = (event) => {
+ // Check if this specific WebSocket instance is allowed to reconnect
+ const canReconnect = allowedToReconnectRef.current.has(ws);
setIsConnected(false);
// If the connection closes with an error code, treat it as an error
if (event.code !== 1000) {
@@ -49,21 +82,75 @@ export const useWebSocket = (
`WebSocket closed with code ${event.code}: ${event.reason || "Connection closed unexpectedly"}`,
),
);
- // Also call onError handler for error closures
- options?.onError?.(event);
+ // Also call onError handler for error closures (only if allowed to reconnect)
+ if (canReconnect) {
+ optionsRef.current?.onError?.(event);
+ }
+ }
+ optionsRef.current?.onClose?.(event);
+
+ // Attempt reconnection if enabled and allowed
+ // IMPORTANT: Only reconnect if this specific instance is allowed to reconnect
+ const reconnectEnabled = optionsRef.current?.reconnect?.enabled ?? false;
+ const maxAttempts =
+ optionsRef.current?.reconnect?.maxAttempts ?? Infinity;
+
+ if (
+ reconnectEnabled &&
+ canReconnect &&
+ shouldReconnectRef.current &&
+ attemptCountRef.current < maxAttempts
+ ) {
+ setIsReconnecting(true);
+ attemptCountRef.current += 1;
+
+ reconnectTimeoutRef.current = setTimeout(() => {
+ connectWebSocket();
+ }, 3000); // 3 second delay
+ } else {
+ setIsReconnecting(false);
}
- options?.onClose?.(event);
};
ws.onerror = (event) => {
setIsConnected(false);
- options?.onError?.(event);
+ optionsRef.current?.onError?.(event);
};
+ }, [url]);
+
+ React.useEffect(() => {
+ // Reset shouldReconnect flag and attempt count when creating a new connection
+ shouldReconnectRef.current = true;
+ attemptCountRef.current = 0;
+
+ connectWebSocket();
return () => {
- ws.close();
+ // Disable reconnection on unmount to prevent reconnection attempts
+ // This must be set BEFORE closing the socket, so the onclose handler sees it
+ shouldReconnectRef.current = false;
+ // Clear any pending reconnection timeouts
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current);
+ reconnectTimeoutRef.current = null;
+ }
+ // Close the WebSocket connection
+ if (wsRef.current) {
+ const { readyState } = wsRef.current;
+ // Remove this WebSocket from the allowed list BEFORE closing
+ // so its onclose handler won't try to reconnect
+ allowedToReconnectRef.current.delete(wsRef.current);
+ // Only close if not already closed/closing
+ if (
+ readyState === WebSocket.CONNECTING ||
+ readyState === WebSocket.OPEN
+ ) {
+ wsRef.current.close();
+ }
+ wsRef.current = null;
+ }
};
- }, [url, options]);
+ }, [url, connectWebSocket]);
const sendMessage = React.useCallback(
(data: string | ArrayBufferLike | Blob | ArrayBufferView) => {
@@ -74,6 +161,20 @@ export const useWebSocket = (
[],
);
+ const disconnect = React.useCallback(() => {
+ shouldReconnectRef.current = false;
+ setIsReconnecting(false);
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current);
+ reconnectTimeoutRef.current = null;
+ }
+ if (wsRef.current) {
+ // Remove from allowed list before closing
+ allowedToReconnectRef.current.delete(wsRef.current);
+ wsRef.current.close();
+ }
+ }, []);
+
return {
isConnected,
lastMessage,
@@ -81,5 +182,8 @@ export const useWebSocket = (
error,
socket: wsRef.current,
sendMessage,
+ isReconnecting,
+ attemptCount: attemptCountRef.current,
+ disconnect,
};
};
diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts
index 67b4e88026..e722897ae7 100644
--- a/frontend/src/i18n/declaration.ts
+++ b/frontend/src/i18n/declaration.ts
@@ -757,6 +757,7 @@ export enum I18nKey {
COMMON$LEARN = "COMMON$LEARN",
COMMON$LEARN_SOMETHING_NEW = "COMMON$LEARN_SOMETHING_NEW",
COMMON$STARTING = "COMMON$STARTING",
+ COMMON$STOPPING = "COMMON$STOPPING",
MICROAGENT_MANAGEMENT$ERROR = "MICROAGENT_MANAGEMENT$ERROR",
MICROAGENT_MANAGEMENT$CONVERSATION_STOPPED = "MICROAGENT_MANAGEMENT$CONVERSATION_STOPPED",
MICROAGENT_MANAGEMENT$LEARN_THIS_REPO_MODAL_TITLE = "MICROAGENT_MANAGEMENT$LEARN_THIS_REPO_MODAL_TITLE",
@@ -918,4 +919,18 @@ export enum I18nKey {
COMMON$CONFIRMATION_MODE_ENABLED = "COMMON$CONFIRMATION_MODE_ENABLED",
COMMON$MOST_RECENT = "COMMON$MOST_RECENT",
HOME$NO_REPOSITORY_FOUND = "HOME$NO_REPOSITORY_FOUND",
+ CONVERSATION$VERSION_V1_NEW = "CONVERSATION$VERSION_V1_NEW",
+ CONVERSATION$VERSION_V0_LEGACY = "CONVERSATION$VERSION_V0_LEGACY",
+ CONVERSATION$ERROR_STARTING_CONVERSATION = "CONVERSATION$ERROR_STARTING_CONVERSATION",
+ CONVERSATION$READY = "CONVERSATION$READY",
+ CONVERSATION$STARTING_CONVERSATION = "CONVERSATION$STARTING_CONVERSATION",
+ CONVERSATION$FAILED_TO_START_FROM_TASK = "CONVERSATION$FAILED_TO_START_FROM_TASK",
+ CONVERSATION$NOT_EXIST_OR_NO_PERMISSION = "CONVERSATION$NOT_EXIST_OR_NO_PERMISSION",
+ CONVERSATION$FAILED_TO_START_WITH_ERROR = "CONVERSATION$FAILED_TO_START_WITH_ERROR",
+ TOAST$STARTING_CONVERSATION = "TOAST$STARTING_CONVERSATION",
+ TOAST$FAILED_TO_START_CONVERSATION = "TOAST$FAILED_TO_START_CONVERSATION",
+ TOAST$CONVERSATION_STARTED = "TOAST$CONVERSATION_STARTED",
+ TOAST$STOPPING_CONVERSATION = "TOAST$STOPPING_CONVERSATION",
+ TOAST$FAILED_TO_STOP_CONVERSATION = "TOAST$FAILED_TO_STOP_CONVERSATION",
+ TOAST$CONVERSATION_STOPPED = "TOAST$CONVERSATION_STOPPED",
}
diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json
index 5f4b9863a7..64fb117389 100644
--- a/frontend/src/i18n/translation.json
+++ b/frontend/src/i18n/translation.json
@@ -12111,6 +12111,22 @@
"de": "Wird gestartet",
"uk": "Запуск"
},
+ "COMMON$STOPPING": {
+ "en": "Stopping...",
+ "ja": "停止中...",
+ "zh-CN": "停止中...",
+ "zh-TW": "停止中...",
+ "ko-KR": "중지 중...",
+ "no": "Stopper...",
+ "it": "Arresto...",
+ "pt": "Parando...",
+ "es": "Deteniendo...",
+ "ar": "جارٍ الإيقاف...",
+ "fr": "Arrêt...",
+ "tr": "Durduruluyor...",
+ "de": "Wird gestoppt...",
+ "uk": "Зупинка..."
+ },
"MICROAGENT_MANAGEMENT$ERROR": {
"en": "The system has encountered an error. Please try again later.",
"ja": "システムでエラーが発生しました。後でもう一度お試しください。",
@@ -14686,5 +14702,229 @@
"tr": "Konuşma başlatmak için depo bulunamadı",
"de": "Kein Repository gefunden, um das Gespräch zu starten",
"uk": "Не знайдено репозиторій для запуску розмови"
+ },
+ "CONVERSATION$VERSION_V1_NEW": {
+ "en": "Conversation API Version 1 (New)",
+ "ja": "会話API バージョン1(新規)",
+ "zh-CN": "对话API版本1(新)",
+ "zh-TW": "對話API版本1(新)",
+ "ko-KR": "대화 API 버전 1 (신규)",
+ "no": "Samtale-API versjon 1 (Ny)",
+ "it": "API di conversazione versione 1 (Nuova)",
+ "pt": "API de conversa versão 1 (Nova)",
+ "es": "API de conversación versión 1 (Nueva)",
+ "ar": "واجهة برمجة التطبيقات للمحادثة الإصدار 1 (جديد)",
+ "fr": "API de conversation version 1 (Nouvelle)",
+ "tr": "Konuşma API'si Sürüm 1 (Yeni)",
+ "de": "Konversations-API Version 1 (Neu)",
+ "uk": "API розмови версія 1 (Нова)"
+ },
+ "CONVERSATION$VERSION_V0_LEGACY": {
+ "en": "Conversation API Version 0 (Legacy)",
+ "ja": "会話API バージョン0(レガシー)",
+ "zh-CN": "对话API版本0(旧版)",
+ "zh-TW": "對話API版本0(舊版)",
+ "ko-KR": "대화 API 버전 0 (레거시)",
+ "no": "Samtale-API versjon 0 (Gammel)",
+ "it": "API di conversazione versione 0 (Legacy)",
+ "pt": "API de conversa versão 0 (Legado)",
+ "es": "API de conversación versión 0 (Heredada)",
+ "ar": "واجهة برمجة التطبيقات للمحادثة الإصدار 0 (قديم)",
+ "fr": "API de conversation version 0 (Ancienne)",
+ "tr": "Konuşma API'si Sürüm 0 (Eski)",
+ "de": "Konversations-API Version 0 (Legacy)",
+ "uk": "API розмови версія 0 (Застаріла)"
+ },
+ "CONVERSATION$ERROR_STARTING_CONVERSATION": {
+ "en": "Error starting conversation",
+ "ja": "会話の開始エラー",
+ "zh-CN": "启动对话时出错",
+ "zh-TW": "啟動對話時出錯",
+ "ko-KR": "대화 시작 오류",
+ "no": "Feil ved oppstart av samtale",
+ "it": "Errore nell'avvio della conversazione",
+ "pt": "Erro ao iniciar conversa",
+ "es": "Error al iniciar la conversación",
+ "ar": "خطأ في بدء المحادثة",
+ "fr": "Erreur lors du démarrage de la conversation",
+ "tr": "Konuşma başlatılırken hata",
+ "de": "Fehler beim Starten der Konversation",
+ "uk": "Помилка запуску розмови"
+ },
+ "CONVERSATION$READY": {
+ "en": "Ready",
+ "ja": "準備完了",
+ "zh-CN": "就绪",
+ "zh-TW": "就緒",
+ "ko-KR": "준비됨",
+ "no": "Klar",
+ "it": "Pronto",
+ "pt": "Pronto",
+ "es": "Listo",
+ "ar": "جاهز",
+ "fr": "Prêt",
+ "tr": "Hazır",
+ "de": "Bereit",
+ "uk": "Готово"
+ },
+ "CONVERSATION$STARTING_CONVERSATION": {
+ "en": "Starting conversation...",
+ "ja": "会話を開始しています...",
+ "zh-CN": "正在启动对话...",
+ "zh-TW": "正在啟動對話...",
+ "ko-KR": "대화 시작 중...",
+ "no": "Starter samtale...",
+ "it": "Avvio della conversazione...",
+ "pt": "Iniciando conversa...",
+ "es": "Iniciando conversación...",
+ "ar": "بدء المحادثة...",
+ "fr": "Démarrage de la conversation...",
+ "tr": "Konuşma başlatılıyor...",
+ "de": "Konversation wird gestartet...",
+ "uk": "Запуск розмови..."
+ },
+ "CONVERSATION$FAILED_TO_START_FROM_TASK": {
+ "en": "Failed to start the conversation from task.",
+ "ja": "タスクから会話を開始できませんでした。",
+ "zh-CN": "无法从任务启动对话。",
+ "zh-TW": "無法從任務啟動對話。",
+ "ko-KR": "작업에서 대화를 시작하지 못했습니다.",
+ "no": "Kunne ikke starte samtalen fra oppgave.",
+ "it": "Impossibile avviare la conversazione dall'attività.",
+ "pt": "Falha ao iniciar a conversa da tarefa.",
+ "es": "No se pudo iniciar la conversación desde la tarea.",
+ "ar": "فشل بدء المحادثة من المهمة.",
+ "fr": "Échec du démarrage de la conversation depuis la tâche.",
+ "tr": "Görevden konuşma başlatılamadı.",
+ "de": "Konversation konnte nicht aus Aufgabe gestartet werden.",
+ "uk": "Не вдалося запустити розмову із завдання."
+ },
+ "CONVERSATION$NOT_EXIST_OR_NO_PERMISSION": {
+ "en": "This conversation does not exist, or you do not have permission to access it.",
+ "ja": "この会話は存在しないか、アクセスする権限がありません。",
+ "zh-CN": "此对话不存在,或您没有访问权限。",
+ "zh-TW": "此對話不存在,或您沒有訪問權限。",
+ "ko-KR": "이 대화가 존재하지 않거나 액세스 권한이 없습니다.",
+ "no": "Denne samtalen eksisterer ikke, eller du har ikke tillatelse til å få tilgang til den.",
+ "it": "Questa conversazione non esiste o non hai il permesso di accedervi.",
+ "pt": "Esta conversa não existe ou você não tem permissão para acessá-la.",
+ "es": "Esta conversación no existe o no tienes permiso para acceder a ella.",
+ "ar": "هذه المحادثة غير موجودة أو ليس لديك إذن للوصول إليها.",
+ "fr": "Cette conversation n'existe pas ou vous n'avez pas la permission d'y accéder.",
+ "tr": "Bu konuşma mevcut değil veya erişim izniniz yok.",
+ "de": "Diese Konversation existiert nicht oder Sie haben keine Berechtigung darauf zuzugreifen.",
+ "uk": "Ця розмова не існує або у вас немає дозволу на доступ до неї."
+ },
+ "CONVERSATION$FAILED_TO_START_WITH_ERROR": {
+ "en": "Failed to start conversation: {{error}}",
+ "ja": "会話の開始に失敗しました: {{error}}",
+ "zh-CN": "启动对话失败:{{error}}",
+ "zh-TW": "啟動對話失敗:{{error}}",
+ "ko-KR": "대화 시작 실패: {{error}}",
+ "no": "Kunne ikke starte samtale: {{error}}",
+ "it": "Impossibile avviare la conversazione: {{error}}",
+ "pt": "Falha ao iniciar conversa: {{error}}",
+ "es": "No se pudo iniciar la conversación: {{error}}",
+ "ar": "فشل بدء المحادثة: {{error}}",
+ "fr": "Échec du démarrage de la conversation : {{error}}",
+ "tr": "Konuşma başlatılamadı: {{error}}",
+ "de": "Konversation konnte nicht gestartet werden: {{error}}",
+ "uk": "Не вдалося запустити розмову: {{error}}"
+ },
+ "TOAST$STARTING_CONVERSATION": {
+ "en": "Starting conversation...",
+ "ja": "会話を開始しています...",
+ "zh-CN": "正在启动对话...",
+ "zh-TW": "正在啟動對話...",
+ "ko-KR": "대화 시작 중...",
+ "no": "Starter samtale...",
+ "it": "Avvio della conversazione...",
+ "pt": "Iniciando conversa...",
+ "es": "Iniciando conversación...",
+ "ar": "بدء المحادثة...",
+ "fr": "Démarrage de la conversation...",
+ "tr": "Konuşma başlatılıyor...",
+ "de": "Konversation wird gestartet...",
+ "uk": "Запуск розмови..."
+ },
+ "TOAST$FAILED_TO_START_CONVERSATION": {
+ "en": "Failed to start conversation",
+ "ja": "会話の開始に失敗しました",
+ "zh-CN": "启动对话失败",
+ "zh-TW": "啟動對話失敗",
+ "ko-KR": "대화 시작 실패",
+ "no": "Kunne ikke starte samtale",
+ "it": "Impossibile avviare la conversazione",
+ "pt": "Falha ao iniciar conversa",
+ "es": "No se pudo iniciar la conversación",
+ "ar": "فشل بدء المحادثة",
+ "fr": "Échec du démarrage de la conversation",
+ "tr": "Konuşma başlatılamadı",
+ "de": "Konversation konnte nicht gestartet werden",
+ "uk": "Не вдалося запустити розмову"
+ },
+ "TOAST$CONVERSATION_STARTED": {
+ "en": "Conversation started",
+ "ja": "会話が開始されました",
+ "zh-CN": "对话已启动",
+ "zh-TW": "對話已啟動",
+ "ko-KR": "대화가 시작되었습니다",
+ "no": "Samtale startet",
+ "it": "Conversazione avviata",
+ "pt": "Conversa iniciada",
+ "es": "Conversación iniciada",
+ "ar": "بدأت المحادثة",
+ "fr": "Conversation démarrée",
+ "tr": "Konuşma başlatıldı",
+ "de": "Konversation gestartet",
+ "uk": "Розмову запущено"
+ },
+ "TOAST$STOPPING_CONVERSATION": {
+ "en": "Stopping conversation...",
+ "ja": "会話を停止しています...",
+ "zh-CN": "正在停止对话...",
+ "zh-TW": "正在停止對話...",
+ "ko-KR": "대화 중지 중...",
+ "no": "Stopper samtale...",
+ "it": "Arresto della conversazione...",
+ "pt": "Parando conversa...",
+ "es": "Deteniendo conversación...",
+ "ar": "إيقاف المحادثة...",
+ "fr": "Arrêt de la conversation...",
+ "tr": "Konuşma durduruluyor...",
+ "de": "Konversation wird gestoppt...",
+ "uk": "Зупинка розмови..."
+ },
+ "TOAST$FAILED_TO_STOP_CONVERSATION": {
+ "en": "Failed to stop conversation",
+ "ja": "会話の停止に失敗しました",
+ "zh-CN": "停止对话失败",
+ "zh-TW": "停止對話失敗",
+ "ko-KR": "대화 중지 실패",
+ "no": "Kunne ikke stoppe samtale",
+ "it": "Impossibile arrestare la conversazione",
+ "pt": "Falha ao parar conversa",
+ "es": "No se pudo detener la conversación",
+ "ar": "فشل إيقاف المحادثة",
+ "fr": "Échec de l'arrêt de la conversation",
+ "tr": "Konuşma durdurulamadı",
+ "de": "Konversation konnte nicht gestoppt werden",
+ "uk": "Не вдалося зупинити розмову"
+ },
+ "TOAST$CONVERSATION_STOPPED": {
+ "en": "Conversation stopped",
+ "ja": "会話が停止されました",
+ "zh-CN": "对话已停止",
+ "zh-TW": "對話已停止",
+ "ko-KR": "대화가 중지되었습니다",
+ "no": "Samtale stoppet",
+ "it": "Conversazione arrestata",
+ "pt": "Conversa parada",
+ "es": "Conversación detenida",
+ "ar": "توقفت المحادثة",
+ "fr": "Conversation arrêtée",
+ "tr": "Konuşma durduruldu",
+ "de": "Konversation gestoppt",
+ "uk": "Розмову зупинено"
}
}
diff --git a/frontend/src/mocks/mock-ws-helpers.ts b/frontend/src/mocks/mock-ws-helpers.ts
index 7bf575eb25..c3205b7731 100644
--- a/frontend/src/mocks/mock-ws-helpers.ts
+++ b/frontend/src/mocks/mock-ws-helpers.ts
@@ -128,3 +128,59 @@ export const createMockAgentErrorEvent = (
error: "Failed to execute command: Permission denied",
...overrides,
});
+
+/**
+ * Creates a mock ExecuteBashAction event for testing terminal command handling
+ */
+export const createMockExecuteBashActionEvent = (
+ command: string = "ls -la",
+) => ({
+ id: "bash-action-123",
+ timestamp: new Date().toISOString(),
+ source: "agent",
+ thought: [{ type: "text", text: "Executing bash command" }],
+ thinking_blocks: [],
+ action: {
+ kind: "ExecuteBashAction",
+ command,
+ is_input: false,
+ timeout: null,
+ reset: false,
+ },
+ tool_name: "ExecuteBashAction",
+ tool_call_id: "bash-call-456",
+ tool_call: {
+ id: "bash-call-456",
+ type: "function",
+ function: {
+ name: "ExecuteBashAction",
+ arguments: JSON.stringify({ command }),
+ },
+ },
+ llm_response_id: "llm-response-789",
+ security_risk: { level: "low" },
+});
+
+/**
+ * Creates a mock ExecuteBashObservation event for testing terminal output handling
+ */
+export const createMockExecuteBashObservationEvent = (
+ output: string = "total 24\ndrwxr-xr-x 5 user staff 160 Jan 10 12:00 .",
+ command: string = "ls -la",
+) => ({
+ id: "bash-obs-123",
+ timestamp: new Date().toISOString(),
+ source: "environment",
+ tool_name: "ExecuteBashAction",
+ tool_call_id: "bash-call-456",
+ observation: {
+ kind: "ExecuteBashObservation",
+ output,
+ command,
+ exit_code: 0,
+ error: false,
+ timeout: false,
+ metadata: { cwd: "/home/user" },
+ },
+ action_id: "bash-action-123",
+});
diff --git a/frontend/src/routes/changes-tab.tsx b/frontend/src/routes/changes-tab.tsx
index 325b23c513..620e179389 100644
--- a/frontend/src/routes/changes-tab.tsx
+++ b/frontend/src/routes/changes-tab.tsx
@@ -6,7 +6,7 @@ import { useGetGitChanges } from "#/hooks/query/use-get-git-changes";
import { I18nKey } from "#/i18n/declaration";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { RandomTip } from "#/components/features/tips/random-tip";
-import { useAgentStore } from "#/stores/agent-store";
+import { useAgentState } from "#/hooks/use-agent-state";
// Error message patterns
const GIT_REPO_ERROR_PATTERN = /not a git repository/i;
@@ -33,7 +33,7 @@ function GitChanges() {
null,
);
- const { curAgentState } = useAgentStore();
+ const { curAgentState } = useAgentState();
const runtimeIsActive = !RUNTIME_INACTIVE_STATES.includes(curAgentState);
const isNotGitRepoError =
diff --git a/frontend/src/routes/conversation.tsx b/frontend/src/routes/conversation.tsx
index 4952ff225e..1d1b2cbb5e 100644
--- a/frontend/src/routes/conversation.tsx
+++ b/frontend/src/routes/conversation.tsx
@@ -1,10 +1,9 @@
import React from "react";
import { useNavigate } from "react-router";
-import { useQueryClient } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useCommandStore } from "#/state/command-store";
-import { useEffectOnce } from "#/hooks/use-effect-once";
import { useJupyterStore } from "#/state/jupyter-store";
import { useConversationStore } from "#/state/conversation-store";
import { useAgentStore } from "#/stores/agent-store";
@@ -15,6 +14,7 @@ import { EventHandler } from "../wrapper/event-handler";
import { useConversationConfig } from "#/hooks/query/use-conversation-config";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
+import { useTaskPolling } from "#/hooks/query/use-task-polling";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { useDocumentTitleFromState } from "#/hooks/use-document-title-from-state";
@@ -26,15 +26,23 @@ import { ConversationMain } from "#/components/features/conversation/conversatio
import { ConversationName } from "#/components/features/conversation/conversation-name";
import { ConversationTabs } from "#/components/features/conversation/conversation-tabs/conversation-tabs";
-import { useStartConversation } from "#/hooks/mutation/use-start-conversation";
import { WebSocketProviderWrapper } from "#/contexts/websocket-provider-wrapper";
+import { useErrorMessageStore } from "#/stores/error-message-store";
+import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation";
+import { I18nKey } from "#/i18n/declaration";
function AppContent() {
useConversationConfig();
+ const { t } = useTranslation();
const { conversationId } = useConversationId();
+
+ // Handle both task IDs (task-{uuid}) and regular conversation IDs
+ const { isTask, taskStatus, taskDetail } = useTaskPolling();
+
const { data: conversation, isFetched, refetch } = useActiveConversation();
- const { mutate: startConversation } = useStartConversation();
+ const { mutate: startConversation, isPending: isStarting } =
+ useUnifiedResumeConversationSandbox();
const { data: isAuthed } = useIsAuthed();
const { providers } = useUserProviders();
const { resetConversationState } = useConversationStore();
@@ -44,7 +52,12 @@ function AppContent() {
(state) => state.setCurrentAgentState,
);
const clearJupyter = useJupyterStore((state) => state.clearJupyter);
- const queryClient = useQueryClient();
+ const removeErrorMessage = useErrorMessageStore(
+ (state) => state.removeErrorMessage,
+ );
+
+ // Track which conversation ID we've auto-started to prevent auto-restart after manual stop
+ const processedConversationId = React.useRef(null);
// Fetch batch feedback data when conversation is loaded
useBatchFeedback();
@@ -52,76 +65,123 @@ function AppContent() {
// Set the document title to the conversation title when available
useDocumentTitleFromState();
- // Force fresh conversation data when navigating to prevent stale cache issues
+ // 1. Cleanup Effect - runs when navigating to a different conversation
React.useEffect(() => {
- queryClient.invalidateQueries({
- queryKey: ["user", "conversation", conversationId],
- });
- }, [conversationId, queryClient]);
+ clearTerminal();
+ clearJupyter();
+ resetConversationState();
+ setCurrentAgentState(AgentState.LOADING);
+ removeErrorMessage();
+ // Reset tracking ONLY if we're navigating to a DIFFERENT conversation
+ // Don't reset on StrictMode remounts (conversationId is the same)
+ if (processedConversationId.current !== conversationId) {
+ processedConversationId.current = null;
+ }
+ }, [
+ conversationId,
+ clearTerminal,
+ clearJupyter,
+ resetConversationState,
+ setCurrentAgentState,
+ removeErrorMessage,
+ ]);
+
+ // 2. Task Error Display Effect
React.useEffect(() => {
- if (isFetched && !conversation && isAuthed) {
+ if (isTask && taskStatus === "ERROR") {
displayErrorToast(
- "This conversation does not exist, or you do not have permission to access it.",
+ taskDetail || t(I18nKey.CONVERSATION$FAILED_TO_START_FROM_TASK),
);
+ }
+ }, [isTask, taskStatus, taskDetail, t]);
+
+ // 3. Auto-start Effect - handles conversation not found and auto-starting STOPPED conversations
+ React.useEffect(() => {
+ // Wait for data to be fetched
+ if (!isFetched || !isAuthed) return;
+
+ // Handle conversation not found
+ if (!conversation) {
+ displayErrorToast(t(I18nKey.CONVERSATION$NOT_EXIST_OR_NO_PERMISSION));
navigate("/");
- } else if (conversation?.status === "STOPPED") {
- // If conversation is STOPPED, attempt to start it
+ return;
+ }
+
+ const currentConversationId = conversation.conversation_id;
+ const currentStatus = conversation.status;
+
+ // Skip if we've already processed this conversation
+ if (processedConversationId.current === currentConversationId) {
+ return;
+ }
+
+ // Mark as processed immediately to prevent duplicate calls
+ processedConversationId.current = currentConversationId;
+
+ // Auto-start STOPPED conversations on initial load only
+ if (currentStatus === "STOPPED" && !isStarting) {
startConversation(
- { conversationId: conversation.conversation_id, providers },
+ { conversationId: currentConversationId, providers },
{
onError: (error) => {
- displayErrorToast(`Failed to start conversation: ${error.message}`);
- // Refetch the conversation to ensure UI consistency
+ displayErrorToast(
+ t(I18nKey.CONVERSATION$FAILED_TO_START_WITH_ERROR, {
+ error: error.message,
+ }),
+ );
refetch();
},
},
);
}
+ // NOTE: conversation?.status is intentionally NOT in dependencies
+ // We only want to run when conversation ID changes, not when status changes
+ // This prevents duplicate calls when stale cache data is replaced with fresh data
}, [
conversation?.conversation_id,
- conversation?.status,
isFetched,
isAuthed,
+ isStarting,
providers,
+ startConversation,
+ navigate,
+ refetch,
+ t,
]);
- React.useEffect(() => {
- clearTerminal();
- clearJupyter();
- resetConversationState();
- setCurrentAgentState(AgentState.LOADING);
- }, [
- conversationId,
- clearTerminal,
- setCurrentAgentState,
- resetConversationState,
- ]);
+ const isV1Conversation = conversation?.conversation_version === "V1";
- useEffectOnce(() => {
- clearTerminal();
- clearJupyter();
- resetConversationState();
- setCurrentAgentState(AgentState.LOADING);
- });
+ const content = (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ // Wait for conversation data to load before rendering WebSocket provider
+ // This prevents the provider from unmounting/remounting when version changes from 0 to 1
+ if (!conversation) {
+ return content;
+ }
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ {content}
);
}
diff --git a/frontend/src/routes/vscode-tab.tsx b/frontend/src/routes/vscode-tab.tsx
index 6be68d108a..fe72079e6f 100644
--- a/frontend/src/routes/vscode-tab.tsx
+++ b/frontend/src/routes/vscode-tab.tsx
@@ -5,12 +5,12 @@ import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { useVSCodeUrl } from "#/hooks/query/use-vscode-url";
import { VSCODE_IN_NEW_TAB } from "#/utils/feature-flags";
import { WaitingForRuntimeMessage } from "#/components/features/chat/waiting-for-runtime-message";
-import { useAgentStore } from "#/stores/agent-store";
+import { useAgentState } from "#/hooks/use-agent-state";
function VSCodeTab() {
const { t } = useTranslation();
const { data, isLoading, error } = useVSCodeUrl();
- const { curAgentState } = useAgentStore();
+ const { curAgentState } = useAgentState();
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
const iframeRef = React.useRef(null);
const [isCrossProtocol, setIsCrossProtocol] = useState(false);
diff --git a/frontend/src/stores/v1-conversation-state-store.ts b/frontend/src/stores/v1-conversation-state-store.ts
new file mode 100644
index 0000000000..8c6478cc48
--- /dev/null
+++ b/frontend/src/stores/v1-conversation-state-store.ts
@@ -0,0 +1,26 @@
+import { create } from "zustand";
+import { V1AgentStatus } from "#/types/v1/core/base/common";
+
+interface V1ConversationStateStore {
+ agent_status: V1AgentStatus | null;
+
+ /**
+ * Set the agent status
+ */
+ setAgentStatus: (agent_status: V1AgentStatus) => void;
+
+ /**
+ * Reset the store to initial state
+ */
+ reset: () => void;
+}
+
+export const useV1ConversationStateStore = create(
+ (set) => ({
+ agent_status: null,
+
+ setAgentStatus: (agent_status: V1AgentStatus) => set({ agent_status }),
+
+ reset: () => set({ agent_status: null }),
+ }),
+);
diff --git a/frontend/src/types/v1/core/base/action.ts b/frontend/src/types/v1/core/base/action.ts
index 3147332696..ce08d5a1b9 100644
--- a/frontend/src/types/v1/core/base/action.ts
+++ b/frontend/src/types/v1/core/base/action.ts
@@ -1,28 +1,28 @@
import { ActionBase } from "./base";
import { TaskItem } from "./common";
-interface MCPToolAction extends ActionBase<"MCPToolAction"> {
+export interface MCPToolAction extends ActionBase<"MCPToolAction"> {
/**
* Dynamic data fields from the tool call
*/
data: Record;
}
-interface FinishAction extends ActionBase<"FinishAction"> {
+export interface FinishAction extends ActionBase<"FinishAction"> {
/**
* Final message to send to the user
*/
message: string;
}
-interface ThinkAction extends ActionBase<"ThinkAction"> {
+export interface ThinkAction extends ActionBase<"ThinkAction"> {
/**
* The thought to log
*/
thought: string;
}
-interface ExecuteBashAction extends ActionBase<"ExecuteBashAction"> {
+export interface ExecuteBashAction extends ActionBase<"ExecuteBashAction"> {
/**
* The bash command to execute. Can be empty string to view additional logs when previous exit code is `-1`. Can be `C-c` (Ctrl+C) to interrupt the currently running process.
*/
@@ -41,7 +41,7 @@ interface ExecuteBashAction extends ActionBase<"ExecuteBashAction"> {
reset: boolean;
}
-interface StrReplaceEditorAction extends ActionBase<"StrReplaceEditorAction"> {
+export interface FileEditorAction extends ActionBase<"FileEditorAction"> {
/**
* The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.
*/
@@ -72,7 +72,39 @@ interface StrReplaceEditorAction extends ActionBase<"StrReplaceEditorAction"> {
view_range: [number, number] | null;
}
-interface TaskTrackerAction extends ActionBase<"TaskTrackerAction"> {
+export interface StrReplaceEditorAction
+ extends ActionBase<"StrReplaceEditorAction"> {
+ /**
+ * The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.
+ */
+ command: "view" | "create" | "str_replace" | "insert" | "undo_edit";
+ /**
+ * Absolute path to file or directory.
+ */
+ path: string;
+ /**
+ * Required parameter of `create` command, with the content of the file to be created.
+ */
+ file_text: string | null;
+ /**
+ * Required parameter of `str_replace` command containing the string in `path` to replace.
+ */
+ old_str: string | null;
+ /**
+ * Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert.
+ */
+ new_str: string | null;
+ /**
+ * Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`. Must be >= 1.
+ */
+ insert_line: number | null;
+ /**
+ * Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.
+ */
+ view_range: [number, number] | null;
+}
+
+export interface TaskTrackerAction extends ActionBase<"TaskTrackerAction"> {
/**
* The command to execute. `view` shows the current task list. `plan` creates or updates the task list based on provided requirements and progress. Always `view` the current list before making changes.
*/
@@ -83,7 +115,8 @@ interface TaskTrackerAction extends ActionBase<"TaskTrackerAction"> {
task_list: TaskItem[];
}
-interface BrowserNavigateAction extends ActionBase<"BrowserNavigateAction"> {
+export interface BrowserNavigateAction
+ extends ActionBase<"BrowserNavigateAction"> {
/**
* The URL to navigate to
*/
@@ -94,7 +127,7 @@ interface BrowserNavigateAction extends ActionBase<"BrowserNavigateAction"> {
new_tab: boolean;
}
-interface BrowserClickAction extends ActionBase<"BrowserClickAction"> {
+export interface BrowserClickAction extends ActionBase<"BrowserClickAction"> {
/**
* The index of the element to click (from browser_get_state)
*/
@@ -105,7 +138,7 @@ interface BrowserClickAction extends ActionBase<"BrowserClickAction"> {
new_tab: boolean;
}
-interface BrowserTypeAction extends ActionBase<"BrowserTypeAction"> {
+export interface BrowserTypeAction extends ActionBase<"BrowserTypeAction"> {
/**
* The index of the input element (from browser_get_state)
*/
@@ -116,14 +149,15 @@ interface BrowserTypeAction extends ActionBase<"BrowserTypeAction"> {
text: string;
}
-interface BrowserGetStateAction extends ActionBase<"BrowserGetStateAction"> {
+export interface BrowserGetStateAction
+ extends ActionBase<"BrowserGetStateAction"> {
/**
* Whether to include a screenshot of the current page. Default: False
*/
include_screenshot: boolean;
}
-interface BrowserGetContentAction
+export interface BrowserGetContentAction
extends ActionBase<"BrowserGetContentAction"> {
/**
* Whether to include links in the content (default: False)
@@ -135,29 +169,32 @@ interface BrowserGetContentAction
start_from_char: number;
}
-interface BrowserScrollAction extends ActionBase<"BrowserScrollAction"> {
+export interface BrowserScrollAction extends ActionBase<"BrowserScrollAction"> {
/**
* Direction to scroll. Options: 'up', 'down'. Default: 'down'
*/
direction: "up" | "down";
}
-interface BrowserGoBackAction extends ActionBase<"BrowserGoBackAction"> {
+export interface BrowserGoBackAction extends ActionBase<"BrowserGoBackAction"> {
// No additional properties - this action has no parameters
}
-interface BrowserListTabsAction extends ActionBase<"BrowserListTabsAction"> {
+export interface BrowserListTabsAction
+ extends ActionBase<"BrowserListTabsAction"> {
// No additional properties - this action has no parameters
}
-interface BrowserSwitchTabAction extends ActionBase<"BrowserSwitchTabAction"> {
+export interface BrowserSwitchTabAction
+ extends ActionBase<"BrowserSwitchTabAction"> {
/**
* 4 Character Tab ID of the tab to switch to (from browser_list_tabs)
*/
tab_id: string;
}
-interface BrowserCloseTabAction extends ActionBase<"BrowserCloseTabAction"> {
+export interface BrowserCloseTabAction
+ extends ActionBase<"BrowserCloseTabAction"> {
/**
* 4 Character Tab ID of the tab to close (from browser_list_tabs)
*/
@@ -169,6 +206,7 @@ export type Action =
| FinishAction
| ThinkAction
| ExecuteBashAction
+ | FileEditorAction
| StrReplaceEditorAction
| TaskTrackerAction
| BrowserNavigateAction
diff --git a/frontend/src/types/v1/core/base/base.ts b/frontend/src/types/v1/core/base/base.ts
index e7e806ae39..5925e8599d 100644
--- a/frontend/src/types/v1/core/base/base.ts
+++ b/frontend/src/types/v1/core/base/base.ts
@@ -3,6 +3,7 @@ type EventType =
| "Finish"
| "Think"
| "ExecuteBash"
+ | "FileEditor"
| "StrReplaceEditor"
| "TaskTracker";
diff --git a/frontend/src/types/v1/core/base/common.ts b/frontend/src/types/v1/core/base/common.ts
index 2215764cc0..ae151286d1 100644
--- a/frontend/src/types/v1/core/base/common.ts
+++ b/frontend/src/types/v1/core/base/common.ts
@@ -63,6 +63,17 @@ export enum SecurityRisk {
HIGH = "HIGH",
}
+// Agent status
+export enum V1AgentStatus {
+ IDLE = "idle",
+ RUNNING = "running",
+ PAUSED = "paused",
+ WAITING_FOR_CONFIRMATION = "waiting_for_confirmation",
+ FINISHED = "finished",
+ ERROR = "error",
+ STUCK = "stuck",
+}
+
// Content types for LLM messages
export interface TextContent {
type: "text";
diff --git a/frontend/src/types/v1/core/base/observation.ts b/frontend/src/types/v1/core/base/observation.ts
index df4ab5c88c..5433087548 100644
--- a/frontend/src/types/v1/core/base/observation.ts
+++ b/frontend/src/types/v1/core/base/observation.ts
@@ -6,7 +6,8 @@ import {
ImageContent,
} from "./common";
-interface MCPToolObservation extends ObservationBase<"MCPToolObservation"> {
+export interface MCPToolObservation
+ extends ObservationBase<"MCPToolObservation"> {
/**
* Content returned from the MCP tool converted to LLM Ready TextContent or ImageContent
*/
@@ -21,21 +22,23 @@ interface MCPToolObservation extends ObservationBase<"MCPToolObservation"> {
tool_name: string;
}
-interface FinishObservation extends ObservationBase<"FinishObservation"> {
+export interface FinishObservation
+ extends ObservationBase<"FinishObservation"> {
/**
* Final message sent to the user
*/
message: string;
}
-interface ThinkObservation extends ObservationBase<"ThinkObservation"> {
+export interface ThinkObservation extends ObservationBase<"ThinkObservation"> {
/**
* Confirmation message. DEFAULT: "Your thought has been logged."
*/
content: string;
}
-interface BrowserObservation extends ObservationBase<"BrowserObservation"> {
+export interface BrowserObservation
+ extends ObservationBase<"BrowserObservation"> {
/**
* The output message from the browser operation
*/
@@ -50,7 +53,7 @@ interface BrowserObservation extends ObservationBase<"BrowserObservation"> {
screenshot_data: string | null;
}
-interface ExecuteBashObservation
+export interface ExecuteBashObservation
extends ObservationBase<"ExecuteBashObservation"> {
/**
* The raw output from the tool.
@@ -78,7 +81,40 @@ interface ExecuteBashObservation
metadata: CmdOutputMetadata;
}
-interface StrReplaceEditorObservation
+export interface FileEditorObservation
+ extends ObservationBase<"FileEditorObservation"> {
+ /**
+ * The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.
+ */
+ command: "view" | "create" | "str_replace" | "insert" | "undo_edit";
+ /**
+ * The output message from the tool for the LLM to see.
+ */
+ output: string;
+ /**
+ * The file path that was edited.
+ */
+ path: string | null;
+ /**
+ * Indicates if the file previously existed. If not, it was created.
+ */
+ prev_exist: boolean;
+ /**
+ * The content of the file before the edit.
+ */
+ old_content: string | null;
+ /**
+ * The content of the file after the edit.
+ */
+ new_content: string | null;
+ /**
+ * Error message if any.
+ */
+ error: string | null;
+}
+
+// Keep StrReplaceEditorObservation as a separate interface for backward compatibility
+export interface StrReplaceEditorObservation
extends ObservationBase<"StrReplaceEditorObservation"> {
/**
* The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.
@@ -110,7 +146,7 @@ interface StrReplaceEditorObservation
error: string | null;
}
-interface TaskTrackerObservation
+export interface TaskTrackerObservation
extends ObservationBase<"TaskTrackerObservation"> {
/**
* The formatted task list or status message.
@@ -132,5 +168,6 @@ export type Observation =
| ThinkObservation
| BrowserObservation
| ExecuteBashObservation
+ | FileEditorObservation
| StrReplaceEditorObservation
| TaskTrackerObservation;
diff --git a/frontend/src/types/v1/core/events/action-event.ts b/frontend/src/types/v1/core/events/action-event.ts
index 0c5e30c81f..33d7ce647c 100644
--- a/frontend/src/types/v1/core/events/action-event.ts
+++ b/frontend/src/types/v1/core/events/action-event.ts
@@ -7,7 +7,7 @@ import {
RedactedThinkingBlock,
} from "../base/event";
-export interface ActionEvent extends BaseEvent {
+export interface ActionEvent extends BaseEvent {
/**
* The thought process of the agent before taking this action
*/
@@ -26,7 +26,7 @@ export interface ActionEvent extends BaseEvent {
/**
* Single action (tool call) returned by LLM
*/
- action: Action;
+ action: T;
/**
* The name of the tool being called
diff --git a/frontend/src/types/v1/core/events/conversation-state-event.ts b/frontend/src/types/v1/core/events/conversation-state-event.ts
index 9e52cbc633..b7d74c0dec 100644
--- a/frontend/src/types/v1/core/events/conversation-state-event.ts
+++ b/frontend/src/types/v1/core/events/conversation-state-event.ts
@@ -1,7 +1,15 @@
import { BaseEvent } from "../base/event";
+import { V1AgentStatus } from "../base/common";
-// Conversation state update event - contains conversation state updates
-export interface ConversationStateUpdateEvent extends BaseEvent {
+/**
+ * Conversation state value types
+ */
+export interface ConversationState {
+ agent_status: V1AgentStatus;
+ // Add other conversation state fields here as needed
+}
+
+interface ConversationStateUpdateEventBase extends BaseEvent {
/**
* The source is always "environment" for conversation state update events
*/
@@ -11,12 +19,29 @@ export interface ConversationStateUpdateEvent extends BaseEvent {
* Unique key for this state update event.
* Can be "full_state" for full state snapshots or field names for partial updates.
*/
- key: string;
+ key: "full_state" | "agent_status"; // Extend with other keys as needed
/**
- * Serialized conversation state updates.
- * For "full_state" key, this contains the complete conversation state.
- * For field-specific keys, this contains the updated field value.
+ * Conversation state updates
*/
- value: unknown;
+ value: ConversationState | V1AgentStatus;
}
+
+// Narrowed interfaces for full state update event
+export interface ConversationStateUpdateEventFullState
+ extends ConversationStateUpdateEventBase {
+ key: "full_state";
+ value: ConversationState;
+}
+
+// Narrowed interface for agent status update event
+export interface ConversationStateUpdateEventAgentStatus
+ extends ConversationStateUpdateEventBase {
+ key: "agent_status";
+ value: V1AgentStatus;
+}
+
+// Conversation state update event - contains conversation state updates
+export type ConversationStateUpdateEvent =
+ | ConversationStateUpdateEventFullState
+ | ConversationStateUpdateEventAgentStatus;
diff --git a/frontend/src/types/v1/core/events/observation-event.ts b/frontend/src/types/v1/core/events/observation-event.ts
index 011108372e..62750d7289 100644
--- a/frontend/src/types/v1/core/events/observation-event.ts
+++ b/frontend/src/types/v1/core/events/observation-event.ts
@@ -21,11 +21,12 @@ export interface ObservationBaseEvent extends BaseEvent {
}
// Main observation event interface
-export interface ObservationEvent extends ObservationBaseEvent {
+export interface ObservationEvent
+ extends ObservationBaseEvent {
/**
* The observation (tool call) sent to LLM
*/
- observation: Observation;
+ observation: T;
/**
* The action id that this observation is responding to
diff --git a/frontend/src/types/v1/type-guards.ts b/frontend/src/types/v1/type-guards.ts
index de3c9b45f3..1d3973cfa6 100644
--- a/frontend/src/types/v1/type-guards.ts
+++ b/frontend/src/types/v1/type-guards.ts
@@ -1,7 +1,18 @@
-import { OpenHandsEvent, ObservationEvent, BaseEvent } from "./core";
+import {
+ OpenHandsEvent,
+ ObservationEvent,
+ BaseEvent,
+ ExecuteBashAction,
+ ExecuteBashObservation,
+} from "./core";
import { AgentErrorEvent } from "./core/events/observation-event";
import { MessageEvent } from "./core/events/message-event";
import { ActionEvent } from "./core/events/action-event";
+import {
+ ConversationStateUpdateEvent,
+ ConversationStateUpdateEventAgentStatus,
+ ConversationStateUpdateEventFullState,
+} from "./core/events/conversation-state-event";
import type { OpenHandsParsedEvent } from "../core/index";
/**
@@ -51,17 +62,23 @@ export const isAgentErrorEvent = (
typeof event.tool_call_id === "string" &&
typeof event.error === "string";
+/**
+ * Type guard function to check if an event is a message event (user or assistant)
+ */
+export const isMessageEvent = (event: OpenHandsEvent): event is MessageEvent =>
+ "llm_message" in event &&
+ typeof event.llm_message === "object" &&
+ event.llm_message !== null &&
+ "role" in event.llm_message &&
+ "content" in event.llm_message;
+
/**
* Type guard function to check if an event is a user message event
*/
export const isUserMessageEvent = (
event: OpenHandsEvent,
): event is MessageEvent =>
- "llm_message" in event &&
- typeof event.llm_message === "object" &&
- event.llm_message !== null &&
- "role" in event.llm_message &&
- event.llm_message.role === "user";
+ isMessageEvent(event) && event.llm_message.role === "user";
/**
* Type guard function to check if an event is an action event
@@ -74,6 +91,40 @@ export const isActionEvent = (event: OpenHandsEvent): event is ActionEvent =>
typeof event.tool_name === "string" &&
typeof event.tool_call_id === "string";
+/**
+ * Type guard function to check if an action event is an ExecuteBashAction
+ */
+export const isExecuteBashActionEvent = (
+ event: OpenHandsEvent,
+): event is ActionEvent =>
+ isActionEvent(event) && event.action.kind === "ExecuteBashAction";
+
+/**
+ * Type guard function to check if an observation event is an ExecuteBashObservation
+ */
+export const isExecuteBashObservationEvent = (
+ event: OpenHandsEvent,
+): event is ObservationEvent =>
+ isObservationEvent(event) &&
+ event.observation.kind === "ExecuteBashObservation";
+
+/**
+ * Type guard function to check if an event is a conversation state update event
+ */
+export const isConversationStateUpdateEvent = (
+ event: OpenHandsEvent,
+): event is ConversationStateUpdateEvent =>
+ "kind" in event && event.kind === "ConversationStateUpdateEvent";
+
+export const isFullStateConversationStateUpdateEvent = (
+ event: ConversationStateUpdateEvent,
+): event is ConversationStateUpdateEventFullState => event.key === "full_state";
+
+export const isAgentStatusConversationStateUpdateEvent = (
+ event: ConversationStateUpdateEvent,
+): event is ConversationStateUpdateEventAgentStatus =>
+ event.key === "agent_status";
+
// =============================================================================
// TEMPORARY COMPATIBILITY TYPE GUARDS
// These will be removed once we fully migrate to V1 events
diff --git a/frontend/src/utils/feature-flags.ts b/frontend/src/utils/feature-flags.ts
index db98c2d221..c97c06fcfd 100644
--- a/frontend/src/utils/feature-flags.ts
+++ b/frontend/src/utils/feature-flags.ts
@@ -17,3 +17,5 @@ export const HIDE_LLM_SETTINGS = () => loadFeatureFlag("HIDE_LLM_SETTINGS");
export const VSCODE_IN_NEW_TAB = () => loadFeatureFlag("VSCODE_IN_NEW_TAB");
export const ENABLE_TRAJECTORY_REPLAY = () =>
loadFeatureFlag("TRAJECTORY_REPLAY");
+export const USE_V1_CONVERSATION_API = () =>
+ loadFeatureFlag("USE_V1_CONVERSATION_API");
diff --git a/frontend/src/utils/status.ts b/frontend/src/utils/status.ts
index 3df5ee57cd..11c0314824 100644
--- a/frontend/src/utils/status.ts
+++ b/frontend/src/utils/status.ts
@@ -1,4 +1,4 @@
-import { WebSocketStatus } from "#/context/ws-client-provider";
+import { V0_WebSocketStatus } from "#/context/ws-client-provider";
import { I18nKey } from "#/i18n/declaration";
import { AgentState } from "#/types/agent-state";
import { ConversationStatus } from "#/types/conversation-status";
@@ -43,7 +43,7 @@ export const AGENT_STATUS_MAP: {
};
export function getIndicatorColor(
- webSocketStatus: WebSocketStatus,
+ webSocketStatus: V0_WebSocketStatus,
conversationStatus: ConversationStatus | null,
runtimeStatus: RuntimeStatus | null,
agentState: AgentState | null,
@@ -99,7 +99,7 @@ export function getIndicatorColor(
export function getStatusCode(
statusMessage: StatusMessage,
- webSocketStatus: WebSocketStatus,
+ webSocketStatus: V0_WebSocketStatus,
conversationStatus: ConversationStatus | null,
runtimeStatus: RuntimeStatus | null,
agentState: AgentState | null,
diff --git a/frontend/src/utils/websocket-url.ts b/frontend/src/utils/websocket-url.ts
new file mode 100644
index 0000000000..fa6b907d0e
--- /dev/null
+++ b/frontend/src/utils/websocket-url.ts
@@ -0,0 +1,54 @@
+/**
+ * Extracts the base host from conversation URL
+ * @param conversationUrl The conversation URL containing host/port (e.g., "http://localhost:3000/api/conversations/123")
+ * @returns Base host (e.g., "localhost:3000") or window.location.host as fallback
+ */
+export function extractBaseHost(
+ conversationUrl: string | null | undefined,
+): string {
+ if (conversationUrl && !conversationUrl.startsWith("/")) {
+ try {
+ const url = new URL(conversationUrl);
+ return url.host; // e.g., "localhost:3000"
+ } catch {
+ return window.location.host;
+ }
+ }
+ return window.location.host;
+}
+
+/**
+ * Builds the HTTP base URL for V1 API calls
+ * @param conversationUrl The conversation URL containing host/port
+ * @returns HTTP base URL (e.g., "http://localhost:3000")
+ */
+export function buildHttpBaseUrl(
+ conversationUrl: string | null | undefined,
+): string {
+ const baseHost = extractBaseHost(conversationUrl);
+ const protocol = window.location.protocol === "https:" ? "https:" : "http:";
+ return `${protocol}//${baseHost}`;
+}
+
+/**
+ * Builds the WebSocket URL for V1 conversations (without query params)
+ * @param conversationId The conversation ID
+ * @param conversationUrl The conversation URL containing host/port (e.g., "http://localhost:3000/api/conversations/123")
+ * @returns WebSocket URL or null if inputs are invalid
+ */
+export function buildWebSocketUrl(
+ conversationId: string | undefined,
+ conversationUrl: string | null | undefined,
+): string | null {
+ if (!conversationId) {
+ return null;
+ }
+
+ const baseHost = extractBaseHost(conversationUrl);
+
+ // Build WebSocket URL: ws://host:port/sockets/events/{conversationId}
+ // Note: Query params should be passed via the useWebSocket hook options
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+
+ return `${protocol}//${baseHost}/sockets/events/${conversationId}`;
+}