diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/ActionMenu/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/ActionMenu/index.jsx index f77af8bd..8dc2ab71 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/ActionMenu/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/ActionMenu/index.jsx @@ -22,6 +22,7 @@ function ActionMenu({ chatId, forkThread, isEditing, role }) { }; useEffect(() => { + if (!open) return; const handleClickOutside = (event) => { if (menuRef.current && !menuRef.current.contains(event.target)) { setOpen(false); @@ -32,7 +33,7 @@ function ActionMenu({ chatId, forkThread, isEditing, role }) { return () => { document.removeEventListener("mousedown", handleClickOutside); }; - }, []); + }, [open]); if (!chatId || isEditing || role === "user") return null; diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/DeleteMessage/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/DeleteMessage/index.jsx index 1e9518e7..8c12f045 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/DeleteMessage/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/DeleteMessage/index.jsx @@ -1,40 +1,31 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { Trash } from "@phosphor-icons/react"; import Workspace from "@/models/workspace"; - -const DELETE_EVENT = "delete-message"; +import { + useMessageActionsContext, + DELETE_EVENT, +} from "@/components/WorkspaceChat/ChatContainer/ChatHistory/MessageActionsContext"; export function useWatchDeleteMessage({ chatId = null, role = "user" }) { - const [isDeleted, setIsDeleted] = useState(false); + const context = useMessageActionsContext(); const [completeDelete, setCompleteDelete] = useState(false); + const deleteCalled = useRef(false); + const isDeleted = context?.isDeleted(chatId) ?? false; useEffect(() => { - function listenForEvent() { - if (!chatId) return; - window.addEventListener(DELETE_EVENT, onDeleteEvent); + if (isDeleted && !deleteCalled.current) { + deleteCalled.current = true; + if (role === "assistant") { + Workspace.deleteChat(chatId); + } } - listenForEvent(); - return () => { - window.removeEventListener(DELETE_EVENT, onDeleteEvent); - }; - }, [chatId]); + }, [isDeleted, chatId, role]); function onEndAnimation() { if (!isDeleted) return; setCompleteDelete(true); } - async function onDeleteEvent(e) { - if (e.detail.chatId === chatId) { - setIsDeleted(true); - // Do this to prevent double-emission of the PUT/DELETE api call - // because then there will be a race condition and it will make an error log for nothing - // as one call will complete and the other will fail. - if (role === "assistant") await Workspace.deleteChat(chatId); - return false; - } - } - return { isDeleted, completeDelete, onEndAnimation }; } diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/EditMessage/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/EditMessage/index.jsx index e9b0536a..3a1dc8aa 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/EditMessage/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/EditMessage/index.jsx @@ -1,33 +1,16 @@ import { Info, Pencil } from "@phosphor-icons/react"; -import { useState, useEffect, useRef } from "react"; +import { useRef, useEffect } from "react"; import Appearance from "@/models/appearance"; import { useTranslation } from "react-i18next"; - -const EDIT_EVENT = "toggle-message-edit"; +import { + useMessageActionsContext, + EDIT_EVENT, +} from "@/components/WorkspaceChat/ChatContainer/ChatHistory/MessageActionsContext"; export function useEditMessage({ chatId, role }) { - const [isEditing, setIsEditing] = useState(false); - - function onEditEvent(e) { - if (e.detail.chatId !== chatId || e.detail.role !== role) { - setIsEditing(false); - return false; - } - setIsEditing((prev) => !prev); - } - - useEffect(() => { - function listenForEdits() { - if (!chatId || !role) return; - window.addEventListener(EDIT_EVENT, onEditEvent); - } - listenForEdits(); - return () => { - window.removeEventListener(EDIT_EVENT, onEditEvent); - }; - }, [chatId, role]); - - return { isEditing, setIsEditing }; + const context = useMessageActionsContext(); + const isEditing = context?.isEditing(chatId, role) ?? false; + return { isEditing }; } export function EditMessageAction({ chatId = null, role, isEditing }) { @@ -53,7 +36,7 @@ export function EditMessageAction({ chatId = null, role, isEditing }) { ? t("chat_window.edit_prompt") : t("chat_window.edit_response") } `} - className="border-none text-zinc-300 light:text-slate-500" + className="border-none text-zinc-300 light:text-slate-500 px-0" aria-label={`Edit ${role === "user" ? t("chat_window.edit_prompt") : t("chat_window.edit_response")}`} > diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/MessageActionsContext.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/MessageActionsContext.jsx new file mode 100644 index 00000000..960e0093 --- /dev/null +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/MessageActionsContext.jsx @@ -0,0 +1,87 @@ +import { + createContext, + useContext, + useState, + useEffect, + useCallback, +} from "react"; + +const EDIT_EVENT = "toggle-message-edit"; +const DELETE_EVENT = "delete-message"; + +const MessageActionsContext = createContext(null); + +/** + * Provider that centralizes edit/delete event listeners for all messages. + * Instead of each message registering its own window listener (O(n) listeners), + * this provider registers just 2 listeners total and dispatches to messages via context. + */ +export function MessageActionsProvider({ children }) { + const [editingMessage, setEditingMessage] = useState(null); + const [deletedMessages, setDeletedMessages] = useState(new Set()); + + useEffect(() => { + function handleEditEvent(e) { + const { chatId, role } = e.detail; + if (!chatId || !role) return; + + setEditingMessage((prev) => { + if (prev?.chatId === chatId && prev?.role === role) { + return null; + } + return { chatId, role }; + }); + } + + function handleDeleteEvent(e) { + const { chatId } = e.detail; + if (!chatId) return; + + setDeletedMessages((prev) => { + const next = new Set(prev); + next.add(chatId); + return next; + }); + } + + window.addEventListener(EDIT_EVENT, handleEditEvent); + window.addEventListener(DELETE_EVENT, handleDeleteEvent); + + return () => { + window.removeEventListener(EDIT_EVENT, handleEditEvent); + window.removeEventListener(DELETE_EVENT, handleDeleteEvent); + }; + }, []); + + const isEditing = useCallback( + (chatId, role) => { + return editingMessage?.chatId === chatId && editingMessage?.role === role; + }, + [editingMessage] + ); + + const isDeleted = useCallback( + (chatId) => { + return deletedMessages.has(chatId); + }, + [deletedMessages] + ); + + const clearEditing = useCallback(() => { + setEditingMessage(null); + }, []); + + return ( + + {children} + + ); +} + +export function useMessageActionsContext() { + return useContext(MessageActionsContext); +} + +export { EDIT_EVENT, DELETE_EVENT }; diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx index 5f60fd3e..2ebaeacd 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx @@ -23,6 +23,7 @@ import Appearance from "@/models/appearance"; import useTextSize from "@/hooks/useTextSize"; import useChatHistoryScrollHandle from "@/hooks/useChatHistoryScrollHandle"; import { ThoughtExpansionProvider } from "./ThoughtContainer"; +import { MessageActionsProvider } from "./MessageActionsContext"; export default forwardRef(function ( { @@ -209,41 +210,43 @@ export default forwardRef(function ( ); return ( - - - - {compiledHistory.map((item, index) => - Array.isArray(item) ? renderStatusResponse(item, index) : item + + + + + {compiledHistory.map((item, index) => + Array.isArray(item) ? renderStatusResponse(item, index) : item + )} + + {showing && ( + )} - {showing && ( - - )} - - {!isAtBottom && ( - - - { - scrollToBottom(isStreaming ? false : true); - setIsUserScrolling(false); - }} - > - + {!isAtBottom && ( + + + { + scrollToBottom(isStreaming ? false : true); + setIsUserScrolling(false); + }} + > + + - - )} - + )} + + ); });