diff --git a/frontend/__tests__/api/file-service/file-service.api.test.ts b/frontend/__tests__/api/file-service/file-service.api.test.ts deleted file mode 100644 index dce183a21d..0000000000 --- a/frontend/__tests__/api/file-service/file-service.api.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/frontend/__tests__/build-websocket-url.test.ts b/frontend/__tests__/build-websocket-url.test.ts new file mode 100644 index 0000000000..da20565afc --- /dev/null +++ b/frontend/__tests__/build-websocket-url.test.ts @@ -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("?"); + }); + }); +}); diff --git a/frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx b/frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx index 8250647169..9faef96ac5 100644 --- a/frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx +++ b/frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx @@ -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 () => { diff --git a/frontend/__tests__/components/features/conversation/server-status.test.tsx b/frontend/__tests__/components/features/conversation/server-status.test.tsx index b8fc40369e..8780530d0e 100644 --- a/frontend/__tests__/components/features/conversation/server-status.test.tsx +++ b/frontend/__tests__/components/features/conversation/server-status.test.tsx @@ -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( - , + , ); expect(screen.getByText("Running")).toBeInTheDocument(); // Test STOPPED status - rerender(); + rerender( + , + ); expect(screen.getByText("Server Stopped")).toBeInTheDocument(); // Test STARTING status (shows "Running" due to agent state being RUNNING) - rerender(); + rerender( + , + ); expect(screen.getByText("Running")).toBeInTheDocument(); // Test null status (shows "Running" due to agent state being RUNNING) - rerender(); + rerender( + , + ); expect(screen.getByText("Running")).toBeInTheDocument(); }); @@ -108,7 +145,13 @@ describe("ServerStatus", () => { // Mock agent store to return RUNNING state mockAgentStore(AgentState.RUNNING); - renderWithProviders(); + renderWithProviders( + , + ); 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(); + renderWithProviders( + , + ); 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(); + renderWithProviders( + , + ); 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(); + renderWithProviders( + , + ); 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(); + renderWithProviders( + , + ); 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(); + renderWithProviders( + , + ); 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(); + renderWithProviders( + , + ); 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(); + renderWithProviders( + , + ); const statusText = screen.getByText("Running"); expect(statusText).toBeInTheDocument(); diff --git a/frontend/__tests__/components/interactive-chat-box.test.tsx b/frontend/__tests__/components/interactive-chat-box.test.tsx index f9c9e9a374..b518580650 100644 --- a/frontend/__tests__/components/interactive-chat-box.test.tsx +++ b/frontend/__tests__/components/interactive-chat-box.test.tsx @@ -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( , 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( - + , ); diff --git a/frontend/__tests__/components/jupyter/jupyter.test.tsx b/frontend/__tests__/components/jupyter/jupyter.test.tsx index 05ee023db6..bf6c746963 100644 --- a/frontend/__tests__/components/jupyter/jupyter.test.tsx +++ b/frontend/__tests__/components/jupyter/jupyter.test.tsx @@ -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( diff --git a/frontend/__tests__/components/modals/microagents/microagent-modal.test.tsx b/frontend/__tests__/components/modals/microagents/microagent-modal.test.tsx index 1dc6961345..f3b68c513d 100644 --- a/frontend/__tests__/components/modals/microagents/microagent-modal.test.tsx +++ b/frontend/__tests__/components/modals/microagents/microagent-modal.test.tsx @@ -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(), }); }); diff --git a/frontend/__tests__/conversation-websocket-handler.test.tsx b/frontend/__tests__/conversation-websocket-handler.test.tsx index db59d764d4..3dac31a8e0 100644 --- a/frontend/__tests__/conversation-websocket-handler.test.tsx +++ b/frontend/__tests__/conversation-websocket-handler.test.tsx @@ -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( - + {children} , @@ -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(); + + // 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(); + + // 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)", + ); + }); + }); }); diff --git a/frontend/__tests__/helpers/msw-websocket-setup.ts b/frontend/__tests__/helpers/msw-websocket-setup.ts index 560009e27e..76fe22d30c 100644 --- a/frontend/__tests__/helpers/msw-websocket-setup.ts +++ b/frontend/__tests__/helpers/msw-websocket-setup.ts @@ -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", + ); diff --git a/frontend/__tests__/helpers/websocket-test-components.tsx b/frontend/__tests__/helpers/websocket-test-components.tsx index 15fd2b4617..016a8f38b8 100644 --- a/frontend/__tests__/helpers/websocket-test-components.tsx +++ b/frontend/__tests__/helpers/websocket-test-components.tsx @@ -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 (
-
{connectionState}
+
+ {context?.connectionState || "NOT_AVAILABLE"} +
); } diff --git a/frontend/__tests__/hooks/use-terminal.test.tsx b/frontend/__tests__/hooks/use-terminal.test.tsx index d1937986af..3988c43102 100644 --- a/frontend/__tests__/hooks/use-terminal.test.tsx +++ b/frontend/__tests__/hooks/use-terminal.test.tsx @@ -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
; diff --git a/frontend/__tests__/hooks/use-websocket.test.ts b/frontend/__tests__/hooks/use-websocket.test.ts index 7152f371c2..2559dc700d 100644 --- a/frontend/__tests__/hooks/use-websocket.test.ts +++ b/frontend/__tests__/hooks/use-websocket.test.ts @@ -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"); diff --git a/frontend/__tests__/utils/check-hardcoded-strings.test.tsx b/frontend/__tests__/utils/check-hardcoded-strings.test.tsx index dd0a9075fe..ff0de34962 100644 --- a/frontend/__tests__/utils/check-hardcoded-strings.test.tsx +++ b/frontend/__tests__/utils/check-hardcoded-strings.test.tsx @@ -60,7 +60,7 @@ describe("Check for hardcoded English strings", () => { test("InteractiveChatBox should not have hardcoded English strings", () => { const { container } = renderWithProviders( - {}} onStop={() => {}} /> + {}} /> , ); diff --git a/frontend/src/api/conversation-service/conversation-service.api.ts b/frontend/src/api/conversation-service/conversation-service.api.ts index 04724d78ab..ed0ce8b678 100644 --- a/frontend/src/api/conversation-service/conversation-service.api.ts +++ b/frontend/src/api/conversation-service/conversation-service.api.ts @@ -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 { - 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 { - const url = `${this.getConversationUrl(conversationId)}/select-file`; - const { data } = await openHands.get(url, { - params: { file: path }, - headers: this.getConversationHeaders(), - }); - - return data.code; - } - /** * Upload multiple files to the workspace * @param conversationId ID of the conversation diff --git a/frontend/src/api/conversation-service/v1-conversation-service.api.ts b/frontend/src/api/conversation-service/v1-conversation-service.api.ts new file mode 100644 index 0000000000..935574f1a8 --- /dev/null +++ b/frontend/src/api/conversation-service/v1-conversation-service.api.ts @@ -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 { + const headers: Record = {}; + 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 { + const { data } = await openHands.post( + `/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 { + 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( + "/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 { + 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 { + const params = new URLSearchParams(); + params.append("limit", limit.toString()); + + const { data } = await openHands.get( + `/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 { + 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; diff --git a/frontend/src/api/conversation-service/v1-conversation-service.types.ts b/frontend/src/api/conversation-service/v1-conversation-service.types.ts new file mode 100644 index 0000000000..9ff3499652 --- /dev/null +++ b/frontend/src/api/conversation-service/v1-conversation-service.types.ts @@ -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; +} diff --git a/frontend/src/api/open-hands.types.ts b/frontend/src/api/open-hands.types.ts index b7680029f8..48b5eef736 100644 --- a/frontend/src/api/open-hands.types.ts +++ b/frontend/src/api/open-hands.types.ts @@ -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 { diff --git a/frontend/src/components/features/chat/chat-interface.tsx b/frontend/src/components/features/chat/chat-interface.tsx index 176999cd93..56ab3361bb 100644 --- a/frontend/src/components/features/chat/chat-interface.tsx +++ b/frontend/src/components/features/chat/chat-interface.tsx @@ -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 ( @@ -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 && (
)} - {!isLoadingMessages && userEventsExist && ( - + )} + + {v1UserEventsExist && ( +
- {events.length > 0 && ( + {totalEvents > 0 && ( onClickShareFeedbackActionButton("positive") @@ -235,10 +259,7 @@ export function ChatInterface() { {errorMessage && } - +
{config?.APP_MODE !== "saas" && ( diff --git a/frontend/src/components/features/chat/components/chat-input-actions.tsx b/frontend/src/components/features/chat/components/chat-input-actions.tsx index 5a1a19c3d5..4c20b4454c 100644 --- a/frontend/src/components/features/chat/components/chat-input-actions.tsx +++ b/frontend/src/components/features/chat/components/chat-input-actions.tsx @@ -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 (
- +
handleStop(onStop)} + handleStop={handlePauseAgent} handleResumeAgent={handleResumeAgent} disabled={disabled} + isPausing={isPausing} />
); diff --git a/frontend/src/components/features/chat/components/chat-input-container.tsx b/frontend/src/components/features/chat/components/chat-input-container.tsx index f87c2940d4..39d9bbe745 100644 --- a/frontend/src/components/features/chat/components/chat-input-container.tsx +++ b/frontend/src/components/features/chat/components/chat-input-container.tsx @@ -15,7 +15,6 @@ interface ChatInputContainerProps { chatInputRef: React.RefObject; 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 (
); diff --git a/frontend/src/components/features/chat/custom-chat-input.tsx b/frontend/src/components/features/chat/custom-chat-input.tsx index 5cf3c13442..9c12beefca 100644 --- a/frontend/src/components/features/chat/custom-chat-input.tsx +++ b/frontend/src/components/features/chat/custom-chat-input.tsx @@ -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, fileInputRef as React.RefObject, 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} />
diff --git a/frontend/src/components/features/chat/interactive-chat-box.tsx b/frontend/src/components/features/chat/interactive-chat-box.tsx index 2818cd09e6..4c94df4b42 100644 --- a/frontend/src/components/features/chat/interactive-chat-box.tsx +++ b/frontend/src/components/features/chat/interactive-chat-box.tsx @@ -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({ diff --git a/frontend/src/components/features/controls/agent-status.tsx b/frontend/src/components/features/controls/agent-status.tsx index a549d9bcbc..29ed67e3ca 100644 --- a/frontend/src/components/features/controls/agent-status.tsx +++ b/frontend/src/components/features/controls/agent-status.tsx @@ -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"; diff --git a/frontend/src/components/features/controls/server-status.tsx b/frontend/src/components/features/controls/server-status.tsx index 96e28277e9..aad03df7be 100644 --- a/frontend/src/components/features/controls/server-status.tsx +++ b/frontend/src/components/features/controls/server-status.tsx @@ -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) => { event.preventDefault(); - stopConversationMutation.mutate({ conversationId }); + handleStop(); setShowContextMenu(false); }; const handleStartServer = (event: React.MouseEvent) => { event.preventDefault(); - startConversationMutation.mutate({ - conversationId, - providers, - }); + handleResumeAgent(); setShowContextMenu(false); }; diff --git a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-actions.tsx b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-actions.tsx index 11ccad06f5..43b7bc1987 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-actions.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-actions.tsx @@ -27,6 +27,8 @@ export function ConversationCardActions({ conversationId, showOptions, }: ConversationCardActionsProps) { + const isConversationArchived = conversationStatus === "ARCHIVED"; + return (
diff --git a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx index 3c969dbb59..fb77b58242 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx @@ -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 ( -
+
{selectedRepository?.selected_repository ? ( ) : ( diff --git a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-header.tsx b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-header.tsx index 05b54973ab..e41d9fa111 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-header.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-header.tsx @@ -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 (
{/* Status Indicator */} @@ -26,10 +31,16 @@ export function ConversationCardHeader({ />
)} + {/* Version Badge */} + {/* Status Badges */} {conversationStatus && ( diff --git a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-title.tsx b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-title.tsx index 9faa8d1500..dc10742e88 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-title.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-title.tsx @@ -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 (

{title} diff --git a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card.tsx b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card.tsx index 00f13083de..8c6b895eaf 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card.tsx @@ -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", )} >

@@ -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} />
); diff --git a/frontend/src/components/features/conversation-panel/conversation-card/conversation-status-badges.tsx b/frontend/src/components/features/conversation-panel/conversation-card/conversation-status-badges.tsx index e78e363c24..c6e5522430 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card/conversation-status-badges.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card/conversation-status-badges.tsx @@ -15,7 +15,7 @@ export function ConversationStatusBadges({ if (conversationStatus === "ARCHIVED") { return ( - + {t(I18nKey.COMMON$ARCHIVED)} diff --git a/frontend/src/components/features/conversation-panel/conversation-card/conversation-version-badge.tsx b/frontend/src/components/features/conversation-panel/conversation-card/conversation-version-badge.tsx new file mode 100644 index 0000000000..371b5f98aa --- /dev/null +++ b/frontend/src/components/features/conversation-panel/conversation-card/conversation-version-badge.tsx @@ -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 ( + + + {version} + + + ); +} diff --git a/frontend/src/components/features/conversation-panel/conversation-panel.tsx b/frontend/src/components/features/conversation-panel/conversation-panel.tsx index c502f39453..e38e2f272e 100644 --- a/frontend/src/components/features/conversation-panel/conversation-panel.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-panel.tsx @@ -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) {

{error.message}

)} - {!isFetching && conversations?.length === 0 && ( + {!isFetching && conversations?.length === 0 && !startTasks?.length && (

{t(I18nKey.CONVERSATION$NO_CONVERSATIONS)}

)} + {/* Render in-progress start tasks first */} + {startTasks?.map((task) => ( + + + + ))} + {/* Then render completed conversations */} {conversations?.map((project) => ( 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) diff --git a/frontend/src/components/features/conversation-panel/microagents-modal.tsx b/frontend/src/components/features/conversation-panel/microagents-modal.tsx index f255f4adce..66bc340422 100644 --- a/frontend/src/components/features/conversation-panel/microagents-modal.tsx +++ b/frontend/src/components/features/conversation-panel/microagents-modal.tsx @@ -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>( {}, ); diff --git a/frontend/src/components/features/conversation-panel/start-task-card/start-task-card-footer.tsx b/frontend/src/components/features/conversation-panel/start-task-card/start-task-card-footer.tsx new file mode 100644 index 0000000000..a2f9bb4036 --- /dev/null +++ b/frontend/src/components/features/conversation-panel/start-task-card/start-task-card-footer.tsx @@ -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 ( +
+ {/* Repository Info */} +
+ {selectedRepository ? ( + + ) : ( + + )} + {createdAt && ( +

+ +

+ )} +
+ + {/* Task Detail */} + {detail && ( +
{detail}
+ )} +
+ ); +} diff --git a/frontend/src/components/features/conversation-panel/start-task-card/start-task-card-header.tsx b/frontend/src/components/features/conversation-panel/start-task-card/start-task-card-header.tsx new file mode 100644 index 0000000000..caa8e8f5e4 --- /dev/null +++ b/frontend/src/components/features/conversation-panel/start-task-card/start-task-card-header.tsx @@ -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 ( +
+ {/* Status Indicator */} +
+ +
+ + {/* Version Badge - V1 tasks are always V1 */} + + + {/* Title */} +

+ {title} +

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