mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
feat(frontend): V1 conversation API (PARTIAL) (#11336)
Co-authored-by: Hiep Le <69354317+hieptl@users.noreply.github.com> Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
This commit is contained in:
parent
fab64a51b7
commit
531683abae
@ -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");
|
||||
});
|
||||
});
|
||||
187
frontend/__tests__/build-websocket-url.test.ts
Normal file
187
frontend/__tests__/build-websocket-url.test.ts
Normal file
@ -0,0 +1,187 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { buildWebSocketUrl } from "#/utils/websocket-url";
|
||||
|
||||
describe("buildWebSocketUrl", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Basic URL construction", () => {
|
||||
it("should build WebSocket URL with conversation ID and URL", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "localhost:3000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"http://localhost:8080/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://localhost:8080/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should use wss:// protocol when window.location.protocol is https:", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "https:",
|
||||
host: "localhost:3000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"https://example.com:8080/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("wss://example.com:8080/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should extract host and port from conversation URL", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "localhost:3000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-456",
|
||||
"http://agent-server.com:9000/api/conversations/conv-456",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://agent-server.com:9000/sockets/events/conv-456");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Query parameters handling", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "localhost:3000",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not include query parameters in the URL (handled by useWebSocket hook)", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"http://localhost:8080/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://localhost:8080/sockets/events/conv-123");
|
||||
expect(result).not.toContain("?");
|
||||
expect(result).not.toContain("session_api_key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fallback to window.location.host", () => {
|
||||
it("should use window.location.host when conversation URL is null", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "fallback-host:4000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl("conv-123", null);
|
||||
|
||||
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should use window.location.host when conversation URL is undefined", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "fallback-host:4000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl("conv-123", undefined);
|
||||
|
||||
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should use window.location.host when conversation URL is relative path", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "fallback-host:4000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should use window.location.host when conversation URL is invalid", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "fallback-host:4000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl("conv-123", "not-a-valid-url");
|
||||
|
||||
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "localhost:3000",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null when conversationId is undefined", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
undefined,
|
||||
"http://localhost:8080/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when conversationId is empty string", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
"",
|
||||
"http://localhost:8080/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle conversation URLs with non-standard ports", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"http://example.com:12345/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://example.com:12345/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should handle conversation URLs without port (default port)", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"http://example.com/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://example.com/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should handle conversation IDs with special characters", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123-abc_def",
|
||||
"http://localhost:8080/api/conversations/conv-123-abc_def",
|
||||
);
|
||||
|
||||
expect(result).toBe(
|
||||
"ws://localhost:8080/sockets/events/conv-123-abc_def",
|
||||
);
|
||||
});
|
||||
|
||||
it("should build URL without query parameters", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"http://localhost:8080/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://localhost:8080/sockets/events/conv-123");
|
||||
expect(result).not.toContain("?");
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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 () => {
|
||||
|
||||
@ -6,25 +6,25 @@ import { ServerStatus } from "#/components/features/controls/server-status";
|
||||
import { ServerStatusContextMenu } from "#/components/features/controls/server-status-context-menu";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
|
||||
// Mock the agent store
|
||||
vi.mock("#/stores/agent-store", () => ({
|
||||
useAgentStore: vi.fn(),
|
||||
// Mock the agent state hook
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the custom hooks
|
||||
const mockStartConversationMutate = vi.fn();
|
||||
const mockStopConversationMutate = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/mutation/use-start-conversation", () => ({
|
||||
useStartConversation: () => ({
|
||||
vi.mock("#/hooks/mutation/use-unified-start-conversation", () => ({
|
||||
useUnifiedStartConversation: () => ({
|
||||
mutate: mockStartConversationMutate,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/mutation/use-stop-conversation", () => ({
|
||||
useStopConversation: () => ({
|
||||
vi.mock("#/hooks/mutation/use-unified-stop-conversation", () => ({
|
||||
useUnifiedStopConversation: () => ({
|
||||
mutate: mockStopConversationMutate,
|
||||
}),
|
||||
}));
|
||||
@ -41,6 +41,19 @@ vi.mock("#/hooks/use-user-providers", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-task-polling", () => ({
|
||||
useTaskPolling: () => ({
|
||||
isTask: false,
|
||||
taskId: null,
|
||||
conversationId: "test-conversation-id",
|
||||
task: null,
|
||||
taskStatus: null,
|
||||
taskDetail: null,
|
||||
taskError: null,
|
||||
isLoadingTask: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
@ -66,12 +79,14 @@ vi.mock("react-i18next", async () => {
|
||||
});
|
||||
|
||||
describe("ServerStatus", () => {
|
||||
// Helper function to mock agent store with specific state
|
||||
// Mock functions for handlers
|
||||
const mockHandleStop = vi.fn();
|
||||
const mockHandleResumeAgent = vi.fn();
|
||||
|
||||
// Helper function to mock agent state with specific state
|
||||
const mockAgentStore = (agentState: AgentState) => {
|
||||
vi.mocked(useAgentStore).mockReturnValue({
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: agentState,
|
||||
setCurrentAgentState: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
});
|
||||
};
|
||||
|
||||
@ -85,20 +100,42 @@ describe("ServerStatus", () => {
|
||||
|
||||
// Test RUNNING status
|
||||
const { rerender } = renderWithProviders(
|
||||
<ServerStatus conversationStatus="RUNNING" />,
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
|
||||
// Test STOPPED status
|
||||
rerender(<ServerStatus conversationStatus="STOPPED" />);
|
||||
rerender(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Server Stopped")).toBeInTheDocument();
|
||||
|
||||
// Test STARTING status (shows "Running" due to agent state being RUNNING)
|
||||
rerender(<ServerStatus conversationStatus="STARTING" />);
|
||||
rerender(
|
||||
<ServerStatus
|
||||
conversationStatus="STARTING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
|
||||
// Test null status (shows "Running" due to agent state being RUNNING)
|
||||
rerender(<ServerStatus conversationStatus={null} />);
|
||||
rerender(
|
||||
<ServerStatus
|
||||
conversationStatus={null}
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@ -108,7 +145,13 @@ describe("ServerStatus", () => {
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Running").closest("div");
|
||||
expect(statusContainer).toBeInTheDocument();
|
||||
@ -128,7 +171,13 @@ describe("ServerStatus", () => {
|
||||
// Mock agent store to return STOPPED state
|
||||
mockAgentStore(AgentState.STOPPED);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Server Stopped").closest("div");
|
||||
expect(statusContainer).toBeInTheDocument();
|
||||
@ -148,7 +197,13 @@ describe("ServerStatus", () => {
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="STARTING" />);
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="STARTING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Running").closest("div");
|
||||
expect(statusContainer).toBeInTheDocument();
|
||||
@ -165,12 +220,18 @@ describe("ServerStatus", () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Clear previous calls
|
||||
mockStopConversationMutate.mockClear();
|
||||
mockHandleStop.mockClear();
|
||||
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Running").closest("div");
|
||||
await user.click(statusContainer!);
|
||||
@ -178,21 +239,25 @@ describe("ServerStatus", () => {
|
||||
const stopButton = screen.getByTestId("stop-server-button");
|
||||
await user.click(stopButton);
|
||||
|
||||
expect(mockStopConversationMutate).toHaveBeenCalledWith({
|
||||
conversationId: "test-conversation-id",
|
||||
});
|
||||
expect(mockHandleStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call start conversation mutation when start server is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Clear previous calls
|
||||
mockStartConversationMutate.mockClear();
|
||||
mockHandleResumeAgent.mockClear();
|
||||
|
||||
// Mock agent store to return STOPPED state
|
||||
mockAgentStore(AgentState.STOPPED);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Server Stopped").closest("div");
|
||||
await user.click(statusContainer!);
|
||||
@ -200,10 +265,7 @@ describe("ServerStatus", () => {
|
||||
const startButton = screen.getByTestId("start-server-button");
|
||||
await user.click(startButton);
|
||||
|
||||
expect(mockStartConversationMutate).toHaveBeenCalledWith({
|
||||
conversationId: "test-conversation-id",
|
||||
providers: [],
|
||||
});
|
||||
expect(mockHandleResumeAgent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should close context menu after stop server action", async () => {
|
||||
@ -212,7 +274,13 @@ describe("ServerStatus", () => {
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Running").closest("div");
|
||||
await user.click(statusContainer!);
|
||||
@ -221,9 +289,7 @@ describe("ServerStatus", () => {
|
||||
await user.click(stopButton);
|
||||
|
||||
// Context menu should be closed (handled by the component)
|
||||
expect(mockStopConversationMutate).toHaveBeenCalledWith({
|
||||
conversationId: "test-conversation-id",
|
||||
});
|
||||
expect(mockHandleStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should close context menu after start server action", async () => {
|
||||
@ -232,7 +298,13 @@ describe("ServerStatus", () => {
|
||||
// Mock agent store to return STOPPED state
|
||||
mockAgentStore(AgentState.STOPPED);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Server Stopped").closest("div");
|
||||
await user.click(statusContainer!);
|
||||
@ -250,7 +322,13 @@ describe("ServerStatus", () => {
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus={null} />);
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus={null}
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusText = screen.getByText("Running");
|
||||
expect(statusText).toBeInTheDocument();
|
||||
|
||||
@ -5,12 +5,12 @@ import { MemoryRouter } from "react-router";
|
||||
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
|
||||
// Mock the agent store
|
||||
vi.mock("#/stores/agent-store", () => ({
|
||||
useAgentStore: vi.fn(),
|
||||
// Mock the agent state hook
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the conversation store
|
||||
@ -57,14 +57,11 @@ vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
|
||||
|
||||
describe("InteractiveChatBox", () => {
|
||||
const onSubmitMock = vi.fn();
|
||||
const onStopMock = vi.fn();
|
||||
|
||||
// Helper function to mock stores
|
||||
const mockStores = (agentState: AgentState = AgentState.INIT) => {
|
||||
vi.mocked(useAgentStore).mockReturnValue({
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: agentState,
|
||||
setCurrentAgentState: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
});
|
||||
|
||||
vi.mocked(useConversationStore).mockReturnValue({
|
||||
@ -103,14 +100,13 @@ describe("InteractiveChatBox", () => {
|
||||
};
|
||||
|
||||
// Helper function to render with Router context
|
||||
const renderInteractiveChatBox = (props: any, options: any = {}) => {
|
||||
return renderWithProviders(
|
||||
const renderInteractiveChatBox = (props: any, options: any = {}) =>
|
||||
renderWithProviders(
|
||||
<MemoryRouter>
|
||||
<InteractiveChatBox {...props} />
|
||||
</MemoryRouter>,
|
||||
options,
|
||||
);
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
global.URL.createObjectURL = vi
|
||||
@ -127,7 +123,6 @@ describe("InteractiveChatBox", () => {
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
const chatBox = screen.getByTestId("interactive-chat-box");
|
||||
@ -140,7 +135,6 @@ describe("InteractiveChatBox", () => {
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
const textbox = screen.getByTestId("chat-input");
|
||||
@ -157,7 +151,6 @@ describe("InteractiveChatBox", () => {
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
// Create a larger file to ensure it passes validation
|
||||
@ -184,7 +177,6 @@ describe("InteractiveChatBox", () => {
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
const fileContent = new Array(1024).fill("a").join(""); // 1KB file
|
||||
@ -209,7 +201,6 @@ describe("InteractiveChatBox", () => {
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
const textarea = screen.getByTestId("chat-input");
|
||||
@ -240,7 +231,6 @@ describe("InteractiveChatBox", () => {
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
const button = screen.getByTestId("submit-button");
|
||||
@ -250,33 +240,14 @@ describe("InteractiveChatBox", () => {
|
||||
expect(onSubmitMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should display the stop button when agent is running and call onStop when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockStores(AgentState.RUNNING);
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
// The stop button should be available when agent is running
|
||||
const stopButton = screen.getByTestId("stop-button");
|
||||
expect(stopButton).toBeInTheDocument();
|
||||
|
||||
await user.click(stopButton);
|
||||
expect(onStopMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should handle image upload and message submission correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
const onStop = vi.fn();
|
||||
|
||||
mockStores(AgentState.AWAITING_USER_INPUT);
|
||||
|
||||
const { rerender } = renderInteractiveChatBox({
|
||||
onSubmit: onSubmit,
|
||||
onStop: onStop,
|
||||
onSubmit,
|
||||
});
|
||||
|
||||
// Verify text input has the initial value
|
||||
@ -296,7 +267,7 @@ describe("InteractiveChatBox", () => {
|
||||
// Simulate parent component updating the value prop
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<InteractiveChatBox onSubmit={onSubmit} onStop={onStop} />
|
||||
<InteractiveChatBox onSubmit={onSubmit} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
|
||||
import { screen, waitFor, render } from "@testing-library/react";
|
||||
import { screen, waitFor, render, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import {
|
||||
@ -19,16 +19,34 @@ import { conversationWebSocketTestSetup } from "./helpers/msw-websocket-setup";
|
||||
// MSW WebSocket mock setup
|
||||
const { wsLink, server: mswServer } = conversationWebSocketTestSetup();
|
||||
|
||||
beforeAll(() => mswServer.listen());
|
||||
beforeAll(() => {
|
||||
// The global MSW server from vitest.setup.ts is already running
|
||||
// We just need to start our WebSocket-specific server
|
||||
mswServer.listen({ onUnhandledRequest: "bypass" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mswServer.resetHandlers();
|
||||
// Clean up any React components
|
||||
cleanup();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Close the WebSocket MSW server
|
||||
mswServer.close();
|
||||
|
||||
// Give time for any pending WebSocket connections to close. This is very important to prevent serious memory leaks
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
});
|
||||
afterAll(() => mswServer.close());
|
||||
|
||||
// Helper function to render components with ConversationWebSocketProvider
|
||||
function renderWithWebSocketContext(
|
||||
children: React.ReactNode,
|
||||
conversationId = "test-conversation-default",
|
||||
conversationUrl = "http://localhost:3000/api/conversations/test-conversation-default",
|
||||
sessionApiKey: string | null = null,
|
||||
) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@ -39,7 +57,11 @@ function renderWithWebSocketContext(
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConversationWebSocketProvider conversationId={conversationId}>
|
||||
<ConversationWebSocketProvider
|
||||
conversationId={conversationId}
|
||||
conversationUrl={conversationUrl}
|
||||
sessionApiKey={sessionApiKey}
|
||||
>
|
||||
{children}
|
||||
</ConversationWebSocketProvider>
|
||||
</QueryClientProvider>,
|
||||
@ -394,4 +416,98 @@ describe("Conversation WebSocket Handler", () => {
|
||||
it.todo("should send user actions through WebSocket when connected");
|
||||
it.todo("should handle send attempts when disconnected");
|
||||
});
|
||||
|
||||
// 8. Terminal I/O Tests (ExecuteBashAction and ExecuteBashObservation)
|
||||
describe("Terminal I/O Integration", () => {
|
||||
it("should append command to store when ExecuteBashAction event is received", async () => {
|
||||
const { createMockExecuteBashActionEvent } = await import(
|
||||
"#/mocks/mock-ws-helpers"
|
||||
);
|
||||
const { useCommandStore } = await import("#/state/command-store");
|
||||
|
||||
// Clear the command store before test
|
||||
useCommandStore.getState().clearTerminal();
|
||||
|
||||
// Create a mock ExecuteBashAction event
|
||||
const mockBashActionEvent = createMockExecuteBashActionEvent("npm test");
|
||||
|
||||
// Set up MSW to send the event when connection is established
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send the mock event after connection
|
||||
client.send(JSON.stringify(mockBashActionEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
// Render with WebSocket context (we don't need a component, just need the provider to be active)
|
||||
renderWithWebSocketContext(<ConnectionStatusComponent />);
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
// Wait for the command to be added to the store
|
||||
await waitFor(() => {
|
||||
const { commands } = useCommandStore.getState();
|
||||
expect(commands.length).toBe(1);
|
||||
});
|
||||
|
||||
// Verify the command was added with correct type and content
|
||||
const { commands } = useCommandStore.getState();
|
||||
expect(commands[0].type).toBe("input");
|
||||
expect(commands[0].content).toBe("npm test");
|
||||
});
|
||||
|
||||
it("should append output to store when ExecuteBashObservation event is received", async () => {
|
||||
const { createMockExecuteBashObservationEvent } = await import(
|
||||
"#/mocks/mock-ws-helpers"
|
||||
);
|
||||
const { useCommandStore } = await import("#/state/command-store");
|
||||
|
||||
// Clear the command store before test
|
||||
useCommandStore.getState().clearTerminal();
|
||||
|
||||
// Create a mock ExecuteBashObservation event
|
||||
const mockBashObservationEvent = createMockExecuteBashObservationEvent(
|
||||
"PASS tests/example.test.js\n ✓ should work (2 ms)",
|
||||
"npm test",
|
||||
);
|
||||
|
||||
// Set up MSW to send the event when connection is established
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send the mock event after connection
|
||||
client.send(JSON.stringify(mockBashObservationEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
// Render with WebSocket context
|
||||
renderWithWebSocketContext(<ConnectionStatusComponent />);
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
// Wait for the output to be added to the store
|
||||
await waitFor(() => {
|
||||
const { commands } = useCommandStore.getState();
|
||||
expect(commands.length).toBe(1);
|
||||
});
|
||||
|
||||
// Verify the output was added with correct type and content
|
||||
const { commands } = useCommandStore.getState();
|
||||
expect(commands[0].type).toBe("output");
|
||||
expect(commands[0].content).toBe(
|
||||
"PASS tests/example.test.js\n ✓ should work (2 ms)",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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",
|
||||
);
|
||||
|
||||
@ -10,11 +10,13 @@ import { OpenHandsEvent } from "#/types/v1/core";
|
||||
* Test component to access and display WebSocket connection state
|
||||
*/
|
||||
export function ConnectionStatusComponent() {
|
||||
const { connectionState } = useConversationWebSocket();
|
||||
const context = useConversationWebSocket();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="connection-state">{connectionState}</div>
|
||||
<div data-testid="connection-state">
|
||||
{context?.connectionState || "NOT_AVAILABLE"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -13,6 +13,22 @@ vi.mock("#/context/ws-client-provider", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useActiveConversation
|
||||
vi.mock("#/hooks/query/use-active-conversation", () => ({
|
||||
useActiveConversation: () => ({
|
||||
data: {
|
||||
id: "test-conversation-id",
|
||||
conversation_version: "V0",
|
||||
},
|
||||
isFetched: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useConversationWebSocket (returns null for V0 conversations)
|
||||
vi.mock("#/contexts/conversation-websocket-context", () => ({
|
||||
useConversationWebSocket: () => null,
|
||||
}));
|
||||
|
||||
function TestTerminalComponent() {
|
||||
const ref = useTerminal();
|
||||
return <div ref={ref} />;
|
||||
|
||||
@ -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");
|
||||
|
||||
|
||||
@ -60,7 +60,7 @@ describe("Check for hardcoded English strings", () => {
|
||||
test("InteractiveChatBox should not have hardcoded English strings", () => {
|
||||
const { container } = renderWithProviders(
|
||||
<MemoryRouter>
|
||||
<InteractiveChatBox onSubmit={() => {}} onStop={() => {}} />
|
||||
<InteractiveChatBox onSubmit={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
|
||||
@ -11,7 +11,6 @@ import {
|
||||
CreateMicroagent,
|
||||
FileUploadSuccessResponse,
|
||||
GetFilesResponse,
|
||||
GetFileResponse,
|
||||
} from "../open-hands.types";
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import { Provider } from "#/types/settings";
|
||||
@ -159,19 +158,6 @@ class ConversationService {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the blob of the workspace zip
|
||||
* @returns Blob of the workspace zip
|
||||
*/
|
||||
static async getWorkspaceZip(conversationId: string): Promise<Blob> {
|
||||
const url = `${this.getConversationUrl(conversationId)}/zip-directory`;
|
||||
const response = await openHands.get(url, {
|
||||
responseType: "blob",
|
||||
headers: this.getConversationHeaders(),
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the web hosts
|
||||
* @returns Array of web hosts
|
||||
@ -379,22 +365,6 @@ class ConversationService {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the content of a file
|
||||
* @param conversationId ID of the conversation
|
||||
* @param path Full path of the file to retrieve
|
||||
* @returns Code content of the file
|
||||
*/
|
||||
static async getFile(conversationId: string, path: string): Promise<string> {
|
||||
const url = `${this.getConversationUrl(conversationId)}/select-file`;
|
||||
const { data } = await openHands.get<GetFileResponse>(url, {
|
||||
params: { file: path },
|
||||
headers: this.getConversationHeaders(),
|
||||
});
|
||||
|
||||
return data.code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload multiple files to the workspace
|
||||
* @param conversationId ID of the conversation
|
||||
|
||||
@ -0,0 +1,258 @@
|
||||
import axios from "axios";
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import { ConversationTrigger, GetVSCodeUrlResponse } from "../open-hands.types";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { buildHttpBaseUrl } from "#/utils/websocket-url";
|
||||
import type {
|
||||
V1SendMessageRequest,
|
||||
V1SendMessageResponse,
|
||||
V1AppConversationStartRequest,
|
||||
V1AppConversationStartTask,
|
||||
V1AppConversationStartTaskPage,
|
||||
V1AppConversation,
|
||||
} from "./v1-conversation-service.types";
|
||||
|
||||
class V1ConversationService {
|
||||
/**
|
||||
* Build headers for V1 API requests that require session authentication
|
||||
* @param sessionApiKey Session API key for authentication
|
||||
* @returns Headers object with X-Session-API-Key if provided
|
||||
*/
|
||||
private static buildSessionHeaders(
|
||||
sessionApiKey?: string | null,
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (sessionApiKey) {
|
||||
headers["X-Session-API-Key"] = sessionApiKey;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full URL for V1 runtime-specific endpoints
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param path The API path (e.g., "/api/vscode/url")
|
||||
* @returns Full URL to the runtime endpoint
|
||||
*/
|
||||
private static buildRuntimeUrl(
|
||||
conversationUrl: string | null | undefined,
|
||||
path: string,
|
||||
): string {
|
||||
const baseUrl = buildHttpBaseUrl(conversationUrl);
|
||||
return `${baseUrl}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a V1 conversation
|
||||
* @param conversationId The conversation ID
|
||||
* @param message The message to send
|
||||
* @returns The sent message response
|
||||
*/
|
||||
static async sendMessage(
|
||||
conversationId: string,
|
||||
message: V1SendMessageRequest,
|
||||
): Promise<V1SendMessageResponse> {
|
||||
const { data } = await openHands.post<V1SendMessageResponse>(
|
||||
`/api/conversations/${conversationId}/events`,
|
||||
message,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new V1 conversation using the app-conversations API
|
||||
* Returns the start task immediately with app_conversation_id as null.
|
||||
* You must poll getStartTask() until status is READY to get the conversation ID.
|
||||
*
|
||||
* @returns AppConversationStartTask with task ID
|
||||
*/
|
||||
static async createConversation(
|
||||
selectedRepository?: string,
|
||||
git_provider?: Provider,
|
||||
initialUserMsg?: string,
|
||||
selected_branch?: string,
|
||||
conversationInstructions?: string,
|
||||
trigger?: ConversationTrigger,
|
||||
): Promise<V1AppConversationStartTask> {
|
||||
const body: V1AppConversationStartRequest = {
|
||||
selected_repository: selectedRepository,
|
||||
git_provider,
|
||||
selected_branch,
|
||||
title: conversationInstructions,
|
||||
trigger,
|
||||
};
|
||||
|
||||
// Add initial message if provided
|
||||
if (initialUserMsg) {
|
||||
body.initial_message = {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: initialUserMsg,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const { data } = await openHands.post<V1AppConversationStartTask>(
|
||||
"/api/v1/app-conversations",
|
||||
body,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a start task by ID
|
||||
* Poll this endpoint until status is READY to get the app_conversation_id
|
||||
*
|
||||
* @param taskId The task UUID
|
||||
* @returns AppConversationStartTask or null
|
||||
*/
|
||||
static async getStartTask(
|
||||
taskId: string,
|
||||
): Promise<V1AppConversationStartTask | null> {
|
||||
const { data } = await openHands.get<(V1AppConversationStartTask | null)[]>(
|
||||
`/api/v1/app-conversations/start-tasks?ids=${taskId}`,
|
||||
);
|
||||
|
||||
return data[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for start tasks (ongoing tasks that haven't completed yet)
|
||||
* Use this to find tasks that were started but the user navigated away
|
||||
*
|
||||
* Note: Backend only supports filtering by limit. To filter by repository/trigger,
|
||||
* filter the results client-side after fetching.
|
||||
*
|
||||
* @param limit Maximum number of tasks to return (max 100)
|
||||
* @returns Array of start tasks
|
||||
*/
|
||||
static async searchStartTasks(
|
||||
limit: number = 100,
|
||||
): Promise<V1AppConversationStartTask[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("limit", limit.toString());
|
||||
|
||||
const { data } = await openHands.get<V1AppConversationStartTaskPage>(
|
||||
`/api/v1/app-conversations/start-tasks/search?${params.toString()}`,
|
||||
);
|
||||
|
||||
return data.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the VSCode URL for a V1 conversation
|
||||
* Uses the custom runtime URL from the conversation
|
||||
* Note: V1 endpoint doesn't require conversationId in the URL path - it's identified via session API key header
|
||||
*
|
||||
* @param _conversationId The conversation ID (not used in V1, kept for interface compatibility)
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param sessionApiKey Session API key for authentication (required for V1)
|
||||
* @returns VSCode URL response
|
||||
*/
|
||||
static async getVSCodeUrl(
|
||||
_conversationId: string,
|
||||
conversationUrl: string | null | undefined,
|
||||
sessionApiKey?: string | null,
|
||||
): Promise<GetVSCodeUrlResponse> {
|
||||
const url = this.buildRuntimeUrl(conversationUrl, "/api/vscode/url");
|
||||
const headers = this.buildSessionHeaders(sessionApiKey);
|
||||
|
||||
// V1 API returns {url: '...'} instead of {vscode_url: '...'}
|
||||
// Map it to match the expected interface
|
||||
const { data } = await axios.get<{ url: string | null }>(url, { headers });
|
||||
return {
|
||||
vscode_url: data.url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause a V1 conversation
|
||||
* Uses the custom runtime URL from the conversation
|
||||
*
|
||||
* @param conversationId The conversation ID
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param sessionApiKey Session API key for authentication (required for V1)
|
||||
* @returns Success response
|
||||
*/
|
||||
static async pauseConversation(
|
||||
conversationId: string,
|
||||
conversationUrl: string | null | undefined,
|
||||
sessionApiKey?: string | null,
|
||||
): Promise<{ success: boolean }> {
|
||||
const url = this.buildRuntimeUrl(
|
||||
conversationUrl,
|
||||
`/api/conversations/${conversationId}/pause`,
|
||||
);
|
||||
const headers = this.buildSessionHeaders(sessionApiKey);
|
||||
|
||||
const { data } = await axios.post<{ success: boolean }>(
|
||||
url,
|
||||
{},
|
||||
{ headers },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause a V1 sandbox
|
||||
* Calls the /api/v1/sandboxes/{id}/pause endpoint
|
||||
*
|
||||
* @param sandboxId The sandbox ID to pause
|
||||
* @returns Success response
|
||||
*/
|
||||
static async pauseSandbox(sandboxId: string): Promise<{ success: boolean }> {
|
||||
const { data } = await openHands.post<{ success: boolean }>(
|
||||
`/api/v1/sandboxes/${sandboxId}/pause`,
|
||||
{},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a V1 sandbox
|
||||
* Calls the /api/v1/sandboxes/{id}/resume endpoint
|
||||
*
|
||||
* @param sandboxId The sandbox ID to resume
|
||||
* @returns Success response
|
||||
*/
|
||||
static async resumeSandbox(sandboxId: string): Promise<{ success: boolean }> {
|
||||
const { data } = await openHands.post<{ success: boolean }>(
|
||||
`/api/v1/sandboxes/${sandboxId}/resume`,
|
||||
{},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch get V1 app conversations by their IDs
|
||||
* Returns null for any missing conversations
|
||||
*
|
||||
* @param ids Array of conversation IDs (max 100)
|
||||
* @returns Array of conversations or null for missing ones
|
||||
*/
|
||||
static async batchGetAppConversations(
|
||||
ids: string[],
|
||||
): Promise<(V1AppConversation | null)[]> {
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (ids.length > 100) {
|
||||
throw new Error("Cannot request more than 100 conversations at once");
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
ids.forEach((id) => params.append("ids", id));
|
||||
|
||||
const { data } = await openHands.get<(V1AppConversation | null)[]>(
|
||||
`/api/v1/app-conversations?${params.toString()}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default V1ConversationService;
|
||||
@ -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;
|
||||
}
|
||||
@ -76,6 +76,7 @@ export interface Conversation {
|
||||
url: string | null;
|
||||
session_api_key: string | null;
|
||||
pr_number?: number[] | null;
|
||||
conversation_version?: "V0" | "V1";
|
||||
}
|
||||
|
||||
export interface ResultSet<T> {
|
||||
|
||||
@ -8,16 +8,16 @@ import { createChatMessage } from "#/services/chat-service";
|
||||
import { InteractiveChatBox } from "./interactive-chat-box";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { isOpenHandsAction, isActionOrObservation } from "#/types/core/guards";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
||||
import { FeedbackModal } from "../feedback/feedback-modal";
|
||||
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
|
||||
import { TypingIndicator } from "./typing-indicator";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { Messages } from "./messages";
|
||||
import { Messages as V0Messages } from "./messages";
|
||||
import { ChatSuggestions } from "./chat-suggestions";
|
||||
import { ScrollProvider } from "#/context/scroll-context";
|
||||
import { useInitialQueryStore } from "#/stores/initial-query-store";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useSendMessage } from "#/hooks/use-send-message";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
|
||||
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
@ -30,12 +30,18 @@ import {
|
||||
hasUserEvent,
|
||||
shouldRenderEvent,
|
||||
} from "./event-content-helpers/should-render-event";
|
||||
import {
|
||||
Messages as V1Messages,
|
||||
hasUserEvent as hasV1UserEvent,
|
||||
shouldRenderEvent as shouldRenderV1Event,
|
||||
} from "#/components/v1/chat";
|
||||
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { validateFiles } from "#/utils/file-validation";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import ConfirmationModeEnabled from "./confirmation-mode-enabled";
|
||||
import { isV0Event } from "#/types/v1/type-guards";
|
||||
import { isV0Event, isV1Event } from "#/types/v1/type-guards";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
|
||||
function getEntryPoint(
|
||||
hasRepository: boolean | null,
|
||||
@ -48,8 +54,10 @@ function getEntryPoint(
|
||||
|
||||
export function ChatInterface() {
|
||||
const { setMessageToSend } = useConversationStore();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { errorMessage } = useErrorMessageStore();
|
||||
const { send, isLoadingMessages } = useWsClient();
|
||||
const { isLoadingMessages } = useWsClient();
|
||||
const { send } = useSendMessage();
|
||||
const storeEvents = useEventStore((state) => state.events);
|
||||
const { setOptimisticUserMessage, getOptimisticUserMessage } =
|
||||
useOptimisticUserMessageStore();
|
||||
@ -65,7 +73,7 @@ export function ChatInterface() {
|
||||
} = useScrollToBottom(scrollRef);
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { curAgentState } = useAgentState();
|
||||
|
||||
const [feedbackPolarity, setFeedbackPolarity] = React.useState<
|
||||
"positive" | "negative"
|
||||
@ -77,11 +85,20 @@ export function ChatInterface() {
|
||||
|
||||
const optimisticUserMessage = getOptimisticUserMessage();
|
||||
|
||||
const events = storeEvents
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
// Filter V0 events
|
||||
const v0Events = storeEvents
|
||||
.filter(isV0Event)
|
||||
.filter(isActionOrObservation)
|
||||
.filter(shouldRenderEvent);
|
||||
|
||||
// Filter V1 events
|
||||
const v1Events = storeEvents.filter(isV1Event).filter(shouldRenderV1Event);
|
||||
|
||||
// Combined events count for tracking
|
||||
const totalEvents = v0Events.length || v1Events.length;
|
||||
|
||||
// Check if there are any substantive agent actions (not just system messages)
|
||||
const hasSubstantiveAgentActions = React.useMemo(
|
||||
() =>
|
||||
@ -93,7 +110,8 @@ export function ChatInterface() {
|
||||
isOpenHandsAction(event) &&
|
||||
event.source === "agent" &&
|
||||
event.action !== "system",
|
||||
),
|
||||
) ||
|
||||
storeEvents.filter(isV1Event).some((event) => event.source === "agent"),
|
||||
[storeEvents],
|
||||
);
|
||||
|
||||
@ -105,7 +123,7 @@ export function ChatInterface() {
|
||||
// Create mutable copies of the arrays
|
||||
const images = [...originalImages];
|
||||
const files = [...originalFiles];
|
||||
if (events.length === 0) {
|
||||
if (totalEvents === 0) {
|
||||
posthog.capture("initial_query_submitted", {
|
||||
entry_point: getEntryPoint(
|
||||
selectedRepository !== null,
|
||||
@ -116,7 +134,7 @@ export function ChatInterface() {
|
||||
});
|
||||
} else {
|
||||
posthog.capture("user_message_sent", {
|
||||
session_message_count: events.length,
|
||||
session_message_count: totalEvents,
|
||||
current_message_length: content.length,
|
||||
});
|
||||
}
|
||||
@ -151,11 +169,6 @@ export function ChatInterface() {
|
||||
setMessageToSend("");
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
posthog.capture("stop_button_clicked");
|
||||
send(generateAgentStateChangeEvent(AgentState.STOPPED));
|
||||
};
|
||||
|
||||
const onClickShareFeedbackActionButton = async (
|
||||
polarity: "positive" | "negative",
|
||||
) => {
|
||||
@ -174,7 +187,9 @@ export function ChatInterface() {
|
||||
onChatBodyScroll,
|
||||
};
|
||||
|
||||
const userEventsExist = hasUserEvent(events);
|
||||
const v0UserEventsExist = hasUserEvent(v0Events);
|
||||
const v1UserEventsExist = hasV1UserEvent(v1Events);
|
||||
const userEventsExist = v0UserEventsExist || v1UserEventsExist;
|
||||
|
||||
return (
|
||||
<ScrollProvider value={scrollProviderValue}>
|
||||
@ -193,15 +208,24 @@ export function ChatInterface() {
|
||||
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
||||
className="custom-scrollbar-always flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2 fast-smooth-scroll"
|
||||
>
|
||||
{isLoadingMessages && (
|
||||
{isLoadingMessages && !isV1Conversation && (
|
||||
<div className="flex justify-center">
|
||||
<LoadingSpinner size="small" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingMessages && userEventsExist && (
|
||||
<Messages
|
||||
messages={events}
|
||||
{!isLoadingMessages && v0UserEventsExist && (
|
||||
<V0Messages
|
||||
messages={v0Events}
|
||||
isAwaitingUserConfirmation={
|
||||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{v1UserEventsExist && (
|
||||
<V1Messages
|
||||
messages={v1Events}
|
||||
isAwaitingUserConfirmation={
|
||||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
|
||||
}
|
||||
@ -213,7 +237,7 @@ export function ChatInterface() {
|
||||
<div className="flex justify-between relative">
|
||||
<div className="flex items-center gap-1">
|
||||
<ConfirmationModeEnabled />
|
||||
{events.length > 0 && (
|
||||
{totalEvents > 0 && (
|
||||
<TrajectoryActions
|
||||
onPositiveFeedback={() =>
|
||||
onClickShareFeedbackActionButton("positive")
|
||||
@ -235,10 +259,7 @@ export function ChatInterface() {
|
||||
|
||||
{errorMessage && <ErrorMessageBanner message={errorMessage} />}
|
||||
|
||||
<InteractiveChatBox
|
||||
onSubmit={handleSendMessage}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
<InteractiveChatBox onSubmit={handleSendMessage} />
|
||||
</div>
|
||||
|
||||
{config?.APP_MODE !== "saas" && (
|
||||
|
||||
@ -2,33 +2,73 @@ import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { ServerStatus } from "#/components/features/controls/server-status";
|
||||
import { AgentStatus } from "#/components/features/controls/agent-status";
|
||||
import { Tools } from "../../controls/tools";
|
||||
import { useUnifiedPauseConversationSandbox } from "#/hooks/mutation/use-unified-stop-conversation";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useSendMessage } from "#/hooks/use-send-message";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
|
||||
interface ChatInputActionsProps {
|
||||
conversationStatus: ConversationStatus | null;
|
||||
disabled: boolean;
|
||||
handleStop: (onStop?: () => void) => void;
|
||||
handleResumeAgent: () => void;
|
||||
onStop?: () => void;
|
||||
}
|
||||
|
||||
export function ChatInputActions({
|
||||
conversationStatus,
|
||||
disabled,
|
||||
handleStop,
|
||||
handleResumeAgent,
|
||||
onStop,
|
||||
}: ChatInputActionsProps) {
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const pauseConversationSandboxMutation = useUnifiedPauseConversationSandbox();
|
||||
const resumeConversationSandboxMutation =
|
||||
useUnifiedResumeConversationSandbox();
|
||||
const { conversationId } = useConversationId();
|
||||
const { providers } = useUserProviders();
|
||||
const { send } = useSendMessage();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
const handleStopClick = () => {
|
||||
pauseConversationSandboxMutation.mutate({ conversationId });
|
||||
};
|
||||
|
||||
const handlePauseAgent = () => {
|
||||
if (isV1Conversation) {
|
||||
// V1: Empty function for now
|
||||
return;
|
||||
}
|
||||
|
||||
// V0: Send agent state change event to stop the agent
|
||||
send(generateAgentStateChangeEvent(AgentState.STOPPED));
|
||||
};
|
||||
|
||||
const handleStartClick = () => {
|
||||
resumeConversationSandboxMutation.mutate({ conversationId, providers });
|
||||
};
|
||||
|
||||
const isPausing = pauseConversationSandboxMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Tools />
|
||||
<ServerStatus conversationStatus={conversationStatus} />
|
||||
<ServerStatus
|
||||
conversationStatus={conversationStatus}
|
||||
isPausing={isPausing}
|
||||
handleStop={handleStopClick}
|
||||
handleResumeAgent={handleStartClick}
|
||||
/>
|
||||
</div>
|
||||
<AgentStatus
|
||||
className="ml-2 md:ml-3"
|
||||
handleStop={() => handleStop(onStop)}
|
||||
handleStop={handlePauseAgent}
|
||||
handleResumeAgent={handleResumeAgent}
|
||||
disabled={disabled}
|
||||
isPausing={isPausing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -15,7 +15,6 @@ interface ChatInputContainerProps {
|
||||
chatInputRef: React.RefObject<HTMLDivElement | null>;
|
||||
handleFileIconClick: (isDisabled: boolean) => void;
|
||||
handleSubmit: () => void;
|
||||
handleStop: (onStop?: () => void) => void;
|
||||
handleResumeAgent: () => void;
|
||||
onDragOver: (e: React.DragEvent, isDisabled: boolean) => void;
|
||||
onDragLeave: (e: React.DragEvent, isDisabled: boolean) => void;
|
||||
@ -25,7 +24,6 @@ interface ChatInputContainerProps {
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onStop?: () => void;
|
||||
}
|
||||
|
||||
export function ChatInputContainer({
|
||||
@ -38,7 +36,6 @@ export function ChatInputContainer({
|
||||
chatInputRef,
|
||||
handleFileIconClick,
|
||||
handleSubmit,
|
||||
handleStop,
|
||||
handleResumeAgent,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
@ -48,7 +45,6 @@ export function ChatInputContainer({
|
||||
onKeyDown,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onStop,
|
||||
}: ChatInputContainerProps) {
|
||||
return (
|
||||
<div
|
||||
@ -80,9 +76,7 @@ export function ChatInputContainer({
|
||||
<ChatInputActions
|
||||
conversationStatus={conversationStatus}
|
||||
disabled={disabled}
|
||||
handleStop={handleStop}
|
||||
handleResumeAgent={handleResumeAgent}
|
||||
onStop={onStop}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -15,7 +15,6 @@ export interface CustomChatInputProps {
|
||||
showButton?: boolean;
|
||||
conversationStatus?: ConversationStatus | null;
|
||||
onSubmit: (message: string) => void;
|
||||
onStop?: () => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onFilesPaste?: (files: File[]) => void;
|
||||
@ -28,7 +27,6 @@ export function CustomChatInput({
|
||||
showButton = true,
|
||||
conversationStatus = null,
|
||||
onSubmit,
|
||||
onStop,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onFilesPaste,
|
||||
@ -88,7 +86,7 @@ export function CustomChatInput({
|
||||
messageToSend,
|
||||
);
|
||||
|
||||
const { handleSubmit, handleResumeAgent, handleStop } = useChatSubmission(
|
||||
const { handleSubmit, handleResumeAgent } = useChatSubmission(
|
||||
chatInputRef as React.RefObject<HTMLDivElement | null>,
|
||||
fileInputRef as React.RefObject<HTMLInputElement | null>,
|
||||
smartResize,
|
||||
@ -143,7 +141,6 @@ export function CustomChatInput({
|
||||
chatInputRef={chatInputRef}
|
||||
handleFileIconClick={handleFileIconClick}
|
||||
handleSubmit={handleSubmit}
|
||||
handleStop={handleStop}
|
||||
handleResumeAgent={handleResumeAgent}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
@ -153,7 +150,6 @@ export function CustomChatInput({
|
||||
onKeyDown={(e) => handleKeyDown(e, isDisabled, handleSubmit)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onStop={onStop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -6,18 +6,14 @@ import { AgentState } from "#/types/agent-state";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { GitControlBar } from "./git-control-bar";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { processFiles, processImages } from "#/utils/file-processing";
|
||||
|
||||
interface InteractiveChatBoxProps {
|
||||
onSubmit: (message: string, images: File[], files: File[]) => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
export function InteractiveChatBox({
|
||||
onSubmit,
|
||||
onStop,
|
||||
}: InteractiveChatBoxProps) {
|
||||
export function InteractiveChatBox({ onSubmit }: InteractiveChatBoxProps) {
|
||||
const {
|
||||
images,
|
||||
files,
|
||||
@ -29,7 +25,7 @@ export function InteractiveChatBox({
|
||||
addImageLoading,
|
||||
removeImageLoading,
|
||||
} = useConversationStore();
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { curAgentState } = useAgentState();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
|
||||
// Helper function to validate and filter files
|
||||
@ -145,7 +141,6 @@ export function InteractiveChatBox({
|
||||
<CustomChatInput
|
||||
disabled={isDisabled}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={onStop}
|
||||
onFilesPaste={handleUpload}
|
||||
conversationStatus={conversation?.status || null}
|
||||
/>
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -5,31 +5,29 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { ServerStatusContextMenu } from "./server-status-context-menu";
|
||||
import { useStartConversation } from "#/hooks/mutation/use-start-conversation";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { useStopConversation } from "#/hooks/mutation/use-stop-conversation";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useTaskPolling } from "#/hooks/query/use-task-polling";
|
||||
|
||||
export interface ServerStatusProps {
|
||||
className?: string;
|
||||
conversationStatus: ConversationStatus | null;
|
||||
isPausing?: boolean;
|
||||
handleStop: () => void;
|
||||
handleResumeAgent: () => void;
|
||||
}
|
||||
|
||||
export function ServerStatus({
|
||||
className = "",
|
||||
conversationStatus,
|
||||
isPausing = false,
|
||||
handleStop,
|
||||
handleResumeAgent,
|
||||
}: ServerStatusProps) {
|
||||
const [showContextMenu, setShowContextMenu] = useState(false);
|
||||
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { curAgentState } = useAgentState();
|
||||
const { t } = useTranslation();
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
// Mutation hooks
|
||||
const stopConversationMutation = useStopConversation();
|
||||
const startConversationMutation = useStartConversation();
|
||||
const { providers } = useUserProviders();
|
||||
const { isTask, taskStatus, taskDetail } = useTaskPolling();
|
||||
|
||||
const isStartingStatus =
|
||||
curAgentState === AgentState.LOADING || curAgentState === AgentState.INIT;
|
||||
@ -38,6 +36,19 @@ export function ServerStatus({
|
||||
|
||||
// Get the appropriate color based on agent status
|
||||
const getStatusColor = (): string => {
|
||||
// Show pausing status
|
||||
if (isPausing) {
|
||||
return "#FFD600";
|
||||
}
|
||||
|
||||
// Show task status if we're polling a task
|
||||
if (isTask && taskStatus) {
|
||||
if (taskStatus === "ERROR") {
|
||||
return "#FF684E";
|
||||
}
|
||||
return "#FFD600";
|
||||
}
|
||||
|
||||
if (isStartingStatus) {
|
||||
return "#FFD600";
|
||||
}
|
||||
@ -52,6 +63,31 @@ export function ServerStatus({
|
||||
|
||||
// Get the appropriate status text based on agent status
|
||||
const getStatusText = (): string => {
|
||||
// Show pausing status
|
||||
if (isPausing) {
|
||||
return t(I18nKey.COMMON$STOPPING);
|
||||
}
|
||||
|
||||
// Show task status if we're polling a task
|
||||
if (isTask && taskStatus) {
|
||||
if (taskStatus === "ERROR") {
|
||||
return (
|
||||
taskDetail || t(I18nKey.CONVERSATION$ERROR_STARTING_CONVERSATION)
|
||||
);
|
||||
}
|
||||
if (taskStatus === "READY") {
|
||||
return t(I18nKey.CONVERSATION$READY);
|
||||
}
|
||||
// Format status text: "WAITING_FOR_SANDBOX" -> "Waiting for sandbox"
|
||||
return (
|
||||
taskDetail ||
|
||||
taskStatus
|
||||
.toLowerCase()
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (isStartingStatus) {
|
||||
return t(I18nKey.COMMON$STARTING);
|
||||
}
|
||||
@ -76,16 +112,13 @@ export function ServerStatus({
|
||||
|
||||
const handleStopServer = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
stopConversationMutation.mutate({ conversationId });
|
||||
handleStop();
|
||||
setShowContextMenu(false);
|
||||
};
|
||||
|
||||
const handleStartServer = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
startConversationMutation.mutate({
|
||||
conversationId,
|
||||
providers,
|
||||
});
|
||||
handleResumeAgent();
|
||||
setShowContextMenu(false);
|
||||
};
|
||||
|
||||
|
||||
@ -27,6 +27,8 @@ export function ConversationCardActions({
|
||||
conversationId,
|
||||
showOptions,
|
||||
}: ConversationCardActionsProps) {
|
||||
const isConversationArchived = conversationStatus === "ARCHIVED";
|
||||
|
||||
return (
|
||||
<div className="group">
|
||||
<button
|
||||
@ -37,7 +39,10 @@ export function ConversationCardActions({
|
||||
event.stopPropagation();
|
||||
onContextMenuToggle(!contextMenuOpen);
|
||||
}}
|
||||
className="cursor-pointer w-6 h-6 flex flex-row items-center justify-center translate-x-2.5"
|
||||
className={cn(
|
||||
"cursor-pointer w-6 h-6 flex flex-row items-center justify-center translate-x-2.5",
|
||||
isConversationArchived && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<EllipsisIcon />
|
||||
</button>
|
||||
|
||||
@ -5,22 +5,32 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import { RepositorySelection } from "#/api/open-hands.types";
|
||||
import { ConversationRepoLink } from "./conversation-repo-link";
|
||||
import { NoRepository } from "./no-repository";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
|
||||
interface ConversationCardFooterProps {
|
||||
selectedRepository: RepositorySelection | null;
|
||||
lastUpdatedAt: string; // ISO 8601
|
||||
createdAt?: string; // ISO 8601
|
||||
conversationStatus?: ConversationStatus;
|
||||
}
|
||||
|
||||
export function ConversationCardFooter({
|
||||
selectedRepository,
|
||||
lastUpdatedAt,
|
||||
createdAt,
|
||||
conversationStatus,
|
||||
}: ConversationCardFooterProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isConversationArchived = conversationStatus === "ARCHIVED";
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-row justify-between items-center mt-1")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row justify-between items-center mt-1",
|
||||
isConversationArchived && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{selectedRepository?.selected_repository ? (
|
||||
<ConversationRepoLink selectedRepository={selectedRepository} />
|
||||
) : (
|
||||
|
||||
@ -2,12 +2,14 @@ import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { ConversationCardTitle } from "./conversation-card-title";
|
||||
import { ConversationStatusIndicator } from "../../home/recent-conversations/conversation-status-indicator";
|
||||
import { ConversationStatusBadges } from "./conversation-status-badges";
|
||||
import { ConversationVersionBadge } from "./conversation-version-badge";
|
||||
|
||||
interface ConversationCardHeaderProps {
|
||||
title: string;
|
||||
titleMode: "view" | "edit";
|
||||
onTitleSave: (title: string) => void;
|
||||
conversationStatus?: ConversationStatus;
|
||||
conversationVersion?: "V0" | "V1";
|
||||
}
|
||||
|
||||
export function ConversationCardHeader({
|
||||
@ -15,7 +17,10 @@ export function ConversationCardHeader({
|
||||
titleMode,
|
||||
onTitleSave,
|
||||
conversationStatus,
|
||||
conversationVersion,
|
||||
}: ConversationCardHeaderProps) {
|
||||
const isConversationArchived = conversationStatus === "ARCHIVED";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden mr-2">
|
||||
{/* Status Indicator */}
|
||||
@ -26,10 +31,16 @@ export function ConversationCardHeader({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Version Badge */}
|
||||
<ConversationVersionBadge
|
||||
version={conversationVersion}
|
||||
isConversationArchived={isConversationArchived}
|
||||
/>
|
||||
<ConversationCardTitle
|
||||
title={title}
|
||||
titleMode={titleMode}
|
||||
onSave={onTitleSave}
|
||||
isConversationArchived={isConversationArchived}
|
||||
/>
|
||||
{/* Status Badges */}
|
||||
{conversationStatus && (
|
||||
|
||||
@ -1,15 +1,19 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
export type ConversationCardTitleMode = "view" | "edit";
|
||||
|
||||
export type ConversationCardTitleProps = {
|
||||
titleMode: ConversationCardTitleMode;
|
||||
title: string;
|
||||
onSave: (title: string) => void;
|
||||
isConversationArchived?: boolean;
|
||||
};
|
||||
|
||||
export function ConversationCardTitle({
|
||||
titleMode,
|
||||
title,
|
||||
onSave,
|
||||
isConversationArchived,
|
||||
}: ConversationCardTitleProps) {
|
||||
if (titleMode === "edit") {
|
||||
return (
|
||||
@ -40,7 +44,10 @@ export function ConversationCardTitle({
|
||||
return (
|
||||
<p
|
||||
data-testid="conversation-card-title"
|
||||
className="text-xs leading-6 font-semibold bg-transparent truncate overflow-hidden"
|
||||
className={cn(
|
||||
"text-xs leading-6 font-semibold bg-transparent truncate overflow-hidden",
|
||||
isConversationArchived && "opacity-60",
|
||||
)}
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
|
||||
@ -21,6 +21,7 @@ interface ConversationCardProps {
|
||||
createdAt?: string; // ISO 8601
|
||||
conversationStatus?: ConversationStatus;
|
||||
conversationId?: string; // Optional conversation ID for VS Code URL
|
||||
conversationVersion?: "V0" | "V1";
|
||||
contextMenuOpen?: boolean;
|
||||
onContextMenuToggle?: (isOpen: boolean) => void;
|
||||
}
|
||||
@ -39,6 +40,7 @@ export function ConversationCard({
|
||||
createdAt,
|
||||
conversationId,
|
||||
conversationStatus,
|
||||
conversationVersion,
|
||||
contextMenuOpen = false,
|
||||
onContextMenuToggle,
|
||||
}: ConversationCardProps) {
|
||||
@ -108,7 +110,6 @@ export function ConversationCard({
|
||||
className={cn(
|
||||
"relative h-auto w-full p-3.5 border-b border-neutral-600 cursor-pointer",
|
||||
"data-[context-menu-open=false]:hover:bg-[#454545]",
|
||||
conversationStatus === "ARCHIVED" && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
@ -117,6 +118,7 @@ export function ConversationCard({
|
||||
titleMode={titleMode}
|
||||
onTitleSave={onTitleSave}
|
||||
conversationStatus={conversationStatus}
|
||||
conversationVersion={conversationVersion}
|
||||
/>
|
||||
|
||||
{hasContextMenu && (
|
||||
@ -138,6 +140,7 @@ export function ConversationCard({
|
||||
selectedRepository={selectedRepository}
|
||||
lastUpdatedAt={lastUpdatedAt}
|
||||
createdAt={createdAt}
|
||||
conversationStatus={conversationStatus}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -15,7 +15,7 @@ export function ConversationStatusBadges({
|
||||
|
||||
if (conversationStatus === "ARCHIVED") {
|
||||
return (
|
||||
<span className="flex items-center gap-1 px-1.5 py-0.5 bg-[#868E96] text-white text-xs font-medium rounded-full">
|
||||
<span className="flex items-center gap-1 px-1.5 py-0.5 bg-[#868E96] text-white text-xs font-medium rounded-full opacity-60">
|
||||
<FaArchive size={10} className="text-white" />
|
||||
<span>{t(I18nKey.COMMON$ARCHIVED)}</span>
|
||||
</span>
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
import { Tooltip } from "@heroui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ConversationVersionBadgeProps {
|
||||
version?: "V0" | "V1";
|
||||
isConversationArchived?: boolean;
|
||||
}
|
||||
|
||||
export function ConversationVersionBadge({
|
||||
version,
|
||||
isConversationArchived,
|
||||
}: ConversationVersionBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!version) return null;
|
||||
|
||||
const tooltipText =
|
||||
version === "V1"
|
||||
? t(I18nKey.CONVERSATION$VERSION_V1_NEW)
|
||||
: t(I18nKey.CONVERSATION$VERSION_V0_LEGACY);
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltipText} placement="top">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold shrink-0 cursor-help lowercase",
|
||||
version === "V1"
|
||||
? "bg-green-500/20 text-green-500"
|
||||
: "bg-neutral-500/20 text-neutral-400",
|
||||
isConversationArchived && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{version}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@ -3,9 +3,10 @@ import { NavLink, useParams, useNavigate } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { usePaginatedConversations } from "#/hooks/query/use-paginated-conversations";
|
||||
import { useStartTasks } from "#/hooks/query/use-start-tasks";
|
||||
import { useInfiniteScroll } from "#/hooks/use-infinite-scroll";
|
||||
import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation";
|
||||
import { useStopConversation } from "#/hooks/mutation/use-stop-conversation";
|
||||
import { useUnifiedPauseConversationSandbox } from "#/hooks/mutation/use-unified-stop-conversation";
|
||||
import { ConfirmDeleteModal } from "./confirm-delete-modal";
|
||||
import { ConfirmStopModal } from "./confirm-stop-modal";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
@ -15,6 +16,7 @@ import { Provider } from "#/types/settings";
|
||||
import { useUpdateConversation } from "#/hooks/mutation/use-update-conversation";
|
||||
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
|
||||
import { ConversationCard } from "./conversation-card/conversation-card";
|
||||
import { StartTaskCard } from "./start-task-card/start-task-card";
|
||||
|
||||
interface ConversationPanelProps {
|
||||
onClose: () => void;
|
||||
@ -37,6 +39,8 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
const [selectedConversationId, setSelectedConversationId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [selectedConversationVersion, setSelectedConversationVersion] =
|
||||
React.useState<"V0" | "V1" | undefined>(undefined);
|
||||
const [openContextMenuId, setOpenContextMenuId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
@ -50,11 +54,15 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
fetchNextPage,
|
||||
} = usePaginatedConversations();
|
||||
|
||||
// Fetch in-progress start tasks
|
||||
const { data: startTasks } = useStartTasks();
|
||||
|
||||
// Flatten all pages into a single array of conversations
|
||||
const conversations = data?.pages.flatMap((page) => page.results) ?? [];
|
||||
|
||||
const { mutate: deleteConversation } = useDeleteConversation();
|
||||
const { mutate: stopConversation } = useStopConversation();
|
||||
const { mutate: pauseConversationSandbox } =
|
||||
useUnifiedPauseConversationSandbox();
|
||||
const { mutate: updateConversation } = useUpdateConversation();
|
||||
|
||||
// Set up infinite scroll
|
||||
@ -70,9 +78,13 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
setSelectedConversationId(conversationId);
|
||||
};
|
||||
|
||||
const handleStopConversation = (conversationId: string) => {
|
||||
const handleStopConversation = (
|
||||
conversationId: string,
|
||||
version?: "V0" | "V1",
|
||||
) => {
|
||||
setConfirmStopModalVisible(true);
|
||||
setSelectedConversationId(conversationId);
|
||||
setSelectedConversationVersion(version);
|
||||
};
|
||||
|
||||
const handleConversationTitleChange = async (
|
||||
@ -106,7 +118,10 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
|
||||
const handleConfirmStop = () => {
|
||||
if (selectedConversationId) {
|
||||
stopConversation({ conversationId: selectedConversationId });
|
||||
pauseConversationSandbox({
|
||||
conversationId: selectedConversationId,
|
||||
version: selectedConversationVersion,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -131,13 +146,24 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
<p className="text-danger">{error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
{!isFetching && conversations?.length === 0 && (
|
||||
{!isFetching && conversations?.length === 0 && !startTasks?.length && (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<p className="text-neutral-400">
|
||||
{t(I18nKey.CONVERSATION$NO_CONVERSATIONS)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Render in-progress start tasks first */}
|
||||
{startTasks?.map((task) => (
|
||||
<NavLink
|
||||
key={task.id}
|
||||
to={`/conversations/task-${task.id}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<StartTaskCard task={task} />
|
||||
</NavLink>
|
||||
))}
|
||||
{/* Then render completed conversations */}
|
||||
{conversations?.map((project) => (
|
||||
<NavLink
|
||||
key={project.conversation_id}
|
||||
@ -146,7 +172,12 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
>
|
||||
<ConversationCard
|
||||
onDelete={() => handleDeleteProject(project.conversation_id)}
|
||||
onStop={() => handleStopConversation(project.conversation_id)}
|
||||
onStop={() =>
|
||||
handleStopConversation(
|
||||
project.conversation_id,
|
||||
project.conversation_version,
|
||||
)
|
||||
}
|
||||
onChangeTitle={(title) =>
|
||||
handleConversationTitleChange(project.conversation_id, title)
|
||||
}
|
||||
@ -160,6 +191,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
createdAt={project.created_at}
|
||||
conversationStatus={project.status}
|
||||
conversationId={project.conversation_id}
|
||||
conversationVersion={project.conversation_version}
|
||||
contextMenuOpen={openContextMenuId === project.conversation_id}
|
||||
onContextMenuToggle={(isOpen) =>
|
||||
setOpenContextMenuId(isOpen ? project.conversation_id : null)
|
||||
|
||||
@ -10,7 +10,7 @@ import { MicroagentsModalHeader } from "./microagents-modal-header";
|
||||
import { MicroagentsLoadingState } from "./microagents-loading-state";
|
||||
import { MicroagentsEmptyState } from "./microagents-empty-state";
|
||||
import { MicroagentItem } from "./microagent-item";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
|
||||
interface MicroagentsModalProps {
|
||||
onClose: () => void;
|
||||
@ -18,7 +18,7 @@ interface MicroagentsModalProps {
|
||||
|
||||
export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { curAgentState } = useAgentState();
|
||||
const [expandedAgents, setExpandedAgents] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
|
||||
@ -0,0 +1,46 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { ConversationRepoLink } from "../conversation-card/conversation-repo-link";
|
||||
import { NoRepository } from "../conversation-card/no-repository";
|
||||
import type { RepositorySelection } from "#/api/open-hands.types";
|
||||
|
||||
interface StartTaskCardFooterProps {
|
||||
selectedRepository: RepositorySelection | null;
|
||||
createdAt: string; // ISO 8601
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
export function StartTaskCardFooter({
|
||||
selectedRepository,
|
||||
createdAt,
|
||||
detail,
|
||||
}: StartTaskCardFooterProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1 mt-1")}>
|
||||
{/* Repository Info */}
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
{selectedRepository ? (
|
||||
<ConversationRepoLink selectedRepository={selectedRepository} />
|
||||
) : (
|
||||
<NoRepository />
|
||||
)}
|
||||
{createdAt && (
|
||||
<p className="text-xs text-[#A3A3A3] flex-1 text-right">
|
||||
<time>
|
||||
{`${formatTimeDelta(new Date(createdAt))} ${t(I18nKey.CONVERSATION$AGO)}`}
|
||||
</time>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task Detail */}
|
||||
{detail && (
|
||||
<div className="text-xs text-neutral-500 truncate">{detail}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
import type { V1AppConversationStartTaskStatus } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import { ConversationVersionBadge } from "../conversation-card/conversation-version-badge";
|
||||
import { StartTaskStatusIndicator } from "./start-task-status-indicator";
|
||||
import { StartTaskStatusBadge } from "./start-task-status-badge";
|
||||
|
||||
interface StartTaskCardHeaderProps {
|
||||
title: string;
|
||||
taskStatus: V1AppConversationStartTaskStatus;
|
||||
}
|
||||
|
||||
export function StartTaskCardHeader({
|
||||
title,
|
||||
taskStatus,
|
||||
}: StartTaskCardHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden mr-2">
|
||||
{/* Status Indicator */}
|
||||
<div className="flex items-center">
|
||||
<StartTaskStatusIndicator taskStatus={taskStatus} />
|
||||
</div>
|
||||
|
||||
{/* Version Badge - V1 tasks are always V1 */}
|
||||
<ConversationVersionBadge version="V1" />
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-sm font-medium text-neutral-100 truncate flex-1">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Status Badge */}
|
||||
<StartTaskStatusBadge taskStatus={taskStatus} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { V1AppConversationStartTask } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { StartTaskCardHeader } from "./start-task-card-header";
|
||||
import { StartTaskCardFooter } from "./start-task-card-footer";
|
||||
|
||||
interface StartTaskCardProps {
|
||||
task: V1AppConversationStartTask;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function StartTaskCard({ task, onClick }: StartTaskCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const title =
|
||||
task.request.title ||
|
||||
task.detail ||
|
||||
t(I18nKey.CONVERSATION$STARTING_CONVERSATION);
|
||||
|
||||
const selectedRepository = task.request.selected_repository
|
||||
? {
|
||||
selected_repository: task.request.selected_repository,
|
||||
selected_branch: task.request.selected_branch || null,
|
||||
git_provider: task.request.git_provider || null,
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="start-task-card"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"relative h-auto w-full p-3.5 border-b border-neutral-600 cursor-pointer",
|
||||
"hover:bg-[#454545]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<StartTaskCardHeader title={title} taskStatus={task.status} />
|
||||
</div>
|
||||
|
||||
<StartTaskCardFooter
|
||||
selectedRepository={selectedRepository}
|
||||
createdAt={task.created_at}
|
||||
detail={task.detail}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
import type { V1AppConversationStartTaskStatus } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface StartTaskStatusBadgeProps {
|
||||
taskStatus: V1AppConversationStartTaskStatus;
|
||||
}
|
||||
|
||||
export function StartTaskStatusBadge({
|
||||
taskStatus,
|
||||
}: StartTaskStatusBadgeProps) {
|
||||
// Don't show badge for WORKING status (most common, clutters UI)
|
||||
if (taskStatus === "WORKING") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Format status for display
|
||||
const formatStatus = (status: string) =>
|
||||
status
|
||||
.toLowerCase()
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
// Get status color
|
||||
const getStatusStyle = () => {
|
||||
switch (taskStatus) {
|
||||
case "READY":
|
||||
return "bg-green-500/10 text-green-400 border-green-500/20";
|
||||
case "ERROR":
|
||||
return "bg-red-500/10 text-red-400 border-red-500/20";
|
||||
default:
|
||||
return "bg-yellow-500/10 text-yellow-400 border-yellow-500/20";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium px-2 py-0.5 rounded border flex-shrink-0",
|
||||
getStatusStyle(),
|
||||
)}
|
||||
>
|
||||
{formatStatus(taskStatus)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
import type { V1AppConversationStartTaskStatus } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface StartTaskStatusIndicatorProps {
|
||||
taskStatus: V1AppConversationStartTaskStatus;
|
||||
}
|
||||
|
||||
export function StartTaskStatusIndicator({
|
||||
taskStatus,
|
||||
}: StartTaskStatusIndicatorProps) {
|
||||
const getStatusColor = () => {
|
||||
switch (taskStatus) {
|
||||
case "READY":
|
||||
return "bg-green-500";
|
||||
case "ERROR":
|
||||
return "bg-red-500";
|
||||
case "WORKING":
|
||||
case "WAITING_FOR_SANDBOX":
|
||||
case "PREPARING_REPOSITORY":
|
||||
case "RUNNING_SETUP_SCRIPT":
|
||||
case "SETTING_UP_GIT_HOOKS":
|
||||
case "STARTING_CONVERSATION":
|
||||
return "bg-yellow-500 animate-pulse";
|
||||
default:
|
||||
return "bg-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("w-2 h-2 rounded-full flex-shrink-0", getStatusColor())}
|
||||
aria-label={`Task status: ${taskStatus}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -13,6 +13,7 @@ import { MicroagentsModal } from "../conversation-panel/microagents-modal";
|
||||
import { ConfirmDeleteModal } from "../conversation-panel/confirm-delete-modal";
|
||||
import { ConfirmStopModal } from "../conversation-panel/confirm-stop-modal";
|
||||
import { MetricsModal } from "./metrics-modal/metrics-modal";
|
||||
import { ConversationVersionBadge } from "../conversation-panel/conversation-card/conversation-version-badge";
|
||||
|
||||
export function ConversationName() {
|
||||
const { t } = useTranslation();
|
||||
@ -148,6 +149,12 @@ export function ConversationName() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{titleMode !== "edit" && (
|
||||
<ConversationVersionBadge
|
||||
version={conversation.conversation_version}
|
||||
/>
|
||||
)}
|
||||
|
||||
{titleMode !== "edit" && (
|
||||
<div className="relative flex items-center">
|
||||
<EllipsisButton fill="#B1B9D3" onClick={handleEllipsisClick} />
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -0,0 +1,198 @@
|
||||
import { ActionEvent } from "#/types/v1/core";
|
||||
import { getDefaultEventContent, MAX_CONTENT_LENGTH } from "./shared";
|
||||
import i18n from "#/i18n";
|
||||
import { SecurityRisk } from "#/types/v1/core/base/common";
|
||||
import {
|
||||
ExecuteBashAction,
|
||||
FileEditorAction,
|
||||
StrReplaceEditorAction,
|
||||
MCPToolAction,
|
||||
ThinkAction,
|
||||
FinishAction,
|
||||
TaskTrackerAction,
|
||||
BrowserNavigateAction,
|
||||
BrowserClickAction,
|
||||
BrowserTypeAction,
|
||||
BrowserGetStateAction,
|
||||
BrowserGetContentAction,
|
||||
BrowserScrollAction,
|
||||
BrowserGoBackAction,
|
||||
BrowserListTabsAction,
|
||||
BrowserSwitchTabAction,
|
||||
BrowserCloseTabAction,
|
||||
} from "#/types/v1/core/base/action";
|
||||
|
||||
const getRiskText = (risk: SecurityRisk) => {
|
||||
switch (risk) {
|
||||
case SecurityRisk.LOW:
|
||||
return i18n.t("SECURITY$LOW_RISK");
|
||||
case SecurityRisk.MEDIUM:
|
||||
return i18n.t("SECURITY$MEDIUM_RISK");
|
||||
case SecurityRisk.HIGH:
|
||||
return i18n.t("SECURITY$HIGH_RISK");
|
||||
case SecurityRisk.UNKNOWN:
|
||||
default:
|
||||
return i18n.t("SECURITY$UNKNOWN_RISK");
|
||||
}
|
||||
};
|
||||
|
||||
const getNoContentActionContent = (): string => "";
|
||||
|
||||
// File Editor Actions
|
||||
const getFileEditorActionContent = (
|
||||
action: FileEditorAction | StrReplaceEditorAction,
|
||||
): string => {
|
||||
// Early return if not a create command or no file text
|
||||
if (action.command !== "create" || !action.file_text) {
|
||||
return getNoContentActionContent();
|
||||
}
|
||||
|
||||
// Process file text with length truncation
|
||||
let fileText = action.file_text;
|
||||
if (fileText.length > MAX_CONTENT_LENGTH) {
|
||||
fileText = `${fileText.slice(0, MAX_CONTENT_LENGTH)}...`;
|
||||
}
|
||||
|
||||
return `${action.path}\n${fileText}`;
|
||||
};
|
||||
|
||||
// Command Actions
|
||||
const getExecuteBashActionContent = (
|
||||
event: ActionEvent<ExecuteBashAction>,
|
||||
): string => {
|
||||
let content = `Command:\n\`${event.action.command}\``;
|
||||
|
||||
// Add security risk information if it's HIGH or MEDIUM
|
||||
if (
|
||||
event.security_risk === SecurityRisk.HIGH ||
|
||||
event.security_risk === SecurityRisk.MEDIUM
|
||||
) {
|
||||
content += `\n\n${getRiskText(event.security_risk)}`;
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
// Tool Actions
|
||||
const getMCPToolActionContent = (action: MCPToolAction): string => {
|
||||
// For V1, the tool name is in the event's tool_name property, not in the action
|
||||
let details = `**MCP Tool Call**\n\n`;
|
||||
details += `**Arguments:**\n\`\`\`json\n${JSON.stringify(action.data, null, 2)}\n\`\`\``;
|
||||
return details;
|
||||
};
|
||||
|
||||
// Simple Actions
|
||||
const getThinkActionContent = (action: ThinkAction): string => action.thought;
|
||||
|
||||
const getFinishActionContent = (action: FinishAction): string =>
|
||||
action.message.trim();
|
||||
|
||||
// Complex Actions
|
||||
const getTaskTrackerActionContent = (action: TaskTrackerAction): string => {
|
||||
let content = `**Command:** \`${action.command}\``;
|
||||
|
||||
// Handle plan command with task list
|
||||
if (action.command === "plan") {
|
||||
if (action.task_list && action.task_list.length > 0) {
|
||||
content += `\n\n**Task List (${action.task_list.length} ${action.task_list.length === 1 ? "item" : "items"}):**\n`;
|
||||
action.task_list.forEach((task, index: number) => {
|
||||
const statusMap = {
|
||||
todo: "⏳",
|
||||
in_progress: "🔄",
|
||||
done: "✅",
|
||||
};
|
||||
const statusIcon =
|
||||
statusMap[task.status as keyof typeof statusMap] || "❓";
|
||||
content += `\n${index + 1}. ${statusIcon} **[${task.status.toUpperCase().replace("_", " ")}]** ${task.title}`;
|
||||
if (task.notes) {
|
||||
content += `\n *Notes: ${task.notes}*`;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
content += "\n\n**Task List:** Empty";
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
// Browser Actions
|
||||
type BrowserAction =
|
||||
| BrowserNavigateAction
|
||||
| BrowserClickAction
|
||||
| BrowserTypeAction
|
||||
| BrowserGetStateAction
|
||||
| BrowserGetContentAction
|
||||
| BrowserScrollAction
|
||||
| BrowserGoBackAction
|
||||
| BrowserListTabsAction
|
||||
| BrowserSwitchTabAction
|
||||
| BrowserCloseTabAction;
|
||||
|
||||
const getBrowserActionContent = (action: BrowserAction): string => {
|
||||
switch (action.kind) {
|
||||
case "BrowserNavigateAction":
|
||||
if ("url" in action) {
|
||||
return `Browsing ${action.url}`;
|
||||
}
|
||||
break;
|
||||
case "BrowserClickAction":
|
||||
case "BrowserTypeAction":
|
||||
case "BrowserGetStateAction":
|
||||
case "BrowserGetContentAction":
|
||||
case "BrowserScrollAction":
|
||||
case "BrowserGoBackAction":
|
||||
case "BrowserListTabsAction":
|
||||
case "BrowserSwitchTabAction":
|
||||
case "BrowserCloseTabAction":
|
||||
// These browser actions typically don't need detailed content display
|
||||
return getNoContentActionContent();
|
||||
default:
|
||||
return getNoContentActionContent();
|
||||
}
|
||||
|
||||
return getNoContentActionContent();
|
||||
};
|
||||
|
||||
export const getActionContent = (event: ActionEvent): string => {
|
||||
const { action } = event;
|
||||
const actionType = action.kind;
|
||||
|
||||
switch (actionType) {
|
||||
case "FileEditorAction":
|
||||
case "StrReplaceEditorAction":
|
||||
return getFileEditorActionContent(action);
|
||||
|
||||
case "ExecuteBashAction":
|
||||
return getExecuteBashActionContent(
|
||||
event as ActionEvent<ExecuteBashAction>,
|
||||
);
|
||||
|
||||
case "MCPToolAction":
|
||||
return getMCPToolActionContent(action);
|
||||
|
||||
case "ThinkAction":
|
||||
return getThinkActionContent(action);
|
||||
|
||||
case "FinishAction":
|
||||
return getFinishActionContent(action);
|
||||
|
||||
case "TaskTrackerAction":
|
||||
return getTaskTrackerActionContent(action);
|
||||
|
||||
case "BrowserNavigateAction":
|
||||
case "BrowserClickAction":
|
||||
case "BrowserTypeAction":
|
||||
case "BrowserGetStateAction":
|
||||
case "BrowserGetContentAction":
|
||||
case "BrowserScrollAction":
|
||||
case "BrowserGoBackAction":
|
||||
case "BrowserListTabsAction":
|
||||
case "BrowserSwitchTabAction":
|
||||
case "BrowserCloseTabAction":
|
||||
return getBrowserActionContent(action);
|
||||
|
||||
default:
|
||||
return getDefaultEventContent(event);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,168 @@
|
||||
import { Trans } from "react-i18next";
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import { isActionEvent, isObservationEvent } from "#/types/v1/type-guards";
|
||||
import { MonoComponent } from "../../../features/chat/mono-component";
|
||||
import { PathComponent } from "../../../features/chat/path-component";
|
||||
import { getActionContent } from "./get-action-content";
|
||||
import { getObservationContent } from "./get-observation-content";
|
||||
import i18n from "#/i18n";
|
||||
|
||||
const trimText = (text: string, maxLength: number): string => {
|
||||
if (!text) return "";
|
||||
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
|
||||
};
|
||||
|
||||
// Helper function to create title from translation key
|
||||
const createTitleFromKey = (
|
||||
key: string,
|
||||
values: Record<string, unknown>,
|
||||
): React.ReactNode => {
|
||||
if (!i18n.exists(key)) {
|
||||
return key;
|
||||
}
|
||||
|
||||
return (
|
||||
<Trans
|
||||
i18nKey={key}
|
||||
values={values}
|
||||
components={{
|
||||
path: <PathComponent />,
|
||||
cmd: <MonoComponent />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Action Event Processing
|
||||
const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => {
|
||||
// Early return if not an action event
|
||||
if (!isActionEvent(event)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const actionType = event.action.kind;
|
||||
let actionKey = "";
|
||||
let actionValues: Record<string, unknown> = {};
|
||||
|
||||
switch (actionType) {
|
||||
case "ExecuteBashAction":
|
||||
actionKey = "ACTION_MESSAGE$RUN";
|
||||
actionValues = {
|
||||
command: trimText(event.action.command, 80),
|
||||
};
|
||||
break;
|
||||
case "FileEditorAction":
|
||||
case "StrReplaceEditorAction":
|
||||
if (event.action.command === "view") {
|
||||
actionKey = "ACTION_MESSAGE$READ";
|
||||
} else if (event.action.command === "create") {
|
||||
actionKey = "ACTION_MESSAGE$WRITE";
|
||||
} else {
|
||||
actionKey = "ACTION_MESSAGE$EDIT";
|
||||
}
|
||||
actionValues = {
|
||||
path: event.action.path,
|
||||
};
|
||||
break;
|
||||
case "MCPToolAction":
|
||||
actionKey = "ACTION_MESSAGE$CALL_TOOL_MCP";
|
||||
actionValues = {
|
||||
mcp_tool_name: event.tool_name,
|
||||
};
|
||||
break;
|
||||
case "ThinkAction":
|
||||
actionKey = "ACTION_MESSAGE$THINK";
|
||||
break;
|
||||
case "FinishAction":
|
||||
actionKey = "ACTION_MESSAGE$FINISH";
|
||||
break;
|
||||
case "TaskTrackerAction":
|
||||
actionKey = "ACTION_MESSAGE$TASK_TRACKING";
|
||||
break;
|
||||
case "BrowserNavigateAction":
|
||||
actionKey = "ACTION_MESSAGE$BROWSE";
|
||||
break;
|
||||
default:
|
||||
// For unknown actions, use the type name
|
||||
return actionType.replace("Action", "").toUpperCase();
|
||||
}
|
||||
|
||||
if (actionKey) {
|
||||
return createTitleFromKey(actionKey, actionValues);
|
||||
}
|
||||
|
||||
return actionType;
|
||||
};
|
||||
|
||||
// Observation Event Processing
|
||||
const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => {
|
||||
// Early return if not an observation event
|
||||
if (!isObservationEvent(event)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const observationType = event.observation.kind;
|
||||
let observationKey = "";
|
||||
let observationValues: Record<string, unknown> = {};
|
||||
|
||||
switch (observationType) {
|
||||
case "ExecuteBashObservation":
|
||||
observationKey = "OBSERVATION_MESSAGE$RUN";
|
||||
observationValues = {
|
||||
command: event.observation.command
|
||||
? trimText(event.observation.command, 80)
|
||||
: "",
|
||||
};
|
||||
break;
|
||||
case "FileEditorObservation":
|
||||
case "StrReplaceEditorObservation":
|
||||
if (event.observation.command === "view") {
|
||||
observationKey = "OBSERVATION_MESSAGE$READ";
|
||||
} else {
|
||||
observationKey = "OBSERVATION_MESSAGE$EDIT";
|
||||
}
|
||||
observationValues = {
|
||||
path: event.observation.path || "",
|
||||
};
|
||||
break;
|
||||
case "MCPToolObservation":
|
||||
observationKey = "OBSERVATION_MESSAGE$MCP";
|
||||
observationValues = {
|
||||
mcp_tool_name: event.observation.tool_name,
|
||||
};
|
||||
break;
|
||||
case "BrowserObservation":
|
||||
observationKey = "OBSERVATION_MESSAGE$BROWSE";
|
||||
break;
|
||||
case "TaskTrackerObservation":
|
||||
observationKey = "OBSERVATION_MESSAGE$TASK_TRACKING";
|
||||
break;
|
||||
default:
|
||||
// For unknown observations, use the type name
|
||||
return observationType.replace("Observation", "").toUpperCase();
|
||||
}
|
||||
|
||||
if (observationKey) {
|
||||
return createTitleFromKey(observationKey, observationValues);
|
||||
}
|
||||
|
||||
return observationType;
|
||||
};
|
||||
|
||||
export const getEventContent = (event: OpenHandsEvent) => {
|
||||
let title: React.ReactNode = "";
|
||||
let details: string = "";
|
||||
|
||||
if (isActionEvent(event)) {
|
||||
title = getActionEventTitle(event);
|
||||
details = getActionContent(event);
|
||||
} else if (isObservationEvent(event)) {
|
||||
title = getObservationEventTitle(event);
|
||||
details = getObservationContent(event);
|
||||
}
|
||||
|
||||
return {
|
||||
title: title || i18n.t("EVENT$UNKNOWN_EVENT"),
|
||||
details: details || i18n.t("EVENT$UNKNOWN_EVENT"),
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,203 @@
|
||||
import { ObservationEvent } from "#/types/v1/core";
|
||||
import { getObservationResult } from "./get-observation-result";
|
||||
import { getDefaultEventContent, MAX_CONTENT_LENGTH } from "./shared";
|
||||
import i18n from "#/i18n";
|
||||
import {
|
||||
MCPToolObservation,
|
||||
FinishObservation,
|
||||
ThinkObservation,
|
||||
BrowserObservation,
|
||||
ExecuteBashObservation,
|
||||
FileEditorObservation,
|
||||
StrReplaceEditorObservation,
|
||||
TaskTrackerObservation,
|
||||
} from "#/types/v1/core/base/observation";
|
||||
|
||||
// File Editor Observations
|
||||
const getFileEditorObservationContent = (
|
||||
event: ObservationEvent<FileEditorObservation | StrReplaceEditorObservation>,
|
||||
): string => {
|
||||
const { observation } = event;
|
||||
|
||||
const successMessage = getObservationResult(event) === "success";
|
||||
|
||||
// For view commands or successful edits with content changes, format as code block
|
||||
if (
|
||||
(successMessage &&
|
||||
"old_content" in observation &&
|
||||
"new_content" in observation &&
|
||||
observation.old_content &&
|
||||
observation.new_content) ||
|
||||
observation.command === "view"
|
||||
) {
|
||||
return `\`\`\`\n${observation.output}\n\`\`\``;
|
||||
}
|
||||
|
||||
// For other commands, return the output as-is
|
||||
return observation.output;
|
||||
};
|
||||
|
||||
// Command Observations
|
||||
const getExecuteBashObservationContent = (
|
||||
event: ObservationEvent<ExecuteBashObservation>,
|
||||
): string => {
|
||||
const { observation } = event;
|
||||
|
||||
let { output } = observation;
|
||||
|
||||
if (output.length > MAX_CONTENT_LENGTH) {
|
||||
output = `${output.slice(0, MAX_CONTENT_LENGTH)}...`;
|
||||
}
|
||||
|
||||
return `Output:\n\`\`\`sh\n${output.trim() || i18n.t("OBSERVATION$COMMAND_NO_OUTPUT")}\n\`\`\``;
|
||||
};
|
||||
|
||||
// Tool Observations
|
||||
const getBrowserObservationContent = (
|
||||
event: ObservationEvent<BrowserObservation>,
|
||||
): string => {
|
||||
const { observation } = event;
|
||||
|
||||
let contentDetails = "";
|
||||
|
||||
if ("error" in observation && observation.error) {
|
||||
contentDetails += `**Error:**\n${observation.error}\n\n`;
|
||||
}
|
||||
|
||||
contentDetails += `**Output:**\n${observation.output}`;
|
||||
|
||||
if (contentDetails.length > MAX_CONTENT_LENGTH) {
|
||||
contentDetails = `${contentDetails.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`;
|
||||
}
|
||||
|
||||
return contentDetails;
|
||||
};
|
||||
|
||||
const getMCPToolObservationContent = (
|
||||
event: ObservationEvent<MCPToolObservation>,
|
||||
): string => {
|
||||
const { observation } = event;
|
||||
|
||||
// Extract text content from the observation
|
||||
const textContent = observation.content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
|
||||
let content = `**Tool:** ${observation.tool_name}\n\n`;
|
||||
|
||||
if (observation.is_error) {
|
||||
content += `**Error:**\n${textContent}`;
|
||||
} else {
|
||||
content += `**Result:**\n${textContent}`;
|
||||
}
|
||||
|
||||
if (content.length > MAX_CONTENT_LENGTH) {
|
||||
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
// Complex Observations
|
||||
const getTaskTrackerObservationContent = (
|
||||
event: ObservationEvent<TaskTrackerObservation>,
|
||||
): string => {
|
||||
const { observation } = event;
|
||||
|
||||
const { command, task_list: taskList } = observation;
|
||||
let content = `**Command:** \`${command}\``;
|
||||
|
||||
if (command === "plan" && taskList.length > 0) {
|
||||
content += `\n\n**Task List (${taskList.length} ${taskList.length === 1 ? "item" : "items"}):**\n`;
|
||||
|
||||
taskList.forEach((task, index: number) => {
|
||||
const statusMap = {
|
||||
todo: "⏳",
|
||||
in_progress: "🔄",
|
||||
done: "✅",
|
||||
};
|
||||
const statusIcon =
|
||||
statusMap[task.status as keyof typeof statusMap] || "❓";
|
||||
|
||||
content += `\n${index + 1}. ${statusIcon} **[${task.status.toUpperCase().replace("_", " ")}]** ${task.title}`;
|
||||
if (task.notes) {
|
||||
content += `\n *Notes: ${task.notes}*`;
|
||||
}
|
||||
});
|
||||
} else if (command === "plan") {
|
||||
content += "\n\n**Task List:** Empty";
|
||||
}
|
||||
|
||||
if (
|
||||
"content" in observation &&
|
||||
observation.content &&
|
||||
observation.content.trim()
|
||||
) {
|
||||
content += `\n\n**Result:** ${observation.content.trim()}`;
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
// Simple Observations
|
||||
const getThinkObservationContent = (
|
||||
event: ObservationEvent<ThinkObservation>,
|
||||
): string => {
|
||||
const { observation } = event;
|
||||
return observation.content || "";
|
||||
};
|
||||
|
||||
const getFinishObservationContent = (
|
||||
event: ObservationEvent<FinishObservation>,
|
||||
): string => {
|
||||
const { observation } = event;
|
||||
return observation.message || "";
|
||||
};
|
||||
|
||||
export const getObservationContent = (event: ObservationEvent): string => {
|
||||
const observationType = event.observation.kind;
|
||||
|
||||
switch (observationType) {
|
||||
case "FileEditorObservation":
|
||||
case "StrReplaceEditorObservation":
|
||||
return getFileEditorObservationContent(
|
||||
event as ObservationEvent<
|
||||
FileEditorObservation | StrReplaceEditorObservation
|
||||
>,
|
||||
);
|
||||
|
||||
case "ExecuteBashObservation":
|
||||
return getExecuteBashObservationContent(
|
||||
event as ObservationEvent<ExecuteBashObservation>,
|
||||
);
|
||||
|
||||
case "BrowserObservation":
|
||||
return getBrowserObservationContent(
|
||||
event as ObservationEvent<BrowserObservation>,
|
||||
);
|
||||
|
||||
case "MCPToolObservation":
|
||||
return getMCPToolObservationContent(
|
||||
event as ObservationEvent<MCPToolObservation>,
|
||||
);
|
||||
|
||||
case "TaskTrackerObservation":
|
||||
return getTaskTrackerObservationContent(
|
||||
event as ObservationEvent<TaskTrackerObservation>,
|
||||
);
|
||||
|
||||
case "ThinkObservation":
|
||||
return getThinkObservationContent(
|
||||
event as ObservationEvent<ThinkObservation>,
|
||||
);
|
||||
|
||||
case "FinishObservation":
|
||||
return getFinishObservationContent(
|
||||
event as ObservationEvent<FinishObservation>,
|
||||
);
|
||||
|
||||
default:
|
||||
return getDefaultEventContent(event);
|
||||
}
|
||||
};
|
||||
@ -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";
|
||||
}
|
||||
};
|
||||
@ -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];
|
||||
};
|
||||
@ -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\`\`\``;
|
||||
@ -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");
|
||||
@ -0,0 +1,49 @@
|
||||
import React from "react";
|
||||
import { AgentErrorEvent } from "#/types/v1/core";
|
||||
import { isAgentErrorEvent } from "#/types/v1/type-guards";
|
||||
import { ErrorMessage } from "../../../features/chat/error-message";
|
||||
import { MicroagentStatusWrapper } from "../../../features/chat/event-message-components/microagent-status-wrapper";
|
||||
// TODO: Implement V1 LikertScaleWrapper when API supports V1 event IDs
|
||||
// import { LikertScaleWrapper } from "../../../features/chat/event-message-components/likert-scale-wrapper";
|
||||
import { MicroagentStatus } from "#/types/microagent-status";
|
||||
|
||||
interface ErrorEventMessageProps {
|
||||
event: AgentErrorEvent;
|
||||
microagentStatus?: MicroagentStatus | null;
|
||||
microagentConversationId?: string;
|
||||
microagentPRUrl?: string;
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function ErrorEventMessage({
|
||||
event,
|
||||
microagentStatus,
|
||||
microagentConversationId,
|
||||
microagentPRUrl,
|
||||
actions,
|
||||
}: ErrorEventMessageProps) {
|
||||
if (!isAgentErrorEvent(event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ErrorMessage
|
||||
// V1 doesn't have error_id, use event.id instead
|
||||
errorId={event.id}
|
||||
defaultMessage={event.error}
|
||||
/>
|
||||
<MicroagentStatusWrapper
|
||||
microagentStatus={microagentStatus}
|
||||
microagentConversationId={microagentConversationId}
|
||||
microagentPRUrl={microagentPRUrl}
|
||||
actions={actions}
|
||||
/>
|
||||
{/* LikertScaleWrapper expects V0 event types, skip for now */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
import { ActionEvent } from "#/types/v1/core";
|
||||
import { FinishAction } from "#/types/v1/core/base/action";
|
||||
import { ChatMessage } from "../../../features/chat/chat-message";
|
||||
import { MicroagentStatusWrapper } from "../../../features/chat/event-message-components/microagent-status-wrapper";
|
||||
// TODO: Implement V1 LikertScaleWrapper when API supports V1 event IDs
|
||||
// import { LikertScaleWrapper } from "../../../features/chat/event-message-components/likert-scale-wrapper";
|
||||
import { getEventContent } from "../event-content-helpers/get-event-content";
|
||||
import { MicroagentStatus } from "#/types/microagent-status";
|
||||
|
||||
interface FinishEventMessageProps {
|
||||
event: ActionEvent<FinishAction>;
|
||||
microagentStatus?: MicroagentStatus | null;
|
||||
microagentConversationId?: string;
|
||||
microagentPRUrl?: string;
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function FinishEventMessage({
|
||||
event,
|
||||
microagentStatus,
|
||||
microagentConversationId,
|
||||
microagentPRUrl,
|
||||
actions,
|
||||
}: FinishEventMessageProps) {
|
||||
return (
|
||||
<>
|
||||
<ChatMessage
|
||||
type="agent"
|
||||
message={getEventContent(event).details}
|
||||
actions={actions}
|
||||
/>
|
||||
<MicroagentStatusWrapper
|
||||
microagentStatus={microagentStatus}
|
||||
microagentConversationId={microagentConversationId}
|
||||
microagentPRUrl={microagentPRUrl}
|
||||
actions={actions}
|
||||
/>
|
||||
{/* LikertScaleWrapper expects V0 event types, skip for now */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
import React from "react";
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import { GenericEventMessage } from "../../../features/chat/generic-event-message";
|
||||
import { getEventContent } from "../event-content-helpers/get-event-content";
|
||||
import { getObservationResult } from "../event-content-helpers/get-observation-result";
|
||||
import { isObservationEvent } from "#/types/v1/type-guards";
|
||||
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
|
||||
|
||||
interface GenericEventMessageWrapperProps {
|
||||
event: OpenHandsEvent;
|
||||
shouldShowConfirmationButtons: boolean;
|
||||
}
|
||||
|
||||
export function GenericEventMessageWrapper({
|
||||
event,
|
||||
shouldShowConfirmationButtons,
|
||||
}: GenericEventMessageWrapperProps) {
|
||||
const { title, details } = getEventContent(event);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<GenericEventMessage
|
||||
title={title}
|
||||
details={details}
|
||||
success={
|
||||
isObservationEvent(event) ? getObservationResult(event) : undefined
|
||||
}
|
||||
initiallyExpanded={false}
|
||||
/>
|
||||
{shouldShowConfirmationButtons && <ConfirmationButtons />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
import { ActionEvent } from "#/types/v1/core";
|
||||
import { isActionEvent } from "#/types/v1/type-guards";
|
||||
import { ChatMessage } from "../../../features/chat/chat-message";
|
||||
import { MicroagentStatusWrapper } from "../../../features/chat/event-message-components/microagent-status-wrapper";
|
||||
import { MicroagentStatus } from "#/types/microagent-status";
|
||||
|
||||
interface ObservationPairEventMessageProps {
|
||||
event: ActionEvent;
|
||||
microagentStatus?: MicroagentStatus | null;
|
||||
microagentConversationId?: string;
|
||||
microagentPRUrl?: string;
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function ObservationPairEventMessage({
|
||||
event,
|
||||
microagentStatus,
|
||||
microagentConversationId,
|
||||
microagentPRUrl,
|
||||
actions,
|
||||
}: ObservationPairEventMessageProps) {
|
||||
if (!isActionEvent(event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if there's thought content to display
|
||||
const thoughtContent = event.thought
|
||||
.filter((t) => t.type === "text")
|
||||
.map((t) => t.text)
|
||||
.join("\n");
|
||||
|
||||
if (thoughtContent && event.action.kind !== "ThinkAction") {
|
||||
return (
|
||||
<div>
|
||||
<ChatMessage type="agent" message={thoughtContent} actions={actions} />
|
||||
<MicroagentStatusWrapper
|
||||
microagentStatus={microagentStatus}
|
||||
microagentConversationId={microagentConversationId}
|
||||
microagentPRUrl={microagentPRUrl}
|
||||
actions={actions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MicroagentStatusWrapper
|
||||
microagentStatus={microagentStatus}
|
||||
microagentConversationId={microagentConversationId}
|
||||
microagentPRUrl={microagentPRUrl}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
import React from "react";
|
||||
import { MessageEvent } from "#/types/v1/core";
|
||||
import { ChatMessage } from "../../../features/chat/chat-message";
|
||||
import { ImageCarousel } from "../../../features/images/image-carousel";
|
||||
// TODO: Implement file_urls support for V1 messages
|
||||
// import { FileList } from "../../../features/files/file-list";
|
||||
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
|
||||
import { MicroagentStatusWrapper } from "../../../features/chat/event-message-components/microagent-status-wrapper";
|
||||
// TODO: Implement V1 LikertScaleWrapper when API supports V1 event IDs
|
||||
// import { LikertScaleWrapper } from "../../../features/chat/event-message-components/likert-scale-wrapper";
|
||||
import { parseMessageFromEvent } from "../event-content-helpers/parse-message-from-event";
|
||||
import { MicroagentStatus } from "#/types/microagent-status";
|
||||
|
||||
interface UserAssistantEventMessageProps {
|
||||
event: MessageEvent;
|
||||
shouldShowConfirmationButtons: boolean;
|
||||
microagentStatus?: MicroagentStatus | null;
|
||||
microagentConversationId?: string;
|
||||
microagentPRUrl?: string;
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function UserAssistantEventMessage({
|
||||
event,
|
||||
shouldShowConfirmationButtons,
|
||||
microagentStatus,
|
||||
microagentConversationId,
|
||||
microagentPRUrl,
|
||||
actions,
|
||||
}: UserAssistantEventMessageProps) {
|
||||
const message = parseMessageFromEvent(event);
|
||||
|
||||
// Extract image URLs from the message content
|
||||
const imageUrls: string[] = [];
|
||||
if (Array.isArray(event.llm_message.content)) {
|
||||
event.llm_message.content.forEach((content) => {
|
||||
if (content.type === "image") {
|
||||
imageUrls.push(...content.image_urls);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChatMessage type={event.source} message={message} actions={actions}>
|
||||
{imageUrls.length > 0 && (
|
||||
<ImageCarousel size="small" images={imageUrls} />
|
||||
)}
|
||||
{/* TODO: Handle file_urls if V1 messages support them */}
|
||||
{shouldShowConfirmationButtons && <ConfirmationButtons />}
|
||||
</ChatMessage>
|
||||
<MicroagentStatusWrapper
|
||||
microagentStatus={microagentStatus}
|
||||
microagentConversationId={microagentConversationId}
|
||||
microagentPRUrl={microagentPRUrl}
|
||||
actions={actions}
|
||||
/>
|
||||
{/* LikertScaleWrapper expects V0 event types, skip for now */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
119
frontend/src/components/v1/chat/event-message.tsx
Normal file
119
frontend/src/components/v1/chat/event-message.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import React from "react";
|
||||
import { OpenHandsEvent, MessageEvent, ActionEvent } from "#/types/v1/core";
|
||||
import { FinishAction } from "#/types/v1/core/base/action";
|
||||
import {
|
||||
isActionEvent,
|
||||
isObservationEvent,
|
||||
isAgentErrorEvent,
|
||||
} from "#/types/v1/type-guards";
|
||||
import { MicroagentStatus } from "#/types/microagent-status";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
// TODO: Implement V1 feedback functionality when API supports V1 event IDs
|
||||
// import { useFeedbackExists } from "#/hooks/query/use-feedback-exists";
|
||||
import {
|
||||
ErrorEventMessage,
|
||||
UserAssistantEventMessage,
|
||||
FinishEventMessage,
|
||||
ObservationPairEventMessage,
|
||||
GenericEventMessageWrapper,
|
||||
} from "./event-message-components";
|
||||
|
||||
interface EventMessageProps {
|
||||
event: OpenHandsEvent;
|
||||
hasObservationPair: boolean;
|
||||
isAwaitingUserConfirmation: boolean;
|
||||
isLastMessage: boolean;
|
||||
microagentStatus?: MicroagentStatus | null;
|
||||
microagentConversationId?: string;
|
||||
microagentPRUrl?: string;
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}>;
|
||||
isInLast10Actions: boolean;
|
||||
}
|
||||
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
export function EventMessage({
|
||||
event,
|
||||
hasObservationPair,
|
||||
isAwaitingUserConfirmation,
|
||||
isLastMessage,
|
||||
microagentStatus,
|
||||
microagentConversationId,
|
||||
microagentPRUrl,
|
||||
actions,
|
||||
isInLast10Actions,
|
||||
}: EventMessageProps) {
|
||||
const shouldShowConfirmationButtons =
|
||||
isLastMessage && event.source === "agent" && isAwaitingUserConfirmation;
|
||||
|
||||
const { data: config } = useConfig();
|
||||
|
||||
// V1 events use string IDs, but useFeedbackExists expects number
|
||||
// For now, we'll skip feedback functionality for V1 events
|
||||
const feedbackData = { exists: false };
|
||||
const isCheckingFeedback = false;
|
||||
|
||||
// Common props for components that need them
|
||||
const commonProps = {
|
||||
microagentStatus,
|
||||
microagentConversationId,
|
||||
microagentPRUrl,
|
||||
actions,
|
||||
isLastMessage,
|
||||
isInLast10Actions,
|
||||
config,
|
||||
isCheckingFeedback,
|
||||
feedbackData,
|
||||
};
|
||||
|
||||
// Agent error events
|
||||
if (isAgentErrorEvent(event)) {
|
||||
return <ErrorEventMessage event={event} {...commonProps} />;
|
||||
}
|
||||
|
||||
// Observation pairs with actions
|
||||
if (hasObservationPair && isActionEvent(event)) {
|
||||
return (
|
||||
<ObservationPairEventMessage
|
||||
event={event}
|
||||
microagentStatus={microagentStatus}
|
||||
microagentConversationId={microagentConversationId}
|
||||
microagentPRUrl={microagentPRUrl}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Finish actions
|
||||
if (isActionEvent(event) && event.action.kind === "FinishAction") {
|
||||
return (
|
||||
<FinishEventMessage
|
||||
event={event as ActionEvent<FinishAction>}
|
||||
{...commonProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Message events (user and assistant messages)
|
||||
if (!isActionEvent(event) && !isObservationEvent(event)) {
|
||||
// This is a MessageEvent
|
||||
return (
|
||||
<UserAssistantEventMessage
|
||||
event={event as MessageEvent}
|
||||
shouldShowConfirmationButtons={shouldShowConfirmationButtons}
|
||||
{...commonProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Generic fallback for all other events (including observation events)
|
||||
return (
|
||||
<GenericEventMessageWrapper
|
||||
event={event}
|
||||
shouldShowConfirmationButtons={shouldShowConfirmationButtons}
|
||||
/>
|
||||
);
|
||||
}
|
||||
8
frontend/src/components/v1/chat/index.ts
Normal file
8
frontend/src/components/v1/chat/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export { Messages } from "./messages";
|
||||
export { EventMessage } from "./event-message";
|
||||
export * from "./event-message-components";
|
||||
export { getEventContent } from "./event-content-helpers/get-event-content";
|
||||
export {
|
||||
shouldRenderEvent,
|
||||
hasUserEvent,
|
||||
} from "./event-content-helpers/should-render-event";
|
||||
73
frontend/src/components/v1/chat/messages.tsx
Normal file
73
frontend/src/components/v1/chat/messages.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import React from "react";
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import { isActionEvent, isObservationEvent } from "#/types/v1/type-guards";
|
||||
import { EventMessage } from "./event-message";
|
||||
import { ChatMessage } from "../../features/chat/chat-message";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
// TODO: Implement microagent functionality for V1 when APIs support V1 event IDs
|
||||
// import { AgentState } from "#/types/agent-state";
|
||||
// import MemoryIcon from "#/icons/memory_icon.svg?react";
|
||||
|
||||
interface MessagesProps {
|
||||
messages: OpenHandsEvent[];
|
||||
isAwaitingUserConfirmation: boolean;
|
||||
}
|
||||
|
||||
export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
({ messages, isAwaitingUserConfirmation }) => {
|
||||
const { getOptimisticUserMessage } = useOptimisticUserMessageStore();
|
||||
|
||||
const optimisticUserMessage = getOptimisticUserMessage();
|
||||
|
||||
const actionHasObservationPair = React.useCallback(
|
||||
(event: OpenHandsEvent): boolean => {
|
||||
if (isActionEvent(event)) {
|
||||
// Check if there's a corresponding observation event
|
||||
return !!messages.some(
|
||||
(msg) => isObservationEvent(msg) && msg.action_id === event.id,
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[messages],
|
||||
);
|
||||
|
||||
// TODO: Implement microagent functionality for V1 if needed
|
||||
// For now, we'll skip microagent features
|
||||
|
||||
return (
|
||||
<>
|
||||
{messages.map((message, index) => (
|
||||
<EventMessage
|
||||
key={message.id}
|
||||
event={message}
|
||||
hasObservationPair={actionHasObservationPair(message)}
|
||||
isAwaitingUserConfirmation={isAwaitingUserConfirmation}
|
||||
isLastMessage={messages.length - 1 === index}
|
||||
isInLast10Actions={messages.length - 1 - index < 10}
|
||||
// Microagent props - not implemented yet for V1
|
||||
// microagentStatus={undefined}
|
||||
// microagentConversationId={undefined}
|
||||
// microagentPRUrl={undefined}
|
||||
// actions={undefined}
|
||||
/>
|
||||
))}
|
||||
|
||||
{optimisticUserMessage && (
|
||||
<ChatMessage type="user" message={optimisticUserMessage} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Prevent re-renders if messages are the same length
|
||||
if (prevProps.messages.length !== nextProps.messages.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
Messages.displayName = "Messages";
|
||||
1
frontend/src/components/v1/index.ts
Normal file
1
frontend/src/components/v1/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./chat";
|
||||
@ -28,7 +28,12 @@ import { useErrorMessageStore } from "#/stores/error-message-store";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
|
||||
export type WebSocketStatus = "CONNECTING" | "CONNECTED" | "DISCONNECTED";
|
||||
/**
|
||||
* @deprecated Use `V1_WebSocketConnectionState` from `conversation-websocket-context.tsx` instead.
|
||||
* This type is for legacy V0 conversations only.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export type V0_WebSocketStatus = "CONNECTING" | "CONNECTED" | "DISCONNECTED";
|
||||
|
||||
const hasValidMessageProperty = (obj: unknown): obj is { message: string } =>
|
||||
typeof obj === "object" &&
|
||||
@ -69,7 +74,7 @@ const isMessageAction = (
|
||||
isUserMessage(event) || isAssistantMessage(event);
|
||||
|
||||
interface UseWsClient {
|
||||
webSocketStatus: WebSocketStatus;
|
||||
webSocketStatus: V0_WebSocketStatus;
|
||||
isLoadingMessages: boolean;
|
||||
send: (event: Record<string, unknown>) => void;
|
||||
}
|
||||
@ -132,7 +137,7 @@ export function WsClientProvider({
|
||||
const queryClient = useQueryClient();
|
||||
const sioRef = React.useRef<Socket | null>(null);
|
||||
const [webSocketStatus, setWebSocketStatus] =
|
||||
React.useState<WebSocketStatus>("DISCONNECTED");
|
||||
React.useState<V0_WebSocketStatus>("DISCONNECTED");
|
||||
const lastEventRef = React.useRef<Record<string, unknown> | null>(null);
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
|
||||
@ -7,20 +7,37 @@ import React, {
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useWebSocket } from "#/hooks/use-websocket";
|
||||
import { useWebSocket, WebSocketHookOptions } from "#/hooks/use-websocket";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import { useErrorMessageStore } from "#/stores/error-message-store";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import { useV1ConversationStateStore } from "#/stores/v1-conversation-state-store";
|
||||
import { useCommandStore } from "#/state/command-store";
|
||||
import {
|
||||
isV1Event,
|
||||
isAgentErrorEvent,
|
||||
isUserMessageEvent,
|
||||
isActionEvent,
|
||||
isConversationStateUpdateEvent,
|
||||
isFullStateConversationStateUpdateEvent,
|
||||
isAgentStatusConversationStateUpdateEvent,
|
||||
isExecuteBashActionEvent,
|
||||
isExecuteBashObservationEvent,
|
||||
} from "#/types/v1/type-guards";
|
||||
import { handleActionEventCacheInvalidation } from "#/utils/cache-utils";
|
||||
import { buildWebSocketUrl } from "#/utils/websocket-url";
|
||||
import type { V1SendMessageRequest } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export type V1_WebSocketConnectionState =
|
||||
| "CONNECTING"
|
||||
| "OPEN"
|
||||
| "CLOSED"
|
||||
| "CLOSING";
|
||||
|
||||
interface ConversationWebSocketContextType {
|
||||
connectionState: "CONNECTING" | "OPEN" | "CLOSED" | "CLOSING";
|
||||
connectionState: V1_WebSocketConnectionState;
|
||||
sendMessage: (message: V1SendMessageRequest) => Promise<void>;
|
||||
}
|
||||
|
||||
const ConversationWebSocketContext = createContext<
|
||||
@ -30,22 +47,42 @@ const ConversationWebSocketContext = createContext<
|
||||
export function ConversationWebSocketProvider({
|
||||
children,
|
||||
conversationId,
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
conversationId?: string;
|
||||
conversationUrl?: string | null;
|
||||
sessionApiKey?: string | null;
|
||||
}) {
|
||||
const [connectionState, setConnectionState] = useState<
|
||||
"CONNECTING" | "OPEN" | "CLOSED" | "CLOSING"
|
||||
>("CONNECTING");
|
||||
const [connectionState, setConnectionState] =
|
||||
useState<V1_WebSocketConnectionState>("CONNECTING");
|
||||
// Track if we've ever successfully connected
|
||||
// Don't show errors until after first successful connection
|
||||
const hasConnectedRef = React.useRef(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { addEvent } = useEventStore();
|
||||
const { setErrorMessage, removeErrorMessage } = useErrorMessageStore();
|
||||
const { removeOptimisticUserMessage } = useOptimisticUserMessageStore();
|
||||
const { setAgentStatus } = useV1ConversationStateStore();
|
||||
const { appendInput, appendOutput } = useCommandStore();
|
||||
|
||||
// Build WebSocket URL from props
|
||||
const wsUrl = useMemo(
|
||||
() => buildWebSocketUrl(conversationId, conversationUrl),
|
||||
[conversationId, conversationUrl],
|
||||
);
|
||||
|
||||
// Reset hasConnected flag when conversation changes
|
||||
useEffect(() => {
|
||||
hasConnectedRef.current = false;
|
||||
}, [conversationId]);
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(messageEvent: MessageEvent) => {
|
||||
try {
|
||||
const event = JSON.parse(messageEvent.data);
|
||||
|
||||
// Use type guard to validate v1 event structure
|
||||
if (isV1Event(event)) {
|
||||
addEvent(event);
|
||||
@ -70,25 +107,68 @@ export function ConversationWebSocketProvider({
|
||||
queryClient,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle conversation state updates
|
||||
// TODO: Tests
|
||||
if (isConversationStateUpdateEvent(event)) {
|
||||
if (isFullStateConversationStateUpdateEvent(event)) {
|
||||
setAgentStatus(event.value.agent_status);
|
||||
}
|
||||
if (isAgentStatusConversationStateUpdateEvent(event)) {
|
||||
setAgentStatus(event.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle ExecuteBashAction events - add command as input to terminal
|
||||
if (isExecuteBashActionEvent(event)) {
|
||||
appendInput(event.action.command);
|
||||
}
|
||||
|
||||
// Handle ExecuteBashObservation events - add output to terminal
|
||||
if (isExecuteBashObservationEvent(event)) {
|
||||
appendOutput(event.observation.output);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Failed to parse WebSocket message as JSON:", error);
|
||||
}
|
||||
},
|
||||
[addEvent, setErrorMessage, removeOptimisticUserMessage, queryClient],
|
||||
[
|
||||
addEvent,
|
||||
setErrorMessage,
|
||||
removeOptimisticUserMessage,
|
||||
queryClient,
|
||||
conversationId,
|
||||
setAgentStatus,
|
||||
appendInput,
|
||||
appendOutput,
|
||||
],
|
||||
);
|
||||
|
||||
const websocketOptions = useMemo(
|
||||
() => ({
|
||||
const websocketOptions: WebSocketHookOptions = useMemo(() => {
|
||||
const queryParams: Record<string, string | boolean> = {
|
||||
resend_all: true,
|
||||
};
|
||||
|
||||
// Add session_api_key if available
|
||||
if (sessionApiKey) {
|
||||
queryParams.session_api_key = sessionApiKey;
|
||||
}
|
||||
|
||||
return {
|
||||
queryParams,
|
||||
reconnect: { enabled: true },
|
||||
onOpen: () => {
|
||||
setConnectionState("OPEN");
|
||||
hasConnectedRef.current = true; // Mark that we've successfully connected
|
||||
removeErrorMessage(); // Clear any previous error messages on successful connection
|
||||
},
|
||||
onClose: (event: CloseEvent) => {
|
||||
setConnectionState("CLOSED");
|
||||
// Set error message for unexpected disconnects (not normal closure)
|
||||
if (event.code !== 1000) {
|
||||
// Only show error message if we've previously connected successfully
|
||||
// This prevents showing errors during initial connection attempts (e.g., when auto-starting a conversation)
|
||||
if (event.code !== 1000 && hasConnectedRef.current) {
|
||||
setErrorMessage(
|
||||
`Connection lost: ${event.reason || "Unexpected disconnect"}`,
|
||||
);
|
||||
@ -96,20 +176,44 @@ export function ConversationWebSocketProvider({
|
||||
},
|
||||
onError: () => {
|
||||
setConnectionState("CLOSED");
|
||||
setErrorMessage("Failed to connect to server");
|
||||
// Only show error message if we've previously connected successfully
|
||||
if (hasConnectedRef.current) {
|
||||
setErrorMessage("Failed to connect to server");
|
||||
}
|
||||
},
|
||||
onMessage: handleMessage,
|
||||
}),
|
||||
[handleMessage, setErrorMessage, removeErrorMessage],
|
||||
);
|
||||
};
|
||||
}, [handleMessage, setErrorMessage, removeErrorMessage, sessionApiKey]);
|
||||
|
||||
const { socket } = useWebSocket(
|
||||
"ws://localhost/events/socket",
|
||||
websocketOptions,
|
||||
// Build a fallback URL to prevent hook from connecting if conversation data isn't ready
|
||||
const websocketUrl = wsUrl || "ws://localhost/placeholder";
|
||||
const { socket } = useWebSocket(websocketUrl, websocketOptions);
|
||||
|
||||
// V1 send message function via WebSocket
|
||||
const sendMessage = useCallback(
|
||||
async (message: V1SendMessageRequest) => {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
const error = "WebSocket is not connected";
|
||||
setErrorMessage(error);
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
try {
|
||||
// Send message through WebSocket as JSON
|
||||
socket.send(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to send message";
|
||||
setErrorMessage(errorMessage);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[socket, setErrorMessage],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (socket) {
|
||||
// Only process socket updates if we have a valid URL
|
||||
if (socket && wsUrl) {
|
||||
// Update state based on socket readyState
|
||||
const updateState = () => {
|
||||
switch (socket.readyState) {
|
||||
@ -133,9 +237,12 @@ export function ConversationWebSocketProvider({
|
||||
|
||||
updateState();
|
||||
}
|
||||
}, [socket]);
|
||||
}, [socket, wsUrl]);
|
||||
|
||||
const contextValue = useMemo(() => ({ connectionState }), [connectionState]);
|
||||
const contextValue = useMemo(
|
||||
() => ({ connectionState, sendMessage }),
|
||||
[connectionState, sendMessage],
|
||||
);
|
||||
|
||||
return (
|
||||
<ConversationWebSocketContext.Provider value={contextValue}>
|
||||
@ -145,12 +252,9 @@ export function ConversationWebSocketProvider({
|
||||
}
|
||||
|
||||
export const useConversationWebSocket =
|
||||
(): ConversationWebSocketContextType => {
|
||||
(): ConversationWebSocketContextType | null => {
|
||||
const context = useContext(ConversationWebSocketContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
"useConversationWebSocket must be used within a ConversationWebSocketProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
// Return null instead of throwing when not in provider
|
||||
// This allows the hook to be called conditionally based on conversation version
|
||||
return context || null;
|
||||
};
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { WsClientProvider } from "#/context/ws-client-provider";
|
||||
import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
|
||||
interface WebSocketProviderWrapperProps {
|
||||
children: React.ReactNode;
|
||||
@ -33,6 +34,9 @@ export function WebSocketProviderWrapper({
|
||||
conversationId,
|
||||
version,
|
||||
}: WebSocketProviderWrapperProps) {
|
||||
// Get conversation data for V1 provider
|
||||
const { data: conversation } = useActiveConversation();
|
||||
|
||||
if (version === 0) {
|
||||
return (
|
||||
<WsClientProvider conversationId={conversationId}>
|
||||
@ -43,7 +47,11 @@ export function WebSocketProviderWrapper({
|
||||
|
||||
if (version === 1) {
|
||||
return (
|
||||
<ConversationWebSocketProvider conversationId={conversationId}>
|
||||
<ConversationWebSocketProvider
|
||||
conversationId={conversationId}
|
||||
conversationUrl={conversation?.url}
|
||||
sessionApiKey={conversation?.session_api_key}
|
||||
>
|
||||
{children}
|
||||
</ConversationWebSocketProvider>
|
||||
);
|
||||
|
||||
122
frontend/src/hooks/mutation/conversation-mutation-utils.ts
Normal file
122
frontend/src/hooks/mutation/conversation-mutation-utils.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { Provider } from "#/types/settings";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
/**
|
||||
* Gets the conversation version from the cache
|
||||
*/
|
||||
export const getConversationVersionFromQueryCache = (
|
||||
queryClient: QueryClient,
|
||||
conversationId: string,
|
||||
): "V0" | "V1" => {
|
||||
const conversation = queryClient.getQueryData<{
|
||||
conversation_version?: string;
|
||||
}>(["user", "conversation", conversationId]);
|
||||
|
||||
return conversation?.conversation_version === "V1" ? "V1" : "V0";
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches a V1 conversation's sandbox_id
|
||||
*/
|
||||
const fetchV1ConversationSandboxId = async (
|
||||
conversationId: string,
|
||||
): Promise<string> => {
|
||||
const conversations = await V1ConversationService.batchGetAppConversations([
|
||||
conversationId,
|
||||
]);
|
||||
|
||||
const appConversation = conversations[0];
|
||||
if (!appConversation) {
|
||||
throw new Error(`V1 conversation not found: ${conversationId}`);
|
||||
}
|
||||
|
||||
return appConversation.sandbox_id;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pause a V1 conversation sandbox by fetching the sandbox_id and pausing it
|
||||
*/
|
||||
export const pauseV1ConversationSandbox = async (conversationId: string) => {
|
||||
const sandboxId = await fetchV1ConversationSandboxId(conversationId);
|
||||
return V1ConversationService.pauseSandbox(sandboxId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops a V0 conversation using the legacy API
|
||||
*/
|
||||
export const stopV0Conversation = async (conversationId: string) =>
|
||||
ConversationService.stopConversation(conversationId);
|
||||
|
||||
/**
|
||||
* Resumes a V1 conversation sandbox by fetching the sandbox_id and resuming it
|
||||
*/
|
||||
export const resumeV1ConversationSandbox = async (conversationId: string) => {
|
||||
const sandboxId = await fetchV1ConversationSandboxId(conversationId);
|
||||
return V1ConversationService.resumeSandbox(sandboxId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts a V0 conversation using the legacy API
|
||||
*/
|
||||
export const startV0Conversation = async (
|
||||
conversationId: string,
|
||||
providers?: Provider[],
|
||||
) => ConversationService.startConversation(conversationId, providers);
|
||||
|
||||
/**
|
||||
* Optimistically updates the conversation status in the cache
|
||||
*/
|
||||
export const updateConversationStatusInCache = (
|
||||
queryClient: QueryClient,
|
||||
conversationId: string,
|
||||
status: string,
|
||||
): void => {
|
||||
// Update the individual conversation cache
|
||||
queryClient.setQueryData<{ status: string }>(
|
||||
["user", "conversation", conversationId],
|
||||
(oldData) => {
|
||||
if (!oldData) return oldData;
|
||||
return { ...oldData, status };
|
||||
},
|
||||
);
|
||||
|
||||
// Update the conversations list cache
|
||||
queryClient.setQueriesData<{
|
||||
pages: Array<{
|
||||
results: Array<{ conversation_id: string; status: string }>;
|
||||
}>;
|
||||
}>({ queryKey: ["user", "conversations"] }, (oldData) => {
|
||||
if (!oldData) return oldData;
|
||||
|
||||
return {
|
||||
...oldData,
|
||||
pages: oldData.pages.map((page) => ({
|
||||
...page,
|
||||
results: page.results.map((conv) =>
|
||||
conv.conversation_id === conversationId ? { ...conv, status } : conv,
|
||||
),
|
||||
})),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Invalidates all queries related to conversation mutations (start/stop)
|
||||
*/
|
||||
export const invalidateConversationQueries = (
|
||||
queryClient: QueryClient,
|
||||
conversationId: string,
|
||||
): void => {
|
||||
// Invalidate the specific conversation query to trigger automatic refetch
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["user", "conversation", conversationId],
|
||||
});
|
||||
// Also invalidate the conversations list for consistency
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "conversations"] });
|
||||
// Invalidate V1 batch get queries
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["v1-batch-get-app-conversations"],
|
||||
});
|
||||
};
|
||||
@ -1,9 +1,11 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import posthog from "posthog-js";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import { SuggestedTask } from "#/utils/types";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { CreateMicroagent } from "#/api/open-hands.types";
|
||||
import { CreateMicroagent, Conversation } from "#/api/open-hands.types";
|
||||
import { USE_V1_CONVERSATION_API } from "#/utils/feature-flags";
|
||||
|
||||
interface CreateConversationVariables {
|
||||
query?: string;
|
||||
@ -17,12 +19,24 @@ interface CreateConversationVariables {
|
||||
createMicroagent?: CreateMicroagent;
|
||||
}
|
||||
|
||||
// Response type that combines both V1 and legacy responses
|
||||
interface CreateConversationResponse extends Partial<Conversation> {
|
||||
conversation_id: string;
|
||||
session_api_key: string | null;
|
||||
url: string | null;
|
||||
// V1 specific fields
|
||||
v1_task_id?: string;
|
||||
is_v1?: boolean;
|
||||
}
|
||||
|
||||
export const useCreateConversation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ["create-conversation"],
|
||||
mutationFn: async (variables: CreateConversationVariables) => {
|
||||
mutationFn: async (
|
||||
variables: CreateConversationVariables,
|
||||
): Promise<CreateConversationResponse> => {
|
||||
const {
|
||||
query,
|
||||
repository,
|
||||
@ -31,7 +45,33 @@ export const useCreateConversation = () => {
|
||||
createMicroagent,
|
||||
} = variables;
|
||||
|
||||
return ConversationService.createConversation(
|
||||
const useV1 = USE_V1_CONVERSATION_API();
|
||||
|
||||
if (useV1) {
|
||||
// Use V1 API - creates a conversation start task
|
||||
const startTask = await V1ConversationService.createConversation(
|
||||
repository?.name,
|
||||
repository?.gitProvider,
|
||||
query,
|
||||
repository?.branch,
|
||||
conversationInstructions,
|
||||
undefined, // trigger - will be set by backend
|
||||
);
|
||||
|
||||
// Return a special task ID that the frontend will recognize
|
||||
// Format: "task-{uuid}" so the conversation screen can poll the task
|
||||
// Once the task is ready, it will navigate to the actual conversation ID
|
||||
return {
|
||||
conversation_id: `task-${startTask.id}`,
|
||||
session_api_key: null,
|
||||
url: startTask.agent_server_url,
|
||||
v1_task_id: startTask.id,
|
||||
is_v1: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Use legacy API
|
||||
const conversation = await ConversationService.createConversation(
|
||||
repository?.name,
|
||||
repository?.gitProvider,
|
||||
query,
|
||||
@ -40,6 +80,11 @@ export const useCreateConversation = () => {
|
||||
conversationInstructions,
|
||||
createMicroagent,
|
||||
);
|
||||
|
||||
return {
|
||||
...conversation,
|
||||
is_v1: false,
|
||||
};
|
||||
},
|
||||
onSuccess: async (_, { query, repository }) => {
|
||||
posthog.capture("initial_query_submitted", {
|
||||
|
||||
@ -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",
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
93
frontend/src/hooks/mutation/use-unified-stop-conversation.ts
Normal file
93
frontend/src/hooks/mutation/use-unified-stop-conversation.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOAST_OPTIONS } from "#/utils/custom-toast-handlers";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import {
|
||||
getConversationVersionFromQueryCache,
|
||||
pauseV1ConversationSandbox,
|
||||
stopV0Conversation,
|
||||
updateConversationStatusInCache,
|
||||
invalidateConversationQueries,
|
||||
} from "./conversation-mutation-utils";
|
||||
|
||||
/**
|
||||
* Unified hook that automatically routes to the correct pause conversation sandbox
|
||||
* implementation based on the conversation version (V0 or V1).
|
||||
*
|
||||
* This hook checks the cached conversation data to determine the version, then calls
|
||||
* the appropriate API directly. Returns a single useMutation instance that all components share.
|
||||
*
|
||||
* Usage is the same as useStopConversation:
|
||||
* const { mutate: stopConversation } = useUnifiedPauseConversationSandbox();
|
||||
* stopConversation({ conversationId: "some-id" });
|
||||
*/
|
||||
export const useUnifiedPauseConversationSandbox = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const params = useParams<{ conversationId: string }>();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ["stop-conversation"],
|
||||
mutationFn: async (variables: {
|
||||
conversationId: string;
|
||||
version?: "V0" | "V1";
|
||||
}) => {
|
||||
// Use provided version or fallback to cache lookup
|
||||
const version =
|
||||
variables.version ||
|
||||
getConversationVersionFromQueryCache(
|
||||
queryClient,
|
||||
variables.conversationId,
|
||||
);
|
||||
|
||||
if (version === "V1") {
|
||||
return pauseV1ConversationSandbox(variables.conversationId);
|
||||
}
|
||||
|
||||
return stopV0Conversation(variables.conversationId);
|
||||
},
|
||||
onMutate: async () => {
|
||||
toast.loading(t(I18nKey.TOAST$STOPPING_CONVERSATION), TOAST_OPTIONS);
|
||||
|
||||
await queryClient.cancelQueries({ queryKey: ["user", "conversations"] });
|
||||
const previousConversations = queryClient.getQueryData([
|
||||
"user",
|
||||
"conversations",
|
||||
]);
|
||||
|
||||
return { previousConversations };
|
||||
},
|
||||
onError: (_, __, context) => {
|
||||
toast.dismiss();
|
||||
toast.error(t(I18nKey.TOAST$FAILED_TO_STOP_CONVERSATION), TOAST_OPTIONS);
|
||||
|
||||
if (context?.previousConversations) {
|
||||
queryClient.setQueryData(
|
||||
["user", "conversations"],
|
||||
context.previousConversations,
|
||||
);
|
||||
}
|
||||
},
|
||||
onSettled: (_, __, variables) => {
|
||||
invalidateConversationQueries(queryClient, variables.conversationId);
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
toast.dismiss();
|
||||
toast.success(t(I18nKey.TOAST$CONVERSATION_STOPPED), TOAST_OPTIONS);
|
||||
|
||||
updateConversationStatusInCache(
|
||||
queryClient,
|
||||
variables.conversationId,
|
||||
"STOPPED",
|
||||
);
|
||||
|
||||
// Only redirect if we're stopping the conversation we're currently viewing
|
||||
if (params.conversationId === variables.conversationId) {
|
||||
navigate("/");
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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;
|
||||
|
||||
@ -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"],
|
||||
|
||||
25
frontend/src/hooks/query/use-start-tasks.ts
Normal file
25
frontend/src/hooks/query/use-start-tasks.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
/**
|
||||
* Hook to fetch in-progress V1 conversation start tasks
|
||||
*
|
||||
* Use case: Show tasks that are provisioning sandboxes, cloning repos, etc.
|
||||
* These are conversations that started but haven't reached READY or ERROR status yet.
|
||||
*
|
||||
* Note: Filters out READY and ERROR status tasks client-side since backend doesn't support status filtering.
|
||||
*
|
||||
* @param limit Maximum number of tasks to return (max 100)
|
||||
* @returns Query result with array of in-progress start tasks
|
||||
*/
|
||||
export const useStartTasks = (limit = 10) =>
|
||||
useQuery({
|
||||
queryKey: ["start-tasks", "search", limit],
|
||||
queryFn: () => V1ConversationService.searchStartTasks(limit),
|
||||
select: (tasks) =>
|
||||
tasks.filter(
|
||||
(task) => task.status !== "READY" && task.status !== "ERROR",
|
||||
),
|
||||
staleTime: 1000 * 60 * 1, // 1 minute (short since these are in-progress)
|
||||
gcTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
72
frontend/src/hooks/query/use-task-polling.ts
Normal file
72
frontend/src/hooks/query/use-task-polling.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
|
||||
/**
|
||||
* Hook that polls V1 conversation start tasks and navigates when ready.
|
||||
*
|
||||
* This hook:
|
||||
* - Detects if the conversationId URL param is a task ID (format: "task-{uuid}")
|
||||
* - Polls the V1 start task API every 3 seconds until status is READY or ERROR
|
||||
* - Automatically navigates to the conversation URL when the task becomes READY
|
||||
* - Exposes task status and details for UI components to show loading states and errors
|
||||
*
|
||||
* URL patterns:
|
||||
* - /conversations/task-{uuid} → Polls start task, then navigates to /conversations/{conversation-id}
|
||||
* - /conversations/{uuid or hex} → No polling (handled by useActiveConversation)
|
||||
*
|
||||
* Note: This hook does NOT fetch conversation data. It only handles task polling and navigation.
|
||||
*/
|
||||
export const useTaskPolling = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Check if this is a task ID (format: "task-{uuid}")
|
||||
const isTask = conversationId.startsWith("task-");
|
||||
const taskId = isTask ? conversationId.replace("task-", "") : null;
|
||||
|
||||
// Poll the task if this is a task ID
|
||||
const taskQuery = useQuery({
|
||||
queryKey: ["start-task", taskId],
|
||||
queryFn: async () => {
|
||||
if (!taskId) return null;
|
||||
return V1ConversationService.getStartTask(taskId);
|
||||
},
|
||||
enabled: !!taskId,
|
||||
refetchInterval: (query) => {
|
||||
const task = query.state.data;
|
||||
if (!task) return false;
|
||||
|
||||
// Stop polling if ready or error
|
||||
if (task.status === "READY" || task.status === "ERROR") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Poll every 3 seconds while task is in progress
|
||||
return 3000;
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Navigate to conversation ID when task is ready
|
||||
useEffect(() => {
|
||||
const task = taskQuery.data;
|
||||
if (task?.status === "READY" && task.app_conversation_id) {
|
||||
// Replace the URL with the actual conversation ID
|
||||
navigate(`/conversations/${task.app_conversation_id}`, { replace: true });
|
||||
}
|
||||
}, [taskQuery.data, navigate]);
|
||||
|
||||
return {
|
||||
isTask,
|
||||
taskId,
|
||||
conversationId: isTask ? null : conversationId,
|
||||
task: taskQuery.data,
|
||||
taskStatus: taskQuery.data?.status,
|
||||
taskDetail: taskQuery.data?.detail,
|
||||
taskError: taskQuery.error,
|
||||
isLoadingTask: taskQuery.isLoading,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
|
||||
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
|
||||
@ -15,13 +17,31 @@ interface VSCodeUrlResult {
|
||||
export const useVSCodeUrl = () => {
|
||||
const { t } = useTranslation();
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
return useQuery<VSCodeUrlResult>({
|
||||
queryKey: ["vscode_url", conversationId],
|
||||
queryKey: [
|
||||
"vscode_url",
|
||||
conversationId,
|
||||
isV1Conversation,
|
||||
conversation?.url,
|
||||
conversation?.session_api_key,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!conversationId) throw new Error("No conversation ID");
|
||||
const data = await ConversationService.getVSCodeUrl(conversationId);
|
||||
|
||||
// Use appropriate API based on conversation version
|
||||
const data = isV1Conversation
|
||||
? await V1ConversationService.getVSCodeUrl(
|
||||
conversationId,
|
||||
conversation?.url,
|
||||
conversation?.session_api_key,
|
||||
)
|
||||
: await ConversationService.getVSCodeUrl(conversationId);
|
||||
|
||||
if (data.vscode_url) {
|
||||
return {
|
||||
url: transformVSCodeUrl(data.vscode_url),
|
||||
|
||||
56
frontend/src/hooks/use-agent-state.ts
Normal file
56
frontend/src/hooks/use-agent-state.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { useMemo } from "react";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useV1ConversationStateStore } from "#/stores/v1-conversation-state-store";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { V1AgentStatus } from "#/types/v1/core/base/common";
|
||||
|
||||
/**
|
||||
* Maps V1 agent status to V0 AgentState
|
||||
*/
|
||||
function mapV1StatusToV0State(status: V1AgentStatus | null): AgentState {
|
||||
if (!status) {
|
||||
return AgentState.LOADING;
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case V1AgentStatus.IDLE:
|
||||
return AgentState.AWAITING_USER_INPUT;
|
||||
case V1AgentStatus.RUNNING:
|
||||
return AgentState.RUNNING;
|
||||
case V1AgentStatus.PAUSED:
|
||||
return AgentState.PAUSED;
|
||||
case V1AgentStatus.WAITING_FOR_CONFIRMATION:
|
||||
return AgentState.AWAITING_USER_CONFIRMATION;
|
||||
case V1AgentStatus.FINISHED:
|
||||
return AgentState.FINISHED;
|
||||
case V1AgentStatus.ERROR:
|
||||
return AgentState.ERROR;
|
||||
case V1AgentStatus.STUCK:
|
||||
return AgentState.ERROR; // Map STUCK to ERROR for now
|
||||
default:
|
||||
return AgentState.LOADING;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified hook that returns the current agent state
|
||||
* - For V0 conversations: Returns state from useAgentStore
|
||||
* - For V1 conversations: Returns mapped state from useV1ConversationStateStore
|
||||
*/
|
||||
export function useAgentState() {
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const v0State = useAgentStore((state) => state.curAgentState);
|
||||
const v1Status = useV1ConversationStateStore((state) => state.agent_status);
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
const curAgentState = useMemo(() => {
|
||||
if (isV1Conversation) {
|
||||
return mapV1StatusToV0State(v1Status);
|
||||
}
|
||||
return v0State;
|
||||
}, [isV1Conversation, v1Status, v0State]);
|
||||
|
||||
return { curAgentState };
|
||||
}
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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]);
|
||||
};
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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" &&
|
||||
|
||||
73
frontend/src/hooks/use-send-message.ts
Normal file
73
frontend/src/hooks/use-send-message.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { useCallback } from "react";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
|
||||
import { V1MessageContent } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
|
||||
/**
|
||||
* Unified hook for sending messages that works with both V0 and V1 conversations
|
||||
* - For V0 conversations: Uses Socket.IO WebSocket via useWsClient
|
||||
* - For V1 conversations: Uses native WebSocket via ConversationWebSocketProvider
|
||||
*/
|
||||
export function useSendMessage() {
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { send: v0Send } = useWsClient();
|
||||
|
||||
// Get V1 context (will be null if not in V1 provider)
|
||||
const v1Context = useConversationWebSocket();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
const send = useCallback(
|
||||
async (event: Record<string, unknown>) => {
|
||||
if (isV1Conversation && v1Context) {
|
||||
// V1: Convert V0 event format to V1 message format
|
||||
const { action, args } = event as {
|
||||
action: string;
|
||||
args?: {
|
||||
content?: string;
|
||||
image_urls?: string[];
|
||||
file_urls?: string[];
|
||||
timestamp?: string;
|
||||
};
|
||||
};
|
||||
|
||||
if (action === "message" && args?.content) {
|
||||
// Build V1 message content array
|
||||
const content: Array<V1MessageContent> = [
|
||||
{
|
||||
type: "text",
|
||||
text: args.content,
|
||||
},
|
||||
];
|
||||
|
||||
// Add images if present
|
||||
if (args.image_urls && args.image_urls.length > 0) {
|
||||
args.image_urls.forEach((url) => {
|
||||
content.push({
|
||||
type: "image_url",
|
||||
image_url: { url },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Send via V1 WebSocket context (uses correct host/port)
|
||||
await v1Context.sendMessage({
|
||||
role: "user",
|
||||
content,
|
||||
});
|
||||
} else {
|
||||
// For non-message events, fall back to V0 send
|
||||
// (e.g., agent state changes, other control events)
|
||||
v0Send(event);
|
||||
}
|
||||
} else {
|
||||
// V0: Use Socket.IO
|
||||
v0Send(event);
|
||||
}
|
||||
},
|
||||
[isV1Conversation, v1Context, v0Send],
|
||||
);
|
||||
|
||||
return { send };
|
||||
}
|
||||
@ -3,10 +3,10 @@ import { Terminal } from "@xterm/xterm";
|
||||
import React from "react";
|
||||
import { Command, useCommandStore } from "#/state/command-store";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { getTerminalCommand } from "#/services/terminal-service";
|
||||
import { parseTerminalOutput } from "#/utils/parse-terminal-output";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useSendMessage } from "#/hooks/use-send-message";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
|
||||
/*
|
||||
NOTE: Tests for this hook are indirectly covered by the tests for the XTermTerminal component.
|
||||
@ -36,8 +36,8 @@ const renderCommand = (
|
||||
const persistentLastCommandIndex = { current: 0 };
|
||||
|
||||
export const useTerminal = () => {
|
||||
const { send } = useWsClient();
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { send } = useSendMessage();
|
||||
const { curAgentState } = useAgentState();
|
||||
const commands = useCommandStore((state) => state.commands);
|
||||
const terminal = React.useRef<Terminal | null>(null);
|
||||
const fitAddon = React.useRef<FitAddon | null>(null);
|
||||
|
||||
39
frontend/src/hooks/use-unified-websocket-status.ts
Normal file
39
frontend/src/hooks/use-unified-websocket-status.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { useMemo } from "react";
|
||||
import { useWsClient, V0_WebSocketStatus } from "#/context/ws-client-provider";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
|
||||
|
||||
/**
|
||||
* Unified hook that returns the current WebSocket status
|
||||
* - For V0 conversations: Returns status from useWsClient
|
||||
* - For V1 conversations: Returns status from ConversationWebSocketProvider
|
||||
*/
|
||||
export function useUnifiedWebSocketStatus(): V0_WebSocketStatus {
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const v0Status = useWsClient();
|
||||
const v1Context = useConversationWebSocket();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
const webSocketStatus = useMemo(() => {
|
||||
if (isV1Conversation) {
|
||||
// Map V1 connection state to WebSocketStatus
|
||||
if (!v1Context) return "DISCONNECTED";
|
||||
|
||||
switch (v1Context.connectionState) {
|
||||
case "OPEN":
|
||||
return "CONNECTED";
|
||||
case "CONNECTING":
|
||||
return "CONNECTING";
|
||||
case "CLOSED":
|
||||
case "CLOSING":
|
||||
return "DISCONNECTED";
|
||||
default:
|
||||
return "DISCONNECTED";
|
||||
}
|
||||
}
|
||||
return v0Status.webSocketStatus;
|
||||
}, [isV1Conversation, v1Context, v0Status.webSocketStatus]);
|
||||
|
||||
return webSocketStatus;
|
||||
}
|
||||
@ -1,45 +1,78 @@
|
||||
import React from "react";
|
||||
|
||||
export interface WebSocketHookOptions {
|
||||
queryParams?: Record<string, string | boolean>;
|
||||
onOpen?: (event: Event) => void;
|
||||
onClose?: (event: CloseEvent) => void;
|
||||
onMessage?: (event: MessageEvent) => void;
|
||||
onError?: (event: Event) => void;
|
||||
reconnect?: {
|
||||
enabled?: boolean;
|
||||
maxAttempts?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const useWebSocket = <T = string>(
|
||||
url: string,
|
||||
options?: {
|
||||
queryParams?: Record<string, string>;
|
||||
onOpen?: (event: Event) => void;
|
||||
onClose?: (event: CloseEvent) => void;
|
||||
onMessage?: (event: MessageEvent) => void;
|
||||
onError?: (event: Event) => void;
|
||||
},
|
||||
options?: WebSocketHookOptions,
|
||||
) => {
|
||||
const [isConnected, setIsConnected] = React.useState(false);
|
||||
const [lastMessage, setLastMessage] = React.useState<T | null>(null);
|
||||
const [messages, setMessages] = React.useState<T[]>([]);
|
||||
const [error, setError] = React.useState<Error | null>(null);
|
||||
const [isReconnecting, setIsReconnecting] = React.useState(false);
|
||||
const wsRef = React.useRef<WebSocket | null>(null);
|
||||
const attemptCountRef = React.useRef(0);
|
||||
const reconnectTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
const shouldReconnectRef = React.useRef(true); // Only set to false by disconnect()
|
||||
// Track which WebSocket instances are allowed to reconnect using a WeakSet
|
||||
const allowedToReconnectRef = React.useRef<WeakSet<WebSocket>>(new WeakSet());
|
||||
|
||||
// Store options in a ref to avoid reconnecting when callbacks change
|
||||
const optionsRef = React.useRef(options);
|
||||
React.useEffect(() => {
|
||||
optionsRef.current = options;
|
||||
}, [options]);
|
||||
|
||||
const connectWebSocket = React.useCallback(() => {
|
||||
// Build URL with query parameters if provided
|
||||
let wsUrl = url;
|
||||
if (options?.queryParams) {
|
||||
const params = new URLSearchParams(options.queryParams);
|
||||
if (optionsRef.current?.queryParams) {
|
||||
const stringParams = Object.entries(
|
||||
optionsRef.current.queryParams,
|
||||
).reduce(
|
||||
(acc, [key, value]) => {
|
||||
acc[key] = String(value);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
const params = new URLSearchParams(stringParams);
|
||||
wsUrl = `${url}?${params.toString()}`;
|
||||
}
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
// Mark this WebSocket instance as allowed to reconnect
|
||||
allowedToReconnectRef.current.add(ws);
|
||||
|
||||
ws.onopen = (event) => {
|
||||
setIsConnected(true);
|
||||
setError(null); // Clear any previous errors
|
||||
options?.onOpen?.(event);
|
||||
setIsReconnecting(false);
|
||||
attemptCountRef.current = 0; // Reset attempt count on successful connection
|
||||
optionsRef.current?.onOpen?.(event);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
setLastMessage(event.data);
|
||||
setMessages((prev) => [...prev, event.data]);
|
||||
options?.onMessage?.(event);
|
||||
optionsRef.current?.onMessage?.(event);
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
// Check if this specific WebSocket instance is allowed to reconnect
|
||||
const canReconnect = allowedToReconnectRef.current.has(ws);
|
||||
setIsConnected(false);
|
||||
// If the connection closes with an error code, treat it as an error
|
||||
if (event.code !== 1000) {
|
||||
@ -49,21 +82,75 @@ export const useWebSocket = <T = string>(
|
||||
`WebSocket closed with code ${event.code}: ${event.reason || "Connection closed unexpectedly"}`,
|
||||
),
|
||||
);
|
||||
// Also call onError handler for error closures
|
||||
options?.onError?.(event);
|
||||
// Also call onError handler for error closures (only if allowed to reconnect)
|
||||
if (canReconnect) {
|
||||
optionsRef.current?.onError?.(event);
|
||||
}
|
||||
}
|
||||
optionsRef.current?.onClose?.(event);
|
||||
|
||||
// Attempt reconnection if enabled and allowed
|
||||
// IMPORTANT: Only reconnect if this specific instance is allowed to reconnect
|
||||
const reconnectEnabled = optionsRef.current?.reconnect?.enabled ?? false;
|
||||
const maxAttempts =
|
||||
optionsRef.current?.reconnect?.maxAttempts ?? Infinity;
|
||||
|
||||
if (
|
||||
reconnectEnabled &&
|
||||
canReconnect &&
|
||||
shouldReconnectRef.current &&
|
||||
attemptCountRef.current < maxAttempts
|
||||
) {
|
||||
setIsReconnecting(true);
|
||||
attemptCountRef.current += 1;
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
connectWebSocket();
|
||||
}, 3000); // 3 second delay
|
||||
} else {
|
||||
setIsReconnecting(false);
|
||||
}
|
||||
options?.onClose?.(event);
|
||||
};
|
||||
|
||||
ws.onerror = (event) => {
|
||||
setIsConnected(false);
|
||||
options?.onError?.(event);
|
||||
optionsRef.current?.onError?.(event);
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Reset shouldReconnect flag and attempt count when creating a new connection
|
||||
shouldReconnectRef.current = true;
|
||||
attemptCountRef.current = 0;
|
||||
|
||||
connectWebSocket();
|
||||
|
||||
return () => {
|
||||
ws.close();
|
||||
// Disable reconnection on unmount to prevent reconnection attempts
|
||||
// This must be set BEFORE closing the socket, so the onclose handler sees it
|
||||
shouldReconnectRef.current = false;
|
||||
// Clear any pending reconnection timeouts
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
// Close the WebSocket connection
|
||||
if (wsRef.current) {
|
||||
const { readyState } = wsRef.current;
|
||||
// Remove this WebSocket from the allowed list BEFORE closing
|
||||
// so its onclose handler won't try to reconnect
|
||||
allowedToReconnectRef.current.delete(wsRef.current);
|
||||
// Only close if not already closed/closing
|
||||
if (
|
||||
readyState === WebSocket.CONNECTING ||
|
||||
readyState === WebSocket.OPEN
|
||||
) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [url, options]);
|
||||
}, [url, connectWebSocket]);
|
||||
|
||||
const sendMessage = React.useCallback(
|
||||
(data: string | ArrayBufferLike | Blob | ArrayBufferView) => {
|
||||
@ -74,6 +161,20 @@ export const useWebSocket = <T = string>(
|
||||
[],
|
||||
);
|
||||
|
||||
const disconnect = React.useCallback(() => {
|
||||
shouldReconnectRef.current = false;
|
||||
setIsReconnecting(false);
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
// Remove from allowed list before closing
|
||||
allowedToReconnectRef.current.delete(wsRef.current);
|
||||
wsRef.current.close();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
lastMessage,
|
||||
@ -81,5 +182,8 @@ export const useWebSocket = <T = string>(
|
||||
error,
|
||||
socket: wsRef.current,
|
||||
sendMessage,
|
||||
isReconnecting,
|
||||
attemptCount: attemptCountRef.current,
|
||||
disconnect,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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",
|
||||
}
|
||||
|
||||
@ -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": "Розмову зупинено"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
});
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useCommandStore } from "#/state/command-store";
|
||||
import { useEffectOnce } from "#/hooks/use-effect-once";
|
||||
import { useJupyterStore } from "#/state/jupyter-store";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
@ -15,6 +14,7 @@ import { EventHandler } from "../wrapper/event-handler";
|
||||
import { useConversationConfig } from "#/hooks/query/use-conversation-config";
|
||||
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useTaskPolling } from "#/hooks/query/use-task-polling";
|
||||
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { useDocumentTitleFromState } from "#/hooks/use-document-title-from-state";
|
||||
@ -26,15 +26,23 @@ import { ConversationMain } from "#/components/features/conversation/conversatio
|
||||
import { ConversationName } from "#/components/features/conversation/conversation-name";
|
||||
|
||||
import { ConversationTabs } from "#/components/features/conversation/conversation-tabs/conversation-tabs";
|
||||
import { useStartConversation } from "#/hooks/mutation/use-start-conversation";
|
||||
import { WebSocketProviderWrapper } from "#/contexts/websocket-provider-wrapper";
|
||||
import { useErrorMessageStore } from "#/stores/error-message-store";
|
||||
import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
function AppContent() {
|
||||
useConversationConfig();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
// Handle both task IDs (task-{uuid}) and regular conversation IDs
|
||||
const { isTask, taskStatus, taskDetail } = useTaskPolling();
|
||||
|
||||
const { data: conversation, isFetched, refetch } = useActiveConversation();
|
||||
const { mutate: startConversation } = useStartConversation();
|
||||
const { mutate: startConversation, isPending: isStarting } =
|
||||
useUnifiedResumeConversationSandbox();
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
const { providers } = useUserProviders();
|
||||
const { resetConversationState } = useConversationStore();
|
||||
@ -44,7 +52,12 @@ function AppContent() {
|
||||
(state) => state.setCurrentAgentState,
|
||||
);
|
||||
const clearJupyter = useJupyterStore((state) => state.clearJupyter);
|
||||
const queryClient = useQueryClient();
|
||||
const removeErrorMessage = useErrorMessageStore(
|
||||
(state) => state.removeErrorMessage,
|
||||
);
|
||||
|
||||
// Track which conversation ID we've auto-started to prevent auto-restart after manual stop
|
||||
const processedConversationId = React.useRef<string | null>(null);
|
||||
|
||||
// Fetch batch feedback data when conversation is loaded
|
||||
useBatchFeedback();
|
||||
@ -52,76 +65,123 @@ function AppContent() {
|
||||
// Set the document title to the conversation title when available
|
||||
useDocumentTitleFromState();
|
||||
|
||||
// Force fresh conversation data when navigating to prevent stale cache issues
|
||||
// 1. Cleanup Effect - runs when navigating to a different conversation
|
||||
React.useEffect(() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["user", "conversation", conversationId],
|
||||
});
|
||||
}, [conversationId, queryClient]);
|
||||
clearTerminal();
|
||||
clearJupyter();
|
||||
resetConversationState();
|
||||
setCurrentAgentState(AgentState.LOADING);
|
||||
removeErrorMessage();
|
||||
|
||||
// Reset tracking ONLY if we're navigating to a DIFFERENT conversation
|
||||
// Don't reset on StrictMode remounts (conversationId is the same)
|
||||
if (processedConversationId.current !== conversationId) {
|
||||
processedConversationId.current = null;
|
||||
}
|
||||
}, [
|
||||
conversationId,
|
||||
clearTerminal,
|
||||
clearJupyter,
|
||||
resetConversationState,
|
||||
setCurrentAgentState,
|
||||
removeErrorMessage,
|
||||
]);
|
||||
|
||||
// 2. Task Error Display Effect
|
||||
React.useEffect(() => {
|
||||
if (isFetched && !conversation && isAuthed) {
|
||||
if (isTask && taskStatus === "ERROR") {
|
||||
displayErrorToast(
|
||||
"This conversation does not exist, or you do not have permission to access it.",
|
||||
taskDetail || t(I18nKey.CONVERSATION$FAILED_TO_START_FROM_TASK),
|
||||
);
|
||||
}
|
||||
}, [isTask, taskStatus, taskDetail, t]);
|
||||
|
||||
// 3. Auto-start Effect - handles conversation not found and auto-starting STOPPED conversations
|
||||
React.useEffect(() => {
|
||||
// Wait for data to be fetched
|
||||
if (!isFetched || !isAuthed) return;
|
||||
|
||||
// Handle conversation not found
|
||||
if (!conversation) {
|
||||
displayErrorToast(t(I18nKey.CONVERSATION$NOT_EXIST_OR_NO_PERMISSION));
|
||||
navigate("/");
|
||||
} else if (conversation?.status === "STOPPED") {
|
||||
// If conversation is STOPPED, attempt to start it
|
||||
return;
|
||||
}
|
||||
|
||||
const currentConversationId = conversation.conversation_id;
|
||||
const currentStatus = conversation.status;
|
||||
|
||||
// Skip if we've already processed this conversation
|
||||
if (processedConversationId.current === currentConversationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as processed immediately to prevent duplicate calls
|
||||
processedConversationId.current = currentConversationId;
|
||||
|
||||
// Auto-start STOPPED conversations on initial load only
|
||||
if (currentStatus === "STOPPED" && !isStarting) {
|
||||
startConversation(
|
||||
{ conversationId: conversation.conversation_id, providers },
|
||||
{ conversationId: currentConversationId, providers },
|
||||
{
|
||||
onError: (error) => {
|
||||
displayErrorToast(`Failed to start conversation: ${error.message}`);
|
||||
// Refetch the conversation to ensure UI consistency
|
||||
displayErrorToast(
|
||||
t(I18nKey.CONVERSATION$FAILED_TO_START_WITH_ERROR, {
|
||||
error: error.message,
|
||||
}),
|
||||
);
|
||||
refetch();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
// NOTE: conversation?.status is intentionally NOT in dependencies
|
||||
// We only want to run when conversation ID changes, not when status changes
|
||||
// This prevents duplicate calls when stale cache data is replaced with fresh data
|
||||
}, [
|
||||
conversation?.conversation_id,
|
||||
conversation?.status,
|
||||
isFetched,
|
||||
isAuthed,
|
||||
isStarting,
|
||||
providers,
|
||||
startConversation,
|
||||
navigate,
|
||||
refetch,
|
||||
t,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
clearTerminal();
|
||||
clearJupyter();
|
||||
resetConversationState();
|
||||
setCurrentAgentState(AgentState.LOADING);
|
||||
}, [
|
||||
conversationId,
|
||||
clearTerminal,
|
||||
setCurrentAgentState,
|
||||
resetConversationState,
|
||||
]);
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
useEffectOnce(() => {
|
||||
clearTerminal();
|
||||
clearJupyter();
|
||||
resetConversationState();
|
||||
setCurrentAgentState(AgentState.LOADING);
|
||||
});
|
||||
const content = (
|
||||
<ConversationSubscriptionsProvider>
|
||||
<EventHandler>
|
||||
<div
|
||||
data-testid="app-route"
|
||||
className="p-3 md:p-0 flex flex-col h-full gap-3"
|
||||
>
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4.5 pt-2 lg:pt-0">
|
||||
<ConversationName />
|
||||
<ConversationTabs />
|
||||
</div>
|
||||
|
||||
<ConversationMain />
|
||||
</div>
|
||||
</EventHandler>
|
||||
</ConversationSubscriptionsProvider>
|
||||
);
|
||||
|
||||
// Wait for conversation data to load before rendering WebSocket provider
|
||||
// This prevents the provider from unmounting/remounting when version changes from 0 to 1
|
||||
if (!conversation) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<WebSocketProviderWrapper version={0} conversationId={conversationId}>
|
||||
<ConversationSubscriptionsProvider>
|
||||
<EventHandler>
|
||||
<div
|
||||
data-testid="app-route"
|
||||
className="p-3 md:p-0 flex flex-col h-full gap-3"
|
||||
>
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4.5 pt-2 lg:pt-0">
|
||||
<ConversationName />
|
||||
<ConversationTabs />
|
||||
</div>
|
||||
|
||||
<ConversationMain />
|
||||
</div>
|
||||
</EventHandler>
|
||||
</ConversationSubscriptionsProvider>
|
||||
<WebSocketProviderWrapper
|
||||
version={isV1Conversation ? 1 : 0}
|
||||
conversationId={conversationId}
|
||||
>
|
||||
{content}
|
||||
</WebSocketProviderWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,12 +5,12 @@ import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { useVSCodeUrl } from "#/hooks/query/use-vscode-url";
|
||||
import { VSCODE_IN_NEW_TAB } from "#/utils/feature-flags";
|
||||
import { WaitingForRuntimeMessage } from "#/components/features/chat/waiting-for-runtime-message";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
|
||||
function VSCodeTab() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, error } = useVSCodeUrl();
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { curAgentState } = useAgentState();
|
||||
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
||||
const iframeRef = React.useRef<HTMLIFrameElement>(null);
|
||||
const [isCrossProtocol, setIsCrossProtocol] = useState(false);
|
||||
|
||||
26
frontend/src/stores/v1-conversation-state-store.ts
Normal file
26
frontend/src/stores/v1-conversation-state-store.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { create } from "zustand";
|
||||
import { V1AgentStatus } from "#/types/v1/core/base/common";
|
||||
|
||||
interface V1ConversationStateStore {
|
||||
agent_status: V1AgentStatus | null;
|
||||
|
||||
/**
|
||||
* Set the agent status
|
||||
*/
|
||||
setAgentStatus: (agent_status: V1AgentStatus) => void;
|
||||
|
||||
/**
|
||||
* Reset the store to initial state
|
||||
*/
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useV1ConversationStateStore = create<V1ConversationStateStore>(
|
||||
(set) => ({
|
||||
agent_status: null,
|
||||
|
||||
setAgentStatus: (agent_status: V1AgentStatus) => set({ agent_status }),
|
||||
|
||||
reset: () => set({ agent_status: null }),
|
||||
}),
|
||||
);
|
||||
@ -1,28 +1,28 @@
|
||||
import { ActionBase } from "./base";
|
||||
import { TaskItem } from "./common";
|
||||
|
||||
interface MCPToolAction extends ActionBase<"MCPToolAction"> {
|
||||
export interface MCPToolAction extends ActionBase<"MCPToolAction"> {
|
||||
/**
|
||||
* Dynamic data fields from the tool call
|
||||
*/
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface FinishAction extends ActionBase<"FinishAction"> {
|
||||
export interface FinishAction extends ActionBase<"FinishAction"> {
|
||||
/**
|
||||
* Final message to send to the user
|
||||
*/
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ThinkAction extends ActionBase<"ThinkAction"> {
|
||||
export interface ThinkAction extends ActionBase<"ThinkAction"> {
|
||||
/**
|
||||
* The thought to log
|
||||
*/
|
||||
thought: string;
|
||||
}
|
||||
|
||||
interface ExecuteBashAction extends ActionBase<"ExecuteBashAction"> {
|
||||
export interface ExecuteBashAction extends ActionBase<"ExecuteBashAction"> {
|
||||
/**
|
||||
* The bash command to execute. Can be empty string to view additional logs when previous exit code is `-1`. Can be `C-c` (Ctrl+C) to interrupt the currently running process.
|
||||
*/
|
||||
@ -41,7 +41,7 @@ interface ExecuteBashAction extends ActionBase<"ExecuteBashAction"> {
|
||||
reset: boolean;
|
||||
}
|
||||
|
||||
interface StrReplaceEditorAction extends ActionBase<"StrReplaceEditorAction"> {
|
||||
export interface FileEditorAction extends ActionBase<"FileEditorAction"> {
|
||||
/**
|
||||
* The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.
|
||||
*/
|
||||
@ -72,7 +72,39 @@ interface StrReplaceEditorAction extends ActionBase<"StrReplaceEditorAction"> {
|
||||
view_range: [number, number] | null;
|
||||
}
|
||||
|
||||
interface TaskTrackerAction extends ActionBase<"TaskTrackerAction"> {
|
||||
export interface StrReplaceEditorAction
|
||||
extends ActionBase<"StrReplaceEditorAction"> {
|
||||
/**
|
||||
* The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.
|
||||
*/
|
||||
command: "view" | "create" | "str_replace" | "insert" | "undo_edit";
|
||||
/**
|
||||
* Absolute path to file or directory.
|
||||
*/
|
||||
path: string;
|
||||
/**
|
||||
* Required parameter of `create` command, with the content of the file to be created.
|
||||
*/
|
||||
file_text: string | null;
|
||||
/**
|
||||
* Required parameter of `str_replace` command containing the string in `path` to replace.
|
||||
*/
|
||||
old_str: string | null;
|
||||
/**
|
||||
* Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert.
|
||||
*/
|
||||
new_str: string | null;
|
||||
/**
|
||||
* Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`. Must be >= 1.
|
||||
*/
|
||||
insert_line: number | null;
|
||||
/**
|
||||
* Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.
|
||||
*/
|
||||
view_range: [number, number] | null;
|
||||
}
|
||||
|
||||
export interface TaskTrackerAction extends ActionBase<"TaskTrackerAction"> {
|
||||
/**
|
||||
* The command to execute. `view` shows the current task list. `plan` creates or updates the task list based on provided requirements and progress. Always `view` the current list before making changes.
|
||||
*/
|
||||
@ -83,7 +115,8 @@ interface TaskTrackerAction extends ActionBase<"TaskTrackerAction"> {
|
||||
task_list: TaskItem[];
|
||||
}
|
||||
|
||||
interface BrowserNavigateAction extends ActionBase<"BrowserNavigateAction"> {
|
||||
export interface BrowserNavigateAction
|
||||
extends ActionBase<"BrowserNavigateAction"> {
|
||||
/**
|
||||
* The URL to navigate to
|
||||
*/
|
||||
@ -94,7 +127,7 @@ interface BrowserNavigateAction extends ActionBase<"BrowserNavigateAction"> {
|
||||
new_tab: boolean;
|
||||
}
|
||||
|
||||
interface BrowserClickAction extends ActionBase<"BrowserClickAction"> {
|
||||
export interface BrowserClickAction extends ActionBase<"BrowserClickAction"> {
|
||||
/**
|
||||
* The index of the element to click (from browser_get_state)
|
||||
*/
|
||||
@ -105,7 +138,7 @@ interface BrowserClickAction extends ActionBase<"BrowserClickAction"> {
|
||||
new_tab: boolean;
|
||||
}
|
||||
|
||||
interface BrowserTypeAction extends ActionBase<"BrowserTypeAction"> {
|
||||
export interface BrowserTypeAction extends ActionBase<"BrowserTypeAction"> {
|
||||
/**
|
||||
* The index of the input element (from browser_get_state)
|
||||
*/
|
||||
@ -116,14 +149,15 @@ interface BrowserTypeAction extends ActionBase<"BrowserTypeAction"> {
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface BrowserGetStateAction extends ActionBase<"BrowserGetStateAction"> {
|
||||
export interface BrowserGetStateAction
|
||||
extends ActionBase<"BrowserGetStateAction"> {
|
||||
/**
|
||||
* Whether to include a screenshot of the current page. Default: False
|
||||
*/
|
||||
include_screenshot: boolean;
|
||||
}
|
||||
|
||||
interface BrowserGetContentAction
|
||||
export interface BrowserGetContentAction
|
||||
extends ActionBase<"BrowserGetContentAction"> {
|
||||
/**
|
||||
* Whether to include links in the content (default: False)
|
||||
@ -135,29 +169,32 @@ interface BrowserGetContentAction
|
||||
start_from_char: number;
|
||||
}
|
||||
|
||||
interface BrowserScrollAction extends ActionBase<"BrowserScrollAction"> {
|
||||
export interface BrowserScrollAction extends ActionBase<"BrowserScrollAction"> {
|
||||
/**
|
||||
* Direction to scroll. Options: 'up', 'down'. Default: 'down'
|
||||
*/
|
||||
direction: "up" | "down";
|
||||
}
|
||||
|
||||
interface BrowserGoBackAction extends ActionBase<"BrowserGoBackAction"> {
|
||||
export interface BrowserGoBackAction extends ActionBase<"BrowserGoBackAction"> {
|
||||
// No additional properties - this action has no parameters
|
||||
}
|
||||
|
||||
interface BrowserListTabsAction extends ActionBase<"BrowserListTabsAction"> {
|
||||
export interface BrowserListTabsAction
|
||||
extends ActionBase<"BrowserListTabsAction"> {
|
||||
// No additional properties - this action has no parameters
|
||||
}
|
||||
|
||||
interface BrowserSwitchTabAction extends ActionBase<"BrowserSwitchTabAction"> {
|
||||
export interface BrowserSwitchTabAction
|
||||
extends ActionBase<"BrowserSwitchTabAction"> {
|
||||
/**
|
||||
* 4 Character Tab ID of the tab to switch to (from browser_list_tabs)
|
||||
*/
|
||||
tab_id: string;
|
||||
}
|
||||
|
||||
interface BrowserCloseTabAction extends ActionBase<"BrowserCloseTabAction"> {
|
||||
export interface BrowserCloseTabAction
|
||||
extends ActionBase<"BrowserCloseTabAction"> {
|
||||
/**
|
||||
* 4 Character Tab ID of the tab to close (from browser_list_tabs)
|
||||
*/
|
||||
@ -169,6 +206,7 @@ export type Action =
|
||||
| FinishAction
|
||||
| ThinkAction
|
||||
| ExecuteBashAction
|
||||
| FileEditorAction
|
||||
| StrReplaceEditorAction
|
||||
| TaskTrackerAction
|
||||
| BrowserNavigateAction
|
||||
|
||||
@ -3,6 +3,7 @@ type EventType =
|
||||
| "Finish"
|
||||
| "Think"
|
||||
| "ExecuteBash"
|
||||
| "FileEditor"
|
||||
| "StrReplaceEditor"
|
||||
| "TaskTracker";
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
RedactedThinkingBlock,
|
||||
} from "../base/event";
|
||||
|
||||
export interface ActionEvent extends BaseEvent {
|
||||
export interface ActionEvent<T extends Action = Action> extends BaseEvent {
|
||||
/**
|
||||
* The thought process of the agent before taking this action
|
||||
*/
|
||||
@ -26,7 +26,7 @@ export interface ActionEvent extends BaseEvent {
|
||||
/**
|
||||
* Single action (tool call) returned by LLM
|
||||
*/
|
||||
action: Action;
|
||||
action: T;
|
||||
|
||||
/**
|
||||
* The name of the tool being called
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -21,11 +21,12 @@ export interface ObservationBaseEvent extends BaseEvent {
|
||||
}
|
||||
|
||||
// Main observation event interface
|
||||
export interface ObservationEvent extends ObservationBaseEvent {
|
||||
export interface ObservationEvent<T extends Observation = Observation>
|
||||
extends ObservationBaseEvent {
|
||||
/**
|
||||
* The observation (tool call) sent to LLM
|
||||
*/
|
||||
observation: Observation;
|
||||
observation: T;
|
||||
|
||||
/**
|
||||
* The action id that this observation is responding to
|
||||
|
||||
@ -1,7 +1,18 @@
|
||||
import { OpenHandsEvent, ObservationEvent, BaseEvent } from "./core";
|
||||
import {
|
||||
OpenHandsEvent,
|
||||
ObservationEvent,
|
||||
BaseEvent,
|
||||
ExecuteBashAction,
|
||||
ExecuteBashObservation,
|
||||
} from "./core";
|
||||
import { AgentErrorEvent } from "./core/events/observation-event";
|
||||
import { MessageEvent } from "./core/events/message-event";
|
||||
import { ActionEvent } from "./core/events/action-event";
|
||||
import {
|
||||
ConversationStateUpdateEvent,
|
||||
ConversationStateUpdateEventAgentStatus,
|
||||
ConversationStateUpdateEventFullState,
|
||||
} from "./core/events/conversation-state-event";
|
||||
import type { OpenHandsParsedEvent } from "../core/index";
|
||||
|
||||
/**
|
||||
@ -51,17 +62,23 @@ export const isAgentErrorEvent = (
|
||||
typeof event.tool_call_id === "string" &&
|
||||
typeof event.error === "string";
|
||||
|
||||
/**
|
||||
* Type guard function to check if an event is a message event (user or assistant)
|
||||
*/
|
||||
export const isMessageEvent = (event: OpenHandsEvent): event is MessageEvent =>
|
||||
"llm_message" in event &&
|
||||
typeof event.llm_message === "object" &&
|
||||
event.llm_message !== null &&
|
||||
"role" in event.llm_message &&
|
||||
"content" in event.llm_message;
|
||||
|
||||
/**
|
||||
* Type guard function to check if an event is a user message event
|
||||
*/
|
||||
export const isUserMessageEvent = (
|
||||
event: OpenHandsEvent,
|
||||
): event is MessageEvent =>
|
||||
"llm_message" in event &&
|
||||
typeof event.llm_message === "object" &&
|
||||
event.llm_message !== null &&
|
||||
"role" in event.llm_message &&
|
||||
event.llm_message.role === "user";
|
||||
isMessageEvent(event) && event.llm_message.role === "user";
|
||||
|
||||
/**
|
||||
* Type guard function to check if an event is an action event
|
||||
@ -74,6 +91,40 @@ export const isActionEvent = (event: OpenHandsEvent): event is ActionEvent =>
|
||||
typeof event.tool_name === "string" &&
|
||||
typeof event.tool_call_id === "string";
|
||||
|
||||
/**
|
||||
* Type guard function to check if an action event is an ExecuteBashAction
|
||||
*/
|
||||
export const isExecuteBashActionEvent = (
|
||||
event: OpenHandsEvent,
|
||||
): event is ActionEvent<ExecuteBashAction> =>
|
||||
isActionEvent(event) && event.action.kind === "ExecuteBashAction";
|
||||
|
||||
/**
|
||||
* Type guard function to check if an observation event is an ExecuteBashObservation
|
||||
*/
|
||||
export const isExecuteBashObservationEvent = (
|
||||
event: OpenHandsEvent,
|
||||
): event is ObservationEvent<ExecuteBashObservation> =>
|
||||
isObservationEvent(event) &&
|
||||
event.observation.kind === "ExecuteBashObservation";
|
||||
|
||||
/**
|
||||
* Type guard function to check if an event is a conversation state update event
|
||||
*/
|
||||
export const isConversationStateUpdateEvent = (
|
||||
event: OpenHandsEvent,
|
||||
): event is ConversationStateUpdateEvent =>
|
||||
"kind" in event && event.kind === "ConversationStateUpdateEvent";
|
||||
|
||||
export const isFullStateConversationStateUpdateEvent = (
|
||||
event: ConversationStateUpdateEvent,
|
||||
): event is ConversationStateUpdateEventFullState => event.key === "full_state";
|
||||
|
||||
export const isAgentStatusConversationStateUpdateEvent = (
|
||||
event: ConversationStateUpdateEvent,
|
||||
): event is ConversationStateUpdateEventAgentStatus =>
|
||||
event.key === "agent_status";
|
||||
|
||||
// =============================================================================
|
||||
// TEMPORARY COMPATIBILITY TYPE GUARDS
|
||||
// These will be removed once we fully migrate to V1 events
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { WebSocketStatus } from "#/context/ws-client-provider";
|
||||
import { V0_WebSocketStatus } from "#/context/ws-client-provider";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
@ -43,7 +43,7 @@ export const AGENT_STATUS_MAP: {
|
||||
};
|
||||
|
||||
export function getIndicatorColor(
|
||||
webSocketStatus: WebSocketStatus,
|
||||
webSocketStatus: V0_WebSocketStatus,
|
||||
conversationStatus: ConversationStatus | null,
|
||||
runtimeStatus: RuntimeStatus | null,
|
||||
agentState: AgentState | null,
|
||||
@ -99,7 +99,7 @@ export function getIndicatorColor(
|
||||
|
||||
export function getStatusCode(
|
||||
statusMessage: StatusMessage,
|
||||
webSocketStatus: WebSocketStatus,
|
||||
webSocketStatus: V0_WebSocketStatus,
|
||||
conversationStatus: ConversationStatus | null,
|
||||
runtimeStatus: RuntimeStatus | null,
|
||||
agentState: AgentState | null,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user