Skip to content

Commit a8b33de

Browse files
committed
display cost in frontend
1 parent 30109e8 commit a8b33de

File tree

7 files changed

+192
-67
lines changed

7 files changed

+192
-67
lines changed

frontend/src/components/features/controls/controls.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
4040
<ConversationCard
4141
variant="compact"
4242
onDownloadWorkspace={handleDownloadWorkspace}
43+
onDisplayCost={() => {}}
4344
title={conversation?.title ?? ""}
4445
lastUpdatedAt={conversation?.created_at ?? ""}
4546
selectedRepository={conversation?.selected_repository ?? null}

frontend/src/components/features/conversation-panel/conversation-card-context-menu.tsx

+7
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ interface ConversationCardContextMenuProps {
88
onDelete?: (event: React.MouseEvent<HTMLButtonElement>) => void;
99
onEdit?: (event: React.MouseEvent<HTMLButtonElement>) => void;
1010
onDownload?: (event: React.MouseEvent<HTMLButtonElement>) => void;
11+
onDisplayCost?: (event: React.MouseEvent<HTMLButtonElement>) => void;
1112
position?: "top" | "bottom";
1213
}
1314

@@ -16,6 +17,7 @@ export function ConversationCardContextMenu({
1617
onDelete,
1718
onEdit,
1819
onDownload,
20+
onDisplayCost,
1921
position = "bottom",
2022
}: ConversationCardContextMenuProps) {
2123
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
@@ -45,6 +47,11 @@ export function ConversationCardContextMenu({
4547
Download Workspace
4648
</ContextMenuListItem>
4749
)}
50+
{onDisplayCost && (
51+
<ContextMenuListItem testId="display-cost-button" onClick={onDisplayCost}>
52+
Display Cost
53+
</ContextMenuListItem>
54+
)}
4855
</ContextMenu>
4956
);
5057
}

frontend/src/components/features/conversation-panel/conversation-card.tsx

