diff --git a/apps/web/src/components/v2/manager-chat.tsx b/apps/web/src/components/v2/manager-chat.tsx index 9993df67..833f8a92 100644 --- a/apps/web/src/components/v2/manager-chat.tsx +++ b/apps/web/src/components/v2/manager-chat.tsx @@ -13,6 +13,7 @@ import { BasicMarkdownText } from "../thread/markdown-text"; import { ErrorState } from "./types"; import { CollapsibleAlert } from "./collapsible-alert"; import { Loader2 } from "lucide-react"; +import { parsePartialJson } from "@langchain/core/output_parsers"; function MessageCopyButton({ content }: { content: string }) { const [copied, setCopied] = useState(false); @@ -82,6 +83,23 @@ function extractResponseFromMessage(message: Message): string { if (!isAIMessageSDK(message)) { return getMessageContentString(message.content); } + if ( + Array.isArray(message.content) && + ["input_json_delta", "tool_use"].includes( + message.content[0].type as string, + ) && + "input" in message.content[0] && + message.content[0].input + ) { + try { + const parsedJson = parsePartialJson(message.content[0].input as string); + if (parsedJson.response) { + return parsedJson.response; + } + } catch { + // no-op + } + } const toolCall = message.tool_calls?.[0]; const response = toolCall?.args?.response; @@ -91,6 +109,50 @@ function extractResponseFromMessage(message: Message): string { return response; } +function LoadingMessageDots() { + return ( +
+ +
+
+
+
+
+
+ ); +} + export function ManagerChat({ messages, chatInput, @@ -153,9 +215,13 @@ export function ManagerChat({
- - {messageContentString} - + {messageContentString ? ( + + {messageContentString} + + ) : ( + + )}
); diff --git a/apps/web/src/components/v2/terminal-input.tsx b/apps/web/src/components/v2/terminal-input.tsx index ec20077c..9e8cf3cf 100644 --- a/apps/web/src/components/v2/terminal-input.tsx +++ b/apps/web/src/components/v2/terminal-input.tsx @@ -110,6 +110,25 @@ export function TerminalInput({ // set session storage so the stream can be resumed after redirect. sessionStorage.setItem(`lg:stream:${newThreadId}`, run.run_id); + + // Store the initial message for optimistic rendering + try { + const initialMessageData = { + message: newHumanMessage, + timestamp: new Date().toISOString(), + }; + sessionStorage.setItem( + `lg:initial-message:${newThreadId}`, + JSON.stringify(initialMessageData), + ); + } catch (error) { + // If sessionStorage fails, continue without optimistic rendering + console.error( + "Failed to store initial message in sessionStorage:", + error, + ); + } + push(`/chat/${newThreadId}`); clearCurrentDraft(); setMessage(""); diff --git a/apps/web/src/components/v2/thread-view.tsx b/apps/web/src/components/v2/thread-view.tsx index 376222a0..920cc6a3 100644 --- a/apps/web/src/components/v2/thread-view.tsx +++ b/apps/web/src/components/v2/thread-view.tsx @@ -18,7 +18,10 @@ import { } from "@open-swe/shared/open-swe/types"; import { ActionsRenderer } from "./actions-renderer"; import { ThemeToggle } from "../theme-toggle"; -import { HumanMessage } from "@langchain/core/messages"; +import { + coerceMessageLikeToMessage, + HumanMessage, +} from "@langchain/core/messages"; import { DO_NOT_RENDER_ID_PREFIX, PROGRAMMER_GRAPH_ID, @@ -43,6 +46,8 @@ import { } from "@open-swe/shared/open-swe/custom-node-events"; import { StickToBottom } from "use-stick-to-bottom"; import { TokenUsage } from "./token-usage"; +import { HumanMessage as HumanMessageSDK } from "@langchain/langgraph-sdk"; +import { getMessageContentString } from "@open-swe/shared/messages"; import { useUser } from "@/hooks/useUser"; interface ThreadViewProps { @@ -100,6 +105,8 @@ export function ThreadView({ useState(); const [isTaskSidebarOpen, setIsTaskSidebarOpen] = useState(false); const [programmerTaskPlan, setProgrammerTaskPlan] = useState(); + const [optimisticMessage, setOptimisticMessage] = + useState(null); const { status: realTimeStatus, taskPlan: realTimeTaskPlan } = useThreadStatus(displayThread.id, { @@ -108,6 +115,60 @@ export function ThreadView({ const [errorState, setErrorState] = useState(null); + // Load optimistic message from sessionStorage + useEffect(() => { + try { + const storedData = sessionStorage.getItem( + `lg:initial-message:${displayThread.id}`, + ); + if (storedData) { + const { message: stringifiedMessage } = JSON.parse(storedData); + const message = coerceMessageLikeToMessage(stringifiedMessage); + const reconstructedMessage: HumanMessageSDK = { + type: "human", + id: message.id, + content: getMessageContentString(message.content), + }; + setOptimisticMessage(reconstructedMessage); + } + } catch (error) { + console.error( + "Failed to load optimistic message from sessionStorage:", + error, + ); + } + }, [displayThread.id, stream.messages.length]); + + // If there's more than 1 message, we've received both the human and ai message, so we can remove the optimistic message + useEffect(() => { + if (stream.messages.length > 1 && optimisticMessage) { + setOptimisticMessage(null); + if (displayThread.id) { + try { + sessionStorage.removeItem(`lg:initial-message:${displayThread.id}`); + } catch (error) { + console.error( + "Failed to remove optimistic message from sessionStorage:", + error, + ); + } + } + } + }, [stream.messages, optimisticMessage, displayThread.id]); + + // Clean up sessionStorage on unmount + useEffect(() => { + return () => { + if (displayThread.id) { + try { + sessionStorage.removeItem(`lg:initial-message:${displayThread.id}`); + } catch { + // no-op + } + } + }; + }, [displayThread.id]); + const [customPlannerNodeEvents, setCustomPlannerNodeEvents] = useState< CustomNodeEvent[] >([]); @@ -279,6 +340,14 @@ export function ThreadView({ return !message.id?.startsWith(DO_NOT_RENDER_ID_PREFIX); }); + // Merge optimistic message with stream messages + const displayMessages = optimisticMessage + ? [ + optimisticMessage, + ...filteredMessages.filter((msg) => msg.id !== optimisticMessage.id), + ] + : filteredMessages; + return (
{/* Header */} @@ -320,7 +389,7 @@ export function ThreadView({ {/* Main Content - Split Layout */}