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.<chatID>` 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
This commit is contained in:
Kyle Carberry
2026-03-04 14:42:13 +00:00
committed by GitHub
parent 52a42af1ca
commit 1635b18856
3 changed files with 161 additions and 10 deletions
@@ -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();
});
});
+46 -7
View File
@@ -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<typeof useChatStore>["store"];
const isChatMessage = (
@@ -287,14 +289,28 @@ const AgentDetailInput: FC<AgentDetailInputProps> = ({
);
};
function useConversationEditingState(deps: {
/** @internal Exported for testing. */
export function useConversationEditingState(deps: {
chatID: string | undefined;
onSend: (message: string, editedMessageID?: number) => Promise<void>;
onDeleteQueuedMessage: (id: number) => Promise<void>;
}) {
const { onSend, onDeleteQueuedMessage } = deps;
const { chatID, onSend, onDeleteQueuedMessage } = deps;
const draftStorageKey = chatID
? `${draftInputStorageKeyPrefix}${chatID}`
: null;
const inputValueRef = useRef("");
const chatInputRef = useRef<ChatMessageInputRef>(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<number | null>(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}
+11 -3
View File
@@ -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<AgentsEmptyStateProps> = ({
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) => {