feat(frontend): V1 conversation API (PARTIAL) (#11336)

Co-authored-by: Hiep Le <69354317+hieptl@users.noreply.github.com>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
This commit is contained in:
sp.wack 2025-10-20 20:57:40 +04:00 committed by GitHub
parent fab64a51b7
commit 531683abae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
101 changed files with 4173 additions and 459 deletions

View File

@ -1,29 +0,0 @@
import { describe, expect, it } from "vitest";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import {
FILE_VARIANTS_1,
FILE_VARIANTS_2,
} from "#/mocks/file-service-handlers";
/**
* File service API tests. The actual API calls are mocked using MSW.
* You can find the mock handlers in `frontend/src/mocks/file-service-handlers.ts`.
*/
describe("ConversationService File API", () => {
it("should get a list of files", async () => {
await expect(
ConversationService.getFiles("test-conversation-id"),
).resolves.toEqual(FILE_VARIANTS_1);
await expect(
ConversationService.getFiles("test-conversation-id-2"),
).resolves.toEqual(FILE_VARIANTS_2);
});
it("should get content of a file", async () => {
await expect(
ConversationService.getFile("test-conversation-id", "file1.txt"),
).resolves.toEqual("Content of file1.txt");
});
});

View File

@ -0,0 +1,187 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { buildWebSocketUrl } from "#/utils/websocket-url";
describe("buildWebSocketUrl", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
describe("Basic URL construction", () => {
it("should build WebSocket URL with conversation ID and URL", () => {
vi.stubGlobal("location", {
protocol: "http:",
host: "localhost:3000",
});
const result = buildWebSocketUrl(
"conv-123",
"http://localhost:8080/api/conversations/conv-123",
);
expect(result).toBe("ws://localhost:8080/sockets/events/conv-123");
});
it("should use wss:// protocol when window.location.protocol is https:", () => {
vi.stubGlobal("location", {
protocol: "https:",
host: "localhost:3000",
});
const result = buildWebSocketUrl(
"conv-123",
"https://example.com:8080/api/conversations/conv-123",
);
expect(result).toBe("wss://example.com:8080/sockets/events/conv-123");
});
it("should extract host and port from conversation URL", () => {
vi.stubGlobal("location", {
protocol: "http:",
host: "localhost:3000",
});
const result = buildWebSocketUrl(
"conv-456",
"http://agent-server.com:9000/api/conversations/conv-456",
);
expect(result).toBe("ws://agent-server.com:9000/sockets/events/conv-456");
});
});
describe("Query parameters handling", () => {
beforeEach(() => {
vi.stubGlobal("location", {
protocol: "http:",
host: "localhost:3000",
});
});
it("should not include query parameters in the URL (handled by useWebSocket hook)", () => {
const result = buildWebSocketUrl(
"conv-123",
"http://localhost:8080/api/conversations/conv-123",
);
expect(result).toBe("ws://localhost:8080/sockets/events/conv-123");
expect(result).not.toContain("?");
expect(result).not.toContain("session_api_key");
});
});
describe("Fallback to window.location.host", () => {
it("should use window.location.host when conversation URL is null", () => {
vi.stubGlobal("location", {
protocol: "http:",
host: "fallback-host:4000",
});
const result = buildWebSocketUrl("conv-123", null);
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
});
it("should use window.location.host when conversation URL is undefined", () => {
vi.stubGlobal("location", {
protocol: "http:",
host: "fallback-host:4000",
});
const result = buildWebSocketUrl("conv-123", undefined);
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
});
it("should use window.location.host when conversation URL is relative path", () => {
vi.stubGlobal("location", {
protocol: "http:",
host: "fallback-host:4000",
});
const result = buildWebSocketUrl(
"conv-123",
"/api/conversations/conv-123",
);
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
});
it("should use window.location.host when conversation URL is invalid", () => {
vi.stubGlobal("location", {
protocol: "http:",
host: "fallback-host:4000",
});
const result = buildWebSocketUrl("conv-123", "not-a-valid-url");
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
});
});
describe("Edge cases", () => {
beforeEach(() => {
vi.stubGlobal("location", {
protocol: "http:",
host: "localhost:3000",
});
});
it("should return null when conversationId is undefined", () => {
const result = buildWebSocketUrl(
undefined,
"http://localhost:8080/api/conversations/conv-123",
);
expect(result).toBeNull();
});
it("should return null when conversationId is empty string", () => {
const result = buildWebSocketUrl(
"",
"http://localhost:8080/api/conversations/conv-123",
);
expect(result).toBeNull();
});
it("should handle conversation URLs with non-standard ports", () => {
const result = buildWebSocketUrl(
"conv-123",
"http://example.com:12345/api/conversations/conv-123",
);
expect(result).toBe("ws://example.com:12345/sockets/events/conv-123");
});
it("should handle conversation URLs without port (default port)", () => {
const result = buildWebSocketUrl(
"conv-123",
"http://example.com/api/conversations/conv-123",
);
expect(result).toBe("ws://example.com/sockets/events/conv-123");
});
it("should handle conversation IDs with special characters", () => {
const result = buildWebSocketUrl(
"conv-123-abc_def",
"http://localhost:8080/api/conversations/conv-123-abc_def",
);
expect(result).toBe(
"ws://localhost:8080/sockets/events/conv-123-abc_def",
);
});
it("should build URL without query parameters", () => {
const result = buildWebSocketUrl(
"conv-123",
"http://localhost:8080/api/conversations/conv-123",
);
expect(result).toBe("ws://localhost:8080/sockets/events/conv-123");
expect(result).not.toContain("?");
});
});
});

View File

