fix: Refactor manager chat into its own component (#322)

This commit is contained in:
Brace Sproul 2025-06-27 11:26:53 -06:00 • committed by GitHub
parent 9d7a74a976
commit b88db80b26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 168 additions and 168 deletions

View 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>
);
}

View file

@ -1,23 +1,10 @@
"use client";
import { v4 as uuidv4 } from "uuid";
import { useState, useEffect, useRef } from "react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
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 { ArrowLeft, GitBranch, Terminal, Clock } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ThreadSwitcher } from "./thread-switcher";
import { ThreadDisplayInfo } from "./types";
@ -34,57 +21,11 @@ import {
StickyToBottomContent,
ScrollToBottom,
} from "../../utils/scroll-utils";
import { ManagerChat } from "./manager-chat";
const PROGRAMMER_ASSISTANT_ID = process.env.NEXT_PUBLIC_PROGRAMMER_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 {
stream: ReturnType<typeof useStream<ManagerGraphState>>;
displayThread: ThreadDisplayInfo;
@ -107,24 +48,6 @@ export function ThreadView({
const [programmerSession, setProgrammerSession] =
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 = () => {
if (chatInput.trim()) {
const newHumanMessage = new HumanMessage({
@ -197,92 +120,12 @@ export function ThreadView({
{/* Main Content - Split Layout */}
<div className="flex h-full w-full pt-12">
{/* Left Side - Chat Interface */}
<div className="border-border bg-muted/30 flex h-full w-1/3 flex-col border-r dark:bg-gray-950">
{/* Chat Messages */}
<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={
<>
{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>
<ManagerChat
messages={filteredMessages}
chatInput={chatInput}
setChatInput={setChatInput}
handleSendMessage={handleSendMessage}
/>
{/* Right Side - Actions & Plan */}
<div className="flex h-full flex-1 flex-col">
@ -361,7 +204,7 @@ export function ThreadView({
</Tabs>
}
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" />
</div>
}