Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 87 additions & 3 deletions app/components/evaluations/EvalRunCard.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
"use client";

import { useState } from "react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { colors } from "@/app/lib/colors";
import {
EvalJob,
AssistantConfig,
getScoreObject,
} from "@/app/components/types";
import { getStatusColor } from "@/app/components/utils";
import { getStatusColor, formatCostUSD } from "@/app/components/utils";
import { timeAgo } from "@/app/lib/utils";
import ConfigModal from "@/app/components/ConfigModal";
import ScoreDisplay from "@/app/components/ScoreDisplay";
Expand All @@ -24,11 +24,20 @@ export default function EvalRunCard({
}: EvalRunCardProps) {
const router = useRouter();
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
const [isCostTooltipOpen, setIsCostTooltipOpen] = useState(false);
const [costTooltipPos, setCostTooltipPos] = useState({ top: 0, left: 0 });

const isCompleted = job.status?.toLowerCase() === "completed";
const scoreObj = getScoreObject(job);
const statusColor = getStatusColor(job.status || "");

useEffect(() => {
if (!isCostTooltipOpen) return;
const handleScroll = () => setIsCostTooltipOpen(false);
window.addEventListener("scroll", handleScroll, true);
return () => window.removeEventListener("scroll", handleScroll, true);
}, [isCostTooltipOpen]);

return (
<div
className="rounded-lg overflow-hidden"
Expand Down Expand Up @@ -81,7 +90,7 @@ export default function EvalRunCard({
</div>
)}

{/* Row 3: Dataset + Config (left) | Actions (right) */}
{/* Row 3: Dataset + Config + Cost (left) | Actions (right) */}
<div className="flex items-center justify-between gap-4 mt-3">
<div
className="flex items-center gap-3 text-xs"
Expand Down Expand Up @@ -113,6 +122,81 @@ export default function EvalRunCard({
{assistantConfig.name}
</span>
)}
{job.cost?.total_cost_usd != null && (
<span className="flex items-center gap-1.5">
<svg
className="w-3.5 h-3.5 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v12m-3-2.818.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
{formatCostUSD(job.cost.total_cost_usd)}
<div
className="inline-flex items-center justify-center w-4 h-4 rounded-full text-xs font-normal cursor-help"
style={{
backgroundColor: isCostTooltipOpen ? "#171717" : "#fafafa",
color: isCostTooltipOpen ? "#ffffff" : "#737373",
}}
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const tooltipWidth = 280;
const centerX = rect.left + rect.width / 2;
const clampedLeft = Math.min(
Math.max(centerX - tooltipWidth / 2, 8),
window.innerWidth - tooltipWidth - 8,
);
setCostTooltipPos({
top: rect.top - 8,
left: clampedLeft,
});
setIsCostTooltipOpen(true);
}}
onMouseLeave={() => setIsCostTooltipOpen(false)}
>
i
</div>
{isCostTooltipOpen && (
<div
className="fixed z-50 px-3 py-2 rounded-md text-xs whitespace-normal pointer-events-none space-y-1"
style={{
backgroundColor: "#171717",
color: "#ffffff",
width: "260px",
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
top: costTooltipPos.top,
left: costTooltipPos.left,
transform: "translateY(-100%)",
}}
>
{job.cost.response && (
<div className="flex justify-between gap-3">
<span style={{ color: "#a3a3a3" }}>
Response generation
</span>
<span>{formatCostUSD(job.cost.response.cost_usd)}</span>
</div>
)}
{job.cost.embedding && (
<div className="flex justify-between gap-3">
<span style={{ color: "#a3a3a3" }}>
Cosine similarity calculation
</span>
<span>
{formatCostUSD(job.cost.embedding.cost_usd)}
</span>
</div>
)}
Comment on lines +165 to +195
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Tooltip can render blank when no breakdown entries are present.

Lines 178-195 only render response/embedding. Since these fields are optional in EvalCost, users can get an empty tooltip even when total cost is shown.

Proposed fallback row
                 {isCostTooltipOpen && (
                   <div
                     className="fixed z-50 px-3 py-2 rounded-md text-xs whitespace-normal pointer-events-none space-y-1"
                     style={{
                       backgroundColor: "#171717",
                       color: "#ffffff",
                       width: "260px",
                       boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
                       top: costTooltipPos.top,
                       left: costTooltipPos.left,
                       transform: "translateY(-100%)",
                     }}
                   >
+                    {!job.cost.response && !job.cost.embedding && (
+                      <div className="flex justify-between gap-3">
+                        <span style={{ color: "#a3a3a3" }}>Total</span>
+                        <span>{formatCostUSD(job.cost.total_cost_usd)}</span>
+                      </div>
+                    )}
                     {job.cost.response && (
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{isCostTooltipOpen && (
<div
className="fixed z-50 px-3 py-2 rounded-md text-xs whitespace-normal pointer-events-none space-y-1"
style={{
backgroundColor: "#171717",
color: "#ffffff",
width: "260px",
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
top: costTooltipPos.top,
left: costTooltipPos.left,
transform: "translateY(-100%)",
}}
>
{job.cost.response && (
<div className="flex justify-between gap-3">
<span style={{ color: "#a3a3a3" }}>
Response generation
</span>
<span>{formatCostUSD(job.cost.response.cost_usd)}</span>
</div>
)}
{job.cost.embedding && (
<div className="flex justify-between gap-3">
<span style={{ color: "#a3a3a3" }}>
Cosine similarity calculation
</span>
<span>
{formatCostUSD(job.cost.embedding.cost_usd)}
</span>
</div>
)}
{isCostTooltipOpen && (
<div
className="fixed z-50 px-3 py-2 rounded-md text-xs whitespace-normal pointer-events-none space-y-1"
style={{
backgroundColor: "#171717",
color: "#ffffff",
width: "260px",
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
top: costTooltipPos.top,
left: costTooltipPos.left,
transform: "translateY(-100%)",
}}
>
{!job.cost.response && !job.cost.embedding && (
<div className="flex justify-between gap-3">
<span style={{ color: "#a3a3a3" }}>Total</span>
<span>{formatCostUSD(job.cost.total_cost_usd)}</span>
</div>
)}
{job.cost.response && (
<div className="flex justify-between gap-3">
<span style={{ color: "#a3a3a3" }}>
Response generation
</span>
<span>{formatCostUSD(job.cost.response.cost_usd)}</span>
</div>
)}
{job.cost.embedding && (
<div className="flex justify-between gap-3">
<span style={{ color: "#a3a3a3" }}>
Cosine similarity calculation
</span>
<span>
{formatCostUSD(job.cost.embedding.cost_usd)}
</span>
</div>
)}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/evaluations/EvalRunCard.tsx` around lines 165 - 195, The
tooltip can end up empty because it only renders job.cost.response and
job.cost.embedding; update the EvalRunCard tooltip rendering to include a
fallback row when neither response nor embedding exist: inside the
isCostTooltipOpen block (where costTooltipPos and formatCostUSD are used) add a
default line that displays a descriptive label like "Total cost" and formats
job.cost.total (or job.cost.cost_usd / computed total) so the tooltip always
shows at least the aggregate cost when EvalCost lacks response/embedding
entries.

</div>
)}
Comment on lines +141 to +197
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Cost tooltip is mouse-only and not keyboard/touch accessible.

On Lines 147-162, the trigger is a div with hover handlers only. Keyboard and touch users can’t reliably open this tooltip.

Proposed accessibility fix
-                <div
+                <button
+                  type="button"
+                  aria-label="Show cost breakdown"
+                  aria-expanded={isCostTooltipOpen}
+                  aria-describedby={`cost-tooltip-${job.id}`}
                   className="inline-flex items-center justify-center w-4 h-4 rounded-full text-xs font-normal cursor-help"
                   style={{
                     backgroundColor: isCostTooltipOpen ? "#171717" : "#fafafa",
                     color: isCostTooltipOpen ? "#ffffff" : "#737373",
                   }}
                   onMouseEnter={(e) => {
                     const rect = e.currentTarget.getBoundingClientRect();
                     const tooltipWidth = 280;
                     const centerX = rect.left + rect.width / 2;
                     const clampedLeft = Math.min(
                       Math.max(centerX - tooltipWidth / 2, 8),
                       window.innerWidth - tooltipWidth - 8,
                     );
                     setCostTooltipPos({
                       top: rect.top - 8,
                       left: clampedLeft,
                     });
                     setIsCostTooltipOpen(true);
                   }}
                   onMouseLeave={() => setIsCostTooltipOpen(false)}
+                  onFocus={(e) => {
+                    const rect = e.currentTarget.getBoundingClientRect();
+                    const tooltipWidth = 280;
+                    const centerX = rect.left + rect.width / 2;
+                    const clampedLeft = Math.min(
+                      Math.max(centerX - tooltipWidth / 2, 8),
+                      window.innerWidth - tooltipWidth - 8,
+                    );
+                    setCostTooltipPos({ top: rect.top - 8, left: clampedLeft });
+                    setIsCostTooltipOpen(true);
+                  }}
+                  onBlur={() => setIsCostTooltipOpen(false)}
+                  onClick={() => setIsCostTooltipOpen((prev) => !prev)}
                 >
                   i
-                </div>
+                </button>
                 {isCostTooltipOpen && (
                   <div
+                    id={`cost-tooltip-${job.id}`}
+                    role="tooltip"
                     className="fixed z-50 px-3 py-2 rounded-md text-xs whitespace-normal pointer-events-none space-y-1"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div
className="inline-flex items-center justify-center w-4 h-4 rounded-full text-xs font-normal cursor-help"
style={{
backgroundColor: isCostTooltipOpen ? "#171717" : "#fafafa",
color: isCostTooltipOpen ? "#ffffff" : "#737373",
}}
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const tooltipWidth = 280;
const centerX = rect.left + rect.width / 2;
const clampedLeft = Math.min(
Math.max(centerX - tooltipWidth / 2, 8),
window.innerWidth - tooltipWidth - 8,
);
setCostTooltipPos({
top: rect.top - 8,
left: clampedLeft,
});
setIsCostTooltipOpen(true);
}}
onMouseLeave={() => setIsCostTooltipOpen(false)}
>
i
</div>
{isCostTooltipOpen && (
<div
className="fixed z-50 px-3 py-2 rounded-md text-xs whitespace-normal pointer-events-none space-y-1"
style={{
backgroundColor: "#171717",
color: "#ffffff",
width: "260px",
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
top: costTooltipPos.top,
left: costTooltipPos.left,
transform: "translateY(-100%)",
}}
>
{job.cost.response && (
<div className="flex justify-between gap-3">
<span style={{ color: "#a3a3a3" }}>
Response generation
</span>
<span>{formatCostUSD(job.cost.response.cost_usd)}</span>
</div>
)}
{job.cost.embedding && (
<div className="flex justify-between gap-3">
<span style={{ color: "#a3a3a3" }}>
Cosine similarity calculation
</span>
<span>
{formatCostUSD(job.cost.embedding.cost_usd)}
</span>
</div>
)}
</div>
)}
<button
type="button"
aria-label="Show cost breakdown"
aria-expanded={isCostTooltipOpen}
aria-describedby={`cost-tooltip-${job.id}`}
className="inline-flex items-center justify-center w-4 h-4 rounded-full text-xs font-normal cursor-help"
style={{
backgroundColor: isCostTooltipOpen ? "#171717" : "#fafafa",
color: isCostTooltipOpen ? "#ffffff" : "#737373",
}}
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const tooltipWidth = 280;
const centerX = rect.left + rect.width / 2;
const clampedLeft = Math.min(
Math.max(centerX - tooltipWidth / 2, 8),
window.innerWidth - tooltipWidth - 8,
);
setCostTooltipPos({
top: rect.top - 8,
left: clampedLeft,
});
setIsCostTooltipOpen(true);
}}
onMouseLeave={() => setIsCostTooltipOpen(false)}
onFocus={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const tooltipWidth = 280;
const centerX = rect.left + rect.width / 2;
const clampedLeft = Math.min(
Math.max(centerX - tooltipWidth / 2, 8),
window.innerWidth - tooltipWidth - 8,
);
setCostTooltipPos({ top: rect.top - 8, left: clampedLeft });
setIsCostTooltipOpen(true);
}}
onBlur={() => setIsCostTooltipOpen(false)}
onClick={() => setIsCostTooltipOpen((prev) => !prev)}
>
i
</button>
{isCostTooltipOpen && (
<div
id={`cost-tooltip-${job.id}`}
role="tooltip"
className="fixed z-50 px-3 py-2 rounded-md text-xs whitespace-normal pointer-events-none space-y-1"
style={{
backgroundColor: "#171717",
color: "#ffffff",
width: "260px",
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
top: costTooltipPos.top,
left: costTooltipPos.left,
transform: "translateY(-100%)",
}}
>
{job.cost.response && (
<div className="flex justify-between gap-3">
<span style={{ color: "#a3a3a3" }}>
Response generation
</span>
<span>{formatCostUSD(job.cost.response.cost_usd)}</span>
</div>
)}
{job.cost.embedding && (
<div className="flex justify-between gap-3">
<span style={{ color: "#a3a3a3" }}>
Cosine similarity calculation
</span>
<span>
{formatCostUSD(job.cost.embedding.cost_usd)}
</span>
</div>
)}
</div>
)}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/evaluations/EvalRunCard.tsx` around lines 141 - 197, The
tooltip trigger is a plain div with only mouse handlers; make it keyboard and
touch accessible by replacing or enhancing the trigger element (the current
inline div that references isCostTooltipOpen, setIsCostTooltipOpen,
costTooltipPos, setCostTooltipPos) so it is focusable and operable: add
role="button" or use a real <button>, include tabIndex,
aria-label/aria-describedby (pointing to the tooltip container) and
aria-expanded tied to isCostTooltipOpen, add onFocus/onBlur handlers that mirror
onMouseEnter/onMouseLeave to set costTooltipPos and open/close, implement
onKeyDown to open on Enter/Space and close on Escape, and add onClick to support
touch toggling; ensure the tooltip container is referenced by id for
accessibility and retains pointer-events: none only if you still need it
visually but allow screen readers to access its contents.

</span>
)}
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<button
Expand Down
16 changes: 16 additions & 0 deletions app/components/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,21 @@ export interface AssistantConfig {
is_deleted: boolean;
}

export interface EvalCostEntry {
model: string;
cost_usd: number;
input_tokens?: number;
output_tokens?: number;
prompt_tokens?: number;
total_tokens: number;
}

export interface EvalCost {
response?: EvalCostEntry;
embedding?: EvalCostEntry;
total_cost_usd: number;
}

export interface EvalJob {
id: number;
run_name: string;
Expand All @@ -130,6 +145,7 @@ export interface EvalJob {
assistant_id?: string;
organization_id: number;
project_id: number;
cost?: EvalCost | null;
inserted_at: string;
updated_at: string;
}
Expand Down
15 changes: 15 additions & 0 deletions app/components/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,21 @@ export const getStatusColor = (
}
};

/**
* Formats a USD cost value for display
* @param cost - Cost in USD
* @returns Formatted cost string (e.g., "$0.0013", "$1.25")
*/
export const formatCostUSD = (cost: number): string => {
if (!Number.isFinite(cost)) {
return "N/A";
}
if (cost < 0.01) {
return `$${cost.toFixed(4)}`;
}
return `$${cost.toFixed(2)}`;
};

/**
* Calculates dynamic thresholds for color coding based on score distribution
* @param scores - Array of similarity scores
Expand Down
Loading