refactor(frontend): conversation card (#10986)

This commit is contained in:
Hiep Le 2025-09-18 20:22:59 +07:00 committed by GitHub
parent 774caf0607
commit faeec48365
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 181 additions and 330 deletions

View File

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

View File

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

View File

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

View File

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

View File

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