Spaces:
Build error
Build error
| 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 { | |
| ProjectStatus, | |
| ConversationStateIndicator, | |
| } from "./conversation-state-indicator"; | |
| import { EllipsisButton } from "./ellipsis-button"; | |
| import { ConversationCardContextMenu } from "./conversation-card-context-menu"; | |
| import { SystemMessageModal } from "./system-message-modal"; | |
| 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 OpenHands from "#/api/open-hands"; | |
| import { useWsClient } from "#/context/ws-client-provider"; | |
| import { isSystemMessage } from "#/types/core/guards"; | |
| interface ConversationCardProps { | |
| onClick?: () => void; | |
| onDelete?: () => void; | |
| onChangeTitle?: (title: string) => void; | |
| showOptions?: boolean; | |
| isActive?: boolean; | |
| title: string; | |
| selectedRepository: string | null; | |
| lastUpdatedAt: string; // ISO 8601 | |
| createdAt?: string; // ISO 8601 | |
| status?: ProjectStatus; | |
| variant?: "compact" | "default"; | |
| conversationId?: string; // Optional conversation ID for VS Code URL | |
| } | |
| const MAX_TIME_BETWEEN_CREATION_AND_UPDATE = 1000 * 60 * 30; // 30 minutes | |
| export function ConversationCard({ | |
| onClick, | |
| onDelete, | |
| onChangeTitle, | |
| showOptions, | |
| isActive, | |
| title, | |
| selectedRepository, | |
| // lastUpdatedAt is kept in props for backward compatibility | |
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | |
| lastUpdatedAt, | |
| createdAt, | |
| status = "STOPPED", | |
| variant = "default", | |
| conversationId, | |
| }: ConversationCardProps) { | |
| const { t } = useTranslation(); | |
| const { parsedEvents } = useWsClient(); | |
| const [contextMenuVisible, setContextMenuVisible] = React.useState(false); | |
| const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view"); | |
| const [metricsModalVisible, setMetricsModalVisible] = React.useState(false); | |
| const [systemModalVisible, setSystemModalVisible] = React.useState(false); | |
| const inputRef = React.useRef<HTMLInputElement>(null); | |
| const systemMessage = parsedEvents.find(isSystemMessage); | |
| // Subscribe to metrics data from Redux store | |
| const metrics = useSelector((state: RootState) => state.metrics); | |
| const handleBlur = () => { | |
| if (inputRef.current?.value) { | |
| const trimmed = inputRef.current.value.trim(); | |
| onChangeTitle?.(trimmed); | |
| inputRef.current!.value = trimmed; | |
| } else { | |
| // reset the value if it's empty | |
| inputRef.current!.value = title; | |
| } | |
| setTitleMode("view"); | |
| }; | |
| const handleKeyUp = (event: React.KeyboardEvent<HTMLInputElement>) => { | |
| if (event.key === "Enter") { | |
| event.currentTarget.blur(); | |
| } | |
| }; | |
| const handleInputClick = (event: React.MouseEvent<HTMLInputElement>) => { | |
| if (titleMode === "edit") { | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| } | |
| }; | |
| const handleDelete = (event: React.MouseEvent<HTMLButtonElement>) => { | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| onDelete?.(); | |
| setContextMenuVisible(false); | |
| }; | |
| const handleEdit = (event: React.MouseEvent<HTMLButtonElement>) => { | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| setTitleMode("edit"); | |
| setContextMenuVisible(false); | |
| }; | |
| const handleDownloadViaVSCode = async ( | |
| event: React.MouseEvent<HTMLButtonElement>, | |
| ) => { | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| posthog.capture("download_via_vscode_button_clicked"); | |
| // Fetch the VS Code URL from the API | |
| if (conversationId) { | |
| try { | |
| const data = await OpenHands.getVSCodeUrl(conversationId); | |
| if (data.vscode_url) { | |
| const transformedUrl = transformVSCodeUrl(data.vscode_url); | |
| if (transformedUrl) { | |
| window.open(transformedUrl, "_blank"); | |
| } | |
| } | |
| // VS Code URL not available | |
| } catch (error) { | |
| // Failed to fetch VS Code URL | |
| } | |
| } | |
| setContextMenuVisible(false); | |
| }; | |
| const handleDisplayCost = (event: React.MouseEvent<HTMLButtonElement>) => { | |
| event.stopPropagation(); | |
| setMetricsModalVisible(true); | |
| }; | |
| const handleShowAgentTools = (event: React.MouseEvent<HTMLButtonElement>) => { | |
| event.stopPropagation(); | |
| setSystemModalVisible(true); | |
| }; | |
| React.useEffect(() => { | |
| if (titleMode === "edit") { | |
| inputRef.current?.focus(); | |
| } | |
| }, [titleMode]); | |
| const hasContextMenu = !!(onDelete || onChangeTitle || showOptions); | |
| const timeBetweenUpdateAndCreation = createdAt | |
| ? new Date(lastUpdatedAt).getTime() - new Date(createdAt).getTime() | |
| : 0; | |
| const showUpdateTime = | |
| createdAt && | |
| timeBetweenUpdateAndCreation > MAX_TIME_BETWEEN_CREATION_AND_UPDATE; | |
| return ( | |
| <> | |
| <div | |
| data-testid="conversation-card" | |
| onClick={onClick} | |
| className={cn( | |
| "h-[100px] w-full px-[18px] py-4 border-b border-neutral-600 cursor-pointer", | |
| variant === "compact" && | |
| "md:w-fit h-auto rounded-xl border border-[#525252]", | |
| )} | |
| > | |
| <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"> | |
| {isActive && ( | |
| <span className="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0" /> | |
| )} | |
| {titleMode === "edit" && ( | |
| <input | |
| ref={inputRef} | |
| data-testid="conversation-card-title" | |
| onClick={handleInputClick} | |
| onBlur={handleBlur} | |
| onKeyUp={handleKeyUp} | |
| type="text" | |
| defaultValue={title} | |
| className="text-sm leading-6 font-semibold bg-transparent w-full" | |
| /> | |
| )} | |
| {titleMode === "view" && ( | |
| <p | |
| data-testid="conversation-card-title" | |
| className="text-sm leading-6 font-semibold bg-transparent truncate overflow-hidden" | |
| title={title} | |
| > | |
| {title} | |
| </p> | |
| )} | |
| </div> | |
| <div className="flex items-center"> | |
| <ConversationStateIndicator status={status} /> | |
| {hasContextMenu && ( | |
| <div className="pl-2"> | |
| <EllipsisButton | |
| onClick={(event) => { | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| setContextMenuVisible((prev) => !prev); | |
| }} | |
| /> | |
| </div> | |
| )} | |
| <div className="relative"> | |
| {contextMenuVisible && ( | |
| <ConversationCardContextMenu | |
| onClose={() => setContextMenuVisible(false)} | |
| onDelete={onDelete && handleDelete} | |
| onEdit={onChangeTitle && handleEdit} | |
| onDownloadViaVSCode={ | |
| conversationId && showOptions | |
| ? handleDownloadViaVSCode | |
| : undefined | |
| } | |
| onDisplayCost={showOptions ? handleDisplayCost : undefined} | |
| onShowAgentTools={ | |
| showOptions && systemMessage | |
| ? handleShowAgentTools | |
| : undefined | |
| } | |
| position={variant === "compact" ? "top" : "bottom"} | |
| /> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| <div | |
| className={cn( | |
| variant === "compact" && "flex items-center justify-between mt-1", | |
| )} | |
| > | |
| {selectedRepository && ( | |
| <ConversationRepoLink selectedRepository={selectedRepository} /> | |
| )} | |
| <p className="text-xs text-neutral-400"> | |
| <span>{t(I18nKey.CONVERSATION$CREATED)} </span> | |
| <time> | |
| {formatTimeDelta(new Date(createdAt || lastUpdatedAt))}{" "} | |
| {t(I18nKey.CONVERSATION$AGO)} | |
| </time> | |
| {showUpdateTime && ( | |
| <> | |
| <span>{t(I18nKey.CONVERSATION$UPDATED)} </span> | |
| <time> | |
| {formatTimeDelta(new Date(lastUpdatedAt))}{" "} | |
| {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 border-b border-neutral-700 pb-2"> | |
| <span className="text-lg font-semibold"> | |
| {t(I18nKey.CONVERSATION$TOTAL_COST)} | |
| </span> | |
| <span className="font-semibold"> | |
| ${metrics.cost.toFixed(4)} | |
| </span> | |
| </div> | |
| )} | |
| {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">Cache Hit:</span> | |
| <span className="text-right"> | |
| {metrics.usage.cache_read_tokens.toLocaleString()} | |
| </span> | |
| <span className="text-neutral-400">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} | |
| /> | |
| </> | |
| ); | |
| } | |