@ -8,6 +8,14 @@ import { ConversationPanel } from "#/components/features/conversation-panel/conv
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { Conversation } from "#/api/open-hands.types";
// Mock the unified stop conversation hook
const mockStopConversationMutate = vi.fn();
vi.mock("#/hooks/mutation/use-unified-stop-conversation", () => ({
useUnifiedPauseConversationSandbox: () => ({
mutate: mockStopConversationMutate,
}),
}));
describe("ConversationPanel", () => {
const onCloseMock = vi.fn();
const RouterStub = createRoutesStub([
@ -73,7 +81,7 @@ describe("ConversationPanel", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
mockStopConversationMutate.mockClear();
// Setup default mock for getUserConversations
vi.spyOn(ConversationService, "getUserConversations").mockResolvedValue({
results: [...mockConversations],
@ -430,19 +438,6 @@ describe("ConversationPanel", () => {
next_page_id: null,
}));
const stopConversationSpy = vi.spyOn(
ConversationService,
"stopConversation",
);
stopConversationSpy.mockImplementation(async (id: string) => {
const conversation = mockData.find((conv) => conv.conversation_id === id);
if (conversation) {
conversation.status = "STOPPED";
return conversation;
}
return null;
});
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
@ -465,9 +460,12 @@ describe("ConversationPanel", () => {
screen.queryByRole("button", { name: /confirm/i }),
).not.toBeInTheDocument();
// Verify the API was called
expect(stopConversationSpy).toHaveBeenCalledWith("1");
expect(stopConversationSpy).toHaveBeenCalledTimes(1);
// Verify the mutation was called
expect(mockStopConversationMutate).toHaveBeenCalledWith({
conversationId: "1",
version: undefined,
});
expect(mockStopConversationMutate).toHaveBeenCalledTimes(1);
});
it("should only show stop button for STARTING or RUNNING conversations", async () => {

View File

@ -6,25 +6,25 @@ import { ServerStatus } from "#/components/features/controls/server-status";
import { ServerStatusContextMenu } from "#/components/features/controls/server-status-context-menu";
import { ConversationStatus } from "#/types/conversation-status";
import { AgentState } from "#/types/agent-state";
import { useAgentStore } from "#/stores/agent-store";
import { useAgentState } from "#/hooks/use-agent-state";
// Mock the agent store
vi.mock("#/stores/agent-store", () => ({
useAgentStore: vi.fn(),
// Mock the agent state hook
vi.mock("#/hooks/use-agent-state", () => ({
useAgentState: vi.fn(),
}));
// Mock the custom hooks
const mockStartConversationMutate = vi.fn();
const mockStopConversationMutate = vi.fn();
vi.mock("#/hooks/mutation/use-start-conversation", () => ({
useStartConversation: () => ({
vi.mock("#/hooks/mutation/use-unified-start-conversation", () => ({
useUnifiedStartConversation: () => ({
mutate: mockStartConversationMutate,
}),
}));
vi.mock("#/hooks/mutation/use-stop-conversation", () => ({
useStopConversation: () => ({
vi.mock("#/hooks/mutation/use-unified-stop-conversation", () => ({
useUnifiedStopConversation: () => ({
mutate: mockStopConversationMutate,
}),
}));
@ -41,6 +41,19 @@ vi.mock("#/hooks/use-user-providers", () => ({
}),
}));
vi.mock("#/hooks/query/use-task-polling", () => ({
useTaskPolling: () => ({
isTask: false,
taskId: null,
conversationId: "test-conversation-id",
task: null,
taskStatus: null,
taskDetail: null,
taskError: null,
isLoadingTask: false,
}),
}));
// Mock react-i18next
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
@ -66,12 +79,14 @@ vi.mock("react-i18next", async () => {
});
describe("ServerStatus", () => {
// Helper function to mock agent store with specific state
// Mock functions for handlers
const mockHandleStop = vi.fn();
const mockHandleResumeAgent = vi.fn();
// Helper function to mock agent state with specific state
const mockAgentStore = (agentState: AgentState) => {
vi.mocked(useAgentStore).mockReturnValue({
vi.mocked(useAgentState).mockReturnValue({
curAgentState: agentState,
setCurrentAgentState: vi.fn(),
reset: vi.fn(),
});
};
@ -85,20 +100,42 @@ describe("ServerStatus", () => {
// Test RUNNING status
const { rerender } = renderWithProviders(
<ServerStatus conversationStatus="RUNNING" />,
<ServerStatus
conversationStatus="RUNNING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
expect(screen.getByText("Running")).toBeInTheDocument();
// Test STOPPED status
rerender(<ServerStatus conversationStatus="STOPPED" />);
rerender(
<ServerStatus
conversationStatus="STOPPED"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
expect(screen.getByText("Server Stopped")).toBeInTheDocument();
// Test STARTING status (shows "Running" due to agent state being RUNNING)
rerender(<ServerStatus conversationStatus="STARTING" />);
rerender(
<ServerStatus
conversationStatus="STARTING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
expect(screen.getByText("Running")).toBeInTheDocument();
// Test null status (shows "Running" due to agent state being RUNNING)
rerender(<ServerStatus conversationStatus={null} />);
rerender(
<ServerStatus
conversationStatus={null}
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
expect(screen.getByText("Running")).toBeInTheDocument();
});
@ -108,7 +145,13 @@ describe("ServerStatus", () => {
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
renderWithProviders(
<ServerStatus
conversationStatus="RUNNING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Running").closest("div");
expect(statusContainer).toBeInTheDocument();
@ -128,7 +171,13 @@ describe("ServerStatus", () => {
// Mock agent store to return STOPPED state
mockAgentStore(AgentState.STOPPED);
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
renderWithProviders(
<ServerStatus
conversationStatus="STOPPED"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Server Stopped").closest("div");
expect(statusContainer).toBeInTheDocument();
@ -148,7 +197,13 @@ describe("ServerStatus", () => {
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(<ServerStatus conversationStatus="STARTING" />);
renderWithProviders(
<ServerStatus
conversationStatus="STARTING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Running").closest("div");
expect(statusContainer).toBeInTheDocument();
@ -165,12 +220,18 @@ describe("ServerStatus", () => {
const user = userEvent.setup();
// Clear previous calls
mockStopConversationMutate.mockClear();
mockHandleStop.mockClear();
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
renderWithProviders(
<ServerStatus
conversationStatus="RUNNING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Running").closest("div");
await user.click(statusContainer!);
@ -178,21 +239,25 @@ describe("ServerStatus", () => {
const stopButton = screen.getByTestId("stop-server-button");
await user.click(stopButton);
expect(mockStopConversationMutate).toHaveBeenCalledWith({
conversationId: "test-conversation-id",
});
expect(mockHandleStop).toHaveBeenCalledTimes(1);
});
it("should call start conversation mutation when start server is clicked", async () => {
const user = userEvent.setup();
// Clear previous calls
mockStartConversationMutate.mockClear();
mockHandleResumeAgent.mockClear();
// Mock agent store to return STOPPED state
mockAgentStore(AgentState.STOPPED);
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
renderWithProviders(
<ServerStatus
conversationStatus="STOPPED"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Server Stopped").closest("div");
await user.click(statusContainer!);
@ -200,10 +265,7 @@ describe("ServerStatus", () => {
const startButton = screen.getByTestId("start-server-button");
await user.click(startButton);
expect(mockStartConversationMutate).toHaveBeenCalledWith({
conversationId: "test-conversation-id",
providers: [],
});
expect(mockHandleResumeAgent).toHaveBeenCalledTimes(1);
});
it("should close context menu after stop server action", async () => {
@ -212,7 +274,13 @@ describe("ServerStatus", () => {
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
renderWithProviders(
<ServerStatus
conversationStatus="RUNNING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Running").closest("div");
await user.click(statusContainer!);
@ -221,9 +289,7 @@ describe("ServerStatus", () => {
await user.click(stopButton);
// Context menu should be closed (handled by the component)
expect(mockStopConversationMutate).toHaveBeenCalledWith({
conversationId: "test-conversation-id",
});
expect(mockHandleStop).toHaveBeenCalledTimes(1);
});
it("should close context menu after start server action", async () => {
@ -232,7 +298,13 @@ describe("ServerStatus", () => {
// Mock agent store to return STOPPED state
mockAgentStore(AgentState.STOPPED);
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
renderWithProviders(
<ServerStatus
conversationStatus="STOPPED"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Server Stopped").closest("div");
await user.click(statusContainer!);
@ -250,7 +322,13 @@ describe("ServerStatus", () => {
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(<ServerStatus conversationStatus={null} />);
renderWithProviders(
<ServerStatus
conversationStatus={null}
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusText = screen.getByText("Running");
expect(statusText).toBeInTheDocument();

View File

@ -5,12 +5,12 @@ import { MemoryRouter } from "react-router";
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
import { renderWithProviders } from "../../test-utils";
import { AgentState } from "#/types/agent-state";
import { useAgentStore } from "#/stores/agent-store";
import { useAgentState } from "#/hooks/use-agent-state";
import { useConversationStore } from "#/state/conversation-store";
// Mock the agent store
vi.mock("#/stores/agent-store", () => ({
useAgentStore: vi.fn(),
// Mock the agent state hook
vi.mock("#/hooks/use-agent-state", () => ({
useAgentState: vi.fn(),
}));
// Mock the conversation store
@ -57,14 +57,11 @@ vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
describe("InteractiveChatBox", () => {
const onSubmitMock = vi.fn();
const onStopMock = vi.fn();
// Helper function to mock stores
const mockStores = (agentState: AgentState = AgentState.INIT) => {
vi.mocked(useAgentStore).mockReturnValue({
vi.mocked(useAgentState).mockReturnValue({
curAgentState: agentState,
setCurrentAgentState: vi.fn(),
reset: vi.fn(),
});
vi.mocked(useConversationStore).mockReturnValue({
@ -103,14 +100,13 @@ describe("InteractiveChatBox", () => {
};
// Helper function to render with Router context
const renderInteractiveChatBox = (props: any, options: any = {}) => {
return renderWithProviders(
const renderInteractiveChatBox = (props: any, options: any = {}) =>
renderWithProviders(
<MemoryRouter>
<InteractiveChatBox {...props} />
</MemoryRouter>,
options,
);
};
beforeAll(() => {
global.URL.createObjectURL = vi
@ -127,7 +123,6 @@ describe("InteractiveChatBox", () => {
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
const chatBox = screen.getByTestId("interactive-chat-box");
@ -140,7 +135,6 @@ describe("InteractiveChatBox", () => {
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
const textbox = screen.getByTestId("chat-input");
@ -157,7 +151,6 @@ describe("InteractiveChatBox", () => {
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
// Create a larger file to ensure it passes validation
@ -184,7 +177,6 @@ describe("InteractiveChatBox", () => {
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
const fileContent = new Array(1024).fill("a").join(""); // 1KB file
@ -209,7 +201,6 @@ describe("InteractiveChatBox", () => {
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
const textarea = screen.getByTestId("chat-input");
@ -240,7 +231,6 @@ describe("InteractiveChatBox", () => {
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
const button = screen.getByTestId("submit-button");
@ -250,33 +240,14 @@ describe("InteractiveChatBox", () => {
expect(onSubmitMock).not.toHaveBeenCalled();
});
it("should display the stop button when agent is running and call onStop when clicked", async () => {
const user = userEvent.setup();
mockStores(AgentState.RUNNING);
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
// The stop button should be available when agent is running
const stopButton = screen.getByTestId("stop-button");
expect(stopButton).toBeInTheDocument();
await user.click(stopButton);
expect(onStopMock).toHaveBeenCalledOnce();
});
it("should handle image upload and message submission correctly", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
const onStop = vi.fn();
mockStores(AgentState.AWAITING_USER_INPUT);
const { rerender } = renderInteractiveChatBox({
onSubmit: onSubmit,
onStop: onStop,
onSubmit,
});
// Verify text input has the initial value
@ -296,7 +267,7 @@ describe("InteractiveChatBox", () => {
// Simulate parent component updating the value prop
rerender(
<MemoryRouter>
<InteractiveChatBox onSubmit={onSubmit} onStop={onStop} />
<InteractiveChatBox onSubmit={onSubmit} />
</MemoryRouter>,
);

View File

@ -2,12 +2,12 @@ import { render, screen } from "@testing-library/react";
import { JupyterEditor } from "#/components/features/jupyter/jupyter";
import { vi, describe, it, expect, beforeEach } from "vitest";
import { AgentState } from "#/types/agent-state";
import { useAgentStore } from "#/stores/agent-store";
import { useAgentState } from "#/hooks/use-agent-state";
import { useJupyterStore } from "#/state/jupyter-store";
// Mock the agent store
vi.mock("#/stores/agent-store", () => ({
useAgentStore: vi.fn(),
// Mock the agent state hook
vi.mock("#/hooks/use-agent-state", () => ({
useAgentState: vi.fn(),
}));
// Mock react-i18next
@ -30,11 +30,9 @@ describe("JupyterEditor", () => {
});
it("should have a scrollable container", () => {
// Mock agent store to return RUNNING state (not in RUNTIME_INACTIVE_STATES)
vi.mocked(useAgentStore).mockReturnValue({
// Mock agent state to return RUNNING state (not in RUNTIME_INACTIVE_STATES)
vi.mocked(useAgentState).mockReturnValue({
curAgentState: AgentState.RUNNING,
setCurrentAgentState: vi.fn(),
reset: vi.fn(),
});
render(

View File

@ -5,11 +5,11 @@ import { renderWithProviders } from "test-utils";
import { MicroagentsModal } from "#/components/features/conversation-panel/microagents-modal";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { AgentState } from "#/types/agent-state";
import { useAgentStore } from "#/stores/agent-store";
import { useAgentState } from "#/hooks/use-agent-state";
// Mock the agent store
vi.mock("#/stores/agent-store", () => ({
useAgentStore: vi.fn(),
// Mock the agent state hook
vi.mock("#/hooks/use-agent-state", () => ({
useAgentState: vi.fn(),
}));
// Mock the conversation ID hook
@ -50,11 +50,9 @@ describe("MicroagentsModal - Refresh Button", () => {
microagents: mockMicroagents,
});
// Mock the agent store to return a ready state
vi.mocked(useAgentStore).mockReturnValue({
// Mock the agent state to return a ready state
vi.mocked(useAgentState).mockReturnValue({
curAgentState: AgentState.AWAITING_USER_INPUT,
setCurrentAgentState: vi.fn(),
reset: vi.fn(),
});
});

View File

@ -1,5 +1,5 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
import { screen, waitFor, render } from "@testing-library/react";
import { screen, waitFor, render, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
import {
@ -19,16 +19,34 @@ import { conversationWebSocketTestSetup } from "./helpers/msw-websocket-setup";
// MSW WebSocket mock setup
const { wsLink, server: mswServer } = conversationWebSocketTestSetup();
beforeAll(() => mswServer.listen());
beforeAll(() => {
// The global MSW server from vitest.setup.ts is already running
// We just need to start our WebSocket-specific server
mswServer.listen({ onUnhandledRequest: "bypass" });
});
afterEach(() => {
mswServer.resetHandlers();
// Clean up any React components
cleanup();
});
afterAll(async () => {
// Close the WebSocket MSW server
mswServer.close();
// Give time for any pending WebSocket connections to close. This is very important to prevent serious memory leaks
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
});
afterAll(() => mswServer.close());
// Helper function to render components with ConversationWebSocketProvider
function renderWithWebSocketContext(
children: React.ReactNode,
conversationId = "test-conversation-default",
conversationUrl = "http://localhost:3000/api/conversations/test-conversation-default",
sessionApiKey: string | null = null,
) {
const queryClient = new QueryClient({
defaultOptions: {
@ -39,7 +57,11 @@ function renderWithWebSocketContext(
return render(
<QueryClientProvider client={queryClient}>
<ConversationWebSocketProvider conversationId={conversationId}>
<ConversationWebSocketProvider
conversationId={conversationId}
conversationUrl={conversationUrl}
sessionApiKey={sessionApiKey}
>
{children}
</ConversationWebSocketProvider>
</QueryClientProvider>,
@ -394,4 +416,98 @@ describe("Conversation WebSocket Handler", () => {
it.todo("should send user actions through WebSocket when connected");
it.todo("should handle send attempts when disconnected");
});
// 8. Terminal I/O Tests (ExecuteBashAction and ExecuteBashObservation)
describe("Terminal I/O Integration", () => {
it("should append command to store when ExecuteBashAction event is received", async () => {
const { createMockExecuteBashActionEvent } = await import(
"#/mocks/mock-ws-helpers"
);
const { useCommandStore } = await import("#/state/command-store");
// Clear the command store before test
useCommandStore.getState().clearTerminal();
// Create a mock ExecuteBashAction event
const mockBashActionEvent = createMockExecuteBashActionEvent("npm test");
// Set up MSW to send the event when connection is established
mswServer.use(
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
// Send the mock event after connection
client.send(JSON.stringify(mockBashActionEvent));
}),
);
// Render with WebSocket context (we don't need a component, just need the provider to be active)
renderWithWebSocketContext(<ConnectionStatusComponent />);
// Wait for connection
await waitFor(() => {
expect(screen.getByTestId("connection-state")).toHaveTextContent(
"OPEN",
);
});
// Wait for the command to be added to the store
await waitFor(() => {
const { commands } = useCommandStore.getState();
expect(commands.length).toBe(1);
});
// Verify the command was added with correct type and content
const { commands } = useCommandStore.getState();
expect(commands[0].type).toBe("input");
expect(commands[0].content).toBe("npm test");
});
it("should append output to store when ExecuteBashObservation event is received", async () => {
const { createMockExecuteBashObservationEvent } = await import(
"#/mocks/mock-ws-helpers"
);
const { useCommandStore } = await import("#/state/command-store");
// Clear the command store before test
useCommandStore.getState().clearTerminal();
// Create a mock ExecuteBashObservation event
const mockBashObservationEvent = createMockExecuteBashObservationEvent(
"PASS tests/example.test.js\n ✓ should work (2 ms)",
"npm test",
);
// Set up MSW to send the event when connection is established
mswServer.use(
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
// Send the mock event after connection
client.send(JSON.stringify(mockBashObservationEvent));
}),
);
// Render with WebSocket context
renderWithWebSocketContext(<ConnectionStatusComponent />);
// Wait for connection
await waitFor(() => {
expect(screen.getByTestId("connection-state")).toHaveTextContent(
"OPEN",
);
});
// Wait for the output to be added to the store
await waitFor(() => {
const { commands } = useCommandStore.getState();
expect(commands.length).toBe(1);
});
// Verify the output was added with correct type and content
const { commands } = useCommandStore.getState();
expect(commands[0].type).toBe("output");
expect(commands[0].content).toBe(
"PASS tests/example.test.js\n ✓ should work (2 ms)",
);
});
});
});

View File

@ -37,6 +37,9 @@ export const createWebSocketTestSetup = (
/**
* Standard WebSocket test setup for conversation WebSocket handler tests
* Updated to use the V1 WebSocket URL pattern: /sockets/events/{conversationId}
*/
export const conversationWebSocketTestSetup = () =>
createWebSocketTestSetup("ws://localhost/events/socket");
createWebSocketTestSetup(
"ws://localhost:3000/sockets/events/test-conversation-default",
);

View File

@ -10,11 +10,13 @@ import { OpenHandsEvent } from "#/types/v1/core";
* Test component to access and display WebSocket connection state
*/
export function ConnectionStatusComponent() {
const { connectionState } = useConversationWebSocket();
const context = useConversationWebSocket();
return (
<div>
<div data-testid="connection-state">{connectionState}</div>
<div data-testid="connection-state">
{context?.connectionState || "NOT_AVAILABLE"}
</div>
</div>
);
}

View File

@ -13,6 +13,22 @@ vi.mock("#/context/ws-client-provider", () => ({
}),
}));
// Mock useActiveConversation
vi.mock("#/hooks/query/use-active-conversation", () => ({
useActiveConversation: () => ({
data: {
id: "test-conversation-id",
conversation_version: "V0",
},
isFetched: true,
}),
}));
// Mock useConversationWebSocket (returns null for V0 conversations)
vi.mock("#/contexts/conversation-websocket-context", () => ({
useConversationWebSocket: () => null,
}));
function TestTerminalComponent() {
const ref = useTerminal();
return <div ref={ref} />;

View File

@ -12,7 +12,7 @@ import { ws } from "msw";
import { setupServer } from "msw/node";
import { useWebSocket } from "#/hooks/use-websocket";
describe.skip("useWebSocket", () => {
describe("useWebSocket", () => {
// MSW WebSocket mock setup
const wsLink = ws.link("ws://acme.com/ws");

View File

@ -60,7 +60,7 @@ describe("Check for hardcoded English strings", () => {
test("InteractiveChatBox should not have hardcoded English strings", () => {
const { container } = renderWithProviders(
<MemoryRouter>
<InteractiveChatBox onSubmit={() => {}} onStop={() => {}} />
<InteractiveChatBox onSubmit={() => {}} />
</MemoryRouter>,
);

View File

@ -11,7 +11,6 @@ import {
CreateMicroagent,
FileUploadSuccessResponse,
GetFilesResponse,
GetFileResponse,
} from "../open-hands.types";
import { openHands } from "../open-hands-axios";
import { Provider } from "#/types/settings";
@ -159,19 +158,6 @@ class ConversationService {
return data;
}
/**
* Get the blob of the workspace zip
* @returns Blob of the workspace zip
*/
static async getWorkspaceZip(conversationId: string): Promise<Blob> {
const url = `${this.getConversationUrl(conversationId)}/zip-directory`;
const response = await openHands.get(url, {
responseType: "blob",
headers: this.getConversationHeaders(),
});
return response.data;
}
/**
* Get the web hosts
* @returns Array of web hosts
@ -379,22 +365,6 @@ class ConversationService {
return data;
}
/**
* Retrieve the content of a file
* @param conversationId ID of the conversation
* @param path Full path of the file to retrieve
* @returns Code content of the file
*/
static async getFile(conversationId: string, path: string): Promise<string> {
const url = `${this.getConversationUrl(conversationId)}/select-file`;
const { data } = await openHands.get<GetFileResponse>(url, {
params: { file: path },
headers: this.getConversationHeaders(),
});
return data.code;
}
/**
* Upload multiple files to the workspace
* @param conversationId ID of the conversation

View File

@ -0,0 +1,258 @@
import axios from "axios";
import { openHands } from "../open-hands-axios";
import { ConversationTrigger, GetVSCodeUrlResponse } from "../open-hands.types";
import { Provider } from "#/types/settings";
import { buildHttpBaseUrl } from "#/utils/websocket-url";
import type {
V1SendMessageRequest,
V1SendMessageResponse,
V1AppConversationStartRequest,
V1AppConversationStartTask,
V1AppConversationStartTaskPage,
V1AppConversation,
} from "./v1-conversation-service.types";
class V1ConversationService {
/**
* Build headers for V1 API requests that require session authentication
* @param sessionApiKey Session API key for authentication
* @returns Headers object with X-Session-API-Key if provided
*/
private static buildSessionHeaders(
sessionApiKey?: string | null,
): Record<string, string> {
const headers: Record<string, string> = {};
if (sessionApiKey) {
headers["X-Session-API-Key"] = sessionApiKey;
}
return headers;
}
/**
* Build the full URL for V1 runtime-specific endpoints
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
* @param path The API path (e.g., "/api/vscode/url")
* @returns Full URL to the runtime endpoint
*/
private static buildRuntimeUrl(
conversationUrl: string | null | undefined,
path: string,
): string {
const baseUrl = buildHttpBaseUrl(conversationUrl);
return `${baseUrl}${path}`;
}
/**
* Send a message to a V1 conversation
* @param conversationId The conversation ID
* @param message The message to send
* @returns The sent message response
*/
static async sendMessage(
conversationId: string,
message: V1SendMessageRequest,
): Promise<V1SendMessageResponse> {
const { data } = await openHands.post<V1SendMessageResponse>(
`/api/conversations/${conversationId}/events`,
message,
);
return data;
}
/**
* Create a new V1 conversation using the app-conversations API
* Returns the start task immediately with app_conversation_id as null.
* You must poll getStartTask() until status is READY to get the conversation ID.
*
* @returns AppConversationStartTask with task ID
*/
static async createConversation(
selectedRepository?: string,
git_provider?: Provider,
initialUserMsg?: string,
selected_branch?: string,
conversationInstructions?: string,
trigger?: ConversationTrigger,
): Promise<V1AppConversationStartTask> {
const body: V1AppConversationStartRequest = {
selected_repository: selectedRepository,
git_provider,
selected_branch,
title: conversationInstructions,
trigger,
};
// Add initial message if provided
if (initialUserMsg) {
body.initial_message = {
role: "user",
content: [
{
type: "text",
text: initialUserMsg,
},
],
};
}
const { data } = await openHands.post<V1AppConversationStartTask>(
"/api/v1/app-conversations",
body,
);
return data;
}
/**
* Get a start task by ID
* Poll this endpoint until status is READY to get the app_conversation_id
*
* @param taskId The task UUID
* @returns AppConversationStartTask or null
*/
static async getStartTask(
taskId: string,
): Promise<V1AppConversationStartTask | null> {
const { data } = await openHands.get<(V1AppConversationStartTask | null)[]>(
`/api/v1/app-conversations/start-tasks?ids=${taskId}`,
);
return data[0] || null;
}
/**
* Search for start tasks (ongoing tasks that haven't completed yet)
* Use this to find tasks that were started but the user navigated away
*
* Note: Backend only supports filtering by limit. To filter by repository/trigger,
* filter the results client-side after fetching.
*
* @param limit Maximum number of tasks to return (max 100)
* @returns Array of start tasks
*/
static async searchStartTasks(
limit: number = 100,
): Promise<V1AppConversationStartTask[]> {
const params = new URLSearchParams();
params.append("limit", limit.toString());
const { data } = await openHands.get<V1AppConversationStartTaskPage>(
`/api/v1/app-conversations/start-tasks/search?${params.toString()}`,
);
return data.items;
}
/**
* Get the VSCode URL for a V1 conversation
* Uses the custom runtime URL from the conversation
* Note: V1 endpoint doesn't require conversationId in the URL path - it's identified via session API key header
*
* @param _conversationId The conversation ID (not used in V1, kept for interface compatibility)
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
* @param sessionApiKey Session API key for authentication (required for V1)
* @returns VSCode URL response
*/
static async getVSCodeUrl(
_conversationId: string,
conversationUrl: string | null | undefined,
sessionApiKey?: string | null,
): Promise<GetVSCodeUrlResponse> {
const url = this.buildRuntimeUrl(conversationUrl, "/api/vscode/url");
const headers = this.buildSessionHeaders(sessionApiKey);
// V1 API returns {url: '...'} instead of {vscode_url: '...'}
// Map it to match the expected interface
const { data } = await axios.get<{ url: string | null }>(url, { headers });
return {
vscode_url: data.url,
};
}
/**
* Pause a V1 conversation
* Uses the custom runtime URL from the conversation
*
* @param conversationId The conversation ID
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
* @param sessionApiKey Session API key for authentication (required for V1)
* @returns Success response
*/
static async pauseConversation(
conversationId: string,
conversationUrl: string | null | undefined,
sessionApiKey?: string | null,
): Promise<{ success: boolean }> {
const url = this.buildRuntimeUrl(
conversationUrl,
`/api/conversations/${conversationId}/pause`,
);
const headers = this.buildSessionHeaders(sessionApiKey);
const { data } = await axios.post<{ success: boolean }>(
url,
{},
{ headers },
);
return data;
}
/**
* Pause a V1 sandbox
* Calls the /api/v1/sandboxes/{id}/pause endpoint
*
* @param sandboxId The sandbox ID to pause
* @returns Success response
*/
static async pauseSandbox(sandboxId: string): Promise<{ success: boolean }> {
const { data } = await openHands.post<{ success: boolean }>(
`/api/v1/sandboxes/${sandboxId}/pause`,
{},
);
return data;
}
/**
* Resume a V1 sandbox
* Calls the /api/v1/sandboxes/{id}/resume endpoint
*
* @param sandboxId The sandbox ID to resume
* @returns Success response
*/
static async resumeSandbox(sandboxId: string): Promise<{ success: boolean }> {
const { data } = await openHands.post<{ success: boolean }>(
`/api/v1/sandboxes/${sandboxId}/resume`,
{},
);
return data;
}
/**
* Batch get V1 app conversations by their IDs
* Returns null for any missing conversations
*
* @param ids Array of conversation IDs (max 100)
* @returns Array of conversations or null for missing ones
*/
static async batchGetAppConversations(
ids: string[],
): Promise<(V1AppConversation | null)[]> {
if (ids.length === 0) {
return [];
}
if (ids.length > 100) {
throw new Error("Cannot request more than 100 conversations at once");
}
const params = new URLSearchParams();
ids.forEach((id) => params.append("ids", id));
const { data } = await openHands.get<(V1AppConversation | null)[]>(
`/api/v1/app-conversations?${params.toString()}`,
);
return data;
}
}
export default V1ConversationService;

View File

@ -0,0 +1,100 @@
import { ConversationTrigger } from "../open-hands.types";
import { Provider } from "#/types/settings";
// V1 API Types for requests
// Note: This represents the serialized API format, not the internal TextContent/ImageContent types
export interface V1MessageContent {
type: "text" | "image_url";
text?: string;
image_url?: {
url: string;
};
}
type V1Role = "user" | "system" | "assistant" | "tool";
export interface V1SendMessageRequest {
role: V1Role;
content: V1MessageContent[];
}
export interface V1AppConversationStartRequest {
sandbox_id?: string | null;
initial_message?: V1SendMessageRequest | null;
processors?: unknown[]; // EventCallbackProcessor - keeping as unknown for now
llm_model?: string | null;
selected_repository?: string | null;
selected_branch?: string | null;
git_provider?: Provider | null;
title?: string | null;
trigger?: ConversationTrigger | null;
pr_number?: number[];
}
export type V1AppConversationStartTaskStatus =
| "WORKING"
| "WAITING_FOR_SANDBOX"
| "PREPARING_REPOSITORY"
| "RUNNING_SETUP_SCRIPT"
| "SETTING_UP_GIT_HOOKS"
| "STARTING_CONVERSATION"
| "READY"
| "ERROR";
export interface V1AppConversationStartTask {
id: string;
created_by_user_id: string | null;
status: V1AppConversationStartTaskStatus;
detail: string | null;
app_conversation_id: string | null;
sandbox_id: string | null;
agent_server_url: string | null;
request: V1AppConversationStartRequest;
created_at: string;
updated_at: string;
}
export interface V1SendMessageResponse {
role: "user" | "system" | "assistant" | "tool";
content: V1MessageContent[];
}
export interface V1AppConversationStartTaskPage {
items: V1AppConversationStartTask[];
next_page_id: string | null;
}
export type V1SandboxStatus =
| "MISSING"
| "STARTING"
| "RUNNING"
| "STOPPED"
| "PAUSED";
export type V1AgentExecutionStatus =
| "RUNNING"
| "AWAITING_USER_INPUT"
| "AWAITING_USER_CONFIRMATION"
| "FINISHED"
| "PAUSED"
| "STOPPED";
export interface V1AppConversation {
id: string;
created_by_user_id: string | null;
sandbox_id: string;
selected_repository: string | null;
selected_branch: string | null;
git_provider: Provider | null;
title: string | null;
trigger: ConversationTrigger | null;
pr_number: number[];
llm_model: string | null;
metrics: unknown | null;
created_at: string;
updated_at: string;
sandbox_status: V1SandboxStatus;
agent_status: V1AgentExecutionStatus | null;
conversation_url: string | null;
session_api_key: string | null;
}

View File

@ -76,6 +76,7 @@ export interface Conversation {
url: string | null;
session_api_key: string | null;
pr_number?: number[] | null;
conversation_version?: "V0" | "V1";
}
export interface ResultSet<T> {

View File

@ -8,16 +8,16 @@ import { createChatMessage } from "#/services/chat-service";
import { InteractiveChatBox } from "./interactive-chat-box";
import { AgentState } from "#/types/agent-state";
import { isOpenHandsAction, isActionOrObservation } from "#/types/core/guards";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { FeedbackModal } from "../feedback/feedback-modal";
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
import { TypingIndicator } from "./typing-indicator";
import { useWsClient } from "#/context/ws-client-provider";
import { Messages } from "./messages";
import { Messages as V0Messages } from "./messages";
import { ChatSuggestions } from "./chat-suggestions";
import { ScrollProvider } from "#/context/scroll-context";
import { useInitialQueryStore } from "#/stores/initial-query-store";
import { useAgentStore } from "#/stores/agent-store";
import { useSendMessage } from "#/hooks/use-send-message";
import { useAgentState } from "#/hooks/use-agent-state";
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
@ -30,12 +30,18 @@ import {
hasUserEvent,
shouldRenderEvent,
} from "./event-content-helpers/should-render-event";
import {
Messages as V1Messages,
hasUserEvent as hasV1UserEvent,
shouldRenderEvent as shouldRenderV1Event,
} from "#/components/v1/chat";
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
import { useConfig } from "#/hooks/query/use-config";
import { validateFiles } from "#/utils/file-validation";
import { useConversationStore } from "#/state/conversation-store";
import ConfirmationModeEnabled from "./confirmation-mode-enabled";
import { isV0Event } from "#/types/v1/type-guards";
import { isV0Event, isV1Event } from "#/types/v1/type-guards";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
function getEntryPoint(
hasRepository: boolean | null,
@ -48,8 +54,10 @@ function getEntryPoint(
export function ChatInterface() {
const { setMessageToSend } = useConversationStore();
const { data: conversation } = useActiveConversation();
const { errorMessage } = useErrorMessageStore();
const { send, isLoadingMessages } = useWsClient();
const { isLoadingMessages } = useWsClient();
const { send } = useSendMessage();
const storeEvents = useEventStore((state) => state.events);
const { setOptimisticUserMessage, getOptimisticUserMessage } =
useOptimisticUserMessageStore();
@ -65,7 +73,7 @@ export function ChatInterface() {
} = useScrollToBottom(scrollRef);
const { data: config } = useConfig();
const { curAgentState } = useAgentStore();
const { curAgentState } = useAgentState();
const [feedbackPolarity, setFeedbackPolarity] = React.useState<
"positive" | "negative"
@ -77,11 +85,20 @@ export function ChatInterface() {
const optimisticUserMessage = getOptimisticUserMessage();
const events = storeEvents
const isV1Conversation = conversation?.conversation_version === "V1";
// Filter V0 events
const v0Events = storeEvents
.filter(isV0Event)
.filter(isActionOrObservation)
.filter(shouldRenderEvent);
// Filter V1 events
const v1Events = storeEvents.filter(isV1Event).filter(shouldRenderV1Event);
// Combined events count for tracking
const totalEvents = v0Events.length || v1Events.length;
// Check if there are any substantive agent actions (not just system messages)
const hasSubstantiveAgentActions = React.useMemo(
() =>
@ -93,7 +110,8 @@ export function ChatInterface() {
isOpenHandsAction(event) &&
event.source === "agent" &&
event.action !== "system",
),
) ||
storeEvents.filter(isV1Event).some((event) => event.source === "agent"),
[storeEvents],
);
@ -105,7 +123,7 @@ export function ChatInterface() {
// Create mutable copies of the arrays
const images = [...originalImages];
const files = [...originalFiles];
if (events.length === 0) {
if (totalEvents === 0) {
posthog.capture("initial_query_submitted", {
entry_point: getEntryPoint(
selectedRepository !== null,
@ -116,7 +134,7 @@ export function ChatInterface() {
});
} else {
posthog.capture("user_message_sent", {
session_message_count: events.length,
session_message_count: totalEvents,
current_message_length: content.length,
});
}
@ -151,11 +169,6 @@ export function ChatInterface() {
setMessageToSend("");
};
const handleStop = () => {
posthog.capture("stop_button_clicked");
send(generateAgentStateChangeEvent(AgentState.STOPPED));
};
const onClickShareFeedbackActionButton = async (
polarity: "positive" | "negative",
) => {
@ -174,7 +187,9 @@ export function ChatInterface() {
onChatBodyScroll,
};
const userEventsExist = hasUserEvent(events);
const v0UserEventsExist = hasUserEvent(v0Events);
const v1UserEventsExist = hasV1UserEvent(v1Events);
const userEventsExist = v0UserEventsExist || v1UserEventsExist;
return (
<ScrollProvider value={scrollProviderValue}>
@ -193,15 +208,24 @@ export function ChatInterface() {
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
className="custom-scrollbar-always flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2 fast-smooth-scroll"
>
{isLoadingMessages && (
{isLoadingMessages && !isV1Conversation && (
<div className="flex justify-center">
<LoadingSpinner size="small" />
</div>
)}
{!isLoadingMessages && userEventsExist && (
<Messages
messages={events}
{!isLoadingMessages && v0UserEventsExist && (
<V0Messages
messages={v0Events}
isAwaitingUserConfirmation={
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
}
/>
)}
{v1UserEventsExist && (
<V1Messages
messages={v1Events}
isAwaitingUserConfirmation={
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
}
@ -213,7 +237,7 @@ export function ChatInterface() {
<div className="flex justify-between relative">
<div className="flex items-center gap-1">
<ConfirmationModeEnabled />
{events.length > 0 && (
{totalEvents > 0 && (
<TrajectoryActions
onPositiveFeedback={() =>
onClickShareFeedbackActionButton("positive")
@ -235,10 +259,7 @@ export function ChatInterface() {
{errorMessage && <ErrorMessageBanner message={errorMessage} />}
<InteractiveChatBox
onSubmit={handleSendMessage}
onStop={handleStop}
/>
<InteractiveChatBox onSubmit={handleSendMessage} />
</div>
{config?.APP_MODE !== "saas" && (

View File

@ -2,33 +2,73 @@ import { ConversationStatus } from "#/types/conversation-status";
import { ServerStatus } from "#/components/features/controls/server-status";
import { AgentStatus } from "#/components/features/controls/agent-status";
import { Tools } from "../../controls/tools";
import { useUnifiedPauseConversationSandbox } from "#/hooks/mutation/use-unified-stop-conversation";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation";
import { useUserProviders } from "#/hooks/use-user-providers";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useSendMessage } from "#/hooks/use-send-message";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { AgentState } from "#/types/agent-state";
interface ChatInputActionsProps {
conversationStatus: ConversationStatus | null;
disabled: boolean;
handleStop: (onStop?: () => void) => void;
handleResumeAgent: () => void;
onStop?: () => void;
}
export function ChatInputActions({
conversationStatus,
disabled,
handleStop,
handleResumeAgent,
onStop,
}: ChatInputActionsProps) {
const { data: conversation } = useActiveConversation();
const pauseConversationSandboxMutation = useUnifiedPauseConversationSandbox();
const resumeConversationSandboxMutation =
useUnifiedResumeConversationSandbox();
const { conversationId } = useConversationId();
const { providers } = useUserProviders();
const { send } = useSendMessage();
const isV1Conversation = conversation?.conversation_version === "V1";
const handleStopClick = () => {
pauseConversationSandboxMutation.mutate({ conversationId });
};
const handlePauseAgent = () => {
if (isV1Conversation) {
// V1: Empty function for now
return;
}
// V0: Send agent state change event to stop the agent
send(generateAgentStateChangeEvent(AgentState.STOPPED));
};
const handleStartClick = () => {
resumeConversationSandboxMutation.mutate({ conversationId, providers });
};
const isPausing = pauseConversationSandboxMutation.isPending;
return (
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-1">
<Tools />
<ServerStatus conversationStatus={conversationStatus} />
<ServerStatus
conversationStatus={conversationStatus}
isPausing={isPausing}
handleStop={handleStopClick}
handleResumeAgent={handleStartClick}
/>
</div>
<AgentStatus
className="ml-2 md:ml-3"
handleStop={() => handleStop(onStop)}
handleStop={handlePauseAgent}
handleResumeAgent={handleResumeAgent}
disabled={disabled}
isPausing={isPausing}
/>
</div>
);

View File

@ -15,7 +15,6 @@ interface ChatInputContainerProps {
chatInputRef: React.RefObject<HTMLDivElement | null>;
handleFileIconClick: (isDisabled: boolean) => void;
handleSubmit: () => void;
handleStop: (onStop?: () => void) => void;
handleResumeAgent: () => void;
onDragOver: (e: React.DragEvent, isDisabled: boolean) => void;
onDragLeave: (e: React.DragEvent, isDisabled: boolean) => void;
@ -25,7 +24,6 @@ interface ChatInputContainerProps {
onKeyDown: (e: React.KeyboardEvent) => void;
onFocus?: () => void;
onBlur?: () => void;
onStop?: () => void;
}
export function ChatInputContainer({
@ -38,7 +36,6 @@ export function ChatInputContainer({
chatInputRef,
handleFileIconClick,
handleSubmit,
handleStop,
handleResumeAgent,
onDragOver,
onDragLeave,
@ -48,7 +45,6 @@ export function ChatInputContainer({
onKeyDown,
onFocus,
onBlur,
onStop,
}: ChatInputContainerProps) {
return (
<div
@ -80,9 +76,7 @@ export function ChatInputContainer({
<ChatInputActions
conversationStatus={conversationStatus}
disabled={disabled}
handleStop={handleStop}
handleResumeAgent={handleResumeAgent}
onStop={onStop}
/>
</div>
);

View File

@ -15,7 +15,6 @@ export interface CustomChatInputProps {
showButton?: boolean;
conversationStatus?: ConversationStatus | null;
onSubmit: (message: string) => void;
onStop?: () => void;
onFocus?: () => void;
onBlur?: () => void;
onFilesPaste?: (files: File[]) => void;
@ -28,7 +27,6 @@ export function CustomChatInput({
showButton = true,
conversationStatus = null,
onSubmit,
onStop,
onFocus,
onBlur,
onFilesPaste,
@ -88,7 +86,7 @@ export function CustomChatInput({
messageToSend,
);
const { handleSubmit, handleResumeAgent, handleStop } = useChatSubmission(
const { handleSubmit, handleResumeAgent } = useChatSubmission(
chatInputRef as React.RefObject<HTMLDivElement | null>,
fileInputRef as React.RefObject<HTMLInputElement | null>,
smartResize,
@ -143,7 +141,6 @@ export function CustomChatInput({
chatInputRef={chatInputRef}
handleFileIconClick={handleFileIconClick}
handleSubmit={handleSubmit}
handleStop={handleStop}
handleResumeAgent={handleResumeAgent}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@ -153,7 +150,6 @@ export function CustomChatInput({
onKeyDown={(e) => handleKeyDown(e, isDisabled, handleSubmit)}
onFocus={handleFocus}
onBlur={handleBlur}
onStop={onStop}
/>
</div>
</div>

View File

@ -6,18 +6,14 @@ import { AgentState } from "#/types/agent-state";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { GitControlBar } from "./git-control-bar";
import { useConversationStore } from "#/state/conversation-store";
import { useAgentStore } from "#/stores/agent-store";
import { useAgentState } from "#/hooks/use-agent-state";
import { processFiles, processImages } from "#/utils/file-processing";
interface InteractiveChatBoxProps {
onSubmit: (message: string, images: File[], files: File[]) => void;
onStop: () => void;
}
export function InteractiveChatBox({
onSubmit,
onStop,
}: InteractiveChatBoxProps) {
export function InteractiveChatBox({ onSubmit }: InteractiveChatBoxProps) {
const {
images,
files,
@ -29,7 +25,7 @@ export function InteractiveChatBox({
addImageLoading,
removeImageLoading,
} = useConversationStore();
const { curAgentState } = useAgentStore();
const { curAgentState } = useAgentState();
const { data: conversation } = useActiveConversation();
// Helper function to validate and filter files
@ -145,7 +141,6 @@ export function InteractiveChatBox({
<CustomChatInput
disabled={isDisabled}
onSubmit={handleSubmit}
onStop={onStop}
onFilesPaste={handleUpload}
conversationStatus={conversation?.status || null}
/>

View File

@ -1,7 +1,6 @@
import { useTranslation } from "react-i18next";
import { useEffect } from "react";
import { useStatusStore } from "#/state/status-store";
import { useWsClient } from "#/context/ws-client-provider";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { getStatusCode } from "#/utils/status";
import { ChatStopButton } from "../chat/chat-stop-button";
@ -12,13 +11,15 @@ import { cn } from "#/utils/utils";
import { AgentLoading } from "./agent-loading";
import { useConversationStore } from "#/state/conversation-store";
import CircleErrorIcon from "#/icons/circle-error.svg?react";
import { useAgentStore } from "#/stores/agent-store";
import { useAgentState } from "#/hooks/use-agent-state";
import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status";
export interface AgentStatusProps {
className?: string;
handleStop: () => void;
handleResumeAgent: () => void;
disabled?: boolean;
isPausing?: boolean;
}
export function AgentStatus({
@ -26,12 +27,13 @@ export function AgentStatus({
handleStop,
handleResumeAgent,
disabled = false,
isPausing = false,
}: AgentStatusProps) {
const { t } = useTranslation();
const { setShouldShownAgentLoading } = useConversationStore();
const { curAgentState } = useAgentStore();
const { curAgentState } = useAgentState();
const { curStatusMessage } = useStatusStore();
const { webSocketStatus } = useWsClient();
const webSocketStatus = useUnifiedWebSocketStatus();
const { data: conversation } = useActiveConversation();
const statusCode = getStatusCode(
@ -43,6 +45,7 @@ export function AgentStatus({
);
const shouldShownAgentLoading =
isPausing ||
curAgentState === AgentState.INIT ||
curAgentState === AgentState.LOADING ||
webSocketStatus === "CONNECTING";

View File

@ -5,31 +5,29 @@ import { I18nKey } from "#/i18n/declaration";
import { ConversationStatus } from "#/types/conversation-status";
import { AgentState } from "#/types/agent-state";
import { ServerStatusContextMenu } from "./server-status-context-menu";
import { useStartConversation } from "#/hooks/mutation/use-start-conversation";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useUserProviders } from "#/hooks/use-user-providers";
import { useStopConversation } from "#/hooks/mutation/use-stop-conversation";
import { useAgentStore } from "#/stores/agent-store";
import { useAgentState } from "#/hooks/use-agent-state";
import { useTaskPolling } from "#/hooks/query/use-task-polling";
export interface ServerStatusProps {
className?: string;
conversationStatus: ConversationStatus | null;
isPausing?: boolean;
handleStop: () => void;
handleResumeAgent: () => void;
}
export function ServerStatus({
className = "",
conversationStatus,
isPausing = false,
handleStop,
handleResumeAgent,
}: ServerStatusProps) {
const [showContextMenu, setShowContextMenu] = useState(false);
const { curAgentState } = useAgentStore();
const { curAgentState } = useAgentState();
const { t } = useTranslation();
const { conversationId } = useConversationId();
// Mutation hooks
const stopConversationMutation = useStopConversation();
const startConversationMutation = useStartConversation();
const { providers } = useUserProviders();
const { isTask, taskStatus, taskDetail } = useTaskPolling();
const isStartingStatus =
curAgentState === AgentState.LOADING || curAgentState === AgentState.INIT;
@ -38,6 +36,19 @@ export function ServerStatus({
// Get the appropriate color based on agent status
const getStatusColor = (): string => {
// Show pausing status
if (isPausing) {
return "#FFD600";
}
// Show task status if we're polling a task
if (isTask && taskStatus) {
if (taskStatus === "ERROR") {
return "#FF684E";
}
return "#FFD600";
}
if (isStartingStatus) {
return "#FFD600";
}
@ -52,6 +63,31 @@ export function ServerStatus({
// Get the appropriate status text based on agent status
const getStatusText = (): string => {
// Show pausing status
if (isPausing) {
return t(I18nKey.COMMON$STOPPING);
}
// Show task status if we're polling a task
if (isTask && taskStatus) {
if (taskStatus === "ERROR") {
return (
taskDetail || t(I18nKey.CONVERSATION$ERROR_STARTING_CONVERSATION)
);
}
if (taskStatus === "READY") {
return t(I18nKey.CONVERSATION$READY);
}
// Format status text: "WAITING_FOR_SANDBOX" -> "Waiting for sandbox"
return (
taskDetail ||
taskStatus
.toLowerCase()
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase())
);
}
if (isStartingStatus) {
return t(I18nKey.COMMON$STARTING);
}
@ -76,16 +112,13 @@ export function ServerStatus({
const handleStopServer = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
stopConversationMutation.mutate({ conversationId });
handleStop();
setShowContextMenu(false);
};
const handleStartServer = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
startConversationMutation.mutate({
conversationId,
providers,
});
handleResumeAgent();
setShowContextMenu(false);
};

View File

@ -27,6 +27,8 @@ export function ConversationCardActions({
conversationId,
showOptions,
}: ConversationCardActionsProps) {
const isConversationArchived = conversationStatus === "ARCHIVED";
return (
<div className="group">
<button
@ -37,7 +39,10 @@ export function ConversationCardActions({
event.stopPropagation();
onContextMenuToggle(!contextMenuOpen);
}}
className="cursor-pointer w-6 h-6 flex flex-row items-center justify-center translate-x-2.5"
className={cn(
"cursor-pointer w-6 h-6 flex flex-row items-center justify-center translate-x-2.5",
isConversationArchived && "opacity-60",
)}
>
<EllipsisIcon />
</button>

View File

@ -5,22 +5,32 @@ import { I18nKey } from "#/i18n/declaration";
import { RepositorySelection } from "#/api/open-hands.types";
import { ConversationRepoLink } from "./conversation-repo-link";
import { NoRepository } from "./no-repository";
import { ConversationStatus } from "#/types/conversation-status";
interface ConversationCardFooterProps {
selectedRepository: RepositorySelection | null;
lastUpdatedAt: string; // ISO 8601
createdAt?: string; // ISO 8601
conversationStatus?: ConversationStatus;
}
export function ConversationCardFooter({
selectedRepository,
lastUpdatedAt,
createdAt,
conversationStatus,
}: ConversationCardFooterProps) {
const { t } = useTranslation();
const isConversationArchived = conversationStatus === "ARCHIVED";
return (
<div className={cn("flex flex-row justify-between items-center mt-1")}>
<div
className={cn(
"flex flex-row justify-between items-center mt-1",
isConversationArchived && "opacity-60",
)}
>
{selectedRepository?.selected_repository ? (
<ConversationRepoLink selectedRepository={selectedRepository} />
) : (

View File

@ -2,12 +2,14 @@ import { ConversationStatus } from "#/types/conversation-status";
import { ConversationCardTitle } from "./conversation-card-title";
import { ConversationStatusIndicator } from "../../home/recent-conversations/conversation-status-indicator";
import { ConversationStatusBadges } from "./conversation-status-badges";
import { ConversationVersionBadge } from "./conversation-version-badge";
interface ConversationCardHeaderProps {
title: string;
titleMode: "view" | "edit";
onTitleSave: (title: string) => void;
conversationStatus?: ConversationStatus;
conversationVersion?: "V0" | "V1";
}
export function ConversationCardHeader({
@ -15,7 +17,10 @@ export function ConversationCardHeader({
titleMode,
onTitleSave,
conversationStatus,
conversationVersion,
}: ConversationCardHeaderProps) {
const isConversationArchived = conversationStatus === "ARCHIVED";
return (
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden mr-2">
{/* Status Indicator */}
@ -26,10 +31,16 @@ export function ConversationCardHeader({
/>
</div>
)}
{/* Version Badge */}
<ConversationVersionBadge
version={conversationVersion}
isConversationArchived={isConversationArchived}
/>
<ConversationCardTitle
title={title}
titleMode={titleMode}
onSave={onTitleSave}
isConversationArchived={isConversationArchived}
/>
{/* Status Badges */}
{conversationStatus && (

View File

@ -1,15 +1,19 @@
import { cn } from "#/utils/utils";
export type ConversationCardTitleMode = "view" | "edit";
export type ConversationCardTitleProps = {
titleMode: ConversationCardTitleMode;
title: string;
onSave: (title: string) => void;
isConversationArchived?: boolean;
};
export function ConversationCardTitle({
titleMode,
title,
onSave,
isConversationArchived,
}: ConversationCardTitleProps) {
if (titleMode === "edit") {
return (
@ -40,7 +44,10 @@ export function ConversationCardTitle({
return (
<p
data-testid="conversation-card-title"
className="text-xs leading-6 font-semibold bg-transparent truncate overflow-hidden"
className={cn(
"text-xs leading-6 font-semibold bg-transparent truncate overflow-hidden",
isConversationArchived && "opacity-60",
)}
title={title}
>
{title}

View File

@ -21,6 +21,7 @@ interface ConversationCardProps {
createdAt?: string; // ISO 8601
conversationStatus?: ConversationStatus;
conversationId?: string; // Optional conversation ID for VS Code URL
conversationVersion?: "V0" | "V1";
contextMenuOpen?: boolean;
onContextMenuToggle?: (isOpen: boolean) => void;
}
@ -39,6 +40,7 @@ export function ConversationCard({
createdAt,
conversationId,
conversationStatus,
conversationVersion,
contextMenuOpen = false,
onContextMenuToggle,
}: ConversationCardProps) {
@ -108,7 +110,6 @@ export function ConversationCard({
className={cn(
"relative h-auto w-full p-3.5 border-b border-neutral-600 cursor-pointer",
"data-[context-menu-open=false]:hover:bg-[#454545]",
conversationStatus === "ARCHIVED" && "opacity-60",
)}
>
<div className="flex items-center justify-between w-full">
@ -117,6 +118,7 @@ export function ConversationCard({
titleMode={titleMode}
onTitleSave={onTitleSave}
conversationStatus={conversationStatus}
conversationVersion={conversationVersion}
/>
{hasContextMenu && (
@ -138,6 +140,7 @@ export function ConversationCard({
selectedRepository={selectedRepository}
lastUpdatedAt={lastUpdatedAt}
createdAt={createdAt}
conversationStatus={conversationStatus}
/>
</div>
);

View File

@ -15,7 +15,7 @@ export function ConversationStatusBadges({
if (conversationStatus === "ARCHIVED") {
return (
<span className="flex items-center gap-1 px-1.5 py-0.5 bg-[#868E96] text-white text-xs font-medium rounded-full">
<span className="flex items-center gap-1 px-1.5 py-0.5 bg-[#868E96] text-white text-xs font-medium rounded-full opacity-60">
<FaArchive size={10} className="text-white" />
<span>{t(I18nKey.COMMON$ARCHIVED)}</span>
</span>

View File

@ -0,0 +1,39 @@
import { Tooltip } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { cn } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
interface ConversationVersionBadgeProps {
version?: "V0" | "V1";
isConversationArchived?: boolean;
}
export function ConversationVersionBadge({
version,
isConversationArchived,
}: ConversationVersionBadgeProps) {
const { t } = useTranslation();
if (!version) return null;
const tooltipText =
version === "V1"
? t(I18nKey.CONVERSATION$VERSION_V1_NEW)
: t(I18nKey.CONVERSATION$VERSION_V0_LEGACY);
return (
<Tooltip content={tooltipText} placement="top">
<span
className={cn(
"inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold shrink-0 cursor-help lowercase",
version === "V1"
? "bg-green-500/20 text-green-500"
: "bg-neutral-500/20 text-neutral-400",
isConversationArchived && "opacity-60",
)}
>
{version}
</span>
</Tooltip>
);
}

View File

@ -3,9 +3,10 @@ import { NavLink, useParams, useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { usePaginatedConversations } from "#/hooks/query/use-paginated-conversations";
import { useStartTasks } from "#/hooks/query/use-start-tasks";
import { useInfiniteScroll } from "#/hooks/use-infinite-scroll";
import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation";
import { useStopConversation } from "#/hooks/mutation/use-stop-conversation";
import { useUnifiedPauseConversationSandbox } from "#/hooks/mutation/use-unified-stop-conversation";
import { ConfirmDeleteModal } from "./confirm-delete-modal";
import { ConfirmStopModal } from "./confirm-stop-modal";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
@ -15,6 +16,7 @@ import { Provider } from "#/types/settings";
import { useUpdateConversation } from "#/hooks/mutation/use-update-conversation";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
import { ConversationCard } from "./conversation-card/conversation-card";
import { StartTaskCard } from "./start-task-card/start-task-card";
interface ConversationPanelProps {
onClose: () => void;
@ -37,6 +39,8 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
const [selectedConversationId, setSelectedConversationId] = React.useState<
string | null
>(null);
const [selectedConversationVersion, setSelectedConversationVersion] =
React.useState<"V0" | "V1" | undefined>(undefined);
const [openContextMenuId, setOpenContextMenuId] = React.useState<
string | null
>(null);
@ -50,11 +54,15 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
fetchNextPage,
} = usePaginatedConversations();
// Fetch in-progress start tasks
const { data: startTasks } = useStartTasks();
// Flatten all pages into a single array of conversations
const conversations = data?.pages.flatMap((page) => page.results) ?? [];
const { mutate: deleteConversation } = useDeleteConversation();
const { mutate: stopConversation } = useStopConversation();
const { mutate: pauseConversationSandbox } =
useUnifiedPauseConversationSandbox();
const { mutate: updateConversation } = useUpdateConversation();
// Set up infinite scroll
@ -70,9 +78,13 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
setSelectedConversationId(conversationId);
};
const handleStopConversation = (conversationId: string) => {
const handleStopConversation = (
conversationId: string,
version?: "V0" | "V1",
) => {
setConfirmStopModalVisible(true);
setSelectedConversationId(conversationId);
setSelectedConversationVersion(version);
};
const handleConversationTitleChange = async (
@ -106,7 +118,10 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
const handleConfirmStop = () => {
if (selectedConversationId) {
stopConversation({ conversationId: selectedConversationId });
pauseConversationSandbox({
conversationId: selectedConversationId,
version: selectedConversationVersion,
});
}
};
@ -131,13 +146,24 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
<p className="text-danger">{error.message}</p>
</div>
)}
{!isFetching && conversations?.length === 0 && (
{!isFetching && conversations?.length === 0 && !startTasks?.length && (
<div className="flex flex-col items-center justify-center h-full">
<p className="text-neutral-400">
{t(I18nKey.CONVERSATION$NO_CONVERSATIONS)}
</p>
</div>
)}
{/* Render in-progress start tasks first */}
{startTasks?.map((task) => (
<NavLink
key={task.id}
to={`/conversations/task-${task.id}`}
onClick={onClose}
>
<StartTaskCard task={task} />
</NavLink>
))}
{/* Then render completed conversations */}
{conversations?.map((project) => (
<NavLink
key={project.conversation_id}
@ -146,7 +172,12 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
>
<ConversationCard
onDelete={() => handleDeleteProject(project.conversation_id)}
onStop={() => handleStopConversation(project.conversation_id)}
onStop={() =>
handleStopConversation(
project.conversation_id,
project.conversation_version,
)
}
onChangeTitle={(title) =>
handleConversationTitleChange(project.conversation_id, title)
}
@ -160,6 +191,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
createdAt={project.created_at}
conversationStatus={project.status}
conversationId={project.conversation_id}
conversationVersion={project.conversation_version}
contextMenuOpen={openContextMenuId === project.conversation_id}
onContextMenuToggle={(isOpen) =>
setOpenContextMenuId(isOpen ? project.conversation_id : null)

View File

@ -10,7 +10,7 @@ import { MicroagentsModalHeader } from "./microagents-modal-header";
import { MicroagentsLoadingState } from "./microagents-loading-state";
import { MicroagentsEmptyState } from "./microagents-empty-state";
import { MicroagentItem } from "./microagent-item";
import { useAgentStore } from "#/stores/agent-store";
import { useAgentState } from "#/hooks/use-agent-state";
interface MicroagentsModalProps {
onClose: () => void;
@ -18,7 +18,7 @@ interface MicroagentsModalProps {
export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
const { t } = useTranslation();
const { curAgentState } = useAgentStore();
const { curAgentState } = useAgentState();
const [expandedAgents, setExpandedAgents] = useState<Record<string, boolean>>(
{},
);

View File

@ -0,0 +1,46 @@
import { useTranslation } from "react-i18next";
import { formatTimeDelta } from "#/utils/format-time-delta";
import { cn } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
import { ConversationRepoLink } from "../conversation-card/conversation-repo-link";
import { NoRepository } from "../conversation-card/no-repository";
import type { RepositorySelection } from "#/api/open-hands.types";
interface StartTaskCardFooterProps {
selectedRepository: RepositorySelection | null;
createdAt: string; // ISO 8601
detail: string | null;
}
export function StartTaskCardFooter({
selectedRepository,
createdAt,
detail,
}: StartTaskCardFooterProps) {
const { t } = useTranslation();
return (
<div className={cn("flex flex-col gap-1 mt-1")}>
{/* Repository Info */}
<div className="flex flex-row justify-between items-center">
{selectedRepository ? (
<ConversationRepoLink selectedRepository={selectedRepository} />
) : (
<NoRepository />
)}
{createdAt && (
<p className="text-xs text-[#A3A3A3] flex-1 text-right">
<time>
{`${formatTimeDelta(new Date(createdAt))} ${t(I18nKey.CONVERSATION$AGO)}`}
</time>
</p>
)}
</div>
{/* Task Detail */}
{detail && (
<div className="text-xs text-neutral-500 truncate">{detail}</div>
)}
</div>
);
}

View File

@ -0,0 +1,34 @@
import type { V1AppConversationStartTaskStatus } from "#/api/conversation-service/v1-conversation-service.types";
import { ConversationVersionBadge } from "../conversation-card/conversation-version-badge";
import { StartTaskStatusIndicator } from "./start-task-status-indicator";
import { StartTaskStatusBadge } from "./start-task-status-badge";
interface StartTaskCardHeaderProps {
title: string;
taskStatus: V1AppConversationStartTaskStatus;
}
export function StartTaskCardHeader({
title,
taskStatus,
}: StartTaskCardHeaderProps) {
return (
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden mr-2">
{/* Status Indicator */}
<div className="flex items-center">
<StartTaskStatusIndicator taskStatus={taskStatus} />
</div>
{/* Version Badge - V1 tasks are always V1 */}
<ConversationVersionBadge version="V1" />
{/* Title */}
<h3 className="text-sm font-medium text-neutral-100 truncate flex-1">
{title}
</h3>
{/* Status Badge */}
<StartTaskStatusBadge taskStatus={taskStatus} />
</div>
);
}

View File

@ -0,0 +1,48 @@
import { useTranslation } from "react-i18next";
import type { V1AppConversationStartTask } from "#/api/conversation-service/v1-conversation-service.types";
import { cn } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
import { StartTaskCardHeader } from "./start-task-card-header";
import { StartTaskCardFooter } from "./start-task-card-footer";
interface StartTaskCardProps {
task: V1AppConversationStartTask;
onClick?: () => void;
}
export function StartTaskCard({ task, onClick }: StartTaskCardProps) {
const { t } = useTranslation();
const title =
task.request.title ||
task.detail ||
t(I18nKey.CONVERSATION$STARTING_CONVERSATION);
const selectedRepository = task.request.selected_repository
? {
selected_repository: task.request.selected_repository,
selected_branch: task.request.selected_branch || null,
git_provider: task.request.git_provider || null,
}
: null;
return (
<div
data-testid="start-task-card"
onClick={onClick}
className={cn(
"relative h-auto w-full p-3.5 border-b border-neutral-600 cursor-pointer",
"hover:bg-[#454545]",
)}
>
<div className="flex items-center justify-between w-full">
<StartTaskCardHeader title={title} taskStatus={task.status} />
</div>
<StartTaskCardFooter
selectedRepository={selectedRepository}
createdAt={task.created_at}
detail={task.detail}
/>
</div>
);
}

View File

@ -0,0 +1,45 @@
import type { V1AppConversationStartTaskStatus } from "#/api/conversation-service/v1-conversation-service.types";
import { cn } from "#/utils/utils";
interface StartTaskStatusBadgeProps {
taskStatus: V1AppConversationStartTaskStatus;
}
export function StartTaskStatusBadge({
taskStatus,
}: StartTaskStatusBadgeProps) {
// Don't show badge for WORKING status (most common, clutters UI)
if (taskStatus === "WORKING") {
return null;
}
// Format status for display
const formatStatus = (status: string) =>
status
.toLowerCase()
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
// Get status color
const getStatusStyle = () => {
switch (taskStatus) {
case "READY":
return "bg-green-500/10 text-green-400 border-green-500/20";
case "ERROR":
return "bg-red-500/10 text-red-400 border-red-500/20";
default:
return "bg-yellow-500/10 text-yellow-400 border-yellow-500/20";
}
};
return (
<span
className={cn(
"text-xs font-medium px-2 py-0.5 rounded border flex-shrink-0",
getStatusStyle(),
)}
>
{formatStatus(taskStatus)}
</span>
);
}

View File

@ -0,0 +1,35 @@
import type { V1AppConversationStartTaskStatus } from "#/api/conversation-service/v1-conversation-service.types";
import { cn } from "#/utils/utils";
interface StartTaskStatusIndicatorProps {
taskStatus: V1AppConversationStartTaskStatus;
}
export function StartTaskStatusIndicator({
taskStatus,
}: StartTaskStatusIndicatorProps) {
const getStatusColor = () => {
switch (taskStatus) {
case "READY":
return "bg-green-500";
case "ERROR":
return "bg-red-500";
case "WORKING":
case "WAITING_FOR_SANDBOX":
case "PREPARING_REPOSITORY":
case "RUNNING_SETUP_SCRIPT":
case "SETTING_UP_GIT_HOOKS":
case "STARTING_CONVERSATION":
return "bg-yellow-500 animate-pulse";
default:
return "bg-gray-500";
}
};
return (
<div
className={cn("w-2 h-2 rounded-full flex-shrink-0", getStatusColor())}
aria-label={`Task status: ${taskStatus}`}
/>
);
}

View File

@ -13,6 +13,7 @@ import { MicroagentsModal } from "../conversation-panel/microagents-modal";
import { ConfirmDeleteModal } from "../conversation-panel/confirm-delete-modal";
import { ConfirmStopModal } from "../conversation-panel/confirm-stop-modal";
import { MetricsModal } from "./metrics-modal/metrics-modal";
import { ConversationVersionBadge } from "../conversation-panel/conversation-card/conversation-version-badge";
export function ConversationName() {
const { t } = useTranslation();
@ -148,6 +149,12 @@ export function ConversationName() {
</div>
)}
{titleMode !== "edit" && (
<ConversationVersionBadge
version={conversation.conversation_version}
/>
)}
{titleMode !== "edit" && (
<div className="relative flex items-center">
<EllipsisButton fill="#B1B9D3" onClick={handleEllipsisClick} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, unknown>,
): React.ReactNode => {
if (!i18n.exists(key)) {
return key;
}
return (
<Trans
i18nKey={key}
values={values}
components={{
path: <PathComponent />,
cmd: <MonoComponent />,
}}
/>
);
};
// 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<string, unknown> = {};
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<string, unknown> = {};
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"),
};
};

View File

@ -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<FileEditorObservation | StrReplaceEditorObservation>,
): 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<ExecuteBashObservation>,
): 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<BrowserObservation>,
): 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<MCPToolObservation>,
): 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<TaskTrackerObservation>,
): 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<ThinkObservation>,
): string => {
const { observation } = event;
return observation.content || "";
};
const getFinishObservationContent = (
event: ObservationEvent<FinishObservation>,
): 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<ExecuteBashObservation>,
);
case "BrowserObservation":
return getBrowserObservationContent(
event as ObservationEvent<BrowserObservation>,
);
case "MCPToolObservation":
return getMCPToolObservationContent(
event as ObservationEvent<MCPToolObservation>,
);
case "TaskTrackerObservation":
return getTaskTrackerObservationContent(
event as ObservationEvent<TaskTrackerObservation>,
);
case "ThinkObservation":
return getThinkObservationContent(
event as ObservationEvent<ThinkObservation>,
);
case "FinishObservation":
return getFinishObservationContent(
event as ObservationEvent<FinishObservation>,
);
default:
return getDefaultEventContent(event);
}
};

View File

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

View File

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

View File

@ -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\`\`\``;

View File

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

View File

@ -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 (
<div>
<ErrorMessage
// V1 doesn't have error_id, use event.id instead
errorId={event.id}
defaultMessage={event.error}
/>
<MicroagentStatusWrapper
microagentStatus={microagentStatus}
microagentConversationId={microagentConversationId}
microagentPRUrl={microagentPRUrl}
actions={actions}
/>
{/* LikertScaleWrapper expects V0 event types, skip for now */}
</div>
);
}

View File

@ -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<FinishAction>;
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 (
<>
<ChatMessage
type="agent"
message={getEventContent(event).details}
actions={actions}
/>
<MicroagentStatusWrapper
microagentStatus={microagentStatus}
microagentConversationId={microagentConversationId}
microagentPRUrl={microagentPRUrl}
actions={actions}
/>
{/* LikertScaleWrapper expects V0 event types, skip for now */}
</>
);
}

View File

@ -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 (
<div>
<GenericEventMessage
title={title}
details={details}
success={
isObservationEvent(event) ? getObservationResult(event) : undefined
}
initiallyExpanded={false}
/>
{shouldShowConfirmationButtons && <ConfirmationButtons />}
</div>
);
}

View File

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

View File

@ -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 (
<div>
<ChatMessage type="agent" message={thoughtContent} actions={actions} />
<MicroagentStatusWrapper
microagentStatus={microagentStatus}
microagentConversationId={microagentConversationId}
microagentPRUrl={microagentPRUrl}
actions={actions}
/>
</div>
);
}
return (
<MicroagentStatusWrapper
microagentStatus={microagentStatus}
microagentConversationId={microagentConversationId}
microagentPRUrl={microagentPRUrl}
actions={actions}
/>
);
}

View File

@ -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 (
<>
<ChatMessage type={event.source} message={message} actions={actions}>
{imageUrls.length > 0 && (
<ImageCarousel size="small" images={imageUrls} />
)}
{/* TODO: Handle file_urls if V1 messages support them */}
{shouldShowConfirmationButtons && <ConfirmationButtons />}
</ChatMessage>
<MicroagentStatusWrapper
microagentStatus={microagentStatus}
microagentConversationId={microagentConversationId}
microagentPRUrl={microagentPRUrl}
actions={actions}
/>
{/* LikertScaleWrapper expects V0 event types, skip for now */}
</>
);
}

View File

@ -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 <ErrorEventMessage event={event} {...commonProps} />;
}
// Observation pairs with actions
if (hasObservationPair && isActionEvent(event)) {
return (
<ObservationPairEventMessage
event={event}
microagentStatus={microagentStatus}
microagentConversationId={microagentConversationId}
microagentPRUrl={microagentPRUrl}
actions={actions}
/>
);
}
// Finish actions
if (isActionEvent(event) && event.action.kind === "FinishAction") {
return (
<FinishEventMessage
event={event as ActionEvent<FinishAction>}
{...commonProps}
/>
);
}
// Message events (user and assistant messages)
if (!isActionEvent(event) && !isObservationEvent(event)) {
// This is a MessageEvent
return (
<UserAssistantEventMessage
event={event as MessageEvent}
shouldShowConfirmationButtons={shouldShowConfirmationButtons}
{...commonProps}
/>
);
}
// Generic fallback for all other events (including observation events)
return (
<GenericEventMessageWrapper
event={event}
shouldShowConfirmationButtons={shouldShowConfirmationButtons}
/>
);
}

View File

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

View File

@ -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<MessagesProps> = 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) => (
<EventMessage
key={message.id}
event={message}
hasObservationPair={actionHasObservationPair(message)}
isAwaitingUserConfirmation={isAwaitingUserConfirmation}
isLastMessage={messages.length - 1 === index}
isInLast10Actions={messages.length - 1 - index < 10}
// Microagent props - not implemented yet for V1
// microagentStatus={undefined}
// microagentConversationId={undefined}
// microagentPRUrl={undefined}
// actions={undefined}
/>
))}
{optimisticUserMessage && (
<ChatMessage type="user" message={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";

View File

@ -0,0 +1 @@
export * from "./chat";

View File

@ -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<string, unknown>) => void;
}
@ -132,7 +137,7 @@ export function WsClientProvider({
const queryClient = useQueryClient();
const sioRef = React.useRef<Socket | null>(null);
const [webSocketStatus, setWebSocketStatus] =
React.useState<WebSocketStatus>("DISCONNECTED");
React.useState<V0_WebSocketStatus>("DISCONNECTED");
const lastEventRef = React.useRef<Record<string, unknown> | null>(null);
const { providers } = useUserProviders();

View File

@ -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<void>;
}
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<V1_WebSocketConnectionState>("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<string, string | boolean> = {
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 (
<ConversationWebSocketContext.Provider value={contextValue}>
@ -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;
};

View File

@ -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 (
<WsClientProvider conversationId={conversationId}>
@ -43,7 +47,11 @@ export function WebSocketProviderWrapper({
if (version === 1) {
return (
<ConversationWebSocketProvider conversationId={conversationId}>
<ConversationWebSocketProvider
conversationId={conversationId}
conversationUrl={conversation?.url}
sessionApiKey={conversation?.session_api_key}
>
{children}
</ConversationWebSocketProvider>
);

View File

@ -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<string> => {
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"],
});
};

View File

@ -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> {
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<CreateConversationResponse> => {
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", {

View File

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

View File

@ -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("/");
}
},
});
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<VSCodeUrlResult>({
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),

View File

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

View File

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

View File

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

View File

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

View File

@ -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(() => {

View File

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

View File

@ -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<string, unknown>) => {
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<V1MessageContent> = [
{
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 };
}

View File

@ -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<Terminal | null>(null);
const fitAddon = React.useRef<FitAddon | null>(null);

View File

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

View File

@ -1,45 +1,78 @@
import React from "react";
export interface WebSocketHookOptions {
queryParams?: Record<string, string | boolean>;
onOpen?: (event: Event) => void;
onClose?: (event: CloseEvent) => void;
onMessage?: (event: MessageEvent) => void;
onError?: (event: Event) => void;
reconnect?: {
enabled?: boolean;
maxAttempts?: number;
};
}
export const useWebSocket = <T = string>(
url: string,
options?: {
queryParams?: Record<string, string>;
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<T | null>(null);
const [messages, setMessages] = React.useState<T[]>([]);
const [error, setError] = React.useState<Error | null>(null);
const [isReconnecting, setIsReconnecting] = React.useState(false);
const wsRef = React.useRef<WebSocket | null>(null);
const attemptCountRef = React.useRef(0);
const reconnectTimeoutRef = React.useRef<NodeJS.Timeout | null>(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<WeakSet<WebSocket>>(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<string, string>,
);
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 = <T = string>(
`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 = <T = string>(
[],
);
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 = <T = string>(
error,
socket: wsRef.current,
sendMessage,
isReconnecting,
attemptCount: attemptCountRef.current,
disconnect,
};
};

View File

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

View File

@ -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": "Розмову зупинено"
}
}

View File

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

View File

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

View File

@ -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<string | null>(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 = (
<ConversationSubscriptionsProvider>
<EventHandler>
<div
data-testid="app-route"
className="p-3 md:p-0 flex flex-col h-full gap-3"
>
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4.5 pt-2 lg:pt-0">
<ConversationName />
<ConversationTabs />
</div>
<ConversationMain />
</div>
</EventHandler>
</ConversationSubscriptionsProvider>
);
// 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 (
<WebSocketProviderWrapper version={0} conversationId={conversationId}>
<ConversationSubscriptionsProvider>
<EventHandler>
<div
data-testid="app-route"
className="p-3 md:p-0 flex flex-col h-full gap-3"
>
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4.5 pt-2 lg:pt-0">
<ConversationName />
<ConversationTabs />
</div>
<ConversationMain />
</div>
</EventHandler>
</ConversationSubscriptionsProvider>
<WebSocketProviderWrapper
version={isV1Conversation ? 1 : 0}
conversationId={conversationId}
>
{content}
</WebSocketProviderWrapper>
);
}

View File

@ -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<HTMLIFrameElement>(null);
const [isCrossProtocol, setIsCrossProtocol] = useState(false);

View File

@ -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<V1ConversationStateStore>(
(set) => ({
agent_status: null,
setAgentStatus: (agent_status: V1AgentStatus) => set({ agent_status }),
reset: () => set({ agent_status: null }),
}),
);

View File

@ -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<string, unknown>;
}
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

View File

@ -3,6 +3,7 @@ type EventType =
| "Finish"
| "Think"
| "ExecuteBash"
| "FileEditor"
| "StrReplaceEditor"
| "TaskTracker";

View File

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

View File

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

View File

@ -7,7 +7,7 @@ import {
RedactedThinkingBlock,
} from "../base/event";
export interface ActionEvent extends BaseEvent {
export interface ActionEvent<T extends Action = Action> 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

View File

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

View File

@ -21,11 +21,12 @@ export interface ObservationBaseEvent extends BaseEvent {
}
// Main observation event interface
export interface ObservationEvent extends ObservationBaseEvent {
export interface ObservationEvent<T extends Observation = Observation>
extends ObservationBaseEvent {
/**
* The observation (tool call) sent to LLM
*/
observation: Observation;
observation: T;
/**
* The action id that this observation is responding to

View File

@ -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<ExecuteBashAction> =>
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<ExecuteBashObservation> =>
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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More