diff --git a/src/frontend/src/components/core/flowToolbarComponent/components/deploy-dropdown.tsx b/src/frontend/src/components/core/flowToolbarComponent/components/deploy-dropdown.tsx index 8d6c7cabe49a..77c9dd676677 100644 --- a/src/frontend/src/components/core/flowToolbarComponent/components/deploy-dropdown.tsx +++ b/src/frontend/src/components/core/flowToolbarComponent/components/deploy-dropdown.tsx @@ -91,7 +91,7 @@ export default function PublishDropdown({ + ), + SelectContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SelectItem: ({ + children, + value, + onClick, + }: { + children: React.ReactNode; + value: string; + onClick?: () => void; + }) => ( +
onClick?.()}> + {children} +
+ ), +})); + +jest.mock("@/controllers/API/queries/messages/use-rename-session", () => ({ + useUpdateSessionName: () => ({ + mutate: jest.fn(), + }), +})); + +jest.mock("../chat-sessions-dropdown", () => ({ + ChatSessionsDropdown: ({ + onNewChat, + onSessionSelect, + currentSessionId, + }: { + onNewChat?: () => void; + onSessionSelect?: (sessionId: string) => void; + currentSessionId?: string; + }) => ( +
+ + +
+ ), +})); + +jest.mock("../session-logs-modal", () => ({ + SessionLogsModal: ({ + open, + sessionId, + }: { + open: boolean; + sessionId: string; + }) => + open ? ( +
+ ) : null, +})); + +jest.mock("../session-rename", () => ({ + SessionRename: ({ + sessionId, + onSave, + }: { + sessionId?: string; + onSave?: (value: string) => void; + }) => ( +
+ onSave?.(e.target.value)} + /> +
+ ), +})); + +describe("ChatHeader", () => { + const defaultProps = { + currentSessionId: "session-1", + currentFlowId: "flow-1", + onNewChat: jest.fn(), + onSessionSelect: jest.fn(), + onToggleFullscreen: jest.fn(), + onDeleteSession: jest.fn(), + onClose: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders the component", () => { + render(); + expect(screen.getByText("session-1")).toBeInTheDocument(); + }); + + it("displays 'Chat' when no session is selected", () => { + render(); + expect(screen.getByText("Chat")).toBeInTheDocument(); + }); + + it("displays 'Default Session' when currentSessionId equals currentFlowId", () => { + render( + , + ); + expect(screen.getByText("Default Session")).toBeInTheDocument(); + }); + + it("displays the session ID as title when it's not the default session", () => { + render(); + expect(screen.getByText("session-1")).toBeInTheDocument(); + }); + + it("shows sessions dropdown when not in fullscreen", () => { + render(); + expect(screen.getByTestId("chat-sessions-dropdown")).toBeInTheDocument(); + }); + + it("shows fullscreen toggle button", () => { + render(); + const toggleButton = screen.getByLabelText("Enter fullscreen"); + expect(toggleButton).toBeInTheDocument(); + }); + + it("shows exit fullscreen button when in fullscreen", () => { + render(); + const toggleButton = screen.getByLabelText("Exit fullscreen"); + expect(toggleButton).toBeInTheDocument(); + }); + + it("shows close button when in fullscreen", () => { + render(); + const closeButton = screen.getByLabelText("Close and go back to flow"); + expect(closeButton).toBeInTheDocument(); + }); + + it("calls onToggleFullscreen when toggle button is clicked", () => { + const onToggleFullscreen = jest.fn(); + render( + , + ); + const toggleButton = screen.getByLabelText("Enter fullscreen"); + toggleButton.click(); + expect(onToggleFullscreen).toHaveBeenCalledTimes(1); + }); + + it("calls onClose when close button is clicked in fullscreen", () => { + const onClose = jest.fn(); + render( + , + ); + const closeButton = screen.getByLabelText("Close and go back to flow"); + closeButton.click(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("applies custom className", () => { + const { container } = render( + , + ); + expect(container.firstChild).toHaveClass("custom-class"); + }); +}); diff --git a/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/__tests__/chat-sidebar.test.tsx b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/__tests__/chat-sidebar.test.tsx new file mode 100644 index 000000000000..0d9d3264f3cf --- /dev/null +++ b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/__tests__/chat-sidebar.test.tsx @@ -0,0 +1,201 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { ChatSidebar } from "../chat-sidebar"; + +// Mock dependencies +jest.mock("@/components/common/genericIconComponent", () => ({ + __esModule: true, + default: ({ name, className }: { name: string; className?: string }) => ( +
+ {name} +
+ ), +})); + +jest.mock("@/components/common/shadTooltipComponent", () => ({ + __esModule: true, + default: ({ + children, + content, + }: { + children: React.ReactNode; + content: string; + }) =>
{children}
, +})); + +jest.mock("@/components/ui/button", () => ({ + Button: ({ + children, + onClick, + ...props + }: { + children: React.ReactNode; + onClick?: () => void; + } & Record) => ( + + ), +})); + +const mockUseGetFlowId = jest.fn().mockReturnValue("flow-123"); +jest.mock("../../../hooks/use-get-flow-id", () => ({ + useGetFlowId: () => mockUseGetFlowId(), +})); + +const mockUseGetSessionsFromFlowQuery = jest.fn(); +jest.mock( + "@/controllers/API/queries/messages/use-get-sessions-from-flow", + () => ({ + useGetSessionsFromFlowQuery: (params: { id: string }) => + mockUseGetSessionsFromFlowQuery(params), + }), +); + +jest.mock("../session-selector", () => ({ + SessionSelector: ({ + session, + isVisible, + toggleVisibility, + deleteSession, + }: { + session: string; + isVisible: boolean; + toggleVisibility: () => void; + deleteSession: (session: string) => void; + }) => ( +
+ {session} + +
+ ), +})); + +describe("ChatSidebar", () => { + const defaultProps = { + onNewChat: jest.fn(), + onSessionSelect: jest.fn(), + currentSessionId: "session-1", + onDeleteSession: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseGetFlowId.mockReturnValue("flow-123"); + mockUseGetSessionsFromFlowQuery.mockReturnValue({ + data: { sessions: ["session-1", "session-2"] }, + isLoading: false, + }); + }); + + it("renders New Chat button with Plus icon", () => { + render(); + expect(screen.getByTestId("new-chat")).toBeInTheDocument(); + expect(screen.getByTestId("icon-Plus")).toBeInTheDocument(); + expect(screen.getByTestId("tooltip-New Chat")).toBeInTheDocument(); + }); + + it("shows loading state when fetching sessions", () => { + mockUseGetSessionsFromFlowQuery.mockReturnValue({ + data: null, + isLoading: true, + }); + render(); + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); + + it("renders sessions list when data is loaded", () => { + render(); + expect(screen.getByTestId("session-session-1")).toBeInTheDocument(); + expect(screen.getByTestId("session-session-2")).toBeInTheDocument(); + }); + + it("ensures currentFlowId is always in the sessions list", () => { + mockUseGetSessionsFromFlowQuery.mockReturnValue({ + data: { sessions: ["session-1"] }, + isLoading: false, + }); + render(); + // flow-123 should be added as default session + expect(screen.getByTestId("session-flow-123")).toBeInTheDocument(); + }); + + it("ensures currentSessionId is in the list even if not in API response", () => { + mockUseGetSessionsFromFlowQuery.mockReturnValue({ + data: { sessions: [] }, + isLoading: false, + }); + render(); + expect(screen.getByTestId("session-new-session")).toBeInTheDocument(); + }); + + it("calls onNewChat when New Chat button is clicked", () => { + const onNewChat = jest.fn(); + render(); + fireEvent.click(screen.getByTestId("new-chat")); + expect(onNewChat).toHaveBeenCalledTimes(1); + }); + + it("calls onSessionSelect when a session is clicked", () => { + const onSessionSelect = jest.fn(); + render(); + fireEvent.click(screen.getByTestId("session-session-2")); + expect(onSessionSelect).toHaveBeenCalledWith("session-2"); + }); + + it("marks current session as visible", () => { + render(); + expect(screen.getByTestId("session-session-1")).toHaveAttribute( + "data-visible", + "true", + ); + expect(screen.getByTestId("session-session-2")).toHaveAttribute( + "data-visible", + "false", + ); + }); + + it("calls onDeleteSession when delete button is clicked", () => { + const onDeleteSession = jest.fn(); + render(); + fireEvent.click(screen.getByTestId("delete-session-2")); + expect(onDeleteSession).toHaveBeenCalledWith("session-2"); + }); + + it("switches to default session when deleting current session", () => { + const onDeleteSession = jest.fn(); + const onSessionSelect = jest.fn(); + render( + , + ); + fireEvent.click(screen.getByTestId("delete-session-1")); + expect(onDeleteSession).toHaveBeenCalledWith("session-1"); + expect(onSessionSelect).toHaveBeenCalledWith("flow-123"); + }); + + it("handles empty sessions list gracefully", () => { + mockUseGetSessionsFromFlowQuery.mockReturnValue({ + data: { sessions: [] }, + isLoading: false, + }); + render(); + // Should still show the default session (flow-123) + expect(screen.getByTestId("session-flow-123")).toBeInTheDocument(); + }); +}); diff --git a/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/chat-header-actions.tsx b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/chat-header-actions.tsx new file mode 100644 index 000000000000..37664e64cee5 --- /dev/null +++ b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/chat-header-actions.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import ForwardedIconComponent from "@/components/common/genericIconComponent"; +import { cn } from "@/utils/utils"; + +interface ChatHeaderActionsProps { + isFullscreen: boolean; + onToggleFullscreen?: () => void; + onClose?: () => void; +} + +export function ChatHeaderActions({ + isFullscreen, + onToggleFullscreen, + onClose, +}: ChatHeaderActionsProps) { + if (!onToggleFullscreen) { + return null; + } + + return ( +
+ + {isFullscreen && onClose && ( + + )} +
+ ); +} diff --git a/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/chat-header-title.tsx b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/chat-header-title.tsx new file mode 100644 index 000000000000..51abc864c0c3 --- /dev/null +++ b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/chat-header-title.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { cn } from "@/utils/utils"; +import { SessionRename } from "./session-rename"; + +const TITLE_STYLES = + "flex flex-col justify-center flex-[1_0_0] self-stretch text-[#CCC] text-sm font-medium leading-4"; +const TITLE_FONT_FAMILY = { fontFamily: "Inter" } as const; + +interface ChatHeaderTitleProps { + sessionTitle: string; + isEditing: boolean; + currentSessionId?: string; + isFullscreen: boolean; + onRenameSave: (newSessionId: string) => void; +} + +export function ChatHeaderTitle({ + sessionTitle, + isEditing, + currentSessionId, + isFullscreen, + onRenameSave, +}: ChatHeaderTitleProps) { + if (isEditing && currentSessionId) { + return ( +
+ +
+ ); + } + + return ( +

+ {sessionTitle} +

+ ); +} diff --git a/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/chat-header.tsx b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/chat-header.tsx new file mode 100644 index 000000000000..f3aba085da4e --- /dev/null +++ b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/chat-header.tsx @@ -0,0 +1,116 @@ +import React, { useMemo } from "react"; +import { cn } from "@/utils/utils"; +import { useChatHeaderRename } from "../hooks/use-chat-header-rename"; +import { useChatHeaderSessionActions } from "../hooks/use-chat-header-session-actions"; +import type { ChatHeaderProps } from "../types/chat-header.types"; +import { getSessionTitle } from "../utils/get-session-title"; +import { ChatHeaderActions } from "./chat-header-actions"; +import { ChatHeaderTitle } from "./chat-header-title"; +import { ChatSessionsDropdown } from "./chat-sessions-dropdown"; +import { SessionLogsModal } from "./session-logs-modal"; +import { SessionMoreMenu } from "./session-more-menu"; + +export function ChatHeader({ + onNewChat, + onSessionSelect, + currentSessionId, + currentFlowId, + onToggleFullscreen, + isFullscreen = false, + onDeleteSession, + className, + onClose, +}: ChatHeaderProps) { + // Determine the title based on the current session + const sessionTitle = useMemo( + () => getSessionTitle(currentSessionId, currentFlowId), + [currentSessionId, currentFlowId], + ); + + // Rename functionality + const { isEditing, handleRename, handleRenameSave } = useChatHeaderRename({ + currentSessionId, + onSessionSelect, + }); + + // Session actions (message logs, delete) + const { openLogsModal, setOpenLogsModal, handleMessageLogs, handleDelete } = + useChatHeaderSessionActions({ + currentSessionId, + onDeleteSession, + }); + + return ( +
+ {!isFullscreen && ( +
+ + +
+ )} + {isFullscreen && ( +
+ +
+ )} + {!isFullscreen && ( +
+ + +
+ )} + {isFullscreen && ( + + )} + {currentSessionId && ( + + )} +
+ ); +} diff --git a/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/chat-sessions-dropdown.tsx b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/chat-sessions-dropdown.tsx new file mode 100644 index 000000000000..03530a746b2e --- /dev/null +++ b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/chat-sessions-dropdown.tsx @@ -0,0 +1,98 @@ +import React, { useState } from "react"; +import ForwardedIconComponent from "@/components/common/genericIconComponent"; +import { useGetSessionsFromFlowQuery } from "@/controllers/API/queries/messages/use-get-sessions-from-flow"; +import { cn } from "@/utils/utils"; +import { useGetFlowId } from "../../hooks/use-get-flow-id"; + +interface ChatSessionsDropdownProps { + onNewChat?: () => void; + onSessionSelect?: (sessionId: string) => void; + currentSessionId?: string; +} + +export function ChatSessionsDropdown({ + onNewChat, + onSessionSelect, + currentSessionId, +}: ChatSessionsDropdownProps) { + const [isOpen, setIsOpen] = useState(false); + const currentFlowId = useGetFlowId(); + const { data: sessionsData, isLoading } = useGetSessionsFromFlowQuery({ + id: currentFlowId, + }); + + const sessions = sessionsData?.sessions || []; + const hasSessions = sessions.length > 0; + + const handleSessionClick = (sessionId: string) => { + onSessionSelect?.(sessionId); + setIsOpen(false); + }; + + const handleNewChatClick = () => { + onNewChat?.(); + setIsOpen(false); + }; + + return ( +
+ + + {isOpen && ( + <> +
setIsOpen(false)} + /> +
+ {isLoading ? ( +
+ Loading... +
+ ) : hasSessions ? ( + <> + {sessions.map((session, index) => ( + + ))} +
+ +
+ + ) : ( + + )} +
+ + )} +
+ ); +} diff --git a/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/chat-sidebar.tsx b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/chat-sidebar.tsx new file mode 100644 index 000000000000..3316c290103d --- /dev/null +++ b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/chat-sidebar.tsx @@ -0,0 +1,122 @@ +import React from "react"; +import ForwardedIconComponent from "@/components/common/genericIconComponent"; +import ShadTooltip from "@/components/common/shadTooltipComponent"; +import { Button } from "@/components/ui/button"; +import { useGetSessionsFromFlowQuery } from "@/controllers/API/queries/messages/use-get-sessions-from-flow"; +import { useGetFlowId } from "../../hooks/use-get-flow-id"; +import { SessionSelector } from "./session-selector"; + +interface ChatSidebarProps { + onNewChat?: () => void; + onSessionSelect?: (sessionId: string) => void; + currentSessionId?: string; + onDeleteSession?: (sessionId: string) => void; +} + +export function ChatSidebar({ + onNewChat, + onSessionSelect, + currentSessionId, + onDeleteSession, +}: ChatSidebarProps) { + const currentFlowId = useGetFlowId(); + const { data: sessionsData, isLoading } = useGetSessionsFromFlowQuery({ + id: currentFlowId, + }); + + // Use sessions directly from query + // Ensure currentFlowId (Default Session) is always in the list + // Also ensure currentSessionId is included if it's not in the API response + const sessions = React.useMemo(() => { + const sessionList = [...(sessionsData?.sessions || [])]; + + // Always ensure currentFlowId (Default Session) is in the list + if (currentFlowId && !sessionList.includes(currentFlowId)) { + sessionList.unshift(currentFlowId); + } + + // If currentSessionId exists and is not in the list, add it + if ( + currentSessionId && + currentSessionId !== currentFlowId && + !sessionList.includes(currentSessionId) + ) { + sessionList.push(currentSessionId); + } + + return sessionList; + }, [sessionsData?.sessions, currentSessionId, currentFlowId]); + + const visibleSession = currentSessionId; + + const handleDeleteSession = (session: string) => { + onDeleteSession?.(session); + // If deleted session was the current one, switch to default + if (session === currentSessionId) { + onSessionSelect?.(currentFlowId); + } + }; + + const handleSessionClick = (session: string) => { + onSessionSelect?.(session); + }; + + return ( +
+
+
+
+
Sessions
+
+ +
+ +
+
+
+
+ {isLoading ? ( +
Loading...
+ ) : ( +
+ {sessions.map((session, index) => ( + handleSessionClick(session)} + isVisible={visibleSession === session} + updateVisibleSession={handleSessionClick} + inspectSession={() => { + // TODO: Implement session inspection + }} + setActiveSession={() => { + // TODO: Implement active session + }} + selectedView={undefined} + setSelectedView={() => {}} + playgroundPage={true} + /> + ))} +
+ )} +
+ ); +} diff --git a/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/session-logs-modal.tsx b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/session-logs-modal.tsx new file mode 100644 index 000000000000..a7e2b88f4608 --- /dev/null +++ b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/session-logs-modal.tsx @@ -0,0 +1,33 @@ +import ForwardedIconComponent from "@/components/common/genericIconComponent"; +import BaseModal from "@/modals/baseModal"; +import SessionView from "@/modals/IOModal/components/session-view"; + +export interface SessionLogsModalProps { + sessionId: string; + flowId?: string; + open: boolean; + setOpen: (open: boolean) => void; +} + +export const SessionLogsModal = ({ + sessionId, + flowId, + open, + setOpen, +}: SessionLogsModalProps) => { + return ( + + + +
+ Session logs + +
+
+
+ +
+
+
+ ); +}; diff --git a/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/session-more-menu.tsx b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/session-more-menu.tsx new file mode 100644 index 000000000000..c096415ae1ce --- /dev/null +++ b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/session-more-menu.tsx @@ -0,0 +1,131 @@ +import React, { useState } from "react"; +import ForwardedIconComponent from "@/components/common/genericIconComponent"; +import ShadTooltip from "@/components/common/shadTooltipComponent"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from "@/components/ui/select-custom"; +import { cn } from "@/utils/utils"; + +const MENU_ITEM_CLASS = "cursor-pointer px-3 py-2 focus:bg-muted"; + +interface SessionMoreMenuProps { + onRename: () => void; + onMessageLogs?: () => void; + onDelete: () => void; + showMessageLogs?: boolean; + // Positioning props + side?: "top" | "right" | "bottom" | "left"; + align?: "start" | "center" | "end"; + sideOffset?: number; + triggerClassName?: string; + contentClassName?: string; + isVisible?: boolean; + tooltipContent?: string; + tooltipSide?: "top" | "right" | "bottom" | "left"; +} + +const DEFAULT_SIDE_OFFSET = 4; + +export function SessionMoreMenu({ + onRename, + onMessageLogs, + onDelete, + showMessageLogs = true, + side = "bottom", + align = "end", + sideOffset = DEFAULT_SIDE_OFFSET, + triggerClassName, + contentClassName, + isVisible = true, + tooltipContent = "More options", + tooltipSide = "left", +}: SessionMoreMenuProps) { + const [selectValue, setSelectValue] = useState(""); + + const handleValueChange = (value: string) => { + setSelectValue(value); + // Execute the action immediately + switch (value) { + case "rename": + onRename(); + break; + case "messageLogs": + onMessageLogs?.(); + break; + case "delete": + onDelete(); + break; + } + // Reset after a short delay to allow the select to close + setTimeout(() => { + setSelectValue(""); + }, 100); + }; + + return ( +
+ +
+ ); +} diff --git a/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/session-rename.tsx b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/session-rename.tsx new file mode 100644 index 000000000000..fa36b6daea95 --- /dev/null +++ b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/session-rename.tsx @@ -0,0 +1,88 @@ +import type React from "react"; +import { useEffect, useRef, useState } from "react"; +import { Input } from "@/components/ui/input"; + +interface SessionRenameProps { + sessionId?: string; + onSave?: (newSessionId: string) => void; +} + +export const SessionRename = ({ sessionId, onSave }: SessionRenameProps) => { + const inputRef = useRef(null); + const [isInitialMount, setIsInitialMount] = useState(true); + const savedRef = useRef(false); // Track if we've already saved to prevent double-saving + + useEffect(() => { + // Focus and select text when component mounts + const focusInput = () => { + if (inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }; + + // Use requestAnimationFrame to ensure DOM is ready + const rafId = requestAnimationFrame(() => { + setTimeout(() => { + focusInput(); + // Allow blur after a short delay to prevent immediate blur from Select closing + setTimeout(() => { + setIsInitialMount(false); + }, 100); + }, 0); + }); + + return () => { + cancelAnimationFrame(rafId); + savedRef.current = false; // Reset on unmount + }; + }, []); + + const handleSave = (value: string) => { + // Prevent double-saving + if (savedRef.current) { + return; + } + savedRef.current = true; + const trimmedValue = value.trim(); + onSave?.(trimmedValue); + }; + + const handleBlur = (e: React.FocusEvent) => { + e.stopPropagation(); + // Don't save if we just mounted (prevents immediate blur from Select closing) + if (isInitialMount) { + return; + } + handleSave(e.currentTarget.value); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + // Blur the input to trigger save + e.currentTarget.blur(); + handleSave(e.currentTarget.value); + } else if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + // Cancel rename by calling onSave with original value + savedRef.current = true; // Mark as saved to prevent blur from also saving + e.currentTarget.blur(); + onSave?.(sessionId || ""); + } + }; + + return ( + + ); +}; diff --git a/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/session-selector/index.ts b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/session-selector/index.ts new file mode 100644 index 000000000000..7fe7116f5134 --- /dev/null +++ b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/session-selector/index.ts @@ -0,0 +1,2 @@ +export type { SessionSelectorProps } from "./session-selector"; +export { SessionSelector } from "./session-selector"; diff --git a/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/session-selector/session-selector.tsx b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/session-selector/session-selector.tsx new file mode 100644 index 000000000000..21f8e0d32181 --- /dev/null +++ b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/components/session-selector/session-selector.tsx @@ -0,0 +1,129 @@ +import type React from "react"; +import { useState } from "react"; +import ShadTooltip from "@/components/common/shadTooltipComponent"; +import { useUpdateSessionName } from "@/controllers/API/queries/messages/use-rename-session"; +import { useMessagesStore } from "@/stores/messagesStore"; +import { useVoiceStore } from "@/stores/voiceStore"; +import { cn } from "@/utils/utils"; +import { SessionMoreMenu } from "../session-more-menu"; +import { SessionRename } from "../session-rename"; + +export interface SessionSelectorProps { + session: string; + currentFlowId: string; + deleteSession: (session: string) => void; + toggleVisibility: () => void; + isVisible: boolean; + inspectSession?: (session: string) => void; + updateVisibleSession: (session: string) => void; + selectedView?: { type: string; id: string }; + setSelectedView?: (view: { type: string; id: string } | undefined) => void; + playgroundPage?: boolean; + setActiveSession?: (session: string) => void; +} + +export function SessionSelector({ + session, + currentFlowId, + deleteSession, + toggleVisibility, + isVisible, + inspectSession, + updateVisibleSession, + selectedView, + setSelectedView, + playgroundPage = false, + setActiveSession, +}: SessionSelectorProps) { + const [isEditing, setIsEditing] = useState(false); + const { mutate: updateSessionName } = useUpdateSessionName(); + const renameSession = useMessagesStore((state) => state.renameSession); + const setNewSessionCloseVoiceAssistant = useVoiceStore( + (state) => state.setNewSessionCloseVoiceAssistant, + ); + + const handleEditClick = (e?: React.MouseEvent) => { + e?.stopPropagation(); + setIsEditing(true); + }; + + const handleRenameSave = (newSessionId: string) => { + setIsEditing(false); + if (newSessionId.trim() !== session && newSessionId.trim()) { + updateSessionName( + { old_session_id: session, new_session_id: newSessionId.trim() }, + { + onSuccess: () => { + // Update messages in store with new session ID + renameSession(session, newSessionId.trim()); + if (isVisible) { + updateVisibleSession(newSessionId.trim()); + } + if ( + selectedView?.type === "Session" && + selectedView?.id === session && + setSelectedView + ) { + setSelectedView({ type: "Session", id: newSessionId.trim() }); + } + }, + }, + ); + } + }; + + const handleRename = () => { + handleEditClick(); + }; + + const handleMessageLogs = () => { + inspectSession?.(session); + }; + + const handleDelete = () => { + deleteSession(session); + }; + + return ( +
{ + setNewSessionCloseVoiceAssistant(true); + if (isEditing) e.stopPropagation(); + else toggleVisibility(); + }} + className={cn( + "file-component-accordion-div group cursor-pointer rounded-md text-left text-mmd hover:bg-accent", + isVisible ? "bg-accent font-semibold" : "font-normal", + )} + > +
+
+ {isEditing ? ( + + ) : ( + +
+ + {session === currentFlowId ? "Default Session" : session} + +
+
+ )} +
+ +
+
+ ); +} diff --git a/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/hooks/use-chat-header-rename.ts b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/hooks/use-chat-header-rename.ts new file mode 100644 index 000000000000..3d88d03a1902 --- /dev/null +++ b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/hooks/use-chat-header-rename.ts @@ -0,0 +1,75 @@ +import { useCallback, useState } from "react"; +import { useUpdateSessionName } from "@/controllers/API/queries/messages/use-rename-session"; +import { useMessagesStore } from "@/stores/messagesStore"; +import { isNoMessagesError } from "../utils/is-no-messages-error"; + +interface UseChatHeaderRenameProps { + currentSessionId?: string; + onSessionSelect?: (sessionId: string) => void; +} + +export function useChatHeaderRename({ + currentSessionId, + onSessionSelect, +}: UseChatHeaderRenameProps) { + const [isEditing, setIsEditing] = useState(false); + const { mutate: updateSessionName } = useUpdateSessionName(); + const renameSession = useMessagesStore((state) => state.renameSession); + + const handleRename = useCallback(() => { + if (!currentSessionId) { + return; + } + setIsEditing(true); + }, [currentSessionId]); + + const handleRenameSave = useCallback( + (newSessionId: string) => { + if ( + !currentSessionId || + !newSessionId.trim() || + newSessionId.trim() === currentSessionId + ) { + setIsEditing(false); + return; + } + + const trimmedNewId = newSessionId.trim(); + setIsEditing(false); + + // Update via API first, then update UI on success + updateSessionName( + { + old_session_id: currentSessionId, + new_session_id: trimmedNewId, + }, + { + onSuccess: () => { + // Update messages in store with new session ID + renameSession(currentSessionId, trimmedNewId); + // Then update the selected session + if (onSessionSelect) { + onSessionSelect(trimmedNewId); + } + }, + onError: (error: unknown) => { + // If it's a "no messages found" error, the session may be new (no messages yet) + // In this case, we can still update the local session ID + if (isNoMessagesError(error) && onSessionSelect) { + renameSession(currentSessionId, trimmedNewId); + onSessionSelect(trimmedNewId); + } + // For other errors, keep the old session ID (don't update) + }, + }, + ); + }, + [currentSessionId, updateSessionName, onSessionSelect, renameSession], + ); + + return { + isEditing, + handleRename, + handleRenameSave, + }; +} diff --git a/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/hooks/use-chat-header-session-actions.ts b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/hooks/use-chat-header-session-actions.ts new file mode 100644 index 000000000000..c24984fd499f --- /dev/null +++ b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/hooks/use-chat-header-session-actions.ts @@ -0,0 +1,35 @@ +import { useCallback, useState } from "react"; + +interface UseChatHeaderSessionActionsProps { + currentSessionId?: string; + onDeleteSession?: (sessionId: string) => void; +} + +export function useChatHeaderSessionActions({ + currentSessionId, + onDeleteSession, +}: UseChatHeaderSessionActionsProps) { + const [openLogsModal, setOpenLogsModal] = useState(false); + + const handleMessageLogs = useCallback(() => { + if (currentSessionId) { + // Use setTimeout to ensure the Select has closed before opening the modal + setTimeout(() => { + setOpenLogsModal(true); + }, 150); + } + }, [currentSessionId]); + + const handleDelete = useCallback(() => { + if (currentSessionId && onDeleteSession) { + onDeleteSession(currentSessionId); + } + }, [currentSessionId, onDeleteSession]); + + return { + openLogsModal, + setOpenLogsModal, + handleMessageLogs, + handleDelete, + }; +} diff --git a/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/index.ts b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/index.ts new file mode 100644 index 000000000000..14ad0d4993d9 --- /dev/null +++ b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/index.ts @@ -0,0 +1,2 @@ +export { ChatHeader } from "./components/chat-header"; +export type { ChatHeaderProps } from "./types/chat-header.types"; diff --git a/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/types/chat-header.types.ts b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/types/chat-header.types.ts new file mode 100644 index 000000000000..d7726363278d --- /dev/null +++ b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/types/chat-header.types.ts @@ -0,0 +1,11 @@ +export interface ChatHeaderProps { + onNewChat?: () => void; + onSessionSelect?: (sessionId: string) => void; + currentSessionId?: string; + currentFlowId?: string; + onToggleFullscreen?: () => void; + isFullscreen?: boolean; + onDeleteSession?: (sessionId: string) => void; + onClose?: () => void; + className?: string; +} diff --git a/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/utils/get-session-title.ts b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/utils/get-session-title.ts new file mode 100644 index 000000000000..f94478be7cf0 --- /dev/null +++ b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/utils/get-session-title.ts @@ -0,0 +1,12 @@ +export function getSessionTitle( + currentSessionId?: string, + currentFlowId?: string, +): string { + if (!currentSessionId) { + return "Chat"; + } + if (currentSessionId === currentFlowId) { + return "Default Session"; + } + return currentSessionId; +} diff --git a/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/utils/is-no-messages-error.ts b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/utils/is-no-messages-error.ts new file mode 100644 index 000000000000..e3b12d38a4f4 --- /dev/null +++ b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-header/utils/is-no-messages-error.ts @@ -0,0 +1,12 @@ +import type { AxiosError } from "axios"; + +export function isNoMessagesError(error: unknown): boolean { + if (!error) { + return false; + } + + const axiosError = error as AxiosError<{ detail?: string }>; + const detail = axiosError?.response?.data?.detail; + + return typeof detail === "string" && detail.includes("No messages found"); +} diff --git a/src/frontend/src/components/core/playgroundComponent/chat-view/hooks/use-get-flow-id.ts b/src/frontend/src/components/core/playgroundComponent/chat-view/hooks/use-get-flow-id.ts new file mode 100644 index 000000000000..bab7262100bb --- /dev/null +++ b/src/frontend/src/components/core/playgroundComponent/chat-view/hooks/use-get-flow-id.ts @@ -0,0 +1,14 @@ +import { v5 as uuidv5 } from "uuid"; +import useFlowStore from "@/stores/flowStore"; +import useFlowsManagerStore from "@/stores/flowsManagerStore"; +import { useUtilityStore } from "@/stores/utilityStore"; + +export const useGetFlowId = () => { + const clientId = useUtilityStore((state) => state.clientId); + const realFlowId = useFlowsManagerStore((state) => state.currentFlowId); + const playgroundPage = useFlowStore((state) => state.playgroundPage); + const currentFlowId = playgroundPage + ? uuidv5(`${clientId}_${realFlowId}`, uuidv5.DNS) + : realFlowId; + return currentFlowId; +}; diff --git a/src/frontend/src/components/core/playgroundComponent/chat-view/hooks/use-session-management.ts b/src/frontend/src/components/core/playgroundComponent/chat-view/hooks/use-session-management.ts new file mode 100644 index 000000000000..69f16a7403bc --- /dev/null +++ b/src/frontend/src/components/core/playgroundComponent/chat-view/hooks/use-session-management.ts @@ -0,0 +1,115 @@ +import { useEffect, useMemo, useState } from "react"; +import { useDeleteSession } from "@/controllers/API/queries/messages/use-delete-sessions"; +import { useGetSessionsFromFlowQuery } from "@/controllers/API/queries/messages/use-get-sessions-from-flow"; +import { useGetFlowId } from "./use-get-flow-id"; + +//Manages session state and selection logic for the chat view. +//Similar to IOModal's session management but simplified. +export function useSessionManagement(isContainerOpen: boolean) { + const currentFlowId = useGetFlowId(); + + // Fetch sessions only when container is open + const { data: sessionsData, isLoading: sessionsLoading } = + useGetSessionsFromFlowQuery( + { + id: currentFlowId, + }, + { + enabled: isContainerOpen, + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + }, + ); + + // Current selected session (similar to IOModal's visibleSession) + const [currentSessionId, setCurrentSessionId] = useState( + currentFlowId, + ); + + const { mutate: deleteSession } = useDeleteSession(); + + // Process sessions: include default flow ID and ensure no duplicates + const sessions = useMemo(() => { + if (!sessionsData?.sessions) { + return currentFlowId ? [currentFlowId] : []; + } + const sessionList = [...sessionsData.sessions]; + if (currentFlowId && !sessionList.includes(currentFlowId)) { + sessionList.unshift(currentFlowId); + } + return sessionList; + }, [sessionsData?.sessions, currentFlowId]); + + // Auto-select session when sessions are loaded + // If multiple sessions, select the last one (most recent) + // Only auto-select if no session is currently selected (prevents resetting manually selected/renamed sessions) + useEffect(() => { + if (sessionsLoading) return; + + // Only auto-select if no session is currently selected + // This prevents resetting manually selected sessions (e.g., after rename) + if (!currentSessionId) { + if (sessions.length > 0) { + // If multiple sessions, select the last one (most recent) + setCurrentSessionId(sessions[sessions.length - 1]); + } else if (currentFlowId) { + // No sessions, default to flow ID + setCurrentSessionId(currentFlowId); + } + } + // Note: We intentionally don't include currentSessionId in dependencies + // to avoid resetting manually selected sessions when the sessions list updates + }, [sessions, sessionsLoading, currentFlowId]); + + const handleSessionSelect = (sessionId: string) => { + setCurrentSessionId(sessionId); + }; + + const handleNewChat = () => { + const newSessionName = `Session ${new Date().toLocaleString("en-US", { + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit", + hour12: false, + second: "2-digit", + timeZone: "UTC", + })}`; + setCurrentSessionId(newSessionName); + // TODO: Clear messages or reset chat state + }; + + const handleDeleteSession = (sessionId: string) => { + if (!sessions.includes(sessionId)) return; + + deleteSession( + { sessionId }, + { + onSuccess: () => { + // If deleted session was the current one, switch to another + if (sessionId === currentSessionId) { + const remainingSessions = sessions.filter((s) => s !== sessionId); + if (remainingSessions.length > 0) { + setCurrentSessionId( + remainingSessions[remainingSessions.length - 1], + ); + } else { + setCurrentSessionId(currentFlowId); + } + } + }, + }, + ); + }; + + return { + currentSessionId, + sessions, + isLoading: sessionsLoading, + handleSessionSelect, + handleNewChat, + handleDeleteSession, + currentFlowId, + }; +} diff --git a/src/frontend/src/components/core/playgroundComponent/chat-view/messages/components/__tests__/chat-message.test.tsx b/src/frontend/src/components/core/playgroundComponent/chat-view/messages/components/__tests__/chat-message.test.tsx new file mode 100644 index 000000000000..b5d69cff1d53 --- /dev/null +++ b/src/frontend/src/components/core/playgroundComponent/chat-view/messages/components/__tests__/chat-message.test.tsx @@ -0,0 +1,117 @@ +import { render, screen } from "@testing-library/react"; +import ChatMessage from "../chat-message"; + +// Mock dependencies +jest.mock("@/stores/flowStore", () => ({ + __esModule: true, + default: () => ({ + isBuilding: false, + fitViewNode: jest.fn(), + }), +})); + +jest.mock("@/stores/flowsManagerStore", () => ({ + __esModule: true, + default: () => "flow-id", +})); + +jest.mock("@/stores/alertStore", () => ({ + __esModule: true, + default: () => jest.fn(), +})); + +jest.mock("@/controllers/API/queries/messages", () => ({ + useUpdateMessage: () => ({ mutate: jest.fn() }), +})); + +jest.mock("@/components/common/genericIconComponent", () => ({ + __esModule: true, + default: () =>
, + ForwardedIconComponent: () =>
, +})); + +jest.mock("@/components/common/sanitizedHTMLWrapper", () => ({ + __esModule: true, + default: ({ content }) =>
{content}
, +})); + +jest.mock("@/components/core/chatComponents/ContentBlockDisplay", () => ({ + ContentBlockDisplay: () =>
, +})); + +// Mock child components +jest.mock("../edit-message-field", () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock("../file-card-wrapper", () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock("../message-options", () => ({ + EditMessageButton: () =>
, +})); + +jest.mock("@/customization/components/custom-markdown-field", () => ({ + CustomMarkdownField: ({ chatMessage }) => ( +
{chatMessage}
+ ), +})); + +jest.mock("@/customization/components/custom-profile-icon", () => ({ + CustomProfileIcon: () =>
, +})); + +describe("ChatMessage Component", () => { + const mockChat = { + id: "1", + message: "Hello World", + isSend: true, + sender_name: "User", + timestamp: "2024-01-01T10:00:00Z", + session: "session-1", + files: [], + properties: { + source: { + id: "test-source", + display_name: "Test Source", + source: "test", + }, + }, + content_blocks: [], + category: "message", + }; + + const defaultProps = { + chat: mockChat, + lastMessage: false, + updateChat: jest.fn(), + closeChat: jest.fn(), + playgroundPage: true, + }; + + it("renders user message correctly", () => { + render(); + + expect(screen.getByText("User")).toBeInTheDocument(); + expect(screen.getByText("Hello World")).toBeInTheDocument(); + }); + + it("renders bot message correctly", () => { + const botProps = { + ...defaultProps, + chat: { + ...mockChat, + isSend: false, + sender_name: "AI", + }, + }; + + render(); + + expect(screen.getByText("AI")).toBeInTheDocument(); + expect(screen.getByText("Hello World")).toBeInTheDocument(); + }); +}); diff --git a/src/frontend/src/components/core/playgroundComponent/chat-view/messages/components/bot-message-logo.tsx b/src/frontend/src/components/core/playgroundComponent/chat-view/messages/components/bot-message-logo.tsx new file mode 100644 index 000000000000..017ea072b2d8 --- /dev/null +++ b/src/frontend/src/components/core/playgroundComponent/chat-view/messages/components/bot-message-logo.tsx @@ -0,0 +1,14 @@ +import LangflowLogo from "@/assets/LangflowLogo.svg?react"; + +export default function LogoIcon() { + return ( +
+
+ +
+
+ ); +} diff --git a/src/frontend/src/components/core/playgroundComponent/chat-view/messages/components/bot-message.tsx b/src/frontend/src/components/core/playgroundComponent/chat-view/messages/components/bot-message.tsx new file mode 100644 index 000000000000..71e9d432772a --- /dev/null +++ b/src/frontend/src/components/core/playgroundComponent/chat-view/messages/components/bot-message.tsx @@ -0,0 +1,214 @@ +import { memo, useState } from "react"; +import LangflowLogo from "@/assets/LangflowLogo.svg?react"; +import IconComponent, { + ForwardedIconComponent, +} from "@/components/common/genericIconComponent"; +import { ContentBlockDisplay } from "@/components/core/chatComponents/ContentBlockDisplay"; +import { useUpdateMessage } from "@/controllers/API/queries/messages"; +import { CustomMarkdownField } from "@/customization/components/custom-markdown-field"; +import useAlertStore from "@/stores/alertStore"; +import useFlowStore from "@/stores/flowStore"; +import useFlowsManagerStore from "@/stores/flowsManagerStore"; +import type { chatMessagePropsType } from "@/types/components"; +import { cn } from "@/utils/utils"; +import { convertFiles } from "../utils/convert-files"; +import EditMessageField from "./edit-message-field"; +import { EditMessageButton } from "./message-options"; + +export const BotMessage = memo( + ({ chat, lastMessage, updateChat, playgroundPage }: chatMessagePropsType) => { + const setErrorData = useAlertStore((state) => state.setErrorData); + const [editMessage, setEditMessage] = useState(false); + const isBuilding = useFlowStore((state) => state.isBuilding); + const flow_id = useFlowsManagerStore((state) => state.currentFlowId); + + const isAudioMessage = chat.category === "audio"; + const chatMessage = chat.message ? chat.message.toString() : ""; + + let decodedMessage = chatMessage ?? ""; + try { + decodedMessage = decodeURIComponent(chatMessage); + } catch (_e) { + // ignore decode errors + } + + const isEmpty = decodedMessage?.trim() === ""; + const { mutate: updateMessageMutation } = useUpdateMessage(); + + const handleEditMessage = (message: string) => { + updateMessageMutation( + { + message: { + id: chat.id, + files: convertFiles(chat.files), + sender_name: chat.sender_name ?? "AI", + text: message, + sender: "Machine", + flow_id, + session_id: chat.session ?? "", + }, + refetch: true, + }, + { + onSuccess: () => { + updateChat(chat, message); + setEditMessage(false); + }, + onError: () => { + setErrorData({ + title: "Error updating messages.", + }); + }, + }, + ); + }; + + const handleEvaluateAnswer = (evaluation: boolean | null) => { + updateMessageMutation( + { + message: { + ...chat, + files: convertFiles(chat.files), + sender_name: chat.sender_name ?? "AI", + text: chat.message.toString(), + sender: "Machine", + flow_id, + session_id: chat.session ?? "", + properties: { + ...chat.properties, + positive_feedback: evaluation, + }, + }, + refetch: true, + }, + { + onError: () => { + setErrorData({ + title: "Error updating messages.", + }); + }, + }, + ); + }; + + const editedFlag = chat.edit ? ( +
(Edited)
+ ) : null; + + const isEmoji = chat.properties?.icon?.match( + /[\u2600-\u27BF\uD83C-\uDBFF\uDC00-\uDFFF]/, + ); + + return ( + <> +
+
+ {/* Avatar */} +
+
+ {chat.properties?.icon ? ( + isEmoji ? ( + {chat.properties.icon} + ) : ( + + ) + ) : ( + + )} +
+
+ + {/* Content */} +
+ {chat.content_blocks && chat.content_blocks.length > 0 && ( + + )} + +
+
+
+
+
+ {chatMessage === "" && isBuilding && lastMessage ? ( + + ) : ( +
+ {editMessage ? ( + setEditMessage(false)} + /> + ) : ( + + )} +
+ )} +
+
+
+
+
+
+ + {/* Actions */} + {!editMessage && ( +
+ navigator.clipboard.writeText(chatMessage)} + onEdit={ + playgroundPage ? undefined : () => setEditMessage(true) + } + className="h-fit group-hover:visible" + isBotMessage={true} + onEvaluate={handleEvaluateAnswer} + evaluation={chat.properties?.positive_feedback} + isAudioMessage={isAudioMessage} + /> +
+ )} +
+
+
+ + ); + }, +); diff --git a/src/frontend/src/components/core/playgroundComponent/chat-view/messages/components/chat-message.tsx b/src/frontend/src/components/core/playgroundComponent/chat-view/messages/components/chat-message.tsx new file mode 100644 index 000000000000..a93cd765b971 --- /dev/null +++ b/src/frontend/src/components/core/playgroundComponent/chat-view/messages/components/chat-message.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from "react"; +import useFlowStore from "@/stores/flowStore"; +import type { chatMessagePropsType } from "@/types/components"; +import { BotMessage } from "./bot-message"; +import { ErrorView } from "./error-message"; +import { UserMessage } from "./user-message"; + +export default function ChatMessage({ + chat, + lastMessage, + updateChat, + closeChat, + playgroundPage, +}: chatMessagePropsType): JSX.Element { + const fitViewNode = useFlowStore((state) => state.fitViewNode); + const [showError, setShowError] = useState(false); + + // Handle error display delay + useEffect(() => { + if (chat.category === "error") { + const timer = setTimeout(() => { + setShowError(true); + }, 50); + return () => clearTimeout(timer); + } + }, [chat.category]); + + // Error messages + if (chat.category === "error") { + const blocks = chat.content_blocks ?? []; + return ( + + ); + } + + // Check if message is empty (would show "No input message provided") + const chatMessage = chat.message ? chat.message.toString() : ""; + const isEmpty = chatMessage.trim() === ""; + + // User messages (but treat empty messages as bot messages) + if (chat.isSend && !isEmpty) { + return ( + + ); + } + + // Bot messages (and empty user messages) + return ( + + ); +} diff --git a/src/frontend/src/components/core/playgroundComponent/chat-view/messages/components/edit-message-field.tsx b/src/frontend/src/components/core/playgroundComponent/chat-view/messages/components/edit-message-field.tsx new file mode 100644 index 000000000000..d0b72986dd0f --- /dev/null +++ b/src/frontend/src/components/core/playgroundComponent/chat-view/messages/components/edit-message-field.tsx @@ -0,0 +1,78 @@ +import { useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; + +export default function EditMessageField({ + message: initialMessage, + onEdit, + onCancel, +}: { + message: string; + onEdit: (message: string) => void; + onCancel: () => void; +}) { + const [message, setMessage] = useState(initialMessage); + const textareaRef = useRef(null); + // used before to onBlur function, leave it here because in the future we may want this functionality again + const [_isButtonClicked, setIsButtonClicked] = useState(false); + const adjustTextareaHeight = () => { + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 3}px`; + } + }; + useEffect(() => { + adjustTextareaHeight(); + }, []); + + return ( +
+