Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export default function PromptInput({
usePromptInputStorage({
promptInput,
setPromptInput,
workspaceSlug: workspaceSlug ?? workspace?.slug,
threadSlug,
});

/*
Expand Down Expand Up @@ -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({
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/components/WorkspaceChat/ChatContainer/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
131 changes: 91 additions & 40 deletions frontend/src/hooks/usePromptInputStorage.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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]);
}
6 changes: 6 additions & 0 deletions frontend/src/pages/Main/Home/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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 {
Expand Down
Loading