+85-67
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ import {
88
import { EllipsisButton } from "./ellipsis-button";
99
import { ConversationCardContextMenu } from "./conversation-card-context-menu";
1010
import { cn } from "#/utils/utils";
11+
import { MetricsModal } from "../../shared/metrics-modal";
1112

1213
interface ConversationCardProps {
1314
onClick?: () => void;
1415
onDelete?: () => void;
1516
onChangeTitle?: (title: string) => void;
1617
onDownloadWorkspace?: () => void;
18+
onDisplayCost?: () => void;
1719
isActive?: boolean;
1820
title: string;
1921
selectedRepository: string | null;
@@ -27,6 +29,7 @@ export function ConversationCard({
2729
onDelete,
2830
onChangeTitle,
2931
onDownloadWorkspace,
32+
onDisplayCost,
3033
isActive,
3134
title,
3235
selectedRepository,
@@ -36,6 +39,7 @@ export function ConversationCard({
3639
}: ConversationCardProps) {
3740
const [contextMenuVisible, setContextMenuVisible] = React.useState(false);
3841
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
42+
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
3943
const inputRef = React.useRef<HTMLInputElement>(null);
4044

4145
const handleBlur = () => {
@@ -83,87 +87,101 @@ export function ConversationCard({
8387
onDownloadWorkspace?.();
8488
};
8589

90+
const handleDisplayCost = (event: React.MouseEvent<HTMLButtonElement>) => {
91+
event.stopPropagation();
92+
setMetricsModalVisible(true);
93+
setContextMenuVisible(false);
94+
};
95+
8696
React.useEffect(() => {
8797
if (titleMode === "edit") {
8898
inputRef.current?.focus();
8999
}
90100
}, [titleMode]);
91101

92-
const hasContextMenu = !!(onDelete || onChangeTitle || onDownloadWorkspace);
102+
const hasContextMenu = !!(onDelete || onChangeTitle || onDownloadWorkspace || onDisplayCost);
93103

94104
return (
95-
<div
96-
data-testid="conversation-card"
97-
onClick={onClick}
98-
className={cn(
99-
"h-[100px] w-full px-[18px] py-4 border-b border-neutral-600 cursor-pointer",
100-
variant === "compact" &&
101-
"h-auto w-fit rounded-xl border border-[#525252]",
102-
)}
103-
>
104-
<div className="flex items-center justify-between w-full">
105-
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden mr-2">
106-
{isActive && (
107-
<span className="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0" />
108-
)}
109-
{titleMode === "edit" && (
110-
<input
111-
ref={inputRef}
112-
data-testid="conversation-card-title"
113-
onClick={handleInputClick}
114-
onBlur={handleBlur}
115-
onKeyUp={handleKeyUp}
116-
type="text"
117-
defaultValue={title}
118-
className="text-sm leading-6 font-semibold bg-transparent w-full"
119-
/>
120-
)}
121-
{titleMode === "view" && (
122-
<p
123-
data-testid="conversation-card-title"
124-
className="text-sm leading-6 font-semibold bg-transparent truncate overflow-hidden"
125-
title={title}
126-
>
127-
{title}
128-
</p>
129-
)}
105+
<>
106+
<div
107+
data-testid="conversation-card"
108+
onClick={onClick}
109+
className={cn(
110+
"h-[100px] w-full px-[18px] py-4 border-b border-neutral-600 cursor-pointer",
111+
variant === "compact" &&
112+
"h-auto w-fit rounded-xl border border-[#525252]",
113+
)}
114+
>
115+
<div className="flex items-center justify-between w-full">
116+
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden mr-2">
117+
{isActive && (
118+
<span className="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0" />
119+
)}
120+
{titleMode === "edit" && (
121+
<input
122+
ref={inputRef}
123+
data-testid="conversation-card-title"
124+
onClick={handleInputClick}
125+
onBlur={handleBlur}
126+
onKeyUp={handleKeyUp}
127+
type="text"
128+
defaultValue={title}
129+
className="text-sm leading-6 font-semibold bg-transparent w-full"
130+
/>
131+
)}
132+
{titleMode === "view" && (
133+
<p
134+
data-testid="conversation-card-title"
135+
className="text-sm leading-6 font-semibold bg-transparent truncate overflow-hidden"
136+
title={title}
137+
>
138+
{title}
139+
</p>
140+
)}
141+
</div>
142+
143+
<div className="flex items-center gap-2 relative">
144+
<ConversationStateIndicator status={status} />
145+
{hasContextMenu && (
146+
<EllipsisButton
147+
onClick={(event) => {
148+
event.preventDefault();
149+
event.stopPropagation();
150+
setContextMenuVisible((prev) => !prev);
151+
}}
152+
/>
153+
)}
154+
{contextMenuVisible && (
155+
<ConversationCardContextMenu
156+
onClose={() => setContextMenuVisible(false)}
157+
onDelete={onDelete && handleDelete}
158+
onEdit={onChangeTitle && handleEdit}
159+
onDownload={onDownloadWorkspace && handleDownload}
160+
onDisplayCost={handleDisplayCost}
161+
position={variant === "compact" ? "top" : "bottom"}
162+
/>
163+
)}
164+
</div>
130165
</div>
131166

132-
<div className="flex items-center gap-2 relative">
133-
<ConversationStateIndicator status={status} />
134-
{hasContextMenu && (
135-
<EllipsisButton
136-
onClick={(event) => {
137-
event.preventDefault();
138-
event.stopPropagation();
139-
setContextMenuVisible((prev) => !prev);
140-
}}
141-
/>
167+
<div
168+
className={cn(
169+
variant === "compact" && "flex items-center justify-between mt-1",
142170
)}
143-
{contextMenuVisible && (
144-
<ConversationCardContextMenu
145-
onClose={() => setContextMenuVisible(false)}
146-
onDelete={onDelete && handleDelete}
147-
onEdit={onChangeTitle && handleEdit}
148-
onDownload={onDownloadWorkspace && handleDownload}
149-
position={variant === "compact" ? "top" : "bottom"}
150-
/>
171+
>
172+
{selectedRepository && (
173+
<ConversationRepoLink selectedRepository={selectedRepository} />
151174
)}
175+
<p className="text-xs text-neutral-400">
176+
<time>{formatTimeDelta(new Date(lastUpdatedAt))} ago</time>
177+
</p>
152178
</div>
153179
</div>
154180

155-
<div
156-
className={cn(
157-
variant === "compact" && "flex items-center justify-between mt-1",
158-
)}
159-
>
160-
{selectedRepository && (
161-
<ConversationRepoLink selectedRepository={selectedRepository} />
162-
)}
163-
<p className="text-xs text-neutral-400">
164-
<time>{formatTimeDelta(new Date(lastUpdatedAt))} ago</time>
165-
</p>
166-
</div>
167-
</div>
181+
<MetricsModal
182+
isOpen={metricsModalVisible}
183+
onClose={() => setMetricsModalVisible(false)}
184+
/>
185+
</>
168186
);
169187
}

frontend/src/components/features/conversation-panel/conversation-panel.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
105105
onChangeTitle={(title) =>
106106
handleChangeTitle(project.conversation_id, project.title, title)
107107
}
108+
onDisplayCost={() => {}}
108109
title={project.title}
109110
selectedRepository={project.selected_repository}
110111
lastUpdatedAt={project.last_updated_at}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import React from "react";
2+
import { ModalBackdrop } from "./modals/modal-backdrop";
3+
import { ModalBody } from "./modals/modal-body";
4+
import { ModalButton } from "./buttons/modal-button";
5+
6+
interface MetricsModalProps {
7+
isOpen: boolean;
8+
onClose: () => void;
9+
}
10+
11+
export function MetricsModal({ isOpen, onClose }: MetricsModalProps) {
12+
const [metrics, setMetrics] = React.useState<{
13+
cost: number | null;
14+
usage: {
15+
prompt_tokens: number;
16+
completion_tokens: number;
17+
total_tokens: number;
18+
} | null;
19+
}>({
20+
cost: null,
21+
usage: null
22+
});
23+
24+
React.useEffect(() => {
25+
function handleMessage(event: MessageEvent) {
26+
if (event.data?.type === 'metrics_update') {
27+
setMetrics(event.data.metrics);
28+
}
29+
}
30+
31+
window.addEventListener('message', handleMessage);
32+
return () => window.removeEventListener('message', handleMessage);
33+
}, []);
34+
35+
if (!isOpen) return null;
36+
37+
return (
38+
<ModalBackdrop onClose={onClose}>
39+
<ModalBody>
40+
<div className="flex flex-col gap-2 self-start w-full">
41+
<span className="text-xl leading-6 -tracking-[0.01em] font-semibold">
42+
Metrics Information
43+
</span>
44+
<div className="space-y-2">
45+
{metrics.cost !== null && (
46+
<p>Total Cost: ${metrics.cost.toFixed(4)}</p>
47+
)}
48+
{metrics.usage && (
49+
<>
50+
<p>Tokens Used:</p>
51+
<ul className="list-inside space-y-1 ml-2">
52+
<li>- Input: {metrics.usage.prompt_tokens}</li>
53+
<li>- Output: {metrics.usage.completion_tokens}</li>
54+
<li>- Total: {metrics.usage.total_tokens}</li>
55+
</ul>
56+
</>
57+
)}
58+
{!metrics.cost && !metrics.usage && (
59+
<p className="text-neutral-400">No metrics data available</p>
60+
)}
61+
</div>
62+
</div>
63+
<div className="flex justify-end w-full">
64+
<ModalButton
65+
onClick={onClose}
66+
text="Close"
67+
className="bg-neutral-700 hover:bg-neutral-600"
68+
/>
69+
</div>
70+
</ModalBody>
71+
</ModalBackdrop>
72+
);
73+
}

frontend/src/services/actions.ts

+9
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,15 @@ export function handleActionMessage(message: ActionMessage) {
8585
return;
8686
}
8787

88+
// Update metrics if available
89+
if (message.llm_metrics || message.tool_call_metadata?.model_response?.usage) {
90+
const metrics = {
91+
cost: message.llm_metrics?.accumulated_cost || null,
92+
usage: message.tool_call_metadata?.model_response?.usage || null
93+
};
94+
window.postMessage({ type: 'metrics_update', metrics }, '*');
95+
}
96+
8897
if (message.action === ActionType.RUN) {
8998
store.dispatch(appendInput(message.args.command));
9099
}

frontend/src/types/message.tsx

+16
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,22 @@ export interface ActionMessage {
1515

1616
// The timestamp of the message
1717
timestamp: string;
18+
19+
// LLM metrics information
20+
llm_metrics?: {
21+
accumulated_cost: number;
22+
};
23+
24+
// Tool call metadata
25+
tool_call_metadata?: {
26+
model_response?: {
27+
usage: {
28+
prompt_tokens: number;
29+
completion_tokens: number;
30+
total_tokens: number;
31+
};
32+
};
33+
};
1834
}
1935

2036
export interface ObservationMessage {

0 commit comments

Comments
 (0)