feat: Implement optimistic message rendering for initial user messages (#648)

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

* Apply patch [skip ci]

* Empty commit to trigger CI

* cr

* cr

* cr

* cr

* cr

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Brace Sproul <braceasproul@gmail.com>
This commit is contained in:
open-swe[bot] 2025-08-03 19:29:33 +00:00 • committed by GitHub
parent 547c8adeb0
commit 98b62bdbc0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 159 additions and 5 deletions

View file

@ -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 (
<div className="text-foreground flex items-center space-x-1 overflow-x-hidden text-sm">
<style jsx>{`
@keyframes dotBounce {
0%,
80%,
100% {
transform: scale(0.8);
opacity: 0.5;
}
40% {
transform: scale(1.2);
opacity: 1;
}
}
.dot-bounce {
animation: dotBounce 1.4s infinite ease-in-out;
}
`}</style>
<div className="flex space-x-1">
<div
className="dot-bounce h-1 w-1 rounded-full bg-current"
style={{
animationDelay: "0ms",
}}
/>
<div
className="dot-bounce h-1 w-1 rounded-full bg-current"
style={{
animationDelay: "200ms",
}}
/>
<div
className="dot-bounce h-1 w-1 rounded-full bg-current"
style={{
animationDelay: "400ms",
}}
/>
</div>
</div>
);
}
export function ManagerChat({
messages,
chatInput,
@ -153,9 +215,13 @@ export function ManagerChat({
<MessageCopyButton content={messageContentString} />
</div>
</div>
<BasicMarkdownText className="text-foreground overflow-x-hidden text-sm">
{messageContentString}
</BasicMarkdownText>
{messageContentString ? (
<BasicMarkdownText className="text-foreground overflow-x-hidden text-sm">
{messageContentString}
</BasicMarkdownText>
) : (
<LoadingMessageDots />
)}
</div>
</div>
);

View file

@ -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("");

View file

@ -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<ManagerGraphState["programmerSession"]>();
const [isTaskSidebarOpen, setIsTaskSidebarOpen] = useState(false);
const [programmerTaskPlan, setProgrammerTaskPlan] = useState<TaskPlan>();
const [optimisticMessage, setOptimisticMessage] =
useState<HumanMessageSDK | null>(null);
const { status: realTimeStatus, taskPlan: realTimeTaskPlan } =
useThreadStatus(displayThread.id, {
@ -108,6 +115,60 @@ export function ThreadView({
const [errorState, setErrorState] = useState<ErrorState | null>(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 (
<div className="bg-background flex h-screen flex-1 flex-col">
{/* Header */}
@ -320,7 +389,7 @@ export function ThreadView({
{/* Main Content - Split Layout */}
<div className="flex w-full pt-12">
<ManagerChat
messages={filteredMessages}
messages={displayMessages}
chatInput={chatInput}
setChatInput={setChatInput}
handleSendMessage={handleSendMessage}