mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-10 07:18:10 -05:00
Feat multi conversations wiring (#6011)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
@@ -19,9 +19,9 @@ describe("ConversationCard", () => {
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
const expectedDate = `${formatTimeDelta(new Date("2021-10-01T12:00:00Z"))} ago`;
|
||||
@@ -33,20 +33,20 @@ describe("ConversationCard", () => {
|
||||
within(card).getByText(expectedDate);
|
||||
});
|
||||
|
||||
it("should render the repo if available", () => {
|
||||
it("should render the selectedRepository if available", () => {
|
||||
const { rerender } = render(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("conversation-card-repo"),
|
||||
screen.queryByTestId("conversation-card-selected-repository"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
@@ -54,13 +54,13 @@ describe("ConversationCard", () => {
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo="org/repo"
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository="org/selectedRepository"
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByTestId("conversation-card-repo");
|
||||
screen.getByTestId("conversation-card-selected-repository");
|
||||
});
|
||||
|
||||
it("should call onClick when the card is clicked", async () => {
|
||||
@@ -70,9 +70,9 @@ describe("ConversationCard", () => {
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -89,9 +89,9 @@ describe("ConversationCard", () => {
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -114,9 +114,9 @@ describe("ConversationCard", () => {
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -131,21 +131,21 @@ describe("ConversationCard", () => {
|
||||
expect(onDelete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("clicking the repo should not trigger the onClick handler", async () => {
|
||||
test("clicking the selectedRepository should not trigger the onClick handler", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo="org/repo"
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository="org/selectedRepository"
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
const repo = screen.getByTestId("conversation-card-repo");
|
||||
await user.click(repo);
|
||||
const selectedRepository = screen.getByTestId("conversation-card-selected-repository");
|
||||
await user.click(selectedRepository);
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -156,9 +156,9 @@ describe("ConversationCard", () => {
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
onChangeTitle={onChangeTitle}
|
||||
/>,
|
||||
);
|
||||
@@ -180,9 +180,9 @@ describe("ConversationCard", () => {
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -202,9 +202,9 @@ describe("ConversationCard", () => {
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -221,9 +221,9 @@ describe("ConversationCard", () => {
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -239,19 +239,19 @@ 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}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByTestId("cold-indicator");
|
||||
screen.getByTestId("STOPPED-indicator");
|
||||
});
|
||||
|
||||
it("should render the other indicators when provided", () => {
|
||||
@@ -260,15 +260,15 @@ describe("ConversationCard", () => {
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
state="warm"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
status="RUNNING"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("cold-indicator")).not.toBeInTheDocument();
|
||||
screen.getByTestId("warm-indicator");
|
||||
expect(screen.queryByTestId("STOPPED-indicator")).not.toBeInTheDocument();
|
||||
screen.getByTestId("RUNNING-indicator");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -175,7 +175,7 @@ describe("ConversationPanel", () => {
|
||||
|
||||
// Ensure the conversation is renamed
|
||||
expect(updateUserConversationSpy).toHaveBeenCalledWith("3", {
|
||||
name: "Conversation 1 Renamed",
|
||||
title: "Conversation 1 Renamed",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { Sidebar } from "#/components/features/sidebar/sidebar";
|
||||
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
|
||||
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
|
||||
const renderSidebar = () => {
|
||||
const RouterStub = createRoutesStub([
|
||||
@@ -18,7 +18,7 @@ const renderSidebar = () => {
|
||||
};
|
||||
|
||||
describe("Sidebar", () => {
|
||||
it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
|
||||
it.skipIf(!MULTI_CONVERSATION_UI)(
|
||||
"should have the conversation panel open by default",
|
||||
() => {
|
||||
renderSidebar();
|
||||
@@ -26,7 +26,7 @@ describe("Sidebar", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
|
||||
it.skipIf(!MULTI_CONVERSATION_UI)(
|
||||
"should toggle the conversation panel",
|
||||
async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { screen, waitFor } from "@testing-library/react";
|
||||
import toast from "react-hot-toast";
|
||||
import App from "#/routes/_oh.app/route";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
|
||||
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
|
||||
describe("App", () => {
|
||||
const RouteStub = createRoutesStub([
|
||||
@@ -35,7 +35,7 @@ describe("App", () => {
|
||||
await screen.findByTestId("app-route");
|
||||
});
|
||||
|
||||
it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
|
||||
it.skipIf(!MULTI_CONVERSATION_UI)(
|
||||
"should call endSession if the user does not have permission to view conversation",
|
||||
async () => {
|
||||
const errorToastSpy = vi.spyOn(toast, "error");
|
||||
@@ -59,10 +59,10 @@ describe("App", () => {
|
||||
|
||||
getConversationSpy.mockResolvedValue({
|
||||
conversation_id: "9999",
|
||||
lastUpdated: "",
|
||||
name: "",
|
||||
repo: "",
|
||||
state: "cold",
|
||||
last_updated_at: "",
|
||||
title: "",
|
||||
selected_repository: "",
|
||||
status: "STOPPED",
|
||||
});
|
||||
const { rerender } = renderWithProviders(
|
||||
<RouteStub initialEntries={["/conversation/9999"]} />,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
GetVSCodeUrlResponse,
|
||||
AuthenticateResponse,
|
||||
Conversation,
|
||||
ResultSet,
|
||||
} from "./open-hands.types";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings } from "#/services/settings";
|
||||
@@ -222,8 +223,10 @@ class OpenHands {
|
||||
}
|
||||
|
||||
static async getUserConversations(): Promise<Conversation[]> {
|
||||
const { data } = await openHands.get<Conversation[]>("/api/conversations");
|
||||
return data;
|
||||
const { data } = await openHands.get<ResultSet<Conversation>>(
|
||||
"/api/conversations?limit=9",
|
||||
);
|
||||
return data.results;
|
||||
}
|
||||
|
||||
static async deleteUserConversation(conversationId: string): Promise<void> {
|
||||
@@ -232,9 +235,9 @@ class OpenHands {
|
||||
|
||||
static async updateUserConversation(
|
||||
conversationId: string,
|
||||
conversation: Partial<Omit<Conversation, "id">>,
|
||||
conversation: Partial<Omit<Conversation, "conversation_id">>,
|
||||
): Promise<void> {
|
||||
await openHands.put(`/api/conversations/${conversationId}`, conversation);
|
||||
await openHands.patch(`/api/conversations/${conversationId}`, conversation);
|
||||
}
|
||||
|
||||
static async createConversation(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProjectState } from "#/components/features/conversation-panel/conversation-state-indicator";
|
||||
import { ProjectStatus } from "#/components/features/conversation-panel/conversation-state-indicator";
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: string;
|
||||
@@ -62,8 +62,13 @@ export interface AuthenticateResponse {
|
||||
|
||||
export interface Conversation {
|
||||
conversation_id: string;
|
||||
name: string;
|
||||
repo: string | null;
|
||||
lastUpdated: string;
|
||||
state: ProjectState;
|
||||
title: string;
|
||||
selected_repository: string | null;
|
||||
last_updated_at: string;
|
||||
status: ProjectStatus;
|
||||
}
|
||||
|
||||
export interface ResultSet<T> {
|
||||
results: T[];
|
||||
next_page_id: string | null;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export function ContextMenu({
|
||||
<ul
|
||||
data-testid={testId}
|
||||
ref={ref}
|
||||
className={cn("bg-[#404040] rounded-md w-[224px]", className)}
|
||||
className={cn("bg-[#404040] rounded-md w-[140px]", className)}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
import { ConversationRepoLink } from "./conversation-repo-link";
|
||||
import {
|
||||
ProjectState,
|
||||
ProjectStatus,
|
||||
ConversationStateIndicator,
|
||||
} from "./conversation-state-indicator";
|
||||
import { ContextMenu } from "../context-menu/context-menu";
|
||||
@@ -13,20 +13,20 @@ interface ProjectCardProps {
|
||||
onClick: () => void;
|
||||
onDelete: () => void;
|
||||
onChangeTitle: (title: string) => void;
|
||||
name: string;
|
||||
repo: string | null;
|
||||
lastUpdated: string; // ISO 8601
|
||||
state?: ProjectState;
|
||||
title: string;
|
||||
selectedRepository: string | null;
|
||||
lastUpdatedAt: string; // ISO 8601
|
||||
status?: ProjectStatus;
|
||||
}
|
||||
|
||||
export function ConversationCard({
|
||||
onClick,
|
||||
onDelete,
|
||||
onChangeTitle,
|
||||
name,
|
||||
repo,
|
||||
lastUpdated,
|
||||
state = "cold",
|
||||
title,
|
||||
selectedRepository,
|
||||
lastUpdatedAt,
|
||||
status = "STOPPED",
|
||||
}: ProjectCardProps) {
|
||||
const [contextMenuVisible, setContextMenuVisible] = React.useState(false);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
@@ -38,7 +38,13 @@ export function ConversationCard({
|
||||
inputRef.current!.value = trimmed;
|
||||
} else {
|
||||
// reset the value if it's empty
|
||||
inputRef.current!.value = name;
|
||||
inputRef.current!.value = title;
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -55,47 +61,45 @@ export function ConversationCard({
|
||||
<div
|
||||
data-testid="conversation-card"
|
||||
onClick={onClick}
|
||||
className="h-[100px] w-full px-[18px] py-4 border-b border-neutral-600"
|
||||
className="h-[100px] w-full px-[18px] py-4 border-b border-neutral-600 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between space-x-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
data-testid="conversation-card-title"
|
||||
onClick={handleInputClick}
|
||||
onBlur={handleBlur}
|
||||
onKeyUp={handleKeyUp}
|
||||
type="text"
|
||||
defaultValue={name}
|
||||
className="text-sm leading-6 font-semibold bg-transparent"
|
||||
defaultValue={title}
|
||||
className="text-sm leading-6 font-semibold bg-transparent w-full"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<ConversationStateIndicator state={state} />
|
||||
<ConversationStateIndicator status={status} />
|
||||
<EllipsisButton
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setContextMenuVisible((prev) => !prev);
|
||||
}}
|
||||
/>
|
||||
{contextMenuVisible && (
|
||||
<ContextMenu testId="context-menu" className="absolute left-full">
|
||||
<ContextMenuListItem
|
||||
testId="delete-button"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</ContextMenuListItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{repo && (
|
||||
{contextMenuVisible && (
|
||||
<ContextMenu testId="context-menu" className="left-full float-right">
|
||||
<ContextMenuListItem testId="delete-button" onClick={handleDelete}>
|
||||
Delete
|
||||
</ContextMenuListItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{selectedRepository && (
|
||||
<ConversationRepoLink
|
||||
repo={repo}
|
||||
selectedRepository={selectedRepository}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-neutral-400">
|
||||
<time>{formatTimeDelta(new Date(lastUpdated))} ago</time>
|
||||
<time>{formatTimeDelta(new Date(lastUpdatedAt))} ago</time>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -60,7 +60,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
if (oldTitle !== newTitle)
|
||||
updateConversation({
|
||||
id: conversationId,
|
||||
conversation: { name: newTitle },
|
||||
conversation: { title: newTitle },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -72,7 +72,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="conversation-panel"
|
||||
className="w-[350px] h-full border border-neutral-700 bg-neutral-800 rounded-xl"
|
||||
className="w-[350px] h-full border border-neutral-700 bg-neutral-800 rounded-xl overflow-y-auto"
|
||||
>
|
||||
<div className="pt-4 px-4 flex items-center justify-between">
|
||||
{location.pathname.startsWith("/conversation") && (
|
||||
@@ -98,12 +98,12 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
onClick={() => handleClickCard(project.conversation_id)}
|
||||
onDelete={() => handleDeleteProject(project.conversation_id)}
|
||||
onChangeTitle={(title) =>
|
||||
handleChangeTitle(project.conversation_id, project.name, title)
|
||||
handleChangeTitle(project.conversation_id, project.title, title)
|
||||
}
|
||||
name={project.name}
|
||||
repo={project.repo}
|
||||
lastUpdated={project.lastUpdated}
|
||||
state={project.state}
|
||||
title={project.title}
|
||||
selectedRepository={project.selected_repository}
|
||||
lastUpdatedAt={project.last_updated_at}
|
||||
status={project.status}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
interface ConversationRepoLinkProps {
|
||||
repo: string;
|
||||
selectedRepository: string;
|
||||
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
}
|
||||
|
||||
export function ConversationRepoLink({
|
||||
repo,
|
||||
selectedRepository,
|
||||
onClick,
|
||||
}: ConversationRepoLinkProps) {
|
||||
return (
|
||||
<a
|
||||
data-testid="conversation-card-repo"
|
||||
href={`https://github.com/${repo}`}
|
||||
data-testid="conversation-card-selected-repository"
|
||||
href={`https://github.com/${selectedRepository}`}
|
||||
target="_blank noopener noreferrer"
|
||||
onClick={onClick}
|
||||
className="text-xs text-neutral-400 hover:text-neutral-200"
|
||||
>
|
||||
{repo}
|
||||
{selectedRepository}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,39 +1,25 @@
|
||||
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 ProjectStatus = "RUNNING" | "STOPPED";
|
||||
|
||||
const INDICATORS: Record<ProjectState, SVGIcon> = {
|
||||
cold: ColdIcon,
|
||||
cooling: CoolingIcon,
|
||||
finished: FinishedIcon,
|
||||
running: RunningIcon,
|
||||
waiting: WaitingIcon,
|
||||
warm: WarmIcon,
|
||||
const INDICATORS: Record<ProjectStatus, SVGIcon> = {
|
||||
STOPPED: ColdIcon,
|
||||
RUNNING: RunningIcon,
|
||||
};
|
||||
|
||||
interface ConversationStateIndicatorProps {
|
||||
state: ProjectState;
|
||||
status: ProjectStatus;
|
||||
}
|
||||
|
||||
export function ConversationStateIndicator({
|
||||
state,
|
||||
status,
|
||||
}: ConversationStateIndicatorProps) {
|
||||
const StateIcon = INDICATORS[state];
|
||||
const StateIcon = INDICATORS[status];
|
||||
|
||||
return (
|
||||
<div data-testid={`${state}-indicator`}>
|
||||
<div data-testid={`${status}-indicator`}>
|
||||
<StateIcon />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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,8 +16,7 @@ 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";
|
||||
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
|
||||
export function Sidebar() {
|
||||
const location = useLocation();
|
||||
@@ -32,9 +31,18 @@ export function Sidebar() {
|
||||
const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
|
||||
const [startNewProjectModalIsOpen, setStartNewProjectModalIsOpen] =
|
||||
React.useState(false);
|
||||
const [conversationPanelIsOpen, setConversationPanelIsOpen] = React.useState(
|
||||
MULTI_CONVO_UI_IS_ENABLED,
|
||||
);
|
||||
const [conversationPanelIsOpen, setConversationPanelIsOpen] =
|
||||
React.useState(false);
|
||||
const conversationPanelRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const conversationPanel = conversationPanelRef.current;
|
||||
if (conversationPanelIsOpen && conversationPanel) {
|
||||
if (!conversationPanel.contains(event.target as Node)) {
|
||||
setConversationPanelIsOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
// If the github token is invalid, open the account settings modal again
|
||||
@@ -43,6 +51,13 @@ export function Sidebar() {
|
||||
}
|
||||
}, [user.isError]);
|
||||
|
||||
React.useEffect(() => {
|
||||
document.addEventListener("click", handleClick);
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClick);
|
||||
};
|
||||
}, [conversationPanelIsOpen]);
|
||||
|
||||
const handleAccountSettingsModalClose = () => {
|
||||
// If the user closes the modal without connecting to GitHub,
|
||||
// we need to log them out to clear the invalid token from the
|
||||
@@ -77,16 +92,17 @@ export function Sidebar() {
|
||||
/>
|
||||
)}
|
||||
<SettingsButton onClick={() => setSettingsModalIsOpen(true)} />
|
||||
{MULTI_CONVO_UI_IS_ENABLED && (
|
||||
{MULTI_CONVERSATION_UI && (
|
||||
<button
|
||||
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 />
|
||||
@@ -97,6 +113,7 @@ export function Sidebar() {
|
||||
|
||||
{conversationPanelIsOpen && (
|
||||
<div
|
||||
ref={conversationPanelRef}
|
||||
className="absolute h-full left-[calc(100%+12px)] top-0 z-20" // 12px padding (sidebar parent)
|
||||
>
|
||||
<ConversationPanel
|
||||
|
||||
@@ -100,6 +100,10 @@ export function WsClientProvider({
|
||||
setStatus(WsClientProviderStatus.DISCONNECTED);
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
lastEventRef.current = null;
|
||||
}, [conversationId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!conversationId) {
|
||||
throw new Error("No conversation ID provided");
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
|
||||
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
|
||||
export const useUserConversation = (cid: string | null) =>
|
||||
useQuery({
|
||||
queryKey: ["user", "conversation", cid],
|
||||
queryFn: () => OpenHands.getConversation(cid!),
|
||||
enabled: MULTI_CONVO_UI_IS_ENABLED && !!cid,
|
||||
enabled: MULTI_CONVERSATION_UI && !!cid,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
@@ -17,26 +17,30 @@ const userPreferences = {
|
||||
const conversations: Conversation[] = [
|
||||
{
|
||||
conversation_id: "1",
|
||||
name: "My New Project",
|
||||
repo: null,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
state: "running",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -182,8 +186,11 @@ 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?limit=9", async () =>
|
||||
HttpResponse.json({
|
||||
results: Array.from(CONVERSATIONS.values()),
|
||||
next_page_id: null,
|
||||
}),
|
||||
),
|
||||
|
||||
http.delete("/api/conversations/:conversationId", async ({ params }) => {
|
||||
@@ -197,7 +204,7 @@ export const handlers = [
|
||||
return HttpResponse.json(null, { status: 404 });
|
||||
}),
|
||||
|
||||
http.put(
|
||||
http.patch(
|
||||
"/api/conversations/:conversationId",
|
||||
async ({ params, request }) => {
|
||||
const { conversationId } = params;
|
||||
@@ -207,10 +214,10 @@ export const handlers = [
|
||||
|
||||
if (conversation) {
|
||||
const body = await request.json();
|
||||
if (typeof body === "object" && body?.name) {
|
||||
if (typeof body === "object" && body?.title) {
|
||||
CONVERSATIONS.set(conversationId, {
|
||||
...conversation,
|
||||
name: body.name,
|
||||
title: body.title,
|
||||
});
|
||||
return HttpResponse.json(null, { status: 200 });
|
||||
}
|
||||
@@ -224,10 +231,10 @@ 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",
|
||||
title: "New Conversation",
|
||||
selected_repository: null,
|
||||
last_updated_at: new Date().toISOString(),
|
||||
status: "RUNNING",
|
||||
};
|
||||
|
||||
CONVERSATIONS.set(conversation.conversation_id, conversation);
|
||||
|
||||
@@ -34,7 +34,7 @@ import { useUserConversation } from "#/hooks/query/get-conversation-permissions"
|
||||
import { CountBadge } from "#/components/layout/count-badge";
|
||||
import { TerminalStatusLabel } from "#/components/features/terminal/terminal-status-label";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
|
||||
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
|
||||
function AppContent() {
|
||||
const { gitHubToken } = useAuth();
|
||||
@@ -73,7 +73,7 @@ function AppContent() {
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (MULTI_CONVO_UI_IS_ENABLED && isFetched && !conversation) {
|
||||
if (MULTI_CONVERSATION_UI && isFetched && !conversation) {
|
||||
toast.error(
|
||||
"This conversation does not exist, or you do not have permission to access it.",
|
||||
);
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const MULTI_CONVO_UI_IS_ENABLED = false;
|
||||
15
frontend/src/utils/feature-flags.ts
Normal file
15
frontend/src/utils/feature-flags.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
function loadFeatureFlag(
|
||||
flagName: string,
|
||||
defaultValue: boolean = false,
|
||||
): boolean {
|
||||
try {
|
||||
const stringValue =
|
||||
localStorage.getItem(`FEATURE_${flagName}`) || defaultValue.toString();
|
||||
const value = !!JSON.parse(stringValue);
|
||||
return value;
|
||||
} catch (e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
export const MULTI_CONVERSATION_UI = loadFeatureFlag("MULTI_CONVERSATION_UI");
|
||||
@@ -58,7 +58,7 @@ class SandboxConfig:
|
||||
runtime_startup_env_vars: dict[str, str] = field(default_factory=dict)
|
||||
browsergym_eval_env: str | None = None
|
||||
platform: str | None = None
|
||||
close_delay: int = 15
|
||||
close_delay: int = 900
|
||||
remote_runtime_resource_factor: int = 1
|
||||
enable_gpu: bool = False
|
||||
|
||||
|
||||
Reference in New Issue
Block a user