mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-05 15:22:11 +00:00
fix: Refactor manager chat into its own component (#322)
This commit is contained in:
parent
9d7a74a976
commit
b88db80b26
2 changed files with 168 additions and 168 deletions
157
apps/web/src/components/v2/manager-chat.tsx
Normal file
157
apps/web/src/components/v2/manager-chat.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import { ScrollToBottom, StickyToBottomContent } from "@/utils/scroll-utils";
|
||||||
|
import { Message } from "@langchain/langgraph-sdk";
|
||||||
|
import { getMessageContentString } from "@open-swe/shared/messages";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { StickToBottom } from "use-stick-to-bottom";
|
||||||
|
import { TooltipIconButton } from "../ui/tooltip-icon-button";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { Bot, Copy, CopyCheck, Send, User } from "lucide-react";
|
||||||
|
import { Textarea } from "../ui/textarea";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
|
||||||
|
function MessageCopyButton({ content }: { content: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigator.clipboard.writeText(content);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipIconButton
|
||||||
|
onClick={(e) => handleCopy(e)}
|
||||||
|
variant="ghost"
|
||||||
|
tooltip="Copy content"
|
||||||
|
className="size-6 p-1"
|
||||||
|
>
|
||||||
|
<AnimatePresence
|
||||||
|
mode="wait"
|
||||||
|
initial={false}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<motion.div
|
||||||
|
key="check"
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
>
|
||||||
|
<CopyCheck className="h-3 w-3 text-green-500" />
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="copy"
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</TooltipIconButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ManagerChatProps {
|
||||||
|
messages: Message[];
|
||||||
|
chatInput: string;
|
||||||
|
setChatInput: (input: string) => void;
|
||||||
|
handleSendMessage: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ManagerChat({
|
||||||
|
messages,
|
||||||
|
chatInput,
|
||||||
|
setChatInput,
|
||||||
|
handleSendMessage,
|
||||||
|
}: ManagerChatProps) {
|
||||||
|
return (
|
||||||
|
<div className="border-border bg-muted/30 flex h-full w-1/3 flex-col border-r dark:bg-gray-950">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<StickToBottom
|
||||||
|
className="absolute inset-0"
|
||||||
|
initial={false}
|
||||||
|
>
|
||||||
|
<StickyToBottomContent
|
||||||
|
className="h-full overflow-y-auto"
|
||||||
|
contentClassName="space-y-4 p-4"
|
||||||
|
content={
|
||||||
|
<>
|
||||||
|
{messages.map((message) => (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className="group flex gap-3"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{message.type === "human" ? (
|
||||||
|
<div className="bg-muted flex h-6 w-6 items-center justify-center rounded-full dark:bg-gray-700">
|
||||||
|
<User className="text-muted-foreground h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900">
|
||||||
|
<Bot className="h-3 w-3 text-blue-700 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative flex-1 space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground text-xs font-medium">
|
||||||
|
{message.type === "human" ? "You" : "Agent"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-foreground text-sm leading-relaxed">
|
||||||
|
{getMessageContentString(message.content)}
|
||||||
|
</div>
|
||||||
|
<div className="absolute right-0 -bottom-5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<MessageCopyButton
|
||||||
|
content={getMessageContentString(message.content)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<div className="absolute right-0 bottom-4 left-0 flex w-full justify-center">
|
||||||
|
<ScrollToBottom className="animate-in fade-in-0 zoom-in-95" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</StickToBottom>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-border bg-muted/30 border-t p-4 dark:bg-gray-950">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Textarea
|
||||||
|
value={chatInput}
|
||||||
|
onChange={(e) => setChatInput(e.target.value)}
|
||||||
|
placeholder="Type your message..."
|
||||||
|
className="border-border bg-background text-foreground placeholder:text-muted-foreground min-h-[60px] flex-1 resize-none text-sm dark:bg-gray-900"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSendMessage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
disabled={!chatInput.trim()}
|
||||||
|
size="icon"
|
||||||
|
variant="brand"
|
||||||
|
>
|
||||||
|
<Send className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground mt-2 text-xs">
|
||||||
|
Press Cmd+Enter to send
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,23 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { ArrowLeft, GitBranch, Terminal, Clock } from "lucide-react";
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
GitBranch,
|
|
||||||
Send,
|
|
||||||
Terminal,
|
|
||||||
Clock,
|
|
||||||
User,
|
|
||||||
Bot,
|
|
||||||
Copy,
|
|
||||||
CopyCheck,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { TooltipIconButton } from "@/components/ui/tooltip-icon-button";
|
|
||||||
import { getMessageContentString } from "@open-swe/shared/messages";
|
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { ThreadSwitcher } from "./thread-switcher";
|
import { ThreadSwitcher } from "./thread-switcher";
|
||||||
import { ThreadDisplayInfo } from "./types";
|
import { ThreadDisplayInfo } from "./types";
|
||||||
|
|
@ -34,57 +21,11 @@ import {
|
||||||
StickyToBottomContent,
|
StickyToBottomContent,
|
||||||
ScrollToBottom,
|
ScrollToBottom,
|
||||||
} from "../../utils/scroll-utils";
|
} from "../../utils/scroll-utils";
|
||||||
|
import { ManagerChat } from "./manager-chat";
|
||||||
|
|
||||||
const PROGRAMMER_ASSISTANT_ID = process.env.NEXT_PUBLIC_PROGRAMMER_ASSISTANT_ID;
|
const PROGRAMMER_ASSISTANT_ID = process.env.NEXT_PUBLIC_PROGRAMMER_ASSISTANT_ID;
|
||||||
const PLANNER_ASSISTANT_ID = process.env.NEXT_PUBLIC_PLANNER_ASSISTANT_ID;
|
const PLANNER_ASSISTANT_ID = process.env.NEXT_PUBLIC_PLANNER_ASSISTANT_ID;
|
||||||
|
|
||||||
function MessageCopyButton({ content }: { content: string }) {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
const handleCopy = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigator.clipboard.writeText(content);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TooltipIconButton
|
|
||||||
onClick={(e) => handleCopy(e)}
|
|
||||||
variant="ghost"
|
|
||||||
tooltip="Copy content"
|
|
||||||
className="size-6 p-1"
|
|
||||||
>
|
|
||||||
<AnimatePresence
|
|
||||||
mode="wait"
|
|
||||||
initial={false}
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<motion.div
|
|
||||||
key="check"
|
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.8 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
>
|
|
||||||
<CopyCheck className="h-3 w-3 text-green-500" />
|
|
||||||
</motion.div>
|
|
||||||
) : (
|
|
||||||
<motion.div
|
|
||||||
key="copy"
|
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.8 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
>
|
|
||||||
<Copy className="h-3 w-3" />
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</TooltipIconButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ThreadViewProps {
|
interface ThreadViewProps {
|
||||||
stream: ReturnType<typeof useStream<ManagerGraphState>>;
|
stream: ReturnType<typeof useStream<ManagerGraphState>>;
|
||||||
displayThread: ThreadDisplayInfo;
|
displayThread: ThreadDisplayInfo;
|
||||||
|
|
@ -107,24 +48,6 @@ export function ThreadView({
|
||||||
const [programmerSession, setProgrammerSession] =
|
const [programmerSession, setProgrammerSession] =
|
||||||
useState<ManagerGraphState["programmerSession"]>();
|
useState<ManagerGraphState["programmerSession"]>();
|
||||||
|
|
||||||
const prevMessageLength = useRef(0);
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
stream.messages &&
|
|
||||||
stream.messages.length !== prevMessageLength.current
|
|
||||||
) {
|
|
||||||
prevMessageLength.current = stream.messages.length;
|
|
||||||
}
|
|
||||||
}, [stream.messages]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Auto-scroll to bottom when tab switches (requirement fulfilled)
|
|
||||||
// StickToBottom handles the actual scrolling via scrollToBottom()
|
|
||||||
}, [selectedTab]);
|
|
||||||
|
|
||||||
// Don't early return for empty messages - show loading/empty state instead
|
|
||||||
// This prevents blank pages when navigating to existing threads
|
|
||||||
|
|
||||||
const handleSendMessage = () => {
|
const handleSendMessage = () => {
|
||||||
if (chatInput.trim()) {
|
if (chatInput.trim()) {
|
||||||
const newHumanMessage = new HumanMessage({
|
const newHumanMessage = new HumanMessage({
|
||||||
|
|
@ -197,92 +120,12 @@ export function ThreadView({
|
||||||
|
|
||||||
{/* Main Content - Split Layout */}
|
{/* Main Content - Split Layout */}
|
||||||
<div className="flex h-full w-full pt-12">
|
<div className="flex h-full w-full pt-12">
|
||||||
{/* Left Side - Chat Interface */}
|
<ManagerChat
|
||||||
<div className="border-border bg-muted/30 flex h-full w-1/3 flex-col border-r dark:bg-gray-950">
|
messages={filteredMessages}
|
||||||
{/* Chat Messages */}
|
chatInput={chatInput}
|
||||||
<div className="relative flex-1">
|
setChatInput={setChatInput}
|
||||||
<StickToBottom
|
handleSendMessage={handleSendMessage}
|
||||||
className="absolute inset-0"
|
/>
|
||||||
initial={false}
|
|
||||||
>
|
|
||||||
<StickyToBottomContent
|
|
||||||
className="h-full overflow-y-auto"
|
|
||||||
contentClassName="space-y-4 p-4"
|
|
||||||
content={
|
|
||||||
<>
|
|
||||||
{filteredMessages.map((message) => (
|
|
||||||
<div
|
|
||||||
key={message.id}
|
|
||||||
className="group flex gap-3"
|
|
||||||
>
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
{message.type === "human" ? (
|
|
||||||
<div className="bg-muted flex h-6 w-6 items-center justify-center rounded-full dark:bg-gray-700">
|
|
||||||
<User className="text-muted-foreground h-3 w-3" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900">
|
|
||||||
<Bot className="h-3 w-3 text-blue-700 dark:text-blue-400" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="relative flex-1 space-y-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-muted-foreground text-xs font-medium">
|
|
||||||
{message.type === "human" ? "You" : "Agent"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-foreground text-sm leading-relaxed">
|
|
||||||
{getMessageContentString(message.content)}
|
|
||||||
</div>
|
|
||||||
<div className="absolute right-0 -bottom-5 opacity-0 transition-opacity group-hover:opacity-100">
|
|
||||||
<MessageCopyButton
|
|
||||||
content={getMessageContentString(message.content)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<div className="absolute right-0 left-0 bottom-4 flex justify-center w-full">
|
|
||||||
<ScrollToBottom className="animate-in fade-in-0 zoom-in-95" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</StickToBottom>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chat Input - Fixed at bottom */}
|
|
||||||
<div className="border-border bg-muted/30 border-t p-4 dark:bg-gray-950">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Textarea
|
|
||||||
value={chatInput}
|
|
||||||
onChange={(e) => setChatInput(e.target.value)}
|
|
||||||
placeholder="Type your message..."
|
|
||||||
className="border-border bg-background text-foreground placeholder:text-muted-foreground min-h-[60px] flex-1 resize-none text-sm dark:bg-gray-900"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSendMessage();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={handleSendMessage}
|
|
||||||
disabled={!chatInput.trim()}
|
|
||||||
size="icon"
|
|
||||||
variant="brand"
|
|
||||||
>
|
|
||||||
<Send className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground mt-2 text-xs">
|
|
||||||
Press Cmd+Enter to send
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Side - Actions & Plan */}
|
{/* Right Side - Actions & Plan */}
|
||||||
<div className="flex h-full flex-1 flex-col">
|
<div className="flex h-full flex-1 flex-col">
|
||||||
|
|
@ -361,7 +204,7 @@ export function ThreadView({
|
||||||
</Tabs>
|
</Tabs>
|
||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
<div className="absolute right-0 left-0 bottom-4 flex justify-center w-full">
|
<div className="absolute right-0 bottom-4 left-0 flex w-full justify-center">
|
||||||
<ScrollToBottom className="animate-in fade-in-0 zoom-in-95" />
|
<ScrollToBottom className="animate-in fade-in-0 zoom-in-95" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue