mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d20efdd1b | |||
| 0830cdef3a | |||
| 41c98b6229 | |||
| 8c7920d079 | |||
| 32885a5c13 | |||
| 6765e73422 | |||
| 6a0a67bc44 | |||
| a2edd650ff |
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import {
|
||||
clearConversationLocalStorage,
|
||||
getConversationState,
|
||||
@@ -229,4 +229,118 @@ describe("conversation localStorage utilities", () => {
|
||||
expect(parsed.subConversationTaskId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("draft message persistence", () => {
|
||||
it("returns default state with draftMessage as null when no state exists", () => {
|
||||
const conversationId = "conv-123";
|
||||
const state = getConversationState(conversationId);
|
||||
|
||||
expect(state.draftMessage).toBeNull();
|
||||
expect(state.draftTimestamp).toBeNull();
|
||||
});
|
||||
|
||||
it("retrieves draftMessage from localStorage when it exists", () => {
|
||||
const conversationId = "conv-123";
|
||||
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
|
||||
const timestamp = Date.now();
|
||||
|
||||
localStorage.setItem(
|
||||
consolidatedKey,
|
||||
JSON.stringify({
|
||||
draftMessage: "My draft message",
|
||||
draftTimestamp: timestamp,
|
||||
}),
|
||||
);
|
||||
|
||||
const state = getConversationState(conversationId);
|
||||
|
||||
expect(state.draftMessage).toBe("My draft message");
|
||||
expect(state.draftTimestamp).toBe(timestamp);
|
||||
});
|
||||
|
||||
it("persists draftMessage and draftTimestamp to localStorage", () => {
|
||||
const conversationId = "conv-123";
|
||||
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
|
||||
const timestamp = Date.now();
|
||||
|
||||
setConversationState(conversationId, {
|
||||
draftMessage: "Test draft",
|
||||
draftTimestamp: timestamp,
|
||||
});
|
||||
|
||||
const stored = localStorage.getItem(consolidatedKey);
|
||||
expect(stored).not.toBeNull();
|
||||
|
||||
const parsed = JSON.parse(stored!);
|
||||
expect(parsed.draftMessage).toBe("Test draft");
|
||||
expect(parsed.draftTimestamp).toBe(timestamp);
|
||||
});
|
||||
|
||||
it("merges draftMessage with existing state", () => {
|
||||
const conversationId = "conv-123";
|
||||
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
|
||||
|
||||
// Set initial state
|
||||
localStorage.setItem(
|
||||
consolidatedKey,
|
||||
JSON.stringify({
|
||||
selectedTab: "changes",
|
||||
rightPanelShown: false,
|
||||
}),
|
||||
);
|
||||
|
||||
// Update only draftMessage
|
||||
const timestamp = Date.now();
|
||||
setConversationState(conversationId, {
|
||||
draftMessage: "New draft",
|
||||
draftTimestamp: timestamp,
|
||||
});
|
||||
|
||||
const stored = localStorage.getItem(consolidatedKey);
|
||||
const parsed = JSON.parse(stored!);
|
||||
|
||||
expect(parsed.draftMessage).toBe("New draft");
|
||||
expect(parsed.draftTimestamp).toBe(timestamp);
|
||||
expect(parsed.selectedTab).toBe("changes");
|
||||
expect(parsed.rightPanelShown).toBe(false);
|
||||
});
|
||||
|
||||
it("clears draftMessage when set to null", () => {
|
||||
const conversationId = "conv-123";
|
||||
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
|
||||
|
||||
// Set initial state with draft
|
||||
localStorage.setItem(
|
||||
consolidatedKey,
|
||||
JSON.stringify({
|
||||
draftMessage: "Existing draft",
|
||||
draftTimestamp: Date.now(),
|
||||
}),
|
||||
);
|
||||
|
||||
// Clear the draft
|
||||
setConversationState(conversationId, {
|
||||
draftMessage: null,
|
||||
draftTimestamp: null,
|
||||
});
|
||||
|
||||
const stored = localStorage.getItem(consolidatedKey);
|
||||
const parsed = JSON.parse(stored!);
|
||||
|
||||
expect(parsed.draftMessage).toBeNull();
|
||||
expect(parsed.draftTimestamp).toBeNull();
|
||||
});
|
||||
|
||||
it("does not persist draft for task conversation IDs", () => {
|
||||
const conversationId = "task-123";
|
||||
|
||||
setConversationState(conversationId, {
|
||||
draftMessage: "Should not persist",
|
||||
draftTimestamp: Date.now(),
|
||||
});
|
||||
|
||||
const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
|
||||
expect(localStorage.getItem(key)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useDraftPersistence } from "#/hooks/chat/use-draft-persistence";
|
||||
import {
|
||||
getConversationState,
|
||||
LOCAL_STORAGE_KEYS,
|
||||
} from "#/utils/conversation-local-storage";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("useDraftPersistence", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const createMockRef = (innerText: string = "") => ({
|
||||
current: { innerText } as HTMLDivElement,
|
||||
});
|
||||
|
||||
describe("handleDraftChange", () => {
|
||||
it("saves draft to localStorage after debounce delay", async () => {
|
||||
const conversationId = "conv-123";
|
||||
const chatInputRef = createMockRef();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDraftPersistence({
|
||||
conversationId,
|
||||
chatInputRef: chatInputRef as React.RefObject<HTMLDivElement | null>,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleDraftChange("my draft text");
|
||||
});
|
||||
|
||||
// Draft should not be saved immediately
|
||||
const stateBefore = getConversationState(conversationId);
|
||||
expect(stateBefore.draftMessage).toBeNull();
|
||||
|
||||
// Advance timers past debounce delay (300ms)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
// Draft should now be saved
|
||||
const stateAfter = getConversationState(conversationId);
|
||||
expect(stateAfter.draftMessage).toBe("my draft text");
|
||||
expect(stateAfter.draftTimestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it("debounces multiple rapid changes", async () => {
|
||||
const conversationId = "conv-123";
|
||||
const chatInputRef = createMockRef();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDraftPersistence({
|
||||
conversationId,
|
||||
chatInputRef: chatInputRef as React.RefObject<HTMLDivElement | null>,
|
||||
}),
|
||||
);
|
||||
|
||||
// Simulate rapid typing
|
||||
act(() => {
|
||||
result.current.handleDraftChange("h");
|
||||
vi.advanceTimersByTime(100);
|
||||
result.current.handleDraftChange("he");
|
||||
vi.advanceTimersByTime(100);
|
||||
result.current.handleDraftChange("hel");
|
||||
vi.advanceTimersByTime(100);
|
||||
result.current.handleDraftChange("hello");
|
||||
});
|
||||
|
||||
// Only the final value should be saved after debounce
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
const state = getConversationState(conversationId);
|
||||
expect(state.draftMessage).toBe("hello");
|
||||
});
|
||||
|
||||
it("clears draft when empty text is provided", async () => {
|
||||
const conversationId = "conv-123";
|
||||
const chatInputRef = createMockRef();
|
||||
const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
|
||||
|
||||
// Pre-populate with existing draft
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
draftMessage: "existing draft",
|
||||
draftTimestamp: Date.now(),
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDraftPersistence({
|
||||
conversationId,
|
||||
chatInputRef: chatInputRef as React.RefObject<HTMLDivElement | null>,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleDraftChange(" "); // Whitespace only
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
const state = getConversationState(conversationId);
|
||||
expect(state.draftMessage).toBeNull();
|
||||
expect(state.draftTimestamp).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearDraft", () => {
|
||||
it("removes draft from localStorage immediately", () => {
|
||||
const conversationId = "conv-123";
|
||||
const chatInputRef = createMockRef();
|
||||
const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
|
||||
|
||||
// Pre-populate with existing draft
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
draftMessage: "existing draft",
|
||||
draftTimestamp: Date.now(),
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDraftPersistence({
|
||||
conversationId,
|
||||
chatInputRef: chatInputRef as React.RefObject<HTMLDivElement | null>,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.clearDraft();
|
||||
});
|
||||
|
||||
const state = getConversationState(conversationId);
|
||||
expect(state.draftMessage).toBeNull();
|
||||
expect(state.draftTimestamp).toBeNull();
|
||||
});
|
||||
|
||||
it("cancels pending debounced save", () => {
|
||||
const conversationId = "conv-123";
|
||||
const chatInputRef = createMockRef();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDraftPersistence({
|
||||
conversationId,
|
||||
chatInputRef: chatInputRef as React.RefObject<HTMLDivElement | null>,
|
||||
}),
|
||||
);
|
||||
|
||||
// Start a draft change
|
||||
act(() => {
|
||||
result.current.handleDraftChange("pending draft");
|
||||
});
|
||||
|
||||
// Clear before debounce completes
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100); // Only 100ms, not full 300ms
|
||||
result.current.clearDraft();
|
||||
});
|
||||
|
||||
// Advance past debounce time
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
// Draft should remain cleared, not saved
|
||||
const state = getConversationState(conversationId);
|
||||
expect(state.draftMessage).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreDraft", () => {
|
||||
it("restores draft from localStorage to input ref", () => {
|
||||
const conversationId = "conv-123";
|
||||
const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
|
||||
const chatInputRef = createMockRef();
|
||||
|
||||
// Pre-populate with draft
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
draftMessage: "saved draft",
|
||||
draftTimestamp: Date.now(),
|
||||
}),
|
||||
);
|
||||
|
||||
renderHook(() =>
|
||||
useDraftPersistence({
|
||||
conversationId,
|
||||
chatInputRef: chatInputRef as React.RefObject<HTMLDivElement | null>,
|
||||
}),
|
||||
);
|
||||
|
||||
// Draft should be restored to input ref on mount
|
||||
expect(chatInputRef.current?.innerText).toBe("saved draft");
|
||||
});
|
||||
|
||||
it("shows toast when draft is restored", () => {
|
||||
const conversationId = "conv-123";
|
||||
const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
|
||||
const chatInputRef = createMockRef();
|
||||
|
||||
// Pre-populate with draft
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
draftMessage: "saved draft",
|
||||
draftTimestamp: Date.now(),
|
||||
}),
|
||||
);
|
||||
|
||||
renderHook(() =>
|
||||
useDraftPersistence({
|
||||
conversationId,
|
||||
chatInputRef: chatInputRef as React.RefObject<HTMLDivElement | null>,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(toast.success).toHaveBeenCalledWith("Draft restored", { duration: 2000 });
|
||||
});
|
||||
|
||||
it("does not restore stale draft (older than 24 hours)", () => {
|
||||
const conversationId = "conv-123";
|
||||
const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
|
||||
const chatInputRef = createMockRef();
|
||||
|
||||
// Create a stale draft (25 hours old)
|
||||
const staleTimestamp = Date.now() - 25 * 60 * 60 * 1000;
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
draftMessage: "stale draft",
|
||||
draftTimestamp: staleTimestamp,
|
||||
}),
|
||||
);
|
||||
|
||||
renderHook(() =>
|
||||
useDraftPersistence({
|
||||
conversationId,
|
||||
chatInputRef: chatInputRef as React.RefObject<HTMLDivElement | null>,
|
||||
}),
|
||||
);
|
||||
|
||||
// Stale draft should not be restored
|
||||
expect(chatInputRef.current?.innerText).toBe("");
|
||||
|
||||
// Stale draft should be cleared from localStorage
|
||||
const state = getConversationState(conversationId);
|
||||
expect(state.draftMessage).toBeNull();
|
||||
});
|
||||
|
||||
it("does not restore when conversationId is null", () => {
|
||||
const chatInputRef = createMockRef();
|
||||
|
||||
renderHook(() =>
|
||||
useDraftPersistence({
|
||||
conversationId: null,
|
||||
chatInputRef: chatInputRef as React.RefObject<HTMLDivElement | null>,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversation switching", () => {
|
||||
it("saves pending draft when conversation changes", () => {
|
||||
const chatInputRef = createMockRef();
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ conversationId }) =>
|
||||
useDraftPersistence({
|
||||
conversationId,
|
||||
chatInputRef: chatInputRef as React.RefObject<HTMLDivElement | null>,
|
||||
}),
|
||||
{ initialProps: { conversationId: "conv-A" } },
|
||||
);
|
||||
|
||||
// Start typing in conv-A
|
||||
act(() => {
|
||||
result.current.handleDraftChange("draft for A");
|
||||
});
|
||||
|
||||
// Switch to conv-B before debounce completes
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
rerender({ conversationId: "conv-B" });
|
||||
|
||||
// Draft for conv-A should be saved when switching
|
||||
const stateA = getConversationState("conv-A");
|
||||
expect(stateA.draftMessage).toBe("draft for A");
|
||||
});
|
||||
|
||||
it("restores draft for new conversation", () => {
|
||||
const chatInputRef = createMockRef();
|
||||
const keyB = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-conv-B`;
|
||||
|
||||
// Pre-populate conv-B with draft
|
||||
localStorage.setItem(
|
||||
keyB,
|
||||
JSON.stringify({
|
||||
draftMessage: "draft for B",
|
||||
draftTimestamp: Date.now(),
|
||||
}),
|
||||
);
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ conversationId }) =>
|
||||
useDraftPersistence({
|
||||
conversationId,
|
||||
chatInputRef: chatInputRef as React.RefObject<HTMLDivElement | null>,
|
||||
}),
|
||||
{ initialProps: { conversationId: "conv-A" } },
|
||||
);
|
||||
|
||||
// Switch to conv-B
|
||||
rerender({ conversationId: "conv-B" });
|
||||
|
||||
// Draft for conv-B should be restored
|
||||
expect(chatInputRef.current?.innerText).toBe("draft for B");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unmount behavior", () => {
|
||||
it("flushes pending draft on unmount", () => {
|
||||
const conversationId = "conv-123";
|
||||
const chatInputRef = createMockRef();
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useDraftPersistence({
|
||||
conversationId,
|
||||
chatInputRef: chatInputRef as React.RefObject<HTMLDivElement | null>,
|
||||
}),
|
||||
);
|
||||
|
||||
// Start a draft change
|
||||
act(() => {
|
||||
result.current.handleDraftChange("unsaved draft");
|
||||
});
|
||||
|
||||
// Unmount before debounce completes
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
unmount();
|
||||
});
|
||||
|
||||
// Draft should be saved on unmount
|
||||
const state = getConversationState(conversationId);
|
||||
expect(state.draftMessage).toBe("unsaved draft");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -88,6 +88,8 @@ describe("useHandlePlanClick", () => {
|
||||
unpinnedTabs: [],
|
||||
subConversationTaskId: null,
|
||||
conversationMode: "code",
|
||||
draftMessage: null,
|
||||
draftTimestamp: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,6 +119,8 @@ describe("useHandlePlanClick", () => {
|
||||
unpinnedTabs: [],
|
||||
subConversationTaskId: storedTaskId,
|
||||
conversationMode: "code",
|
||||
draftMessage: null,
|
||||
draftTimestamp: null,
|
||||
});
|
||||
|
||||
renderHook(() => useHandlePlanClick());
|
||||
@@ -155,6 +159,8 @@ describe("useHandlePlanClick", () => {
|
||||
unpinnedTabs: [],
|
||||
subConversationTaskId: storedTaskId,
|
||||
conversationMode: "code",
|
||||
draftMessage: null,
|
||||
draftTimestamp: null,
|
||||
});
|
||||
|
||||
renderHook(() => useHandlePlanClick());
|
||||
|
||||
@@ -10,6 +10,8 @@ import { ChatInputGrip } from "./components/chat-input-grip";
|
||||
import { ChatInputContainer } from "./components/chat-input-container";
|
||||
import { HiddenFileInput } from "./components/hidden-file-input";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { getTextContent } from "./utils/chat-input.utils";
|
||||
|
||||
export interface CustomChatInputProps {
|
||||
disabled?: boolean;
|
||||
@@ -41,6 +43,9 @@ export function CustomChatInput({
|
||||
setSubmittedMessage,
|
||||
} = useConversationStore();
|
||||
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const conversationId = conversation?.conversation_id || null;
|
||||
|
||||
// Disable input when conversation is stopped
|
||||
const isConversationStopped = conversationStatus === "STOPPED";
|
||||
const isDisabled = disabled || isConversationStopped;
|
||||
@@ -60,7 +65,9 @@ export function CustomChatInput({
|
||||
messageToSend,
|
||||
checkIsContentEmpty,
|
||||
clearEmptyContentHandler,
|
||||
} = useChatInputLogic();
|
||||
handleDraftChange,
|
||||
clearDraft,
|
||||
} = useChatInputLogic({ conversationId });
|
||||
|
||||
const {
|
||||
fileInputRef,
|
||||
@@ -93,6 +100,7 @@ export function CustomChatInput({
|
||||
smartResize,
|
||||
onSubmit,
|
||||
resetManualResize,
|
||||
clearDraft,
|
||||
);
|
||||
|
||||
const { handleInput, handlePaste, handleKeyDown, handleBlur, handleFocus } =
|
||||
@@ -158,6 +166,10 @@ export function CustomChatInput({
|
||||
onInput={() => {
|
||||
handleInput();
|
||||
updateSlashMenu();
|
||||
// Persist draft on every input change
|
||||
if (chatInputRef.current) {
|
||||
handleDraftChange(getTextContent(chatInputRef.current));
|
||||
}
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={(e) => {
|
||||
|
||||
@@ -5,11 +5,18 @@ import {
|
||||
getTextContent,
|
||||
} from "#/components/features/chat/utils/chat-input.utils";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
import { useDraftPersistence } from "./use-draft-persistence";
|
||||
|
||||
interface UseChatInputLogicParams {
|
||||
conversationId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing chat input content logic
|
||||
*/
|
||||
export const useChatInputLogic = () => {
|
||||
export const useChatInputLogic = ({
|
||||
conversationId,
|
||||
}: UseChatInputLogicParams) => {
|
||||
const chatInputRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const {
|
||||
@@ -19,6 +26,12 @@ export const useChatInputLogic = () => {
|
||||
setIsRightPanelShown,
|
||||
} = useConversationStore();
|
||||
|
||||
// Draft persistence hook
|
||||
const { handleDraftChange, clearDraft } = useDraftPersistence({
|
||||
conversationId,
|
||||
chatInputRef,
|
||||
});
|
||||
|
||||
// Save current input value when drawer state changes
|
||||
useEffect(() => {
|
||||
if (chatInputRef.current) {
|
||||
@@ -51,5 +64,7 @@ export const useChatInputLogic = () => {
|
||||
checkIsContentEmpty,
|
||||
clearEmptyContentHandler,
|
||||
getCurrentMessage,
|
||||
handleDraftChange,
|
||||
clearDraft,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ export const useChatSubmission = (
|
||||
smartResize: () => void,
|
||||
onSubmit: (message: string) => void,
|
||||
resetManualResize?: () => void,
|
||||
clearDraft?: () => void,
|
||||
) => {
|
||||
// Send button click handler
|
||||
const handleSubmit = useCallback(() => {
|
||||
@@ -29,12 +30,22 @@ export const useChatSubmission = (
|
||||
clearTextContent(chatInputRef.current);
|
||||
clearFileInput(fileInputRef.current);
|
||||
|
||||
// Clear draft from localStorage
|
||||
clearDraft?.();
|
||||
|
||||
// Reset height and show suggestions again
|
||||
smartResize();
|
||||
|
||||
// Reset manual resize state for next message
|
||||
resetManualResize?.();
|
||||
}, [chatInputRef, fileInputRef, smartResize, onSubmit, resetManualResize]);
|
||||
}, [
|
||||
chatInputRef,
|
||||
fileInputRef,
|
||||
smartResize,
|
||||
onSubmit,
|
||||
resetManualResize,
|
||||
clearDraft,
|
||||
]);
|
||||
|
||||
// Resume agent button click handler
|
||||
const handleResumeAgent = useCallback(() => {
|
||||
@@ -46,12 +57,22 @@ export const useChatSubmission = (
|
||||
clearTextContent(chatInputRef.current);
|
||||
clearFileInput(fileInputRef.current);
|
||||
|
||||
// Clear draft from localStorage
|
||||
clearDraft?.();
|
||||
|
||||
// Reset height and show suggestions again
|
||||
smartResize();
|
||||
|
||||
// Reset manual resize state for next message
|
||||
resetManualResize?.();
|
||||
}, [chatInputRef, fileInputRef, smartResize, onSubmit, resetManualResize]);
|
||||
}, [
|
||||
chatInputRef,
|
||||
fileInputRef,
|
||||
smartResize,
|
||||
onSubmit,
|
||||
resetManualResize,
|
||||
clearDraft,
|
||||
]);
|
||||
|
||||
// Handle stop button click
|
||||
const handleStop = useCallback((onStop?: () => void) => {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useRef, useCallback, useEffect } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
getConversationState,
|
||||
setConversationState,
|
||||
} from "#/utils/conversation-local-storage";
|
||||
|
||||
const DEBOUNCE_DELAY_MS = 300;
|
||||
const STALE_DRAFT_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
interface UseDraftPersistenceParams {
|
||||
conversationId: string | null;
|
||||
chatInputRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
interface UseDraftPersistenceReturn {
|
||||
handleDraftChange: (text: string) => void;
|
||||
clearDraft: () => void;
|
||||
restoreDraft: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for persisting chat draft messages to localStorage with debouncing.
|
||||
* Drafts are keyed by conversation ID and automatically restored on mount.
|
||||
*/
|
||||
export function useDraftPersistence({
|
||||
conversationId,
|
||||
chatInputRef,
|
||||
}: UseDraftPersistenceParams): UseDraftPersistenceReturn {
|
||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingDraftRef = useRef<string | null>(null);
|
||||
const previousConversationIdRef = useRef<string | null>(null);
|
||||
|
||||
// Save draft to localStorage
|
||||
const saveDraft = useCallback(
|
||||
(text: string) => {
|
||||
if (!conversationId) return;
|
||||
|
||||
const trimmedText = text.trim();
|
||||
|
||||
if (trimmedText) {
|
||||
setConversationState(conversationId, {
|
||||
draftMessage: trimmedText,
|
||||
draftTimestamp: Date.now(),
|
||||
});
|
||||
} else {
|
||||
// Clear draft if empty
|
||||
setConversationState(conversationId, {
|
||||
draftMessage: null,
|
||||
draftTimestamp: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
[conversationId],
|
||||
);
|
||||
|
||||
// Flush any pending debounced save immediately
|
||||
const flushPendingDraft = useCallback(() => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
debounceTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (pendingDraftRef.current !== null) {
|
||||
saveDraft(pendingDraftRef.current);
|
||||
pendingDraftRef.current = null;
|
||||
}
|
||||
}, [saveDraft]);
|
||||
|
||||
// Handle draft changes with debouncing
|
||||
const handleDraftChange = useCallback(
|
||||
(text: string) => {
|
||||
pendingDraftRef.current = text;
|
||||
|
||||
// Clear existing timeout
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Set new debounced save
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
saveDraft(text);
|
||||
pendingDraftRef.current = null;
|
||||
debounceTimeoutRef.current = null;
|
||||
}, DEBOUNCE_DELAY_MS);
|
||||
},
|
||||
[saveDraft],
|
||||
);
|
||||
|
||||
// Clear draft from localStorage
|
||||
const clearDraft = useCallback(() => {
|
||||
// Clear any pending debounced save
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
debounceTimeoutRef.current = null;
|
||||
}
|
||||
pendingDraftRef.current = null;
|
||||
|
||||
if (!conversationId) return;
|
||||
|
||||
setConversationState(conversationId, {
|
||||
draftMessage: null,
|
||||
draftTimestamp: null,
|
||||
});
|
||||
}, [conversationId]);
|
||||
|
||||
// Restore draft from localStorage to the input
|
||||
const restoreDraft = useCallback(() => {
|
||||
if (!conversationId || !chatInputRef.current) return;
|
||||
|
||||
const state = getConversationState(conversationId);
|
||||
|
||||
if (!state.draftMessage || !state.draftTimestamp) return;
|
||||
|
||||
// Check if draft is stale (older than 24 hours)
|
||||
const age = Date.now() - state.draftTimestamp;
|
||||
if (age > STALE_DRAFT_THRESHOLD_MS) {
|
||||
// Clear stale draft
|
||||
setConversationState(conversationId, {
|
||||
draftMessage: null,
|
||||
draftTimestamp: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore draft to input
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
chatInputRef.current.innerText = state.draftMessage;
|
||||
|
||||
// Show toast notification
|
||||
toast.success("Draft restored", { duration: 2000 });
|
||||
}, [conversationId, chatInputRef]);
|
||||
|
||||
// Restore draft on mount and when conversation changes
|
||||
useEffect(() => {
|
||||
const previousConversationId = previousConversationIdRef.current;
|
||||
|
||||
// If switching conversations, flush the draft from the previous conversation
|
||||
if (
|
||||
previousConversationId &&
|
||||
previousConversationId !== conversationId &&
|
||||
pendingDraftRef.current !== null
|
||||
) {
|
||||
// Save pending draft to the previous conversation
|
||||
const pendingText = pendingDraftRef.current;
|
||||
if (pendingText.trim()) {
|
||||
setConversationState(previousConversationId, {
|
||||
draftMessage: pendingText.trim(),
|
||||
draftTimestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
pendingDraftRef.current = null;
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
debounceTimeoutRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore draft for the new conversation
|
||||
restoreDraft();
|
||||
|
||||
// Update previous conversation ID
|
||||
previousConversationIdRef.current = conversationId;
|
||||
}, [conversationId, restoreDraft]);
|
||||
|
||||
// Cleanup on unmount - flush any pending draft
|
||||
useEffect(() => () => flushPendingDraft(), [flushPendingDraft]);
|
||||
|
||||
return {
|
||||
handleDraftChange,
|
||||
clearDraft,
|
||||
restoreDraft,
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,8 @@ export interface ConversationState {
|
||||
unpinnedTabs: string[];
|
||||
conversationMode: ConversationMode;
|
||||
subConversationTaskId: string | null;
|
||||
draftMessage: string | null;
|
||||
draftTimestamp: number | null;
|
||||
}
|
||||
|
||||
const DEFAULT_CONVERSATION_STATE: ConversationState = {
|
||||
@@ -31,6 +33,8 @@ const DEFAULT_CONVERSATION_STATE: ConversationState = {
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: null,
|
||||
draftTimestamp: null,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user