mirror of
https://github.com/coder/coder.git
synced 2026-09-21 12:44:32 +08:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user