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