Fix chat UI event listener bloat (#5323)

This commit is contained in:
Timothy Carambat 2026-04-01 17:00:32 -07:00 committed by GitHub
parent 88ea47b9f4
commit 5a2393e632
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 146 additions and 81 deletions

View File

@ -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;

View File

@ -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 };
}

View File

@ -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")}`}
>
<Pencil size={21} className="mb-1" />

View File

@ -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 (
<MessageActionsContext.Provider
value={{ editingMessage, isEditing, isDeleted, clearEditing }}
>
{children}
</MessageActionsContext.Provider>
);
}
export function useMessageActionsContext() {
return useContext(MessageActionsContext);
}
export { EDIT_EVENT, DELETE_EVENT };

View File

@ -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 (
<ThoughtExpansionProvider>
<div
className={`markdown text-white/80 light:text-theme-text-primary font-light ${textSizeClass} h-full md:h-[83%] pb-[100px] pt-6 md:pt-0 md:pb-20 md:mx-0 overflow-y-scroll flex flex-col items-center justify-start ${showScrollbar ? "show-scrollbar" : "no-scroll"}`}
id="chat-history"
ref={chatHistoryRef}
onScroll={handleScroll}
>
<div className="w-full max-w-[750px]">
{compiledHistory.map((item, index) =>
Array.isArray(item) ? renderStatusResponse(item, index) : item
<MessageActionsProvider>
<ThoughtExpansionProvider>
<div
className={`markdown text-white/80 light:text-theme-text-primary font-light ${textSizeClass} h-full md:h-[83%] pb-[100px] pt-6 md:pt-0 md:pb-20 md:mx-0 overflow-y-scroll flex flex-col items-center justify-start ${showScrollbar ? "show-scrollbar" : "no-scroll"}`}
id="chat-history"
ref={chatHistoryRef}
onScroll={handleScroll}
>
<div className="w-full max-w-[750px]">
{compiledHistory.map((item, index) =>
Array.isArray(item) ? renderStatusResponse(item, index) : item
)}
</div>
{showing && (
<ManageWorkspace
hideModal={hideModal}
providedSlug={workspace.slug}
/>
)}
</div>
{showing && (
<ManageWorkspace
hideModal={hideModal}
providedSlug={workspace.slug}
/>
)}
</div>
{!isAtBottom && (
<div className="absolute bottom-40 right-10 z-50 cursor-pointer animate-pulse">
<div className="flex flex-col items-center">
<div
className="p-1 rounded-full border border-white/10 bg-white/10 hover:bg-white/20 hover:text-white"
onClick={() => {
scrollToBottom(isStreaming ? false : true);
setIsUserScrolling(false);
}}
>
<ArrowDown weight="bold" className="text-white/60 w-5 h-5" />
{!isAtBottom && (
<div className="absolute bottom-40 right-10 z-50 cursor-pointer animate-pulse">
<div className="flex flex-col items-center">
<div
className="p-1 rounded-full border border-white/10 bg-white/10 hover:bg-white/20 hover:text-white"
onClick={() => {
scrollToBottom(isStreaming ? false : true);
setIsUserScrolling(false);
}}
>
<ArrowDown weight="bold" className="text-white/60 w-5 h-5" />
</div>
</div>
</div>
</div>
)}
</ThoughtExpansionProvider>
)}
</ThoughtExpansionProvider>
</MessageActionsProvider>
);
});