mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-03 06:53:29 +00:00
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:
parent
4ed94d74d9
commit
81dbcbaa4d
3 changed files with 404 additions and 305 deletions
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue