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 */}