mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 17:23:15 +00:00
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:
parent
547c8adeb0
commit
98b62bdbc0
3 changed files with 159 additions and 5 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue