mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-04-28 03:30:10 +00:00
Co-authored-by: Shuchang Zheng <wintonzheng0325@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
924 lines
30 KiB
TypeScript
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>
|
|
);
|
|
}
|