From 8ffb7eb6e957715247cd6e2d8ace668f2ea73c6b Mon Sep 17 00:00:00 2001 From: Marcello Fitton <106866560+angelplusultra@users.noreply.github.com> Date: Tue, 2 Dec 2025 13:45:26 -0800 Subject: [PATCH] Store Chat Prompt Input Value in Local Storage (#4680) * Add logic to save prompt input state to local storage and use as initial state on mount * Synchronize prompt input state with parent component on mount * lint * Clear USER_PROMPT_INPUT_VALUE local storage value in all instances of auth clearing * Remove USER_PROMPT_INPUT_VALUE local storage `removeItem` logic from excessive sources * Refactor logic to cache prompt input value state by thread | abstract into a custom hook | rename localStorage key variables for clarity. * Remove console log statement from usePromptInputStorage hook to clean up code. * Update comments in usePromptInputStorage hook for clarity on localStorage handling * Implement debounced localStorage updates in usePromptInputStorage hook to improve performance and prevent writing on every keystroke. * Refactor localStorage handling in usePromptInputStorage hook to utilize safeJsonParse | Remove uneeeded comments * Remove useEffect cleanup comment --------- Co-authored-by: Timothy Carambat --- frontend/src/AuthContext.jsx | 8 ++- .../components/UserMenu/UserButton/index.jsx | 2 + .../ChatContainer/PromptInput/index.jsx | 8 +++ frontend/src/hooks/usePromptInputStorage.js | 71 +++++++++++++++++++ frontend/src/utils/constants.js | 1 + 5 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 frontend/src/hooks/usePromptInputStorage.js diff --git a/frontend/src/AuthContext.jsx b/frontend/src/AuthContext.jsx index 388cd1d4..672241f9 100644 --- a/frontend/src/AuthContext.jsx +++ b/frontend/src/AuthContext.jsx @@ -1,5 +1,10 @@ import React, { useState, createContext } from "react"; -import { AUTH_TIMESTAMP, AUTH_TOKEN, AUTH_USER } from "@/utils/constants"; +import { + AUTH_TIMESTAMP, + AUTH_TOKEN, + AUTH_USER, + USER_PROMPT_INPUT_MAP, +} from "@/utils/constants"; export const AuthContext = createContext(null); export function AuthProvider(props) { @@ -20,6 +25,7 @@ export function AuthProvider(props) { localStorage.removeItem(AUTH_USER); localStorage.removeItem(AUTH_TOKEN); localStorage.removeItem(AUTH_TIMESTAMP); + localStorage.removeItem(USER_PROMPT_INPUT_MAP); setStore({ user: null, authToken: null }); }, }); diff --git a/frontend/src/components/UserMenu/UserButton/index.jsx b/frontend/src/components/UserMenu/UserButton/index.jsx index 8e00e083..a0016f5a 100644 --- a/frontend/src/components/UserMenu/UserButton/index.jsx +++ b/frontend/src/components/UserMenu/UserButton/index.jsx @@ -12,6 +12,7 @@ import { AUTH_TOKEN, AUTH_USER, LAST_VISITED_WORKSPACE, + USER_PROMPT_INPUT_MAP, } from "@/utils/constants"; import { useTranslation } from "react-i18next"; @@ -97,6 +98,7 @@ export default function UserButton() { window.localStorage.removeItem(AUTH_TOKEN); window.localStorage.removeItem(AUTH_TIMESTAMP); window.localStorage.removeItem(LAST_VISITED_WORKSPACE); + window.localStorage.removeItem(USER_PROMPT_INPUT_MAP); window.location.replace(paths.home()); }} type="button" diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx index b8eca495..1e0c95de 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx @@ -24,6 +24,7 @@ import { import useTextSize from "@/hooks/useTextSize"; import { useTranslation } from "react-i18next"; import Appearance from "@/models/appearance"; +import usePromptInputStorage from "@/hooks/usePromptInputStorage"; export const PROMPT_INPUT_ID = "primary-prompt-input"; export const PROMPT_INPUT_EVENT = "set_prompt_input"; @@ -48,6 +49,13 @@ export default function PromptInput({ const redoStack = useRef([]); const { textSizeClass } = useTextSize(); + // Synchronizes prompt input value with localStorage, scoped to the current thread. + usePromptInputStorage({ + onChange, + promptInput, + setPromptInput, + }); + /** * To prevent too many re-renders we remotely listen for updates from the parent * via an event cycle. Otherwise, using message as a prop leads to a re-render every diff --git a/frontend/src/hooks/usePromptInputStorage.js b/frontend/src/hooks/usePromptInputStorage.js new file mode 100644 index 00000000..5d8ce633 --- /dev/null +++ b/frontend/src/hooks/usePromptInputStorage.js @@ -0,0 +1,71 @@ +import { USER_PROMPT_INPUT_MAP } from "@/utils/constants"; +import { useEffect, useMemo } from "react"; +import { useParams } from "react-router-dom"; +import debounce from "lodash.debounce"; +import { safeJsonParse } from "@/utils/request"; + +/** + * 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). + * + * Storage format (stored under USER_PROMPT_INPUT_MAP key): + * ```json + * { + * "thread-slug": "user's draft message...", + * "workspace-slug": "another draft message..." + * } + * ``` + * + * @param {Object} props + * @param {Function} props.onChange - Callback invoked when restoring saved value, receives `{ target: { value: string } }` + * @param {string} props.promptInput - Current prompt input value to sync + * @param {Function} props.setPromptInput - State setter function for prompt input + * @returns {void} + */ +export default function usePromptInputStorage({ + onChange, + 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); + // Notify parent component so message state is synchronized + onChange({ target: { value: userPromptInputValue } }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + 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) + ); + }, 500), + [] + ); + + useEffect(() => { + debouncedWriteToStorage(promptInput, threadSlug ?? workspaceSlug); + + return () => { + debouncedWriteToStorage.cancel(); + }; + }, [promptInput, threadSlug, workspaceSlug, debouncedWriteToStorage]); +} diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js index e4a43a54..c1fae8fc 100644 --- a/frontend/src/utils/constants.js +++ b/frontend/src/utils/constants.js @@ -8,6 +8,7 @@ export const COMPLETE_QUESTIONNAIRE = "anythingllm_completed_questionnaire"; export const SEEN_DOC_PIN_ALERT = "anythingllm_pinned_document_alert"; export const SEEN_WATCH_ALERT = "anythingllm_watched_document_alert"; export const LAST_VISITED_WORKSPACE = "anythingllm_last_visited_workspace"; +export const USER_PROMPT_INPUT_MAP = "anythingllm_user_prompt_input_map"; export const APPEARANCE_SETTINGS = "anythingllm_appearance_settings";