Compare commits

...

68 Commits

Author SHA1 Message Date
amanape
46d893b379 merge 2025-01-03 16:17:29 +04:00
amanape
29dd170211 Pass FE tests 2025-01-02 20:44:40 +04:00
amanape
2439ee068e merge 2025-01-02 20:23:59 +04:00
amanape
d24072f4f0 merge 2025-01-02 18:58:55 +04:00
Tim O'Farrell
46260e62db Fixed broken tests 2025-01-02 07:54:56 -07:00
amanape
7a8c095b91 UI and endpoint fixes 2025-01-02 18:52:51 +04:00
Tim O'Farrell
2b8006062f Ruff 2025-01-02 07:46:30 -07:00
Tim O'Farrell
0315069827 Ruff 2025-01-02 07:42:08 -07:00
Tim O'Farrell
ba67047bb8 Ruff 2025-01-02 07:41:44 -07:00
Tim O'Farrell
c89f5f76d3 Renamed file for clarity 2025-01-02 07:32:03 -07:00
Tim O'Farrell
ea5f583dd5 Moved models as suggested by Enyst 2025-01-02 07:28:55 -07:00
Tim O'Farrell
5becacfa01 Added test for update conversation 2025-01-02 07:22:35 -07:00
amanape
4796506634 merge 2025-01-02 18:21:06 +04:00
Tim O'Farrell
d2a80d92f3 Fixed typo 2025-01-02 07:16:39 -07:00
amanape
59fcb7bd38 Sync changes 2025-01-02 18:16:27 +04:00
Tim O'Farrell
215c633f46 Using put as post is for create 2025-01-02 07:14:01 -07:00
Tim O'Farrell
2d667c3402 Moved as suggested 2025-01-02 07:12:49 -07:00
Tim O'Farrell
cf1d1480dc Implemented default title 2025-01-02 07:05:37 -07:00
Tim O'Farrell
3f4e87fd09 Handled case where there are no conversations 2025-01-02 07:00:13 -07:00
tofarr
1696987430 Merge branch 'main' into feat-search-conversations 2025-01-01 15:27:39 -07:00
Tim O'Farrell
56ef8e392f Merge branch 'main' into feat-search-conversations 2025-01-01 12:38:13 -07:00
Tim O'Farrell
c5f8850dc1 Lint fix 2024-12-31 15:36:30 -07:00
Tim O'Farrell
d3d1b28e3a Ruff 2024-12-31 15:34:41 -07:00
Tim O'Farrell
0e03376b91 Test fixes 2024-12-31 15:34:18 -07:00
Tim O'Farrell
bdd23daeef WIP 2024-12-31 15:19:16 -07:00
Tim O'Farrell
8f7091064d Merge branch 'main' into feat-search-conversations 2024-12-31 15:16:33 -07:00
Tim O'Farrell
370797185f WIP 2024-12-31 15:03:22 -07:00
Tim O'Farrell
2312dba277 Merge branch 'main' into feat-search-conversations 2024-12-31 12:45:37 -07:00
Tim O'Farrell
9d171b5722 WIP 2024-12-31 12:41:09 -07:00
Tim O'Farrell
b1e161b6b7 Merge branch 'main' into feat-search-conversations 2024-12-31 11:32:52 -07:00
Tim O'Farrell
c204108428 Ruff 2024-12-31 08:33:39 -07:00
Tim O'Farrell
4181d0916d WIP 2024-12-31 08:33:03 -07:00
Tim O'Farrell
5b197b4f9c Merge branch 'main' into feat-search-conversations 2024-12-31 08:20:31 -07:00
Tim O'Farrell
858d4cbec0 Ruff 2024-12-30 16:14:17 -07:00
Tim O'Farrell
9f6b63cfd6 Merge branch 'feat-search-conversations' of github.com:All-Hands-AI/OpenHands into feat-search-conversations 2024-12-30 16:08:00 -07:00
Tim O'Farrell
77f0b574bf Merge branch 'main' into feat-search-conversations 2024-12-30 16:07:37 -07:00
tofarr
392e1cc0c7 Merge branch 'main' into feat-search-conversations 2024-12-28 11:26:54 -07:00
Tim O'Farrell
64d8dc8b70 WIP 2024-12-27 14:37:11 -07:00
Tim O'Farrell
d7ccd78c1b WIP 2024-12-27 14:33:59 -07:00
Tim O'Farrell
c54a0502e8 Merge branch 'main' into feat-search-conversations 2024-12-27 14:29:10 -07:00
Tim O'Farrell
02cd435a0a WIP 2024-12-27 14:28:46 -07:00
Tim O'Farrell
e9d3f4b07c WIP 2024-12-27 14:25:25 -07:00
Tim O'Farrell
a8379d7f30 WIP 2024-12-27 14:15:16 -07:00
Tim O'Farrell
a90dd21631 Fix tests 2024-12-27 14:13:43 -07:00
Tim O'Farrell
4305335e59 WIP 2024-12-27 13:42:31 -07:00
Tim O'Farrell
41723655b5 Lint fix 2024-12-27 11:48:35 -07:00
Tim O'Farrell
0220b69352 Ruff 2024-12-27 11:46:48 -07:00
Tim O'Farrell
7bd01d2ac1 WIP 2024-12-27 11:44:35 -07:00
tofarr
4aeeb3f23d Merge branch 'main' into feat-search-conversations 2024-12-27 11:42:19 -07:00
Tim O'Farrell
6b30f2ae67 Lint fixes 2024-12-27 11:41:53 -07:00
Tim O'Farrell
ede28d54a3 dded tests 2024-12-27 11:41:13 -07:00
Tim O'Farrell
8d0896be6e Lint fix 2024-12-27 11:13:33 -07:00
Tim O'Farrell
9d43f1f798 Merge branch 'main' into feat-search-conversations 2024-12-27 11:02:42 -07:00
Tim O'Farrell
74a2068345 Added get conversation endpoint 2024-12-27 11:00:28 -07:00
Tim O'Farrell
ae9ab6db39 Now returning selected repository 2024-12-27 10:31:51 -07:00
Tim O'Farrell
b182ba001d Removed something I don't know how it got in there 2024-12-27 09:53:11 -07:00
Tim O'Farrell
bdbce31c9c Made tests more generic 2024-12-27 09:46:21 -07:00
openhands
8781657a54 Add unit tests for search_utils and implement page_id_to_offset 2024-12-27 16:44:20 +00:00
Tim O'Farrell
5edc93d90e Search conversations endpoint 2024-12-27 09:33:57 -07:00
Tim O'Farrell
fe0acad1f2 Metadata 2024-12-26 18:14:11 -07:00
Tim O'Farrell
413e1b7300 Merge branch 'main' into feat-search-conversations 2024-12-26 17:20:12 -07:00
Tim O'Farrell
251b47fd5b Merge branch 'main' into feat-search-conversations 2024-12-26 13:39:44 -07:00
Tim O'Farrell
76f359c4ec Added specific types 2024-12-23 15:17:27 -07:00
Tim O'Farrell
3ac818b59c WIP 2024-12-23 14:53:40 -07:00
Tim O'Farrell
e3f9b7e3f3 WIP 2024-12-23 14:36:03 -07:00
Tim O'Farrell
dc820946b7 Added endpoint for conversation search 2024-12-23 14:26:58 -07:00
Tim O'Farrell
fbae222f2a Merge branch 'main' into feat-search-conversations 2024-12-23 13:27:28 -07:00
Tim O'Farrell
74682eff0c Conversation endpoint 2024-12-23 13:12:42 -07:00
15 changed files with 160 additions and 110 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
export const MULTI_CONVO_UI_IS_ENABLED = false;
export const MULTI_CONVO_UI_IS_ENABLED = true;

View 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