Compare commits

..

8 Commits

Author SHA1 Message Date
openhands 1d20efdd1b feat: Implement draft persistence for chat messages
This PR implements Layer 1 of the chat message persistence design,
addressing issue #13280 (partial - draft persistence only).

Changes:
- Add draftMessage and draftTimestamp fields to ConversationState
- Create useDraftPersistence hook for debounced save/restore
- Integrate draft persistence into chat input logic
- Clear draft on message submission
- Add comprehensive tests for all new functionality

Features:
- Drafts saved to localStorage after 300ms debounce
- Drafts restored on component mount (if not stale >24 hours)
- Drafts keyed by conversationId (independent per conversation)
- Switching conversations saves current draft, restores target draft
- Submit clears draft from localStorage
- Toast notification shown when draft is restored

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 04:34:55 +00:00
openhands 0830cdef3a Consolidate implementation plan from 6 milestones to 2 PRs
Each PR delivers standalone value:

**PR 1: Draft Persistence**
- Problem: Drafts lost on refresh/remount/conversation switching
- Value: Type message, refresh page, draft restored
- Files: 5 implementation + 3 test files
- Fully testable and shippable independently

**PR 2: Message Queue with Offline Support**
- Problem: Messages lost when disconnected or runtime starting
- Value: Submit while offline/starting, auto-delivery on reconnect
- Includes: Queue store, processing, V1 WebSocket integration,
  submit-during-startup UX, visual status indicators
- Files: 8 implementation + 6 test files

This reduces review burden while ensuring each PR is complete
and provides immediate user benefit.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 04:07:55 +00:00
openhands 41c98b6229 Add explicit test expectations to each implementation milestone
Each milestone (M1-M6) now includes:
- Functionality checklist (files to create/modify)
- **Test Expectations** section with specific assertions

Test expectations cover:
- M1: Draft persistence debouncing, staleness, conversation keying
- M2: Queue store CRUD operations, localStorage persistence, multi-conversation
- M3: Queue processing, retry logic, exponential backoff, WebSocket integration
- M4: Submit during startup, Enter key behavior, button state
- M5: Visual indicators for each status, retry button, optimistic updates
- M6: Edge cases, error recovery, accessibility (ARIA labels, keyboard nav)

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 04:04:10 +00:00
openhands 8c7920d079 Add detailed integration test patterns for conversation switching
Based on existing interactive-chat-box.test.tsx patterns:
- userEvent.type() works with contentEditable divs
- MemoryRouter + rerender for conversation switching
- localStorage assertions with waitFor for debounced saves
- MSW WebSocket mocking for queue tests

Added test examples for:
- Saving draft to localStorage on input
- Restoring draft on remount
- Switching drafts between conversations
- Queuing messages when WebSocket disconnected

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 04:02:02 +00:00
openhands 32885a5c13 Replace E2E tests with RTL + MSW integration tests
The codebase already has excellent integration test infrastructure:
- React Testing Library for component rendering/interaction
- MSW for WebSocket mocking (frontend/__tests__/helpers/msw-websocket-setup.ts)
- JSDOM localStorage mock (built into Vitest)
- Zustand store testing patterns

This allows proper integration testing without Playwright by:
- Mocking WebSocket connect/disconnect states
- Directly manipulating localStorage
- Simulating user input with userEvent
- Testing the full flow from input → storage → queue → send

Added example test patterns and specific test file structure.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 03:59:56 +00:00
openhands 6765e73422 Clarify multi-conversation support for drafts and queued messages
- Explicitly state each conversation maintains independent draft
- Add scenario showing multiple conversations with queued messages
- Clarify queue is keyed by conversation ID and processed per-conversation
- Fix section numbering

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 03:58:53 +00:00
openhands 6a0a67bc44 Simplify design: V1 only scope, use localStorage for queue
- Scope limited to V1 conversations (V0 already has pendingEventsRef)
- Use localStorage instead of sessionStorage for message queue
  - More robust (survives browser crashes)
  - Consistent with draft storage
  - Stale messages cleaned up after 24 hours
- Added cleanupStaleMessages() to queue store
- Removed V0 integration from implementation plan

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 03:57:51 +00:00
openhands a2edd650ff Add design doc for chat message persistence
Addresses issue #13280: Chat messages can be lost when WebSocket is
disconnected or during page refresh.

This design proposes a multi-layered solution:
- Layer 1: Draft persistence to localStorage with debounced continuous sync
- Layer 2: Pending message queue with sessionStorage for offline/startup scenarios
- Layer 3: UX changes to enable submit during runtime startup

Key features:
- Drafts keyed by conversation ID for proper switching behavior
- Queue-based message delivery with retry logic
- Enter key and submit button enabled during runtime startup
- Visual indicators for queued/sending/failed/delivered status

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 03:30:47 +00:00
8 changed files with 722 additions and 5 deletions
@@ -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,
};
};
+23 -2
View File
@@ -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,
};
/**