mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
refactor(frontend): conversation card (#10986)
This commit is contained in:
parent
774caf0607
commit
faeec48365
@ -357,69 +357,6 @@ describe("ConversationCard", () => {
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show display cost button only when showOptions is true", async () => {
|
||||
const onContextMenuToggle = vi.fn();
|
||||
const { rerender } = renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for context menu to appear
|
||||
const menu = await screen.findByTestId("context-menu");
|
||||
expect(
|
||||
within(menu).queryByTestId("display-cost-button"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
showOptions
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for context menu to appear and check for display cost button
|
||||
const newMenu = await screen.findByTestId("context-menu");
|
||||
within(newMenu).getByTestId("display-cost-button");
|
||||
});
|
||||
|
||||
it("should show metrics modal when clicking the display cost button", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onContextMenuToggle = vi.fn();
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
showOptions
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
const menu = screen.getByTestId("context-menu");
|
||||
const displayCostButton = within(menu).getByTestId("display-cost-button");
|
||||
|
||||
await user.click(displayCostButton);
|
||||
|
||||
// Verify if metrics modal is displayed by checking for the modal content
|
||||
expect(screen.getByTestId("metrics-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display the edit or delete options if the handler is not provided", async () => {
|
||||
const onContextMenuToggle = vi.fn();
|
||||
const { rerender } = renderWithProviders(
|
||||
|
||||
@ -0,0 +1,65 @@
|
||||
import React from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { ConversationCardContextMenu } from "./conversation-card-context-menu";
|
||||
import EllipsisIcon from "#/icons/ellipsis.svg?react";
|
||||
|
||||
interface ConversationCardActionsProps {
|
||||
contextMenuOpen: boolean;
|
||||
onContextMenuToggle: (isOpen: boolean) => void;
|
||||
onDelete?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onStop?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onEdit?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onDownloadViaVSCode?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
conversationStatus?: ConversationStatus;
|
||||
conversationId?: string;
|
||||
showOptions?: boolean;
|
||||
}
|
||||
|
||||
export function ConversationCardActions({
|
||||
contextMenuOpen,
|
||||
onContextMenuToggle,
|
||||
onDelete,
|
||||
onStop,
|
||||
onEdit,
|
||||
onDownloadViaVSCode,
|
||||
conversationStatus,
|
||||
conversationId,
|
||||
showOptions,
|
||||
}: ConversationCardActionsProps) {
|
||||
return (
|
||||
<div className="group">
|
||||
<button
|
||||
data-testid="ellipsis-button"
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onContextMenuToggle(!contextMenuOpen);
|
||||
}}
|
||||
className="cursor-pointer w-6 h-6 flex flex-row items-center justify-end"
|
||||
>
|
||||
<EllipsisIcon />
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
// Show on hover (desktop) or when explicitly opened (click/touch)
|
||||
"relative opacity-0 invisible group-hover:opacity-100 group-hover:visible",
|
||||
// Override hover styles when explicitly opened via click
|
||||
contextMenuOpen && "opacity-100 visible",
|
||||
)}
|
||||
>
|
||||
<ConversationCardContextMenu
|
||||
onClose={() => onContextMenuToggle(false)}
|
||||
onDelete={onDelete}
|
||||
onStop={conversationStatus !== "STOPPED" ? onStop : undefined}
|
||||
onEdit={onEdit}
|
||||
onDownloadViaVSCode={
|
||||
conversationId && showOptions ? onDownloadViaVSCode : undefined
|
||||
}
|
||||
position="bottom"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { RepositorySelection } from "#/api/open-hands.types";
|
||||
import { ConversationRepoLink } from "./conversation-repo-link";
|
||||
import { NoRepository } from "./no-repository";
|
||||
|
||||
interface ConversationCardFooterProps {
|
||||
selectedRepository: RepositorySelection | null;
|
||||
lastUpdatedAt: string; // ISO 8601
|
||||
createdAt?: string; // ISO 8601
|
||||
}
|
||||
|
||||
export function ConversationCardFooter({
|
||||
selectedRepository,
|
||||
lastUpdatedAt,
|
||||
createdAt,
|
||||
}: ConversationCardFooterProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-row justify-between items-center mt-1")}>
|
||||
{selectedRepository?.selected_repository ? (
|
||||
<ConversationRepoLink selectedRepository={selectedRepository} />
|
||||
) : (
|
||||
<NoRepository />
|
||||
)}
|
||||
{(createdAt ?? lastUpdatedAt) && (
|
||||
<p className="text-xs text-[#A3A3A3] flex-1 text-right">
|
||||
<time>
|
||||
{`${formatTimeDelta(new Date(lastUpdatedAt ?? createdAt))} ${t(I18nKey.CONVERSATION$AGO)}`}
|
||||
</time>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
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";
|
||||
|
||||
interface ConversationCardHeaderProps {
|
||||
title: string;
|
||||
titleMode: "view" | "edit";
|
||||
onTitleSave: (title: string) => void;
|
||||
conversationStatus?: ConversationStatus;
|
||||
}
|
||||
|
||||
export function ConversationCardHeader({
|
||||
title,
|
||||
titleMode,
|
||||
onTitleSave,
|
||||
conversationStatus,
|
||||
}: ConversationCardHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden mr-2">
|
||||
{/* Status Indicator */}
|
||||
{conversationStatus && (
|
||||
<div className="flex items-center">
|
||||
<ConversationStatusIndicator
|
||||
conversationStatus={conversationStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ConversationCardTitle
|
||||
title={title}
|
||||
titleMode={titleMode}
|
||||
onSave={onTitleSave}
|
||||
/>
|
||||
{/* Status Badges */}
|
||||
{conversationStatus && (
|
||||
<ConversationStatusBadges conversationStatus={conversationStatus} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,28 +1,13 @@
|
||||
import React from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import posthog from "posthog-js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
import { ConversationRepoLink } from "./conversation-repo-link";
|
||||
import { ConversationCardContextMenu } from "./conversation-card-context-menu";
|
||||
import { SystemMessageModal } from "../system-message-modal";
|
||||
import { MicroagentsModal } from "../microagents-modal";
|
||||
import { BudgetDisplay } from "../budget-display";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { BaseModal } from "../../../shared/modals/base-modal/base-modal";
|
||||
import { RootState } from "#/store";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { isSystemMessage } from "#/types/core/guards";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { RepositorySelection } from "#/api/open-hands.types";
|
||||
import EllipsisIcon from "#/icons/ellipsis.svg?react";
|
||||
import { ConversationCardTitle } from "./conversation-card-title";
|
||||
import { ConversationStatusIndicator } from "../../home/recent-conversations/conversation-status-indicator";
|
||||
import { ConversationStatusBadges } from "./conversation-status-badges";
|
||||
import { NoRepository } from "./no-repository";
|
||||
import { ConversationCardHeader } from "./conversation-card-header";
|
||||
import { ConversationCardActions } from "./conversation-card-actions";
|
||||
import { ConversationCardFooter } from "./conversation-card-footer";
|
||||
|
||||
interface ConversationCardProps {
|
||||
onClick?: () => void;
|
||||
@ -57,18 +42,7 @@ export function ConversationCard({
|
||||
contextMenuOpen = false,
|
||||
onContextMenuToggle,
|
||||
}: ConversationCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { parsedEvents } = useWsClient();
|
||||
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
|
||||
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
|
||||
const [systemModalVisible, setSystemModalVisible] = React.useState(false);
|
||||
const [microagentsModalVisible, setMicroagentsModalVisible] =
|
||||
React.useState(false);
|
||||
|
||||
const systemMessage = parsedEvents.find(isSystemMessage);
|
||||
|
||||
// Subscribe to metrics data from Redux store
|
||||
const metrics = useSelector((state: RootState) => state.metrics);
|
||||
|
||||
const onTitleSave = (newTitle: string) => {
|
||||
if (newTitle !== "" && newTitle !== title) {
|
||||
@ -124,250 +98,47 @@ export function ConversationCard({
|
||||
onContextMenuToggle?.(false);
|
||||
};
|
||||
|
||||
const handleDisplayCost = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
setMetricsModalVisible(true);
|
||||
};
|
||||
|
||||
const handleShowAgentTools = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
setSystemModalVisible(true);
|
||||
};
|
||||
|
||||
const handleShowMicroagents = (
|
||||
event: React.MouseEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
setMicroagentsModalVisible(true);
|
||||
};
|
||||
|
||||
const hasContextMenu = !!(onDelete || onChangeTitle || showOptions);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-testid="conversation-card"
|
||||
data-context-menu-open={contextMenuOpen.toString()}
|
||||
onClick={onClick}
|
||||
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
|
||||
data-testid="conversation-card"
|
||||
data-context-menu-open={contextMenuOpen.toString()}
|
||||
onClick={onClick}
|
||||
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">
|
||||
<ConversationCardHeader
|
||||
title={title}
|
||||
titleMode={titleMode}
|
||||
onTitleSave={onTitleSave}
|
||||
conversationStatus={conversationStatus}
|
||||
/>
|
||||
|
||||
{hasContextMenu && (
|
||||
<ConversationCardActions
|
||||
contextMenuOpen={contextMenuOpen}
|
||||
onContextMenuToggle={onContextMenuToggle || (() => {})}
|
||||
onDelete={onDelete && handleDelete}
|
||||
onStop={onStop && handleStop}
|
||||
onEdit={onChangeTitle && handleEdit}
|
||||
onDownloadViaVSCode={handleDownloadViaVSCode}
|
||||
conversationStatus={conversationStatus}
|
||||
conversationId={conversationId}
|
||||
showOptions={showOptions}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden mr-2">
|
||||
{/* Status Indicator */}
|
||||
{conversationStatus && (
|
||||
<div className="flex items-center">
|
||||
<ConversationStatusIndicator
|
||||
conversationStatus={conversationStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ConversationCardTitle
|
||||
title={title}
|
||||
titleMode={titleMode}
|
||||
onSave={onTitleSave}
|
||||
/>
|
||||
{/* Status Badges */}
|
||||
{conversationStatus && (
|
||||
<ConversationStatusBadges
|
||||
conversationStatus={conversationStatus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasContextMenu && (
|
||||
<div className="group">
|
||||
<button
|
||||
data-testid="ellipsis-button"
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onContextMenuToggle?.(!contextMenuOpen);
|
||||
}}
|
||||
className="cursor-pointer w-6 h-6 flex flex-row items-center justify-end"
|
||||
>
|
||||
<EllipsisIcon />
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
// Show on hover (desktop) or when explicitly opened (click/touch)
|
||||
"relative opacity-0 invisible group-hover:opacity-100 group-hover:visible",
|
||||
// Override hover styles when explicitly opened via click
|
||||
contextMenuOpen && "opacity-100 visible",
|
||||
)}
|
||||
>
|
||||
<ConversationCardContextMenu
|
||||
onClose={() => onContextMenuToggle?.(false)}
|
||||
onDelete={onDelete && handleDelete}
|
||||
onStop={
|
||||
conversationStatus !== "STOPPED"
|
||||
? onStop && handleStop
|
||||
: undefined
|
||||
}
|
||||
onEdit={onChangeTitle && handleEdit}
|
||||
onDownloadViaVSCode={
|
||||
conversationId && showOptions
|
||||
? handleDownloadViaVSCode
|
||||
: undefined
|
||||
}
|
||||
onDisplayCost={showOptions ? handleDisplayCost : undefined}
|
||||
onShowAgentTools={
|
||||
showOptions && systemMessage
|
||||
? handleShowAgentTools
|
||||
: undefined
|
||||
}
|
||||
onShowMicroagents={
|
||||
showOptions && conversationId
|
||||
? handleShowMicroagents
|
||||
: undefined
|
||||
}
|
||||
position="bottom"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={cn("flex flex-row justify-between items-center mt-1")}>
|
||||
{selectedRepository?.selected_repository ? (
|
||||
<ConversationRepoLink selectedRepository={selectedRepository} />
|
||||
) : (
|
||||
<NoRepository />
|
||||
)}
|
||||
{(createdAt ?? lastUpdatedAt) && (
|
||||
<p className="text-xs text-[#A3A3A3] flex-1 text-right">
|
||||
<time>
|
||||
{`${formatTimeDelta(new Date(lastUpdatedAt ?? createdAt))} ${t(I18nKey.CONVERSATION$AGO)}`}
|
||||
</time>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseModal
|
||||
isOpen={metricsModalVisible}
|
||||
onOpenChange={setMetricsModalVisible}
|
||||
title={t(I18nKey.CONVERSATION$METRICS_INFO)}
|
||||
testID="metrics-modal"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{(metrics?.cost !== null || metrics?.usage !== null) && (
|
||||
<div className="rounded-md p-3">
|
||||
<div className="grid gap-3">
|
||||
{metrics?.cost !== null && (
|
||||
<div className="flex justify-between items-center pb-2">
|
||||
<span className="text-lg font-semibold">
|
||||
{t(I18nKey.CONVERSATION$TOTAL_COST)}
|
||||
</span>
|
||||
<span className="font-semibold">
|
||||
${metrics.cost.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<BudgetDisplay
|
||||
cost={metrics?.cost ?? null}
|
||||
maxBudgetPerTask={metrics?.max_budget_per_task ?? null}
|
||||
/>
|
||||
|
||||
{metrics?.usage !== null && (
|
||||
<>
|
||||
<div className="flex justify-between items-center pb-2">
|
||||
<span>{t(I18nKey.CONVERSATION$INPUT)}</span>
|
||||
<span className="font-semibold">
|
||||
{metrics.usage.prompt_tokens.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 pl-4 text-sm">
|
||||
<span className="text-neutral-400">
|
||||
{t(I18nKey.CONVERSATION$CACHE_HIT)}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{metrics.usage.cache_read_tokens.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-neutral-400">
|
||||
{t(I18nKey.CONVERSATION$CACHE_WRITE)}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{metrics.usage.cache_write_tokens.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center border-b border-neutral-700 pb-2">
|
||||
<span>{t(I18nKey.CONVERSATION$OUTPUT)}</span>
|
||||
<span className="font-semibold">
|
||||
{metrics.usage.completion_tokens.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center border-b border-neutral-700 pb-2">
|
||||
<span className="font-semibold">
|
||||
{t(I18nKey.CONVERSATION$TOTAL)}
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
{(
|
||||
metrics.usage.prompt_tokens +
|
||||
metrics.usage.completion_tokens
|
||||
).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold">
|
||||
{t(I18nKey.CONVERSATION$CONTEXT_WINDOW)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-neutral-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.min(100, (metrics.usage.per_turn_token / metrics.usage.context_window) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<span className="text-xs text-neutral-400">
|
||||
{metrics.usage.per_turn_token.toLocaleString()} /{" "}
|
||||
{metrics.usage.context_window.toLocaleString()} (
|
||||
{(
|
||||
(metrics.usage.per_turn_token /
|
||||
metrics.usage.context_window) *
|
||||
100
|
||||
).toFixed(2)}
|
||||
% {t(I18nKey.CONVERSATION$USED)})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!metrics?.cost && !metrics?.usage && (
|
||||
<div className="rounded-md p-4 text-center">
|
||||
<p className="text-neutral-400">
|
||||
{t(I18nKey.CONVERSATION$NO_METRICS)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BaseModal>
|
||||
|
||||
<SystemMessageModal
|
||||
isOpen={systemModalVisible}
|
||||
onClose={() => setSystemModalVisible(false)}
|
||||
systemMessage={systemMessage ? systemMessage.args : null}
|
||||
<ConversationCardFooter
|
||||
selectedRepository={selectedRepository}
|
||||
lastUpdatedAt={lastUpdatedAt}
|
||||
createdAt={createdAt}
|
||||
/>
|
||||
|
||||
{microagentsModalVisible && (
|
||||
<MicroagentsModal onClose={() => setMicroagentsModalVisible(false)} />
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user