mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-04 16:02:13 +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 { ErrorState } from "./types";
|
||||||
import { CollapsibleAlert } from "./collapsible-alert";
|
import { CollapsibleAlert } from "./collapsible-alert";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { parsePartialJson } from "@langchain/core/output_parsers";
|
||||||
|
|
||||||
function MessageCopyButton({ content }: { content: string }) {
|
function MessageCopyButton({ content }: { content: string }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
@ -82,6 +83,23 @@ function extractResponseFromMessage(message: Message): string {
|
||||||
if (!isAIMessageSDK(message)) {
|
if (!isAIMessageSDK(message)) {
|
||||||
return getMessageContentString(message.content);
|
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 toolCall = message.tool_calls?.[0];
|
||||||
const response = toolCall?.args?.response;
|
const response = toolCall?.args?.response;
|
||||||
|
|
||||||
|
|
@ -91,6 +109,50 @@ function extractResponseFromMessage(message: Message): string {
|
||||||
return response;
|
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({
|
export function ManagerChat({
|
||||||
messages,
|
messages,
|
||||||
chatInput,
|
chatInput,
|
||||||
|
|
@ -153,9 +215,13 @@ export function ManagerChat({
|
||||||
<MessageCopyButton content={messageContentString} />
|
<MessageCopyButton content={messageContentString} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<BasicMarkdownText className="text-foreground overflow-x-hidden text-sm">
|
{messageContentString ? (
|
||||||
{messageContentString}
|
<BasicMarkdownText className="text-foreground overflow-x-hidden text-sm">
|
||||||
</BasicMarkdownText>
|
{messageContentString}
|
||||||
|
</BasicMarkdownText>
|
||||||
|
) : (
|
||||||
|
<LoadingMessageDots />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,25 @@ export function TerminalInput({
|
||||||
|
|
||||||
// set session storage so the stream can be resumed after redirect.
|
// set session storage so the stream can be resumed after redirect.
|
||||||
sessionStorage.setItem(`lg:stream:${newThreadId}`, run.run_id);
|
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}`);
|
push(`/chat/${newThreadId}`);
|
||||||
clearCurrentDraft();
|
clearCurrentDraft();
|
||||||
setMessage("");
|
setMessage("");
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,10 @@ import {
|
||||||
} from "@open-swe/shared/open-swe/types";
|
} 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 {
|
||||||
|
coerceMessageLikeToMessage,
|
||||||
|
HumanMessage,
|
||||||
|
} from "@langchain/core/messages";
|
||||||
import {
|
import {
|
||||||
DO_NOT_RENDER_ID_PREFIX,
|
DO_NOT_RENDER_ID_PREFIX,
|
||||||
PROGRAMMER_GRAPH_ID,
|
PROGRAMMER_GRAPH_ID,
|
||||||
|
|
@ -43,6 +46,8 @@ import {
|
||||||
} from "@open-swe/shared/open-swe/custom-node-events";
|
} from "@open-swe/shared/open-swe/custom-node-events";
|
||||||
import { StickToBottom } from "use-stick-to-bottom";
|
import { StickToBottom } from "use-stick-to-bottom";
|
||||||
import { TokenUsage } from "./token-usage";
|
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";
|
import { useUser } from "@/hooks/useUser";
|
||||||
|
|
||||||
interface ThreadViewProps {
|
interface ThreadViewProps {
|
||||||
|
|
@ -100,6 +105,8 @@ export function ThreadView({
|
||||||
useState<ManagerGraphState["programmerSession"]>();
|
useState<ManagerGraphState["programmerSession"]>();
|
||||||
const [isTaskSidebarOpen, setIsTaskSidebarOpen] = useState(false);
|
const [isTaskSidebarOpen, setIsTaskSidebarOpen] = useState(false);
|
||||||
const [programmerTaskPlan, setProgrammerTaskPlan] = useState<TaskPlan>();
|
const [programmerTaskPlan, setProgrammerTaskPlan] = useState<TaskPlan>();
|
||||||
|
const [optimisticMessage, setOptimisticMessage] =
|
||||||
|
useState<HumanMessageSDK | null>(null);
|
||||||
|
|
||||||
const { status: realTimeStatus, taskPlan: realTimeTaskPlan } =
|
const { status: realTimeStatus, taskPlan: realTimeTaskPlan } =
|
||||||
useThreadStatus(displayThread.id, {
|
useThreadStatus(displayThread.id, {
|
||||||
|
|
@ -108,6 +115,60 @@ export function ThreadView({
|
||||||
|
|
||||||
const [errorState, setErrorState] = useState<ErrorState | null>(null);
|
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<
|
const [customPlannerNodeEvents, setCustomPlannerNodeEvents] = useState<
|
||||||
CustomNodeEvent[]
|
CustomNodeEvent[]
|
||||||
>([]);
|
>([]);
|
||||||
|
|
@ -279,6 +340,14 @@ export function ThreadView({
|
||||||
return !message.id?.startsWith(DO_NOT_RENDER_ID_PREFIX);
|
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 (
|
return (
|
||||||
<div className="bg-background flex h-screen flex-1 flex-col">
|
<div className="bg-background flex h-screen flex-1 flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -320,7 +389,7 @@ export function ThreadView({
|
||||||
{/* Main Content - Split Layout */}
|
{/* Main Content - Split Layout */}
|
||||||
<div className="flex w-full pt-12">
|
<div className="flex w-full pt-12">
|
||||||
<ManagerChat
|
<ManagerChat
|
||||||
messages={filteredMessages}
|
messages={displayMessages}
|
||||||
chatInput={chatInput}
|
chatInput={chatInput}
|
||||||
setChatInput={setChatInput}
|
setChatInput={setChatInput}
|
||||||
handleSendMessage={handleSendMessage}
|
handleSendMessage={handleSendMessage}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue