From fd280723d2fb54848812e70460f5932434a9b6dd Mon Sep 17 00:00:00 2001 From: Timothy Carambat Date: Wed, 26 Aug 2026 14:58:23 -0700 Subject: [PATCH] fix: agent WSS send while connecting + prompt input draft persistence - Skip relaying feedback over the agent websocket until it is OPEN - session start re-triggers fetchReply while the socket is still connecting and the server already handles the invocation prompt itself - Guard debounced undo-stack snapshot against unmounted PromptInput - Scope prompt drafts correctly per thread/workspace: hydrate input on key change so drafts don't leak across threads, accept slug props on param-less routes (Home), cancel pending writes on draft clear, and stop storing empty drafts --- .../ChatContainer/PromptInput/index.jsx | 5 + .../WorkspaceChat/ChatContainer/index.jsx | 8 ++ frontend/src/hooks/usePromptInputStorage.js | 131 ++++++++++++------ frontend/src/pages/Main/Home/index.jsx | 6 + 4 files changed, 110 insertions(+), 40 deletions(-) diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx index da1349d760f..42ee50c71e6 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx @@ -63,6 +63,8 @@ export default function PromptInput({ usePromptInputStorage({ promptInput, setPromptInput, + workspaceSlug: workspaceSlug ?? workspace?.slug, + threadSlug, }); /* @@ -108,6 +110,9 @@ export default function PromptInput({ * @param {number} adjustment */ function saveCurrentState(adjustment = 0) { + // The debounced call can land after this instance unmounted (eg: the + // empty->chat transition remounts PromptInput mid-debounce). + if (!textareaRef.current) return; if (undoStack.current.length >= MAX_EDIT_STACK_SIZE) undoStack.current.shift(); undoStack.current.push({ diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx index e96655f2c69..c62ef6a27f7 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx @@ -304,6 +304,14 @@ export default function ChatContainer({ // Override hook for new messages to now go to agents until the connection closes if (!!websocket) { if (!promptMessage || !promptMessage?.userMessage) return false; + + // Session start re-triggers this effect (setLoadingResponse(true) in + // handleWSS) while the socket is still CONNECTING. The server already + // begins working on the invocation prompt itself on connect, so there + // is no feedback to relay yet - sending here would both throw + // (InvalidStateError) and duplicate the opening prompt. + if (websocket.readyState !== WebSocket.OPEN) return; + const attachments = promptMessage?.attachments ?? parseAttachments(); window.dispatchEvent(new CustomEvent(CLEAR_ATTACHMENTS_EVENT)); websocket.send( diff --git a/frontend/src/hooks/usePromptInputStorage.js b/frontend/src/hooks/usePromptInputStorage.js index bb72c5720b2..e3662b8d50d 100644 --- a/frontend/src/hooks/usePromptInputStorage.js +++ b/frontend/src/hooks/usePromptInputStorage.js @@ -1,15 +1,52 @@ import { USER_PROMPT_INPUT_MAP } from "@/utils/constants"; -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { useParams } from "react-router-dom"; import debounce from "lodash.debounce"; import { safeJsonParse } from "@/utils/request"; +/** + * Fired by clearPromptInputDraft so any mounted hook instance can cancel a + * still-pending debounced write that would otherwise resurrect the draft + * right after it was cleared (eg: submitting within the debounce window). + */ +export const PROMPT_DRAFT_CLEARED_EVENT = "prompt_draft_cleared"; + +function readPromptInputMap() { + return safeJsonParse(localStorage.getItem(USER_PROMPT_INPUT_MAP) || "{}", {}); +} + +function writePromptInputMap(map) { + // Empty drafts are dropped entirely (incl. legacy "" entries) - a missing + // key already means "no draft", so storing empties is just waste. + for (const key of Object.keys(map)) if (!map[key]) delete map[key]; + localStorage.setItem(USER_PROMPT_INPUT_MAP, JSON.stringify(map)); +} + +/** + * Immediately clears the stored draft for a given thread/workspace key. + * Used before state updates that may remount PromptInput to prevent + * stale text from being restored. + * @param {string} storageKey - thread slug or workspace slug + */ +export function clearPromptInputDraft(storageKey) { + try { + const map = readPromptInputMap(); + delete map[storageKey]; + writePromptInputMap(map); + window.dispatchEvent( + new CustomEvent(PROMPT_DRAFT_CLEARED_EVENT, { detail: { storageKey } }) + ); + } catch {} +} + /** * Synchronizes prompt input value with localStorage, scoped to the current thread. * * Persists unsent prompt text across page refreshes and navigation. Each thread/workspace maintains - * its own draft state independently. Storage key is determined by thread slug (if in a thread) or - * workspace slug (if in default chat). + * its own draft state independently, so the input is re-hydrated (or emptied) whenever the + * thread/workspace changes. Storage key is determined by thread slug (if in a thread) or + * workspace slug (if in default chat) - passed as props on routes without params (eg: Home page) + * with the route params as fallback. * * Storage format (stored under USER_PROMPT_INPUT_MAP key): * ```json @@ -22,56 +59,70 @@ import { safeJsonParse } from "@/utils/request"; * @param {Object} props * @param {string} props.promptInput - Current prompt input value to sync * @param {Function} props.setPromptInput - State setter function for prompt input + * @param {string|null} [props.workspaceSlug] - workspace slug when the route has no params + * @param {string|null} [props.threadSlug] - thread slug when the route has no params * @returns {void} */ -/** - * Immediately clears the stored draft for a given thread/workspace key. - * Used before state updates that may remount PromptInput to prevent - * stale text from being restored. - * @param {string} storageKey - thread slug or workspace slug - */ -export function clearPromptInputDraft(storageKey) { - try { - const map = safeJsonParse(localStorage.getItem(USER_PROMPT_INPUT_MAP), {}); - map[storageKey] = ""; - localStorage.setItem(USER_PROMPT_INPUT_MAP, JSON.stringify(map)); - } catch {} -} - -export default function usePromptInputStorage({ promptInput, setPromptInput }) { - const { threadSlug = null, slug: workspaceSlug } = useParams(); - useEffect(() => { - const serializedPromptInputMap = - localStorage.getItem(USER_PROMPT_INPUT_MAP) || "{}"; - - const promptInputMap = safeJsonParse(serializedPromptInputMap, {}); - - const userPromptInputValue = promptInputMap[threadSlug ?? workspaceSlug]; - if (userPromptInputValue) { - setPromptInput(userPromptInputValue); - } - }, []); +export default function usePromptInputStorage({ + promptInput, + setPromptInput, + workspaceSlug = null, + threadSlug = null, +}) { + const params = useParams(); + const storageKey = + threadSlug ?? params.threadSlug ?? workspaceSlug ?? params.slug ?? null; + const pendingHydration = useRef(true); const debouncedWriteToStorage = useMemo( () => debounce((value, slug) => { - const serializedPromptInputMap = - localStorage.getItem(USER_PROMPT_INPUT_MAP) || "{}"; - const promptInputMap = safeJsonParse(serializedPromptInputMap, {}); - promptInputMap[slug] = value; - localStorage.setItem( - USER_PROMPT_INPUT_MAP, - JSON.stringify(promptInputMap) - ); + const map = readPromptInputMap(); + map[slug] = value; + writePromptInputMap(map); }, 500), [] ); + // Hydrate the input from the stored draft whenever the thread/workspace + // changes. Always sets (falling back to "") so a draft typed in one thread + // never carries over into another. useEffect(() => { - debouncedWriteToStorage(promptInput, threadSlug ?? workspaceSlug); + if (!storageKey) return; + pendingHydration.current = true; + setPromptInput(readPromptInputMap()[storageKey] || ""); + }, [storageKey]); + + useEffect(() => { + if (!storageKey) return; + // Right after a storageKey change, promptInput still holds the previous + // thread's text for one render - skip persisting it under the new key + // and wait for the hydrated value to land. + if (pendingHydration.current) { + pendingHydration.current = false; + return; + } + debouncedWriteToStorage(promptInput, storageKey); return () => { + // Runs before the next write (persist-as-you-type) and on key change / + // unmount, so an in-flight draft is committed under its own key. debouncedWriteToStorage.flush(); }; - }, [promptInput, threadSlug, workspaceSlug, debouncedWriteToStorage]); + }, [promptInput, storageKey, debouncedWriteToStorage]); + + // A cleared draft must also drop any pending write for that key, otherwise + // the debounce/flush can write the old text right back. + useEffect(() => { + function cancelPendingWrite(e) { + if (!e?.detail?.storageKey || e.detail.storageKey === storageKey) + debouncedWriteToStorage.cancel(); + } + window.addEventListener(PROMPT_DRAFT_CLEARED_EVENT, cancelPendingWrite); + return () => + window.removeEventListener( + PROMPT_DRAFT_CLEARED_EVENT, + cancelPendingWrite + ); + }, [storageKey, debouncedWriteToStorage]); } diff --git a/frontend/src/pages/Main/Home/index.jsx b/frontend/src/pages/Main/Home/index.jsx index 8c83072349e..775396fadc8 100644 --- a/frontend/src/pages/Main/Home/index.jsx +++ b/frontend/src/pages/Main/Home/index.jsx @@ -27,6 +27,7 @@ import ChatSettingsMenu from "@/components/WorkspaceChat/ChatContainer/ChatSetti import WorkspaceModelPicker from "@/components/WorkspaceChat/ChatContainer/WorkspaceModelPicker"; import { ChatTooltips } from "@/components/WorkspaceChat/ChatContainer/ChatTooltips"; import { ChatSidebarProvider } from "@/components/WorkspaceChat/ChatContainer/ChatSidebar"; +import { clearPromptInputDraft } from "@/hooks/usePromptInputStorage"; import MemoriesSidebar from "@/components/WorkspaceChat/ChatContainer/MemoriesSidebar"; async function getTargetWorkspace() { @@ -227,6 +228,11 @@ function HomeContent({ workspace, setWorkspace, threadSlug, setThreadSlug }) { JSON.stringify({ message, attachments }) ); + // The message is replayed via PENDING_HOME_MESSAGE on the thread route - + // drop the local draft so the sent text cannot be restored later. + if (threadSlug || workspace?.slug) + clearPromptInputDraft(threadSlug ?? workspace.slug); + if (targetThread) { navigate(paths.workspace.thread(targetWorkspace.slug, targetThread)); } else {