mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
68 Commits
fix/events
...
feat/multi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46d893b379 | ||
|
|
29dd170211 | ||
|
|
2439ee068e | ||
|
|
d24072f4f0 | ||
|
|
46260e62db | ||
|
|
7a8c095b91 | ||
|
|
2b8006062f | ||
|
|
0315069827 | ||
|
|
ba67047bb8 | ||
|
|
c89f5f76d3 | ||
|
|
ea5f583dd5 | ||
|
|
5becacfa01 | ||
|
|
4796506634 | ||
|
|
d2a80d92f3 | ||
|
|
59fcb7bd38 | ||
|
|
215c633f46 | ||
|
|
2d667c3402 | ||
|
|
cf1d1480dc | ||
|
|
3f4e87fd09 | ||
|
|
1696987430 | ||
|
|
56ef8e392f | ||
|
|
c5f8850dc1 | ||
|
|
d3d1b28e3a | ||
|
|
0e03376b91 | ||
|
|
bdd23daeef | ||
|
|
8f7091064d | ||
|
|
370797185f | ||
|
|
2312dba277 | ||
|
|
9d171b5722 | ||
|
|
b1e161b6b7 | ||
|
|
c204108428 | ||
|
|
4181d0916d | ||
|
|
5b197b4f9c | ||
|
|
858d4cbec0 | ||
|
|
9f6b63cfd6 | ||
|
|
77f0b574bf | ||
|
|
392e1cc0c7 | ||
|
|
64d8dc8b70 | ||
|
|
d7ccd78c1b | ||
|
|
c54a0502e8 | ||
|
|
02cd435a0a | ||
|
|
e9d3f4b07c | ||
|
|
a8379d7f30 | ||
|
|
a90dd21631 | ||
|
|
4305335e59 | ||
|
|
41723655b5 | ||
|
|
0220b69352 | ||
|
|
7bd01d2ac1 | ||
|
|
4aeeb3f23d | ||
|
|
6b30f2ae67 | ||
|
|
ede28d54a3 | ||
|
|
8d0896be6e | ||
|
|
9d43f1f798 | ||
|
|
74a2068345 | ||
|
|
ae9ab6db39 | ||
|
|
b182ba001d | ||
|
|
bdbce31c9c | ||
|
|
8781657a54 | ||
|
|
5edc93d90e | ||
|
|
fe0acad1f2 | ||
|
|
413e1b7300 | ||
|
|
251b47fd5b | ||
|
|
76f359c4ec | ||
|
|
3ac818b59c | ||
|
|
e3f9b7e3f3 | ||
|
|
dc820946b7 | ||
|
|
fbae222f2a | ||
|
|
74682eff0c |
@@ -239,7 +239,7 @@ describe("ConversationCard", () => {
|
||||
});
|
||||
|
||||
describe("state indicator", () => {
|
||||
it("should render the 'cold' indicator by default", () => {
|
||||
it("should render the 'STOPPED' indicator by default", () => {
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
@@ -251,7 +251,7 @@ describe("ConversationCard", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByTestId("cold-indicator");
|
||||
screen.getByTestId("STOPPED-indicator");
|
||||
});
|
||||
|
||||
it("should render the other indicators when provided", () => {
|
||||
@@ -263,12 +263,12 @@ describe("ConversationCard", () => {
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
state="warm"
|
||||
state="RUNNING"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("cold-indicator")).not.toBeInTheDocument();
|
||||
screen.getByTestId("warm-indicator");
|
||||
expect(screen.queryByTestId("STOPPED-indicator")).not.toBeInTheDocument();
|
||||
screen.getByTestId("RUNNING-indicator");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,7 +57,10 @@ describe("ConversationPanel", () => {
|
||||
|
||||
it("should display an empty state when there are no conversations", async () => {
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockResolvedValue([]);
|
||||
getUserConversationsSpy.mockResolvedValue({
|
||||
results: [],
|
||||
next_page_id: null,
|
||||
});
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
@@ -161,7 +164,7 @@ describe("ConversationPanel", () => {
|
||||
it("should rename a conversation", async () => {
|
||||
const updateUserConversationSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"updateUserConversation",
|
||||
"updateUserConversationTitle",
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
@@ -174,15 +177,16 @@ describe("ConversationPanel", () => {
|
||||
await user.tab();
|
||||
|
||||
// Ensure the conversation is renamed
|
||||
expect(updateUserConversationSpy).toHaveBeenCalledWith("3", {
|
||||
name: "Conversation 1 Renamed",
|
||||
});
|
||||
expect(updateUserConversationSpy).toHaveBeenCalledWith(
|
||||
"3",
|
||||
"Conversation 1 Renamed",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not rename a conversation when the name is unchanged", async () => {
|
||||
const updateUserConversationSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"updateUserConversation",
|
||||
"updateUserConversationTitle",
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -58,11 +58,11 @@ describe("App", () => {
|
||||
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
|
||||
|
||||
getConversationSpy.mockResolvedValue({
|
||||
conversation_id: "9999",
|
||||
lastUpdated: "",
|
||||
name: "",
|
||||
repo: "",
|
||||
state: "cold",
|
||||
id: "9999",
|
||||
last_updated_at: "",
|
||||
title: "",
|
||||
selected_repository: "",
|
||||
status: "STOPPED",
|
||||
});
|
||||
const { rerender } = renderWithProviders(
|
||||
<RouteStub initialEntries={["/conversation/9999"]} />,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import * as router from "react-router";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
GetVSCodeUrlResponse,
|
||||
AuthenticateResponse,
|
||||
Conversation,
|
||||
ConversationSearchResults,
|
||||
} from "./open-hands.types";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings } from "#/services/settings";
|
||||
@@ -221,20 +222,25 @@ class OpenHands {
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getUserConversations(): Promise<Conversation[]> {
|
||||
const { data } = await openHands.get<Conversation[]>("/api/conversations");
|
||||
static async getUserConversations(): Promise<ConversationSearchResults> {
|
||||
const { data } =
|
||||
await openHands.get<ConversationSearchResults>("/api/conversations");
|
||||
return data;
|
||||
}
|
||||
|
||||
static async deleteUserConversation(conversationId: string): Promise<void> {
|
||||
await openHands.delete(`/api/conversations/${conversationId}`);
|
||||
static async deleteUserConversation(
|
||||
conversationId: string,
|
||||
): Promise<boolean> {
|
||||
return openHands.delete(`/api/conversations/${conversationId}`);
|
||||
}
|
||||
|
||||
static async updateUserConversation(
|
||||
static async updateUserConversationTitle(
|
||||
conversationId: string,
|
||||
conversation: Partial<Omit<Conversation, "id">>,
|
||||
title: string,
|
||||
): Promise<void> {
|
||||
await openHands.put(`/api/conversations/${conversationId}`, conversation);
|
||||
await openHands.put(
|
||||
`/api/conversations/${conversationId}?title=${encodeURIComponent(title)}`,
|
||||
);
|
||||
}
|
||||
|
||||
static async createConversation(
|
||||
@@ -252,7 +258,7 @@ class OpenHands {
|
||||
);
|
||||
|
||||
// TODO: remove this once we have a multi-conversation UI
|
||||
localStorage.setItem("latest_conversation_id", data.conversation_id);
|
||||
localStorage.setItem("latest_conversation_id", data.id);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -61,9 +61,14 @@ export interface AuthenticateResponse {
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
conversation_id: string;
|
||||
name: string;
|
||||
repo: string | null;
|
||||
lastUpdated: string;
|
||||
state: ProjectState;
|
||||
id: string;
|
||||
title: string;
|
||||
selected_repository: string | null;
|
||||
last_updated_at: string;
|
||||
status: ProjectState;
|
||||
}
|
||||
|
||||
export interface ConversationSearchResults {
|
||||
results: Conversation[];
|
||||
next_page_id: string | null;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function ConversationCard({
|
||||
name,
|
||||
repo,
|
||||
lastUpdated,
|
||||
state = "cold",
|
||||
state = "STOPPED",
|
||||
}: ProjectCardProps) {
|
||||
const [contextMenuVisible, setContextMenuVisible] = React.useState(false);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -60,7 +60,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
if (oldTitle !== newTitle)
|
||||
updateConversation({
|
||||
id: conversationId,
|
||||
conversation: { name: newTitle },
|
||||
title: newTitle,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -87,23 +87,23 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
<p className="text-danger">{error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
{conversations?.length === 0 && (
|
||||
{conversations?.results.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<p className="text-neutral-400">No conversations found</p>
|
||||
</div>
|
||||
)}
|
||||
{conversations?.map((project) => (
|
||||
{conversations?.results.map((project) => (
|
||||
<ConversationCard
|
||||
key={project.conversation_id}
|
||||
onClick={() => handleClickCard(project.conversation_id)}
|
||||
onDelete={() => handleDeleteProject(project.conversation_id)}
|
||||
key={project.id}
|
||||
onClick={() => handleClickCard(project.id)}
|
||||
onDelete={() => handleDeleteProject(project.id)}
|
||||
onChangeTitle={(title) =>
|
||||
handleChangeTitle(project.conversation_id, project.name, title)
|
||||
handleChangeTitle(project.id, project.title, title)
|
||||
}
|
||||
name={project.name}
|
||||
repo={project.repo}
|
||||
lastUpdated={project.lastUpdated}
|
||||
state={project.state}
|
||||
name={project.title}
|
||||
repo={project.selected_repository}
|
||||
lastUpdated={project.last_updated_at}
|
||||
state={project.status}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -1,26 +1,12 @@
|
||||
import ColdIcon from "./state-indicators/cold.svg?react";
|
||||
import CoolingIcon from "./state-indicators/cooling.svg?react";
|
||||
import FinishedIcon from "./state-indicators/finished.svg?react";
|
||||
import RunningIcon from "./state-indicators/running.svg?react";
|
||||
import WaitingIcon from "./state-indicators/waiting.svg?react";
|
||||
import WarmIcon from "./state-indicators/warm.svg?react";
|
||||
|
||||
type SVGIcon = React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
|
||||
export type ProjectState =
|
||||
| "cold"
|
||||
| "cooling"
|
||||
| "finished"
|
||||
| "running"
|
||||
| "waiting"
|
||||
| "warm";
|
||||
export type ProjectState = "RUNNING" | "STOPPED";
|
||||
|
||||
const INDICATORS: Record<ProjectState, SVGIcon> = {
|
||||
cold: ColdIcon,
|
||||
cooling: CoolingIcon,
|
||||
finished: FinishedIcon,
|
||||
running: RunningIcon,
|
||||
waiting: WaitingIcon,
|
||||
warm: WarmIcon,
|
||||
STOPPED: ColdIcon,
|
||||
RUNNING: RunningIcon,
|
||||
};
|
||||
|
||||
interface ConversationStateIndicatorProps {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import FolderIcon from "#/icons/docs.svg?react";
|
||||
import { FaListUl } from "react-icons/fa";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useGitHubUser } from "#/hooks/query/use-github-user";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
@@ -16,7 +16,6 @@ import { SettingsModal } from "#/components/shared/modals/settings/settings-moda
|
||||
import { useSettingsUpToDate } from "#/context/settings-up-to-date-context";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { ConversationPanel } from "../conversation-panel/conversation-panel";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
|
||||
|
||||
export function Sidebar() {
|
||||
@@ -82,11 +81,12 @@ export function Sidebar() {
|
||||
data-testid="toggle-conversation-panel"
|
||||
type="button"
|
||||
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
|
||||
className={cn(
|
||||
conversationPanelIsOpen ? "border-b-2 border-[#FFE165]" : "",
|
||||
)}
|
||||
>
|
||||
<FolderIcon width={28} height={28} />
|
||||
<FaListUl
|
||||
width={28}
|
||||
height={28}
|
||||
fill={conversationPanelIsOpen ? "#FFE165" : "#FFFFFF"}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<DocsButton />
|
||||
|
||||
@@ -29,7 +29,7 @@ export const useCreateConversation = () => {
|
||||
selectedRepository || undefined,
|
||||
);
|
||||
},
|
||||
onSuccess: async ({ conversation_id: conversationId }, { q }) => {
|
||||
onSuccess: async ({ id: conversationId }, { q }) => {
|
||||
posthog.capture("initial_query_submitted", {
|
||||
entry_point: "task_form",
|
||||
query_character_length: q?.length,
|
||||
|
||||
@@ -6,11 +6,8 @@ export const useUpdateConversation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (variables: {
|
||||
id: string;
|
||||
conversation: Partial<Omit<Conversation, "id">>;
|
||||
}) =>
|
||||
OpenHands.updateUserConversation(variables.id, variables.conversation),
|
||||
mutationFn: (variables: { id: string; title: Conversation["title"] }) =>
|
||||
OpenHands.updateUserConversationTitle(variables.id, variables.title),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "conversations"] });
|
||||
},
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { delay, http, HttpResponse } from "msw";
|
||||
import { GetConfigResponse, Conversation } from "#/api/open-hands.types";
|
||||
import {
|
||||
GetConfigResponse,
|
||||
Conversation,
|
||||
ConversationSearchResults,
|
||||
} from "#/api/open-hands.types";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
|
||||
const userPreferences = {
|
||||
@@ -16,35 +20,36 @@ const userPreferences = {
|
||||
|
||||
const conversations: Conversation[] = [
|
||||
{
|
||||
conversation_id: "1",
|
||||
name: "My New Project",
|
||||
repo: null,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
state: "running",
|
||||
id: "1",
|
||||
title: "My New Project",
|
||||
selected_repository: null,
|
||||
last_updated_at: new Date().toISOString(),
|
||||
status: "RUNNING",
|
||||
},
|
||||
{
|
||||
conversation_id: "2",
|
||||
name: "Repo Testing",
|
||||
repo: "octocat/hello-world",
|
||||
id: "2",
|
||||
title: "Repo Testing",
|
||||
selected_repository: "octocat/hello-world",
|
||||
// 2 days ago
|
||||
lastUpdated: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
state: "cold",
|
||||
last_updated_at: new Date(
|
||||
Date.now() - 2 * 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
status: "STOPPED",
|
||||
},
|
||||
{
|
||||
conversation_id: "3",
|
||||
name: "Another Project",
|
||||
repo: "octocat/earth",
|
||||
id: "3",
|
||||
title: "Another Project",
|
||||
selected_repository: "octocat/earth",
|
||||
// 5 days ago
|
||||
lastUpdated: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
state: "finished",
|
||||
last_updated_at: new Date(
|
||||
Date.now() - 5 * 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
status: "STOPPED",
|
||||
},
|
||||
];
|
||||
|
||||
const CONVERSATIONS = new Map<string, Conversation>(
|
||||
conversations.map((conversation) => [
|
||||
conversation.conversation_id,
|
||||
conversation,
|
||||
]),
|
||||
conversations.map((conversation) => [conversation.id, conversation]),
|
||||
);
|
||||
|
||||
const openHandsHandlers = [
|
||||
@@ -182,9 +187,15 @@ export const handlers = [
|
||||
|
||||
http.get("/api/options/config", () => HttpResponse.json({ APP_MODE: "oss" })),
|
||||
|
||||
http.get("/api/conversations", async () =>
|
||||
HttpResponse.json(Array.from(CONVERSATIONS.values())),
|
||||
),
|
||||
http.get("/api/conversations", async () => {
|
||||
const values = Array.from(CONVERSATIONS.values());
|
||||
const results: ConversationSearchResults = {
|
||||
results: values,
|
||||
next_page_id: null,
|
||||
};
|
||||
|
||||
return HttpResponse.json(results, { status: 200 });
|
||||
}),
|
||||
|
||||
http.delete("/api/conversations/:conversationId", async ({ params }) => {
|
||||
const { conversationId } = params;
|
||||
@@ -200,20 +211,21 @@ export const handlers = [
|
||||
http.put(
|
||||
"/api/conversations/:conversationId",
|
||||
async ({ params, request }) => {
|
||||
const url = new URL(request.url);
|
||||
const titleParam = url.searchParams.get("title")?.toString();
|
||||
|
||||
const { conversationId } = params;
|
||||
|
||||
if (typeof conversationId === "string") {
|
||||
const conversation = CONVERSATIONS.get(conversationId);
|
||||
|
||||
if (conversation) {
|
||||
const body = await request.json();
|
||||
if (typeof body === "object" && body?.name) {
|
||||
CONVERSATIONS.set(conversationId, {
|
||||
...conversation,
|
||||
name: body.name,
|
||||
});
|
||||
return HttpResponse.json(null, { status: 200 });
|
||||
}
|
||||
if (conversation && titleParam) {
|
||||
CONVERSATIONS.set(conversationId, {
|
||||
...conversation,
|
||||
title: titleParam,
|
||||
});
|
||||
|
||||
return HttpResponse.json(null, { status: 200 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,14 +235,14 @@ export const handlers = [
|
||||
|
||||
http.post("/api/conversations", () => {
|
||||
const conversation: Conversation = {
|
||||
conversation_id: (Math.random() * 100).toString(),
|
||||
name: "New Conversation",
|
||||
repo: null,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
state: "warm",
|
||||
id: (Math.random() * 100).toString(),
|
||||
title: "New Conversation",
|
||||
selected_repository: null,
|
||||
last_updated_at: new Date().toISOString(),
|
||||
status: "RUNNING",
|
||||
};
|
||||
|
||||
CONVERSATIONS.set(conversation.conversation_id, conversation);
|
||||
CONVERSATIONS.set(conversation.id, conversation);
|
||||
return HttpResponse.json(conversation, { status: 201 });
|
||||
}),
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const MULTI_CONVO_UI_IS_ENABLED = false;
|
||||
export const MULTI_CONVO_UI_IS_ENABLED = true;
|
||||
|
||||
41
openhands/utils/conversation_utils.py
Normal file
41
openhands/utils/conversation_utils.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.shared import session_manager
|
||||
from openhands.storage.data_models.conversation_info import ConversationInfo
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
|
||||
from openhands.storage.data_models.conversation_status import ConversationStatus
|
||||
from openhands.storage.locations import get_conversation_events_dir
|
||||
|
||||
|
||||
async def get_conversation_info(
|
||||
conversation: ConversationMetadata,
|
||||
is_running: bool,
|
||||
) -> ConversationInfo | None:
|
||||
try:
|
||||
file_store = session_manager.file_store
|
||||
events_dir = get_conversation_events_dir(conversation.conversation_id)
|
||||
events = file_store.list(events_dir)
|
||||
events = sorted(events)
|
||||
event_path = events[-1]
|
||||
event = json.loads(file_store.read(event_path))
|
||||
title = conversation.title
|
||||
if not title:
|
||||
title = f'Conversation {conversation.conversation_id}'
|
||||
return ConversationInfo(
|
||||
conversation_id=conversation.conversation_id,
|
||||
title=title,
|
||||
last_updated_at=datetime.fromisoformat(event.get('timestamp')),
|
||||
selected_repository=conversation.selected_repository,
|
||||
status=ConversationStatus.RUNNING
|
||||
if is_running
|
||||
else ConversationStatus.STOPPED,
|
||||
)
|
||||
except Exception: # type: ignore
|
||||
logger.warning(
|
||||
f'Error loading conversation: {conversation.conversation_id}',
|
||||
exc_info=True,
|
||||
stack_info=True,
|
||||
)
|
||||
return None
|
||||
Reference in New Issue
Block a user