diff --git a/frontend/__tests__/components/features/conversation-panel/conversation-card.test.tsx b/frontend/__tests__/components/features/conversation-panel/conversation-card.test.tsx index ca60d22a94..65020fa669 100644 --- a/frontend/__tests__/components/features/conversation-panel/conversation-card.test.tsx +++ b/frontend/__tests__/components/features/conversation-panel/conversation-card.test.tsx @@ -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( - , - ); - - // Wait for context menu to appear - const menu = await screen.findByTestId("context-menu"); - expect( - within(menu).queryByTestId("display-cost-button"), - ).not.toBeInTheDocument(); - - rerender( - , - ); - - // 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( - , - ); - - 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( diff --git a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-actions.tsx b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-actions.tsx new file mode 100644 index 0000000000..c39264c3bd --- /dev/null +++ b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-actions.tsx @@ -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) => void; + onStop?: (event: React.MouseEvent) => void; + onEdit?: (event: React.MouseEvent) => void; + onDownloadViaVSCode?: (event: React.MouseEvent) => void; + conversationStatus?: ConversationStatus; + conversationId?: string; + showOptions?: boolean; +} + +export function ConversationCardActions({ + contextMenuOpen, + onContextMenuToggle, + onDelete, + onStop, + onEdit, + onDownloadViaVSCode, + conversationStatus, + conversationId, + showOptions, +}: ConversationCardActionsProps) { + return ( +
+ +
+ onContextMenuToggle(false)} + onDelete={onDelete} + onStop={conversationStatus !== "STOPPED" ? onStop : undefined} + onEdit={onEdit} + onDownloadViaVSCode={ + conversationId && showOptions ? onDownloadViaVSCode : undefined + } + position="bottom" + /> +
+
+ ); +} diff --git a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx new file mode 100644 index 0000000000..3c969dbb59 --- /dev/null +++ b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx @@ -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 ( +
+ {selectedRepository?.selected_repository ? ( + + ) : ( + + )} + {(createdAt ?? lastUpdatedAt) && ( +

+ +

+ )} +
+ ); +} diff --git a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-header.tsx b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-header.tsx new file mode 100644 index 0000000000..05b54973ab --- /dev/null +++ b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-header.tsx @@ -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 ( +
+ {/* Status Indicator */} + {conversationStatus && ( +
+ +
+ )} + + {/* Status Badges */} + {conversationStatus && ( + + )} +
+ ); +} diff --git a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card.tsx b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card.tsx index f2df9a8bf2..13a3a1402b 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card.tsx @@ -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) => { - event.stopPropagation(); - setMetricsModalVisible(true); - }; - - const handleShowAgentTools = (event: React.MouseEvent) => { - event.stopPropagation(); - setSystemModalVisible(true); - }; - - const handleShowMicroagents = ( - event: React.MouseEvent, - ) => { - event.stopPropagation(); - setMicroagentsModalVisible(true); - }; - const hasContextMenu = !!(onDelete || onChangeTitle || showOptions); return ( - <> -
+
+ + + {hasContextMenu && ( + {})} + onDelete={onDelete && handleDelete} + onStop={onStop && handleStop} + onEdit={onChangeTitle && handleEdit} + onDownloadViaVSCode={handleDownloadViaVSCode} + conversationStatus={conversationStatus} + conversationId={conversationId} + showOptions={showOptions} + /> )} - > -
-
- {/* Status Indicator */} - {conversationStatus && ( -
- -
- )} - - {/* Status Badges */} - {conversationStatus && ( - - )} -
- - {hasContextMenu && ( -
- -
- 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" - /> -
-
- )} -
- -
- {selectedRepository?.selected_repository ? ( - - ) : ( - - )} - {(createdAt ?? lastUpdatedAt) && ( -

- -

- )} -
- -
- {(metrics?.cost !== null || metrics?.usage !== null) && ( -
-
- {metrics?.cost !== null && ( -
- - {t(I18nKey.CONVERSATION$TOTAL_COST)} - - - ${metrics.cost.toFixed(4)} - -
- )} - - - {metrics?.usage !== null && ( - <> -
- {t(I18nKey.CONVERSATION$INPUT)} - - {metrics.usage.prompt_tokens.toLocaleString()} - -
- -
- - {t(I18nKey.CONVERSATION$CACHE_HIT)} - - - {metrics.usage.cache_read_tokens.toLocaleString()} - - - {t(I18nKey.CONVERSATION$CACHE_WRITE)} - - - {metrics.usage.cache_write_tokens.toLocaleString()} - -
- -
- {t(I18nKey.CONVERSATION$OUTPUT)} - - {metrics.usage.completion_tokens.toLocaleString()} - -
- -
- - {t(I18nKey.CONVERSATION$TOTAL)} - - - {( - metrics.usage.prompt_tokens + - metrics.usage.completion_tokens - ).toLocaleString()} - -
- -
-
- - {t(I18nKey.CONVERSATION$CONTEXT_WINDOW)} - -
-
-
-
-
- - {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)}) - -
-
- - )} -
-
- )} - - {!metrics?.cost && !metrics?.usage && ( -
-

- {t(I18nKey.CONVERSATION$NO_METRICS)} -

-
- )} -
- - - setSystemModalVisible(false)} - systemMessage={systemMessage ? systemMessage.args : null} + - - {microagentsModalVisible && ( - setMicroagentsModalVisible(false)} /> - )} - +
); }