Compare commits

...

4 Commits

Author SHA1 Message Date
Ray Myers
fe92626a24 Make export display when showOptions is disabled 2025-05-20 20:45:58 -07:00
openhands
f08f934349 Fix TypeScript errors in conversation card tests 2025-05-21 02:23:18 +00:00
openhands
09b0d4dfcb Fix TypeScript errors in conversation card tests 2025-05-21 02:19:23 +00:00
openhands
66db570079 Add export trajectory button to conversation card context menu 2025-05-21 02:06:06 +00:00
3 changed files with 158 additions and 1 deletions

View File

@@ -14,6 +14,8 @@ import { renderWithProviders } from "test-utils";
import { formatTimeDelta } from "#/utils/format-time-delta";
import { ConversationCard } from "#/components/features/conversation-panel/conversation-card";
import { clickOnEditButton } from "./utils";
import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
import { downloadTrajectory } from "#/utils/download-trajectory";
// We'll use the actual i18next implementation but override the translation function
import { I18nextProvider } from "react-i18next";
@@ -29,7 +31,9 @@ vi.mock("react-i18next", async () => {
const translations: Record<string, string> = {
"CONVERSATION$CREATED": "Created",
"CONVERSATION$AGO": "ago",
"CONVERSATION$UPDATED": "Updated"
"CONVERSATION$UPDATED": "Updated",
"CONVERSATION$DOWNLOAD_ERROR": "Download Error",
"CONVERSATION$UNKNOWN": "Unknown"
};
return translations[key] || key;
},
@@ -45,6 +49,29 @@ describe("ConversationCard", () => {
const onDelete = vi.fn();
const onChangeTitle = vi.fn();
// Mock the useGetTrajectory hook
vi.mock("#/hooks/mutation/use-get-trajectory", () => ({
useGetTrajectory: vi.fn().mockReturnValue({
mutate: vi.fn((id, options) => {
if (options?.onSuccess) {
options.onSuccess({ trajectory: [{ test: "data" }] });
}
}),
}),
}));
// Mock the downloadTrajectory function
vi.mock("#/utils/download-trajectory", () => ({
downloadTrajectory: vi.fn(),
}));
// Mock posthog
vi.mock("posthog-js", () => ({
default: {
capture: vi.fn(),
},
}));
beforeAll(() => {
vi.stubGlobal("window", {
open: vi.fn(),
@@ -348,6 +375,52 @@ describe("ConversationCard", () => {
const newMenu = await screen.findByTestId("context-menu");
within(newMenu).getByTestId("display-cost-button");
});
it("should show export trajectory button only when showOptions and conversationId are true", async () => {
const user = userEvent.setup();
const { rerender } = renderWithProviders(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
// Wait for context menu to appear
const menu = await screen.findByTestId("context-menu");
expect(
within(menu).queryByTestId("export-trajectory-button"),
).not.toBeInTheDocument();
// Close menu
await user.click(ellipsisButton);
rerender(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
showOptions
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
conversationId="test-conversation-id"
/>,
);
// Open menu again
await user.click(ellipsisButton);
// Wait for context menu to appear and check for export trajectory button
const newMenu = await screen.findByTestId("context-menu");
within(newMenu).getByTestId("export-trajectory-button");
});
it("should show metrics modal when clicking the display cost button", async () => {
const user = userEvent.setup();
@@ -374,6 +447,46 @@ describe("ConversationCard", () => {
// Verify if metrics modal is displayed by checking for the modal content
expect(screen.getByTestId("metrics-modal")).toBeInTheDocument();
});
it("should call export trajectory when clicking the export trajectory button", async () => {
const user = userEvent.setup();
const getTrajectoryMock = useGetTrajectory as unknown as {
(): { mutate: ReturnType<typeof vi.fn> };
};
const downloadTrajectoryMock = downloadTrajectory as ReturnType<typeof vi.fn>;
renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
showOptions
conversationId="test-conversation-id"
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = screen.getByTestId("context-menu");
const exportTrajectoryButton = within(menu).getByTestId("export-trajectory-button");
await user.click(exportTrajectoryButton);
// Verify that the trajectory was fetched and downloaded
expect(getTrajectoryMock().mutate).toHaveBeenCalledWith(
"test-conversation-id",
expect.objectContaining({
onSuccess: expect.any(Function),
})
);
// Check that downloadTrajectory was called
expect(downloadTrajectoryMock).toHaveBeenCalled();
});
it("should not display the edit or delete options if the handler is not provided", async () => {
const user = userEvent.setup();

View File

@@ -10,6 +10,7 @@ interface ConversationCardContextMenuProps {
onDisplayCost?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onShowAgentTools?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDownloadViaVSCode?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onExportTrajectory?: (event: React.MouseEvent<HTMLButtonElement>) => void;
position?: "top" | "bottom";
}
@@ -20,6 +21,7 @@ export function ConversationCardContextMenu({
onDisplayCost,
onShowAgentTools,
onDownloadViaVSCode,
onExportTrajectory,
position = "bottom",
}: ConversationCardContextMenuProps) {
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
@@ -68,6 +70,14 @@ export function ConversationCardContextMenu({
Show Agent Tools & Metadata
</ContextMenuListItem>
)}
{onExportTrajectory && (
<ContextMenuListItem
testId="export-trajectory-button"
onClick={onExportTrajectory}
>
Export Trajectory
</ContextMenuListItem>
)}
</ContextMenu>
);
}

View File

@@ -19,6 +19,9 @@ import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
import OpenHands from "#/api/open-hands";
import { useWsClient } from "#/context/ws-client-provider";
import { isSystemMessage } from "#/types/core/guards";
import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
import { downloadTrajectory } from "#/utils/download-trajectory";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
interface ConversationCardProps {
onClick?: () => void;
@@ -60,6 +63,7 @@ export function ConversationCard({
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
const [systemModalVisible, setSystemModalVisible] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const { mutate: getTrajectory } = useGetTrajectory();
const systemMessage = parsedEvents.find(isSystemMessage);
@@ -142,6 +146,33 @@ export function ConversationCard({
setSystemModalVisible(true);
};
const handleExportTrajectory = (
event: React.MouseEvent<HTMLButtonElement>,
) => {
event.preventDefault();
event.stopPropagation();
posthog.capture("export_trajectory_button_clicked");
if (!conversationId) {
displayErrorToast(t(I18nKey.CONVERSATION$DOWNLOAD_ERROR));
return;
}
getTrajectory(conversationId, {
onSuccess: async (data) => {
await downloadTrajectory(
conversationId ?? t(I18nKey.CONVERSATION$UNKNOWN),
data.trajectory,
);
},
onError: () => {
displayErrorToast(t(I18nKey.CONVERSATION$DOWNLOAD_ERROR));
},
});
setContextMenuVisible(false);
};
React.useEffect(() => {
if (titleMode === "edit") {
inputRef.current?.focus();
@@ -225,6 +256,9 @@ export function ConversationCard({
? handleShowAgentTools
: undefined
}
onExportTrajectory={
conversationId ? handleExportTrajectory : undefined
}
position={variant === "compact" ? "top" : "bottom"}
/>
)}