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