Skyvern/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.tsx
Aaron Perez 893177fce6
[NO-TICKET] Bump + test deps (#5443)
Co-authored-by: Shuchang Zheng <wintonzheng0325@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:08:55 -07:00

924 lines
30 KiB
TypeScript

import { useState, useEffect, useLayoutEffect, useRef, memo } from "react";
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useParams } from "react-router-dom";
import { ReloadIcon, Cross2Icon } from "@radix-ui/react-icons";
import { stringify as convertToYAML } from "yaml";
import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore";
import { WorkflowCreateYAMLRequest } from "@/routes/workflows/types/workflowYamlTypes";
import { WorkflowApiResponse } from "@/routes/workflows/types/workflowTypes";
import { toast } from "@/components/ui/use-toast";
import { getSseClient } from "@/api/sse";
import {
WorkflowCopilotChatHistoryResponse,
WorkflowCopilotProcessingUpdate,
WorkflowCopilotStreamErrorUpdate,
WorkflowCopilotStreamResponseUpdate,
WorkflowCopilotChatSender,
WorkflowCopilotChatRequest,
WorkflowCopilotClearProposedWorkflowRequest,
} from "./workflowCopilotTypes";
interface ChatMessage {
id: string;
sender: WorkflowCopilotChatSender;
content: string;
timestamp?: string;
}
type WorkflowCopilotSsePayload =
| WorkflowCopilotProcessingUpdate
| WorkflowCopilotStreamResponseUpdate
| WorkflowCopilotStreamErrorUpdate;
const formatChatTimestamp = (value: string) => {
let normalizedValue = value.replace(/\.(\d{3})\d*/, ".$1");
if (!normalizedValue.endsWith("Z")) {
normalizedValue += "Z";
}
return new Date(normalizedValue).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
});
};
interface MessageItemProps {
message: ChatMessage;
footer?: React.ReactNode;
}
const MessageItem = memo(({ message, footer }: MessageItemProps) => {
return (
<div className="flex items-start gap-3">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold text-white ${
message.sender === "ai" ? "bg-blue-600" : "bg-purple-600"
}`}
>
{message.sender === "ai" ? "AI" : "U"}
</div>
<div className="relative flex-1 rounded-lg bg-slate-800 p-3 pr-12">
<p className="whitespace-pre-wrap pr-3 text-sm text-slate-200">
{message.content}
</p>
{footer ? <div className="mt-3 flex gap-2">{footer}</div> : null}
{message.timestamp ? (
<span className="pointer-events-none absolute bottom-2 right-2 rounded bg-slate-900/70 px-1.5 py-0.5 text-[10px] text-slate-400">
{formatChatTimestamp(message.timestamp)}
</span>
) : null}
</div>
</div>
);
});
interface WorkflowCopilotChatProps {
onWorkflowUpdate?: (workflow: WorkflowApiResponse) => void;
onReviewWorkflow?: (
workflow: WorkflowApiResponse,
clearPending: () => void,
) => void;
isOpen?: boolean;
onClose?: () => void;
onMessageCountChange?: (count: number) => void;
buttonRef?: React.RefObject<HTMLButtonElement>;
}
const DEFAULT_WINDOW_WIDTH = 600;
const DEFAULT_WINDOW_HEIGHT = 400;
const MIN_WINDOW_WIDTH = 300;
const MIN_WINDOW_HEIGHT = 300;
const OFFSET = 24;
const calculateDefaultPosition = (
width: number,
height: number,
buttonRef?: React.RefObject<HTMLButtonElement>,
) => {
// If button ref is available, align left edge of window with left edge of button
if (buttonRef?.current) {
const buttonRect = buttonRef.current.getBoundingClientRect();
return {
x: buttonRect.left - OFFSET,
y: window.innerHeight - height - 2 * OFFSET,
};
}
// Fallback to centered position
return {
x: window.innerWidth / 2 - width / 2,
y: window.innerHeight - height - 2 * OFFSET,
};
};
const constrainPosition = (
x: number,
y: number,
width: number,
height: number,
) => {
const maxX = window.innerWidth - width - OFFSET;
const maxY = window.innerHeight - height - OFFSET;
return {
x: Math.min(Math.max(0, x), maxX),
y: Math.min(Math.max(0, y), maxY),
};
};
export function WorkflowCopilotChat({
onWorkflowUpdate,
onReviewWorkflow,
isOpen = true,
onClose,
onMessageCountChange,
buttonRef,
}: WorkflowCopilotChatProps = {}) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [proposedWorkflow, setProposedWorkflow] =
useState<WorkflowApiResponse | null>(null);
const [autoAccept, setAutoAccept] = useState<boolean>(false);
const [inputValue, setInputValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [processingStatus, setProcessingStatus] = useState<string>("");
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
const streamingAbortController = useRef<AbortController | null>(null);
const pendingMessageId = useRef<string | null>(null);
const [workflowCopilotChatId, setWorkflowCopilotChatId] = useState<
string | null
>(null);
const [size, setSize] = useState({
width: DEFAULT_WINDOW_WIDTH,
height: DEFAULT_WINDOW_HEIGHT,
});
const [position, setPosition] = useState(
calculateDefaultPosition(
DEFAULT_WINDOW_WIDTH,
DEFAULT_WINDOW_HEIGHT,
buttonRef,
),
);
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [isResizing, setIsResizing] = useState(false);
const [resizeDirection, setResizeDirection] = useState<
"n" | "s" | "e" | "w" | "se" | "sw" | "ne" | "nw"
>("se");
const [resizeStart, setResizeStart] = useState({
x: 0,
y: 0,
width: 0,
height: 0,
posX: 0,
posY: 0,
});
const credentialGetter = useCredentialGetter();
const { workflowRunId, workflowPermanentId } = useParams();
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { getSaveData } = useWorkflowHasChangesStore();
const hasInitializedPosition = useRef(false);
const hasScrolledOnLoad = useRef(false);
const scrollToBottom = (behavior: ScrollBehavior) => {
messagesEndRef.current?.scrollIntoView({ behavior });
};
const adjustTextareaHeight = () => {
const textarea = textareaRef.current;
if (!textarea) return;
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 150)}px`;
};
useEffect(() => {
adjustTextareaHeight();
}, [inputValue]);
const handleNewChat = () => {
setMessages([]);
setWorkflowCopilotChatId(null);
setProposedWorkflow(null);
setAutoAccept(false);
hasScrolledOnLoad.current = false;
};
const applyWorkflowUpdate = (workflow: WorkflowApiResponse): boolean => {
if (!onWorkflowUpdate) {
return true;
}
try {
onWorkflowUpdate(workflow);
return true;
} catch (updateError) {
console.error("Failed to update workflow:", updateError);
toast({
title: "Update failed",
description: "Failed to apply workflow changes. Please try again.",
variant: "destructive",
});
return false;
}
};
const handleAcceptWorkflow = (
workflow: WorkflowApiResponse,
alwaysAccept: boolean = false,
) => {
if (!applyWorkflowUpdate(workflow)) {
return;
}
setProposedWorkflow(null);
if (alwaysAccept) {
setAutoAccept(true);
}
void clearProposedWorkflow(alwaysAccept);
};
const handleRejectWorkflow = () => {
setProposedWorkflow(null);
void clearProposedWorkflow(false);
};
const clearProposedWorkflow = async (autoAcceptValue: boolean) => {
try {
const client = await getClient(credentialGetter, "sans-api-v1");
await client.post<WorkflowCopilotClearProposedWorkflowRequest>(
"/workflow/copilot/clear-proposed-workflow",
{
workflow_copilot_chat_id: workflowCopilotChatId ?? "",
auto_accept: autoAcceptValue,
} as WorkflowCopilotClearProposedWorkflowRequest,
);
} catch (error) {
console.error("Failed to clear proposed workflow:", error);
toast({
title: "Copilot update failed",
description: autoAcceptValue
? "Workflow was applied, but auto-accept did not update."
: "Failed to clear copilot proposal. Please try again.",
variant: "destructive",
});
}
};
const handleReviewWorkflow = (workflow: WorkflowApiResponse) => {
onReviewWorkflow?.(workflow, () => setProposedWorkflow(null));
};
// Notify parent of message count changes
useEffect(() => {
if (onMessageCountChange) {
onMessageCountChange(messages.length);
}
}, [messages.length, onMessageCountChange]);
useEffect(() => {
if (!isOpen) {
hasScrolledOnLoad.current = false;
return;
}
if (isLoadingHistory) {
return;
}
if (!hasScrolledOnLoad.current) {
scrollToBottom("auto");
hasScrolledOnLoad.current = true;
return;
}
scrollToBottom("smooth");
}, [messages, isLoading, isLoadingHistory, isOpen]);
useEffect(() => {
if (!workflowPermanentId) {
setMessages([]);
setWorkflowCopilotChatId(null);
setProposedWorkflow(null);
setAutoAccept(false);
return;
}
let isMounted = true;
const fetchHistory = async () => {
setIsLoadingHistory(true);
hasScrolledOnLoad.current = false;
try {
const client = await getClient(credentialGetter, "sans-api-v1");
const response = await client.get<WorkflowCopilotChatHistoryResponse>(
"/workflow/copilot/chat-history",
{
params: { workflow_permanent_id: workflowPermanentId },
},
);
if (!isMounted) return;
const historyMessages = response.data.chat_history.map(
(message, index) => ({
id: `${index}-${Date.now()}`,
sender: message.sender,
content: message.content,
timestamp: message.created_at,
}),
);
setMessages(historyMessages);
setWorkflowCopilotChatId(response.data.workflow_copilot_chat_id);
setProposedWorkflow(response.data.proposed_workflow ?? null);
setAutoAccept(response.data.auto_accept ?? false);
} catch (error) {
console.error("Failed to load chat history:", error);
} finally {
if (isMounted) {
setIsLoadingHistory(false);
}
}
};
fetchHistory();
return () => {
isMounted = false;
};
}, [credentialGetter, workflowPermanentId]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape" || !isOpen || !isLoading) {
return;
}
cancelSend();
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [isLoading, isOpen]);
const cancelSend = async () => {
if (!streamingAbortController.current) return;
if (pendingMessageId.current) {
const messageId = pendingMessageId.current;
pendingMessageId.current = null;
setMessages((prev) => prev.filter((message) => message.id !== messageId));
}
setIsLoading(false);
setProcessingStatus("");
streamingAbortController.current?.abort();
};
const handleSend = async () => {
if (!inputValue.trim() || isLoading) return;
if (!workflowPermanentId) {
toast({
title: "Missing workflow",
description: "Workflow permanent ID is required to chat.",
variant: "destructive",
});
return;
}
const userMessageId = Date.now().toString();
const userMessage: ChatMessage = {
id: userMessageId,
sender: "user",
content: inputValue,
};
pendingMessageId.current = userMessageId;
setMessages((prev) => [...prev, userMessage]);
setProposedWorkflow(null);
const messageContent = inputValue;
setInputValue("");
setIsLoading(true);
setProcessingStatus("Starting...");
const abortController = new AbortController();
streamingAbortController.current?.abort();
streamingAbortController.current = abortController;
try {
const saveData = getSaveData();
const workflowId = saveData?.workflow.workflow_id;
let workflowYaml = "";
if (!workflowId) {
toast({
title: "Missing workflow",
description: "Workflow ID is required to chat.",
variant: "destructive",
});
return;
}
if (saveData) {
const extraHttpHeaders: Record<string, string> = {};
if (saveData.settings.extraHttpHeaders) {
try {
const parsedHeaders = JSON.parse(
saveData.settings.extraHttpHeaders,
);
if (
parsedHeaders &&
typeof parsedHeaders === "object" &&
!Array.isArray(parsedHeaders)
) {
for (const [key, value] of Object.entries(parsedHeaders)) {
if (key && typeof key === "string") {
extraHttpHeaders[key] = String(value);
}
}
}
} catch (error) {
console.error("Error parsing extra HTTP headers:", error);
}
}
const scriptCacheKey = saveData.settings.scriptCacheKey ?? "";
const normalizedKey =
scriptCacheKey === "" ? "default" : saveData.settings.scriptCacheKey;
const requestBody: WorkflowCreateYAMLRequest = {
title: saveData.title,
description: saveData.workflow.description,
proxy_location: saveData.settings.proxyLocation,
webhook_callback_url: saveData.settings.webhookCallbackUrl,
persist_browser_session: saveData.settings.persistBrowserSession,
model: saveData.settings.model,
max_screenshot_scrolls: saveData.settings.maxScreenshotScrolls,
totp_verification_url: saveData.workflow.totp_verification_url,
extra_http_headers: extraHttpHeaders,
run_with: saveData.settings.runWith,
cache_key: normalizedKey,
ai_fallback: saveData.settings.aiFallback ?? true,
code_version:
saveData.settings.runWith === "code"
? (saveData.settings.codeVersion ?? 2)
: undefined,
workflow_definition: {
version: saveData.workflowDefinitionVersion,
parameters: saveData.parameters,
blocks: saveData.blocks,
},
is_saved_task: saveData.workflow.is_saved_task,
status: saveData.workflow.status,
run_sequentially: saveData.settings.runSequentially,
sequential_key: saveData.settings.sequentialKey,
};
workflowYaml = convertToYAML(requestBody);
}
const handleProcessingUpdate = (
payload: WorkflowCopilotProcessingUpdate,
) => {
if (payload.status) {
setProcessingStatus(payload.status);
}
const pendingId = pendingMessageId.current;
if (!pendingId || !payload.timestamp) {
return;
}
setMessages((prev) =>
prev.map((message) =>
message.id === pendingId
? { ...message, timestamp: payload.timestamp }
: message,
),
);
};
const handleResponse = (
response: WorkflowCopilotStreamResponseUpdate,
) => {
setWorkflowCopilotChatId(response.workflow_copilot_chat_id);
const aiMessage: ChatMessage = {
id: Date.now().toString(),
sender: "ai",
content: response.message,
timestamp: response.response_time,
};
setMessages((prev) => [...prev, aiMessage]);
if (response.updated_workflow && autoAccept) {
applyWorkflowUpdate(response.updated_workflow);
} else {
setProposedWorkflow(response.updated_workflow ?? null);
}
};
const handleError = (payload: WorkflowCopilotStreamErrorUpdate) => {
const errorMessage: ChatMessage = {
id: Date.now().toString(),
sender: "ai",
content: payload.error,
};
setMessages((prev) => [...prev, errorMessage]);
};
const client = await getSseClient(credentialGetter);
await client.postStreaming<WorkflowCopilotSsePayload>(
"/workflow/copilot/chat-post",
{
workflow_id: workflowId,
workflow_permanent_id: workflowPermanentId,
workflow_copilot_chat_id: workflowCopilotChatId,
workflow_run_id: workflowRunId,
message: messageContent,
workflow_yaml: workflowYaml,
} as WorkflowCopilotChatRequest,
(payload) => {
switch (payload.type) {
case "processing_update":
handleProcessingUpdate(payload);
return false;
case "response":
handleResponse(payload);
return true;
case "error":
handleError(payload);
return true;
default:
return false;
}
},
{ signal: abortController.signal },
);
} catch (error) {
if (abortController.signal.aborted) {
return;
}
console.error("Failed to send message:", error);
const errorMessage: ChatMessage = {
id: Date.now().toString(),
sender: "ai",
content: "Sorry, I encountered an error. Please try again.",
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
if (streamingAbortController.current === abortController) {
streamingAbortController.current = null;
}
pendingMessageId.current = null;
setIsLoading(false);
setProcessingStatus("");
}
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleMouseDown = (e: React.MouseEvent) => {
setIsDragging(true);
setDragStart({
x: e.clientX - position.x,
y: e.clientY - position.y,
});
};
const handleResizeMouseDown = (
e: React.MouseEvent,
direction: "n" | "s" | "e" | "w" | "se" | "sw" | "ne" | "nw",
) => {
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
setResizeDirection(direction);
setResizeStart({
x: e.clientX,
y: e.clientY,
width: size.width,
height: size.height,
posX: position.x,
posY: position.y,
});
};
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
setPosition({
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y,
});
}
if (isResizing) {
const deltaX = e.clientX - resizeStart.x;
const deltaY = e.clientY - resizeStart.y;
let newWidth = resizeStart.width;
let newHeight = resizeStart.height;
let newX = resizeStart.posX;
let newY = resizeStart.posY;
// Corners
if (resizeDirection === "se") {
// Southeast: resize from bottom-right
newWidth = Math.max(MIN_WINDOW_WIDTH, resizeStart.width + deltaX);
newHeight = Math.max(MIN_WINDOW_HEIGHT, resizeStart.height + deltaY);
} else if (resizeDirection === "sw") {
// Southwest: resize from bottom-left
newWidth = Math.max(MIN_WINDOW_WIDTH, resizeStart.width - deltaX);
newHeight = Math.max(MIN_WINDOW_HEIGHT, resizeStart.height + deltaY);
if (resizeStart.width - deltaX >= MIN_WINDOW_WIDTH) {
newX = resizeStart.posX + deltaX;
}
} else if (resizeDirection === "ne") {
// Northeast: resize from top-right
newWidth = Math.max(MIN_WINDOW_WIDTH, resizeStart.width + deltaX);
newHeight = Math.max(MIN_WINDOW_HEIGHT, resizeStart.height - deltaY);
if (resizeStart.height - deltaY >= MIN_WINDOW_HEIGHT) {
newY = resizeStart.posY + deltaY;
}
} else if (resizeDirection === "nw") {
// Northwest: resize from top-left
newWidth = Math.max(MIN_WINDOW_WIDTH, resizeStart.width - deltaX);
newHeight = Math.max(MIN_WINDOW_HEIGHT, resizeStart.height - deltaY);
if (resizeStart.width - deltaX >= MIN_WINDOW_WIDTH) {
newX = resizeStart.posX + deltaX;
}
if (resizeStart.height - deltaY >= MIN_WINDOW_HEIGHT) {
newY = resizeStart.posY + deltaY;
}
}
// Edges
else if (resizeDirection === "n") {
// North: resize from top
newHeight = Math.max(MIN_WINDOW_HEIGHT, resizeStart.height - deltaY);
if (resizeStart.height - deltaY >= MIN_WINDOW_HEIGHT) {
newY = resizeStart.posY + deltaY;
}
} else if (resizeDirection === "s") {
// South: resize from bottom
newHeight = Math.max(MIN_WINDOW_HEIGHT, resizeStart.height + deltaY);
} else if (resizeDirection === "e") {
// East: resize from right
newWidth = Math.max(MIN_WINDOW_WIDTH, resizeStart.width + deltaX);
} else if (resizeDirection === "w") {
// West: resize from left
newWidth = Math.max(MIN_WINDOW_WIDTH, resizeStart.width - deltaX);
if (resizeStart.width - deltaX >= MIN_WINDOW_WIDTH) {
newX = resizeStart.posX + deltaX;
}
}
setSize({
width: newWidth,
height: newHeight,
});
setPosition({
x: newX,
y: newY,
});
}
};
const handleMouseUp = () => {
setIsDragging(false);
setIsResizing(false);
};
if (isDragging || isResizing) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [isDragging, dragStart, isResizing, resizeStart, resizeDirection]);
// Handle window resize to keep chat window within viewport
useEffect(() => {
const handleResize = () => {
setPosition((prev) =>
constrainPosition(prev.x, prev.y, size.width, size.height),
);
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [size]);
// Recalculate position when chat opens to align with button (only first time)
useLayoutEffect(() => {
if (isOpen && buttonRef?.current && !hasInitializedPosition.current) {
const newPosition = calculateDefaultPosition(
size.width,
size.height,
buttonRef,
);
setPosition(newPosition);
hasInitializedPosition.current = true;
}
}, [isOpen, buttonRef, size.width, size.height]);
if (!isOpen) {
return null;
}
return (
<div
className="fixed z-50 flex flex-col rounded-lg border border-slate-700 bg-slate-900 shadow-2xl"
style={{
left: `${position.x}px`,
top: `${position.y}px`,
width: `${size.width}px`,
height: `${size.height}px`,
}}
>
{/* Header */}
<div
className="flex cursor-move items-center justify-between border-b border-slate-700 px-4 py-2"
onMouseDown={handleMouseDown}
>
<h3 className="text-sm font-semibold text-slate-200">
Workflow Copilot (Beta)
</h3>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleNewChat}
onMouseDown={(e) => e.stopPropagation()}
className="rounded border border-slate-700 px-2 py-1 text-xs text-slate-300 hover:bg-slate-800"
>
New chat
</button>
<div className="h-2 w-2 rounded-full bg-green-500"></div>
<span className="text-xs text-slate-400">Active</span>
<button
type="button"
onClick={() => onClose?.()}
onMouseDown={(e) => e.stopPropagation()}
className="ml-2 rounded p-1 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
title="Close"
>
<Cross2Icon className="h-4 w-4" />
</button>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-3">
{!isLoadingHistory && messages.length === 0 && !isLoading ? (
<div className="rounded-lg border border-slate-800 bg-slate-900/60 p-4 text-sm text-slate-300">
<p className="font-semibold text-slate-200">Start a new chat</p>
<p className="mt-2 text-slate-400">
Ask the copilot to draft or edit your workflow. Provide a goal,
the target site, and any credentials it should use.
</p>
<p className="mt-2 text-slate-400">
Example: "Build workflow to find the top post on hackernews
today"
</p>
</div>
) : null}
{messages.map((message, index) => {
const isLastMessage = index === messages.length - 1;
const showProposedPanel = isLastMessage && proposedWorkflow;
return (
<MessageItem
key={message.id}
message={message}
footer={
showProposedPanel ? (
<>
<button
type="button"
onClick={() => handleReviewWorkflow(proposedWorkflow)}
className="rounded border border-blue-500/60 bg-blue-500/10 px-3 py-1 text-xs text-blue-100 hover:bg-blue-500/20"
>
Review
</button>
<button
type="button"
onClick={() => handleAcceptWorkflow(proposedWorkflow)}
className="rounded bg-green-600 px-3 py-1 text-xs text-white hover:bg-green-700"
>
Accept
</button>
<button
type="button"
onClick={() =>
handleAcceptWorkflow(proposedWorkflow, true)
}
className="rounded bg-emerald-600 px-3 py-1 text-xs text-white hover:bg-emerald-700"
>
Always accept
</button>
<button
type="button"
onClick={handleRejectWorkflow}
className="rounded bg-red-600 px-3 py-1 text-xs text-white hover:bg-red-700"
>
Reject
</button>
</>
) : null
}
/>
);
})}
{isLoading && (
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white">
AI
</div>
<div className="flex-1 rounded-lg bg-slate-800 p-3">
<div className="flex items-center gap-2 text-sm text-slate-400">
<ReloadIcon className="h-4 w-4 animate-spin" />
<span>{processingStatus || "Processing..."}</span>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
{/* Input */}
<div className="border-t border-slate-700 p-3">
<div className="flex items-end gap-2">
<textarea
ref={textareaRef}
placeholder="Type your message... (Shift+Enter for new line)"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyPress}
disabled={isLoading}
rows={1}
className="flex-1 resize-none rounded-md border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:border-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
style={{
minHeight: "38px",
maxHeight: "150px",
overflow: "auto",
}}
/>
<button
onClick={handleSend}
disabled={isLoading}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Send
</button>
</div>
</div>
{/* Resize Handles */}
{/* Corners */}
<div
className="absolute bottom-0 right-0 z-10 h-3 w-3 cursor-nwse-resize"
onMouseDown={(e) => handleResizeMouseDown(e, "se")}
title="Resize"
/>
<div
className="absolute bottom-0 left-0 z-10 h-3 w-3 cursor-nesw-resize"
onMouseDown={(e) => handleResizeMouseDown(e, "sw")}
title="Resize"
/>
<div
className="absolute right-0 top-0 z-10 h-3 w-3 cursor-nesw-resize"
onMouseDown={(e) => handleResizeMouseDown(e, "ne")}
title="Resize"
/>
<div
className="absolute left-0 top-0 z-10 h-3 w-3 cursor-nwse-resize"
onMouseDown={(e) => handleResizeMouseDown(e, "nw")}
title="Resize"
/>
{/* Edges */}
<div
className="absolute left-3 right-3 top-0 z-10 h-1 cursor-ns-resize"
onMouseDown={(e) => handleResizeMouseDown(e, "n")}
title="Resize"
/>
<div
className="absolute bottom-0 left-3 right-3 z-10 h-1 cursor-ns-resize"
onMouseDown={(e) => handleResizeMouseDown(e, "s")}
title="Resize"
/>
<div
className="absolute bottom-3 left-0 top-3 z-10 w-1 cursor-ew-resize"
onMouseDown={(e) => handleResizeMouseDown(e, "w")}
title="Resize"
/>
<div
className="absolute bottom-3 right-0 top-3 z-10 w-1 cursor-ew-resize"
onMouseDown={(e) => handleResizeMouseDown(e, "e")}
title="Resize"
/>
</div>
);
}