From 1635b1885639d38b38303944c54d8fc87ed501cb Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 4 Mar 2026 09:42:13 -0500 Subject: [PATCH] fix: persist draft message in localStorage on agent detail page (#22600) ## Problem On the `/agents/:agentId` detail page, text typed into the chat input was lost when navigating away and returning. The empty-state page (`/agents`) already persisted drafts via `localStorage`, but individual conversation pages did not. ## Solution Adds per-conversation draft persistence to `useConversationEditingState` in `AgentDetail.tsx`, following the same patterns used elsewhere in the agents page: - Drafts are stored under `agents.draft-input.` keys - The saved draft is read as the editor's initial value on mount - `localStorage` is updated on every content change - The key is removed when the input is cleared or a message is sent successfully --- site/src/pages/AgentsPage/AgentDetail.test.ts | 104 ++++++++++++++++++ site/src/pages/AgentsPage/AgentDetail.tsx | 53 +++++++-- site/src/pages/AgentsPage/AgentsPage.tsx | 14 ++- 3 files changed, 161 insertions(+), 10 deletions(-) create mode 100644 site/src/pages/AgentsPage/AgentDetail.test.ts diff --git a/site/src/pages/AgentsPage/AgentDetail.test.ts b/site/src/pages/AgentsPage/AgentDetail.test.ts new file mode 100644 index 0000000000..521e285464 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentDetail.test.ts @@ -0,0 +1,104 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + draftInputStorageKeyPrefix, + useConversationEditingState, +} from "./AgentDetail"; + +describe("useConversationEditingState", () => { + const chatID = "chat-abc-123"; + const expectedKey = `${draftInputStorageKeyPrefix}${chatID}`; + + beforeEach(() => { + localStorage.clear(); + }); + + const renderEditing = (id: string | undefined = chatID) => { + const onSend = vi.fn().mockResolvedValue(undefined); + const onDeleteQueuedMessage = vi.fn().mockResolvedValue(undefined); + + const hook = renderHook(() => + useConversationEditingState({ + chatID: id, + onSend, + onDeleteQueuedMessage, + }), + ); + + return { ...hook, onSend, onDeleteQueuedMessage }; + }; + + it("reads the initial value from localStorage for a given chatID", () => { + localStorage.setItem(expectedKey, "saved draft"); + + const { result, unmount } = renderEditing(); + + expect(result.current.editorInitialValue).toBe("saved draft"); + expect(result.current.inputValueRef.current).toBe("saved draft"); + unmount(); + }); + + it("returns empty string when localStorage has no draft", () => { + const { result, unmount } = renderEditing(); + + expect(result.current.editorInitialValue).toBe(""); + expect(result.current.inputValueRef.current).toBe(""); + unmount(); + }); + + it("writes content to localStorage via handleContentChange", () => { + const { result, unmount } = renderEditing(); + + act(() => { + result.current.handleContentChange("work in progress"); + }); + + expect(localStorage.getItem(expectedKey)).toBe("work in progress"); + expect(result.current.inputValueRef.current).toBe("work in progress"); + unmount(); + }); + + it("removes the draft key when handleContentChange receives empty string", () => { + localStorage.setItem(expectedKey, "old draft"); + const { result, unmount } = renderEditing(); + + act(() => { + result.current.handleContentChange(""); + }); + + expect(localStorage.getItem(expectedKey)).toBeNull(); + unmount(); + }); + + it("does not write a draft key when chatID is undefined", () => { + const { result, unmount } = renderEditing(undefined); + + act(() => { + result.current.handleContentChange("should not persist"); + }); + + // The ref is still updated even without persistence. + expect(result.current.inputValueRef.current).toBe("should not persist"); + // No draft for "undefined" chatID should appear. + expect( + localStorage.getItem(`${draftInputStorageKeyPrefix}undefined`), + ).toBeNull(); + unmount(); + }); + + it("clears the draft from localStorage on successful send", async () => { + localStorage.setItem(expectedKey, "draft to clear"); + + const { result, unmount } = renderEditing(); + + expect(localStorage.getItem(expectedKey)).toBe("draft to clear"); + + await act(async () => { + result.current.handleSendFromInput("hello"); + await vi.waitFor(() => { + expect(localStorage.getItem(expectedKey)).toBeNull(); + }); + }); + unmount(); + }); +}); diff --git a/site/src/pages/AgentsPage/AgentDetail.tsx b/site/src/pages/AgentsPage/AgentDetail.tsx index e2e5f0f7e6..dee325f428 100644 --- a/site/src/pages/AgentsPage/AgentDetail.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.tsx @@ -84,6 +84,8 @@ const noopRequestArchiveAndDeleteWorkspace: AgentsOutletContext["requestArchiveA const noopRequestUnarchiveAgent: AgentsOutletContext["requestUnarchiveAgent"] = () => {}; const lastModelConfigIDStorageKey = "agents.last-model-config-id"; +/** @internal Exported for testing. */ +export const draftInputStorageKeyPrefix = "agents.draft-input."; type ChatStoreHandle = ReturnType["store"]; const isChatMessage = ( @@ -287,14 +289,28 @@ const AgentDetailInput: FC = ({ ); }; -function useConversationEditingState(deps: { +/** @internal Exported for testing. */ +export function useConversationEditingState(deps: { + chatID: string | undefined; onSend: (message: string, editedMessageID?: number) => Promise; onDeleteQueuedMessage: (id: number) => Promise; }) { - const { onSend, onDeleteQueuedMessage } = deps; + const { chatID, onSend, onDeleteQueuedMessage } = deps; + const draftStorageKey = chatID + ? `${draftInputStorageKeyPrefix}${chatID}` + : null; const inputValueRef = useRef(""); const chatInputRef = useRef(null); - const [editorInitialValue, setEditorInitialValue] = useState(""); + const [editorInitialValue, setEditorInitialValue] = useState(() => { + if (typeof window === "undefined" || !draftStorageKey) { + return ""; + } + const saved = localStorage.getItem(draftStorageKey); + if (saved) { + inputValueRef.current = saved; + } + return saved ?? ""; + }); // -- History editing state -- const [editingMessageId, setEditingMessageId] = useState(null); @@ -361,6 +377,9 @@ function useConversationEditingState(deps: { chatInputRef.current?.clear(); chatInputRef.current?.focus(); inputValueRef.current = ""; + if (typeof window !== "undefined" && draftStorageKey) { + localStorage.removeItem(draftStorageKey); + } if (editingMessageId !== null) { setEditingMessageId(null); setDraftBeforeHistoryEdit(null); @@ -372,7 +391,27 @@ function useConversationEditingState(deps: { } }); }, - [editingMessageId, editingQueuedMessageID, onDeleteQueuedMessage, onSend], + [ + editingMessageId, + editingQueuedMessageID, + onDeleteQueuedMessage, + onSend, + draftStorageKey, + ], + ); + + const handleContentChange = useCallback( + (content: string) => { + inputValueRef.current = content; + if (typeof window !== "undefined" && draftStorageKey) { + if (content) { + localStorage.setItem(draftStorageKey, content); + } else { + localStorage.removeItem(draftStorageKey); + } + } + }, + [draftStorageKey], ); return { @@ -386,6 +425,7 @@ function useConversationEditingState(deps: { handleStartQueueEdit, handleCancelQueueEdit, handleSendFromInput, + handleContentChange, }; } @@ -674,6 +714,7 @@ const AgentDetail: FC = () => { ); const editing = useConversationEditingState({ + chatID: agentId, onSend: handleSend, onDeleteQueuedMessage: handleDeleteQueuedMessage, }); @@ -974,9 +1015,7 @@ const AgentDetail: FC = () => { modelCatalogStatusMessage={modelCatalogStatusMessage} inputRef={editing.chatInputRef} initialValue={editing.editorInitialValue} - onContentChange={(content) => { - editing.inputValueRef.current = content; - }} + onContentChange={editing.handleContentChange} editingQueuedMessageID={editing.editingQueuedMessageID} onStartQueueEdit={editing.handleStartQueueEdit} onCancelQueueEdit={editing.handleCancelQueueEdit} diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx index 70b6b942ec..529fab36c8 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -56,6 +56,7 @@ import { import { useAgentsPageKeybindings } from "./useAgentsPageKeybindings"; import { WebPushButton } from "./WebPushButton"; +/** @internal Exported for testing. */ const emptyInputStorageKey = "agents.empty-input"; const selectedWorkspaceIdStorageKey = "agents.selected-workspace-id"; const lastModelConfigIDStorageKey = "agents.last-model-config-id"; @@ -343,8 +344,11 @@ const AgentsPage: FC = () => { }; const handleNewAgent = () => { - if (typeof window !== "undefined") { - localStorage.setItem(emptyInputStorageKey, ""); + // Only clear the draft when the user is already on the empty + // state and explicitly requests a blank slate. When navigating + // back from a conversation the existing draft is preserved. + if (typeof window !== "undefined" && !agentId) { + localStorage.removeItem(emptyInputStorageKey); } navigate("/agents"); }; @@ -753,7 +757,11 @@ export const AgentsEmptyState: FC = ({ const handleContentChange = useCallback((content: string) => { inputValueRef.current = content; if (typeof window !== "undefined") { - localStorage.setItem(emptyInputStorageKey, content); + if (content) { + localStorage.setItem(emptyInputStorageKey, content); + } else { + localStorage.removeItem(emptyInputStorageKey); + } } }, []); const handleModelChange = useCallback((value: string) => {