fix: Move useStream uo to thread view to prevent remounting (#477)

* fix: Move useStream uo to thread view to prevent remounting

* ui fixes
This commit is contained in:
Brace Sproul 2025-07-22 14:14:48 -07:00 • committed by GitHub
parent 4ed94d74d9
commit 81dbcbaa4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 404 additions and 305 deletions

View file

@ -1,27 +1,14 @@
import { isAIMessageSDK, isHumanMessageSDK } from "@/lib/langchain-messages"; import { isAIMessageSDK, isHumanMessageSDK } from "@/lib/langchain-messages";
import { UseStream, useStream } from "@langchain/langgraph-sdk/react"; import { UseStream, useStream } from "@langchain/langgraph-sdk/react";
import { AssistantMessage } from "../thread/messages/ai"; import { AssistantMessage } from "../thread/messages/ai";
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react";
import { import {
Dispatch,
SetStateAction,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { ManagerGraphState } from "@open-swe/shared/open-swe/manager/types";
import { useCancelStream } from "@/hooks/useCancelStream";
import {
isCustomNodeEvent,
CustomNodeEvent, CustomNodeEvent,
INITIALIZE_NODE_ID, INITIALIZE_NODE_ID,
ACCEPTED_PLAN_NODE_ID, ACCEPTED_PLAN_NODE_ID,
mapCustomEventsToSteps, mapCustomEventsToSteps,
} from "@open-swe/shared/open-swe/custom-node-events"; } from "@open-swe/shared/open-swe/custom-node-events";
import { import { DO_NOT_RENDER_ID_PREFIX } from "@open-swe/shared/constants";
DO_NOT_RENDER_ID_PREFIX,
PLANNER_GRAPH_ID,
} from "@open-swe/shared/constants";
import { Message } from "@langchain/langgraph-sdk"; import { Message } from "@langchain/langgraph-sdk";
import { InitializeStep } from "../gen-ui/initialize-step"; import { InitializeStep } from "../gen-ui/initialize-step";
import { AcceptedPlanStep } from "../gen-ui/accepted-plan-step"; import { AcceptedPlanStep } from "../gen-ui/accepted-plan-step";
@ -33,7 +20,6 @@ import { Interrupt } from "../thread/messages/interrupt";
import { AlertCircle } from "lucide-react"; import { AlertCircle } from "lucide-react";
import { ErrorState } from "./types"; import { ErrorState } from "./types";
import { CollapsibleAlert } from "./collapsible-alert"; import { CollapsibleAlert } from "./collapsible-alert";
import { TokenUsage } from "./token-usage";
interface AcceptedPlanEventData { interface AcceptedPlanEventData {
planTitle: string; planTitle: string;
@ -72,16 +58,13 @@ function isAcceptedPlanEvents(
return events.every(isAcceptedPlanEvent); return events.every(isAcceptedPlanEvent);
} }
interface ActionsRendererProps { interface ActionsRendererProps<
graphId: string; StateType extends PlannerGraphState | GraphState,
threadId: string; > {
runId?: string; runId?: string;
setProgrammerSession?: ( customNodeEvents: CustomNodeEvent[];
session: ManagerGraphState["programmerSession"], setCustomNodeEvents: Dispatch<SetStateAction<CustomNodeEvent[]>>;
) => void; stream: ReturnType<typeof useStream<StateType>>;
programmerSession?: ManagerGraphState["programmerSession"];
setSelectedTab?: Dispatch<SetStateAction<"planner" | "programmer">>;
onStreamReady: (cancelFn: (() => void) | undefined) => void;
} }
const getCustomNodeEventsFromMessages = ( const getCustomNodeEventsFromMessages = (
@ -106,65 +89,17 @@ const getCustomNodeEventsFromMessages = (
.flat(); .flat();
}; };
export function ActionsRenderer<State extends PlannerGraphState | GraphState>({ export function ActionsRenderer<
graphId, StateType extends PlannerGraphState | GraphState,
threadId, >({
runId, runId,
setProgrammerSession, customNodeEvents,
programmerSession, setCustomNodeEvents,
setSelectedTab, stream,
onStreamReady, }: ActionsRendererProps<StateType>) {
}: ActionsRendererProps) { const [streamLoading, setStreamLoading] = useState(stream.isLoading);
const [customNodeEvents, setCustomNodeEvents] = useState<CustomNodeEvent[]>(
[],
);
const joinedRunId = useRef<string | undefined>(undefined);
const [streamLoading, setStreamLoading] = useState(false);
const [errorState, setErrorState] = useState<ErrorState | null>(null); const [errorState, setErrorState] = useState<ErrorState | null>(null);
const stream = useStream<State>({
apiUrl: process.env.NEXT_PUBLIC_API_URL,
assistantId: graphId,
reconnectOnMount: true,
threadId,
onCustomEvent: (event) => {
if (isCustomNodeEvent(event)) {
setCustomNodeEvents((prev) => [...prev, event]);
}
},
fetchStateHistory: false,
});
useEffect(() => {
if (stream.error) {
const rawErrorMessage =
typeof stream.error === "object" && "message" in stream.error
? (stream.error.message as string)
: "An unknown error occurred in the manager";
if (rawErrorMessage.includes("overloaded_error")) {
setErrorState({
message:
"An Anthropic overloaded error occurred. This error occurs when Anthropic APIs experience high traffic across all users.",
details: rawErrorMessage,
});
} else {
setErrorState({
message: rawErrorMessage,
});
}
} else {
setErrorState(null);
}
}, [stream.error]);
const { cancelRun } = useCancelStream<State>({
stream,
threadId,
runId,
streamName: graphId === "planner" ? "Planner" : "Programmer",
});
const initializeEvents = useMemo( const initializeEvents = useMemo(
() => () =>
customNodeEvents.filter( customNodeEvents.filter(
@ -190,6 +125,20 @@ export function ActionsRenderer<State extends PlannerGraphState | GraphState>({
initStatus = "done"; initStatus = "done";
} }
// Filter out human & do not render messages
const filteredMessages = stream.messages?.filter(
(m) =>
!isHumanMessageSDK(m) &&
!(m.id && m.id.startsWith(DO_NOT_RENDER_ID_PREFIX)),
);
const isLastMessageHidden = !!(
stream.messages?.length > 0 &&
stream.messages[stream.messages.length - 1].id &&
stream.messages[stream.messages.length - 1].id?.startsWith(
DO_NOT_RENDER_ID_PREFIX,
)
);
useEffect(() => { useEffect(() => {
const allCustomEvents = getCustomNodeEventsFromMessages(stream.messages); const allCustomEvents = getCustomNodeEventsFromMessages(stream.messages);
if (!allCustomEvents?.length) { if (!allCustomEvents?.length) {
@ -219,79 +168,42 @@ export function ActionsRenderer<State extends PlannerGraphState | GraphState>({
// Clear streamLoading as soon as we get any content (agent has started running) // Clear streamLoading as soon as we get any content (agent has started running)
useEffect(() => { useEffect(() => {
const hasContent = const hasContent =
(stream.messages && stream.messages.length > 0) || filteredMessages.length > 0 || customNodeEvents.length > 0;
customNodeEvents.length > 0;
if (hasContent && streamLoading) { if (hasContent && streamLoading) {
setStreamLoading(false); setStreamLoading(false);
} }
}, [stream.messages, customNodeEvents, streamLoading]); }, [stream.messages, customNodeEvents, streamLoading]);
// TODO: If the SDK changes go in, use this instead:
// stream.joinStream(runId, undefined, { streamMode: ["values", "messages", "custom"]}).catch(console.error);
useEffect(() => { useEffect(() => {
if (runId && runId !== joinedRunId.current) { if (stream.error) {
joinedRunId.current = runId; const rawErrorMessage =
setStreamLoading(true); typeof stream.error === "object" && "message" in stream.error
stream ? (stream.error.message as string)
.joinStream(runId) : "An unknown error occurred in the manager";
.catch(console.error)
.finally(() => setStreamLoading(false));
} else if (!runId) {
joinedRunId.current = undefined;
}
}, [runId, stream]);
useEffect(() => { if (rawErrorMessage.includes("overloaded_error")) {
if (stream.isLoading) { setErrorState({
onStreamReady(cancelRun); message:
} else { "An Anthropic overloaded error occurred. This error occurs when Anthropic APIs experience high traffic across all users.",
onStreamReady(undefined); details: rawErrorMessage,
} });
}, [onStreamReady, runId]); // Depend on runId instead of cancelRun to avoid infinite loops } else {
setErrorState({
// Filter out human & do not render messages message: rawErrorMessage,
const filteredMessages = stream.messages?.filter( });
(m) =>
!isHumanMessageSDK(m) &&
!(m.id && m.id.startsWith(DO_NOT_RENDER_ID_PREFIX)),
);
const isLastMessageHidden = !!(
stream.messages?.length > 0 &&
stream.messages[stream.messages.length - 1].id &&
stream.messages[stream.messages.length - 1].id?.startsWith(
DO_NOT_RENDER_ID_PREFIX,
)
);
// TODO: Need a better way to handle this. Not great like this...
useEffect(() => {
if (
"programmerSession" in stream.values &&
stream.values.programmerSession &&
(stream.values.programmerSession.runId !== programmerSession?.runId ||
stream.values.programmerSession.threadId !==
programmerSession?.threadId)
) {
setProgrammerSession?.(stream.values.programmerSession);
// Only switch tabs from the planner ActionsRenderer to ensure proper timing
// This allows the accepted plan step to be visible before switching
if (graphId === PLANNER_GRAPH_ID) {
// Add a small delay to allow the accepted plan step to render first
setTimeout(() => {
setSelectedTab?.("programmer");
}, 2000);
} }
} else {
setErrorState(null);
} }
}, [stream.values, graphId]); }, [stream.error]);
if (streamLoading) { if (streamLoading && !errorState) {
return <LoadingActionsCardContent />; return <LoadingActionsCardContent />;
} }
return ( return (
<div className="flex w-full flex-col gap-2"> <div className="flex h-full w-full flex-col gap-2 overflow-y-auto py-4">
{initializeEvents.length > 0 && steps.length > 0 && ( {initializeEvents.length > 0 && steps.length > 0 && (
<InitializeStep <InitializeStep
status={initStatus} status={initStatus}
@ -339,7 +251,6 @@ export function ActionsRenderer<State extends PlannerGraphState | GraphState>({
icon={<AlertCircle className="size-4" />} icon={<AlertCircle className="size-4" />}
/> />
) : null} ) : null}
<TokenUsage tokenData={stream.values.tokenData} />
</div> </div>
); );
} }

View file

@ -11,7 +11,7 @@ import { ThreadMetadata } from "./types";
import { useStream } from "@langchain/langgraph-sdk/react"; import { useStream } from "@langchain/langgraph-sdk/react";
import { ManagerGraphState } from "@open-swe/shared/open-swe/manager/types"; import { ManagerGraphState } from "@open-swe/shared/open-swe/manager/types";
import { PlannerGraphState } from "@open-swe/shared/open-swe/planner/types"; import { PlannerGraphState } from "@open-swe/shared/open-swe/planner/types";
import { GraphState } from "@open-swe/shared/open-swe/types"; import { GraphState, CacheMetrics } from "@open-swe/shared/open-swe/types";
import { ActionsRenderer } from "./actions-renderer"; import { ActionsRenderer } from "./actions-renderer";
import { ThemeToggle } from "../theme-toggle"; import { ThemeToggle } from "../theme-toggle";
import { HumanMessage } from "@langchain/core/messages"; import { HumanMessage } from "@langchain/core/messages";
@ -23,7 +23,6 @@ import {
import { useThreadStatus } from "@/hooks/useThreadStatus"; import { useThreadStatus } from "@/hooks/useThreadStatus";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { StickToBottom } from "use-stick-to-bottom";
import { import {
StickyToBottomContent, StickyToBottomContent,
ScrollToBottom, ScrollToBottom,
@ -31,6 +30,12 @@ import {
import { ManagerChat } from "./manager-chat"; import { ManagerChat } from "./manager-chat";
import { CancelStreamButton } from "./cancel-stream-button"; import { CancelStreamButton } from "./cancel-stream-button";
import { ErrorState } from "./types"; import { ErrorState } from "./types";
import {
CustomNodeEvent,
isCustomNodeEvent,
} from "@open-swe/shared/open-swe/custom-node-events";
import { StickToBottom } from "use-stick-to-bottom";
import { TokenUsage } from "./token-usage";
interface ThreadViewProps { interface ThreadViewProps {
stream: ReturnType<typeof useStream<ManagerGraphState>>; stream: ReturnType<typeof useStream<ManagerGraphState>>;
@ -55,6 +60,65 @@ export function ThreadView({
useState<ManagerGraphState["programmerSession"]>(); useState<ManagerGraphState["programmerSession"]>();
const [errorState, setErrorState] = useState<ErrorState | null>(null); const [errorState, setErrorState] = useState<ErrorState | null>(null);
const [customPlannerNodeEvents, setCustomPlannerNodeEvents] = useState<
CustomNodeEvent[]
>([]);
const [customProgrammerNodeEvents, setCustomProgrammerNodeEvents] = useState<
CustomNodeEvent[]
>([]);
const plannerStream = useStream<PlannerGraphState>({
apiUrl: process.env.NEXT_PUBLIC_API_URL,
assistantId: PLANNER_GRAPH_ID,
reconnectOnMount: true,
threadId: plannerSession?.threadId,
onCustomEvent: (event) => {
if (isCustomNodeEvent(event)) {
setCustomPlannerNodeEvents((prev) => [...prev, event]);
}
},
fetchStateHistory: false,
});
const joinedPlannerRunId = useRef<string | undefined>(undefined);
useEffect(() => {
if (
plannerSession?.runId &&
plannerSession.runId !== joinedPlannerRunId.current
) {
joinedPlannerRunId.current = plannerSession.runId;
plannerStream.joinStream(plannerSession.runId).catch(console.error);
} else if (!plannerSession?.runId) {
joinedPlannerRunId.current = undefined;
}
}, [plannerSession]);
const programmerStream = useStream<GraphState>({
apiUrl: process.env.NEXT_PUBLIC_API_URL,
assistantId: PROGRAMMER_GRAPH_ID,
reconnectOnMount: true,
threadId: programmerSession?.threadId,
onCustomEvent: (event) => {
if (isCustomNodeEvent(event)) {
setCustomProgrammerNodeEvents((prev) => [...prev, event]);
}
},
fetchStateHistory: false,
});
const joinedProgrammerRunId = useRef<string | undefined>(undefined);
useEffect(() => {
if (
programmerSession?.runId &&
programmerSession.runId !== joinedProgrammerRunId.current
) {
joinedProgrammerRunId.current = programmerSession.runId;
plannerStream.joinStream(programmerSession.runId).catch(console.error);
} else if (!programmerSession?.runId) {
joinedProgrammerRunId.current = undefined;
}
}, [programmerSession]);
useEffect(() => { useEffect(() => {
if ( if (
stream?.values?.plannerSession && stream?.values?.plannerSession &&
@ -94,6 +158,27 @@ export function ThreadView({
} }
}, [stream.error]); }, [stream.error]);
useEffect(() => {
if (
plannerStream.values.programmerSession &&
(plannerStream.values.programmerSession.runId !==
programmerSession?.runId ||
plannerStream.values.programmerSession.threadId !==
programmerSession?.threadId)
) {
setProgrammerSession?.(plannerStream.values.programmerSession);
// Only switch tabs from the planner ActionsRenderer to ensure proper timing
// This allows the accepted plan step to be visible before switching
if (selectedTab === PLANNER_GRAPH_ID) {
// Add a small delay to allow the accepted plan step to render first
setTimeout(() => {
setSelectedTab?.("programmer");
}, 2000);
}
}
}, [plannerStream.values, selectedTab]);
const { status: realTimeStatus } = useThreadStatus(displayThread.id); const { status: realTimeStatus } = useThreadStatus(displayThread.id);
const getStatusDotColor = (status: string) => { const getStatusDotColor = (status: string) => {
@ -111,9 +196,6 @@ export function ThreadView({
} }
}; };
const plannerCancelRef = useRef<(() => void) | null>(null);
const programmerCancelRef = useRef<(() => void) | null>(null);
const cancelRun = () => { const cancelRun = () => {
// TODO: ideally this calls stream.client.runs.cancel(threadId, runId) // TODO: ideally this calls stream.client.runs.cancel(threadId, runId)
stream.stop(); stream.stop();
@ -187,7 +269,7 @@ export function ThreadView({
</div> </div>
{/* Main Content - Split Layout */} {/* Main Content - Split Layout */}
<div className="flex h-full w-full pt-12"> <div className="flex w-full pt-12">
<ManagerChat <ManagerChat
messages={filteredMessages} messages={filteredMessages}
chatInput={chatInput} chatInput={chatInput}
@ -198,121 +280,146 @@ export function ThreadView({
errorState={errorState} errorState={errorState}
/> />
{/* Right Side - Actions & Plan */} {/* Right Side - Actions & Plan */}
<div className="flex h-full flex-1 flex-col"> <div
<div className="relative flex-1"> className="flex flex-1 flex-col px-4 pt-4"
<StickToBottom style={{ height: "calc(100vh - 3rem)" }}
className="absolute inset-0" >
initial={true} <div className="min-h-0 flex-1">
<Tabs
defaultValue="planner"
className="flex h-full w-full flex-col"
value={selectedTab}
onValueChange={(value) =>
setSelectedTab(value as "planner" | "programmer")
}
> >
<StickyToBottomContent <div className="flex flex-shrink-0 items-center justify-between">
className="h-full overflow-y-auto" <TabsList className="bg-muted/70 dark:bg-gray-800">
contentClassName="space-y-4 p-4" <TabsTrigger value="planner">Planner</TabsTrigger>
content={ <TabsTrigger value="programmer">Programmer</TabsTrigger>
<Tabs </TabsList>
defaultValue="planner"
className="w-full" <div className="flex items-center justify-center gap-2">
value={selectedTab} {selectedTab === "planner" && plannerStream.isLoading && (
onValueChange={(value) => <CancelStreamButton
setSelectedTab(value as "planner" | "programmer") stream={plannerStream}
threadId={plannerSession?.threadId}
runId={plannerSession?.runId}
streamName="Planner"
/>
)}
{selectedTab === "programmer" &&
programmerStream.isLoading && (
<CancelStreamButton
stream={programmerStream}
threadId={programmerSession?.threadId}
runId={programmerSession?.runId}
streamName="Programmer"
/>
)}
<TokenUsage
tokenData={
[
plannerStream.values.tokenData,
programmerStream.values.tokenData,
].filter(Boolean) as CacheMetrics[]
} }
> />
<div className="flex items-center justify-between"> </div>
<TabsList className="bg-muted/70 dark:bg-gray-800"> </div>
<TabsTrigger value="planner">Planner</TabsTrigger>
<TabsTrigger value="programmer">Programmer</TabsTrigger>
</TabsList>
<div className="flex gap-2"> <TabsContent
{selectedTab === "planner" && value="planner"
plannerCancelRef.current && ( className="mb-10"
<CancelStreamButton >
stream={stream} <Card className="border-border bg-card relative h-full p-0 dark:bg-gray-950">
threadId={plannerSession?.threadId} <CardContent className="h-full p-0">
runId={plannerSession?.runId} <StickToBottom
streamName="Planner" className="absolute inset-0 h-full"
/> initial={true}
)} >
<StickyToBottomContent
{selectedTab === "programmer" && className="scrollbar-pretty-auto h-full"
programmerCancelRef.current && ( content={
<CancelStreamButton <>
stream={stream} {plannerSession ? (
threadId={programmerSession?.threadId} <div className="scrollbar-pretty-auto overflow-y-auto px-2">
runId={programmerSession?.runId} <ActionsRenderer<PlannerGraphState>
streamName="Programmer" runId={plannerSession.runId}
/> customNodeEvents={customPlannerNodeEvents}
)} setCustomNodeEvents={
</div> setCustomPlannerNodeEvents
</div> }
stream={plannerStream}
<TabsContent value="planner"> />
<Card className="border-border bg-card px-0 py-4 dark:bg-gray-950"> </div>
<CardContent className="space-y-2 p-3 pt-0"> ) : (
{plannerSession && ( <div className="flex items-center justify-center gap-2 py-8">
<ActionsRenderer<PlannerGraphState> <Clock className="text-muted-foreground size-4" />
graphId={PLANNER_GRAPH_ID} <span className="text-muted-foreground text-sm">
threadId={plannerSession.threadId} No planner session
runId={plannerSession.runId} </span>
setProgrammerSession={setProgrammerSession} </div>
programmerSession={programmerSession} )}
setSelectedTab={setSelectedTab} </>
onStreamReady={(cancelFn) => { }
if (cancelFn) { footer={
plannerCancelRef.current = cancelFn; <div className="absolute right-0 bottom-4 left-0 flex w-full justify-center">
} else { <ScrollToBottom className="animate-in fade-in-0 zoom-in-95" />
plannerCancelRef.current = null; </div>
} }
}} />
/> </StickToBottom>
)} </CardContent>
{!plannerSession && ( </Card>
<div className="flex items-center justify-center gap-2 py-8"> </TabsContent>
<Clock className="text-muted-foreground size-4" /> <TabsContent
<span className="text-muted-foreground text-sm"> value="programmer"
No planner session className="mb-10"
</span> >
</div> <Card className="border-border bg-card relative h-full p-0 dark:bg-gray-950">
)} <CardContent className="h-full p-0">
</CardContent> <StickToBottom
</Card> className="absolute inset-0 h-full"
</TabsContent> initial={true}
<TabsContent value="programmer"> >
<Card className="border-border bg-card px-0 py-4 dark:bg-gray-950"> <StickyToBottomContent
<CardContent className="space-y-2 p-3 pt-0"> className="scrollbar-pretty-auto h-full"
{programmerSession && ( content={
<ActionsRenderer<GraphState> <>
graphId={PROGRAMMER_GRAPH_ID} {programmerSession ? (
threadId={programmerSession.threadId} <div className="scrollbar-pretty-auto overflow-y-auto px-2">
runId={programmerSession.runId} <ActionsRenderer<GraphState>
onStreamReady={(cancelFn) => { runId={programmerSession.runId}
if (cancelFn) { customNodeEvents={customProgrammerNodeEvents}
programmerCancelRef.current = cancelFn; setCustomNodeEvents={
} else { setCustomProgrammerNodeEvents
programmerCancelRef.current = null; }
} stream={programmerStream}
}} />
/> </div>
)} ) : (
{!programmerSession && ( <div className="flex items-center justify-center gap-2 py-8">
<div className="flex items-center justify-center gap-2 py-8"> <Clock className="text-muted-foreground size-4" />
<Terminal className="text-muted-foreground size-4" /> <span className="text-muted-foreground text-sm">
<span className="text-muted-foreground text-sm"> No programmer session
No programmer session </span>
</span> </div>
</div> )}
)} </>
</CardContent> }
</Card> footer={
</TabsContent> <div className="absolute right-0 bottom-4 left-0 flex w-full justify-center">
</Tabs> <ScrollToBottom className="animate-in fade-in-0 zoom-in-95" />
} </div>
footer={ }
<div className="absolute bottom-4 left-1/2 -translate-x-1/2"> />
<ScrollToBottom className="animate-in fade-in-0 zoom-in-95" /> </StickToBottom>
</div> </CardContent>
} </Card>
/> </TabsContent>
</StickToBottom> </Tabs>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,54 +1,135 @@
import { CacheMetrics } from "@open-swe/shared/open-swe/types"; import { CacheMetrics } from "@open-swe/shared/open-swe/types";
import { calculateCostSavings } from "@open-swe/shared/caching"; import { calculateCostSavings } from "@open-swe/shared/caching";
import { Badge } from "../ui/badge";
import { Separator } from "../ui/separator";
import { import {
Tooltip, HoverCard,
TooltipContent, HoverCardContent,
TooltipProvider, HoverCardTrigger,
TooltipTrigger, } from "../ui/hover-card";
} from "../ui/tooltip"; import {
import { ChartNoAxesColumnIncreasing } from "lucide-react"; ChartNoAxesColumnIncreasing,
Coins,
TrendingUp,
Zap,
} from "lucide-react";
export function TokenUsage({ tokenData }: { tokenData?: CacheMetrics }) { interface TokenUsageProps {
if (!tokenData) return null; tokenData?: CacheMetrics[];
}
const metrics = calculateCostSavings(tokenData); function mergeTokenData(tokenDataArray: CacheMetrics[]): CacheMetrics {
return ( return tokenDataArray.reduce(
<div className="mt-4 ml-auto flex"> (merged, current) => ({
<TooltipProvider> cacheCreationInputTokens:
<Tooltip> merged.cacheCreationInputTokens + current.cacheCreationInputTokens,
<TooltipTrigger> cacheReadInputTokens:
<ChartNoAxesColumnIncreasing /> merged.cacheReadInputTokens + current.cacheReadInputTokens,
</TooltipTrigger> inputTokens: merged.inputTokens + current.inputTokens,
<TooltipContent className="flex w-full flex-col gap-1 text-sm"> outputTokens: merged.outputTokens + current.outputTokens,
<p>Token usage data on actions where caching is enabled:</p> }),
<span className="flex w-full items-center justify-between"> {
<p>Input Tokens:</p> cacheCreationInputTokens: 0,
<p>{metrics.totalInputTokens.toLocaleString()}</p> cacheReadInputTokens: 0,
</span> inputTokens: 0,
<span className="flex w-full items-center justify-between"> outputTokens: 0,
<p>Output Tokens:</p> },
<p>{metrics.totalOutputTokens.toLocaleString()}</p> );
</span> }
<span className="flex w-full items-center justify-between">
<p>Total Tokens:</p> export function TokenUsage({ tokenData }: TokenUsageProps) {
<p>{metrics.totalTokens.toLocaleString()}</p> if (!tokenData || tokenData.length === 0) return null;
</span>
const mergedTokenData = mergeTokenData(tokenData);
<span className="flex w-full items-center justify-between"> const metrics = calculateCostSavings(mergedTokenData);
<p>Output Tokens Cost:</p>
<p>${metrics.totalOutputTokensCost.toFixed(2)}</p> return (
</span> <HoverCard>
<span className="flex w-full items-center justify-between"> <HoverCardTrigger asChild>
<p>Cache Savings:</p> <button className="hover:bg-muted/50 ml-auto flex items-center gap-2 rounded-md p-2 transition-colors">
<p>${metrics.totalSavings.toFixed(2)}</p> <ChartNoAxesColumnIncreasing className="h-4 w-4" />
</span> <Badge
<span className="flex w-full items-center justify-between"> variant="secondary"
<p>Total Cost:</p> className="text-xs"
<p>${metrics.totalCost.toFixed(2)}</p> >
</span> {tokenData.length} agent{tokenData.length !== 1 ? "s" : ""}
</TooltipContent> </Badge>
</Tooltip> </button>
</TooltipProvider> </HoverCardTrigger>
</div> <HoverCardContent className="w-80">
<div className="space-y-4">
<div className="flex items-center gap-2">
<ChartNoAxesColumnIncreasing className="h-4 w-4" />
<h4 className="text-sm font-semibold">Token Usage</h4>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<div className="flex items-center gap-1.5">
<Zap className="h-3 w-3 text-blue-500" />
<span className="text-muted-foreground text-xs font-medium">
Input
</span>
</div>
<p className="text-sm font-semibold">
{metrics.totalInputTokens.toLocaleString()}
</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-1.5">
<TrendingUp className="h-3 w-3 text-green-500" />
<span className="text-muted-foreground text-xs font-medium">
Output
</span>
</div>
<p className="text-sm font-semibold">
{metrics.totalOutputTokens.toLocaleString()}
</p>
</div>
</div>
<Separator />
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs font-medium">
Total Tokens
</span>
<span className="text-sm font-semibold">
{metrics.totalTokens.toLocaleString()}
</span>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Coins className="h-3 w-3 text-amber-500" />
<span className="text-muted-foreground text-xs font-medium">
Cost
</span>
</div>
<span className="text-sm font-semibold">
${metrics.totalCost.toFixed(2)}
</span>
</div>
{metrics.totalSavings > 0 && (
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-green-600">
Cache Savings
</span>
<Badge
variant="outline"
className="border-green-200 text-green-600"
>
-${metrics.totalSavings.toFixed(2)}
</Badge>
</div>
)}
</div>
</div>
</div>
</HoverCardContent>
</HoverCard>
); );
} }