Preserve composer drafts across refreshes (#2839)

Users can type a prompt in a conversation, reload the app, and expect that unsent text to remain tied to the same conversation. Store only the active conversation's composer draft under a project+conversation localStorage key and clear it once the draft is submitted or queued.

Constraint: The composer already remounts by activeConversationId, so persistence can stay local to ChatPane/ChatComposer without changing daemon contracts.

Rejected: Persist draft text in SQLite messages | unsent drafts are local UI state and should not appear in conversation history.

Confidence: high

Scope-risk: narrow

Directive: Keep initialDraft higher priority than stored drafts so seeded workflows are not overwritten by stale local text.

Tested: pnpm --filter @open-design/web test tests/components/ChatComposer.send-key.test.tsx tests/components/ChatComposer.queue-button.test.tsx

Tested: pnpm --filter @open-design/web typecheck

Co-authored-by: nicejames <nicejames@gmail.com>
This commit is contained in:
leessju 2026-05-26 13:03:09 +09:00 committed by GitHub
parent 07fad5d56a
commit 7f8d750d8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 66 additions and 2 deletions

View file

@ -97,6 +97,7 @@ interface Props {
streaming: boolean;
sendDisabled?: boolean;
initialDraft?: string;
draftStorageKey?: string;
// Lazy ensure — the composer calls this before its first upload, so the
// project folder exists on disk before files land in it. Returns the
// project id when ready.
@ -190,6 +191,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
streaming,
sendDisabled = false,
initialDraft,
draftStorageKey,
onEnsureProject,
commentAttachments = [],
onRemoveCommentAttachment,
@ -216,7 +218,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
) {
const t = useT();
const analytics = useAnalytics();
const [draft, setDraft] = useState(initialDraft ?? "");
const [draft, setDraft] = useState(() => initialDraft ?? loadComposerDraft(draftStorageKey) ?? "");
// chat_panel page_view fires from ProjectView (which outlives
// conversation switches) so the event measures real chat-panel
@ -295,6 +297,10 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
}
}, [initialDraft, draft]);
useEffect(() => {
saveComposerDraft(draftStorageKey, draft);
}, [draftStorageKey, draft]);
useEffect(() => {
if (!toolsOpen) return;
function onPointer(e: MouseEvent) {
@ -2679,6 +2685,28 @@ function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function loadComposerDraft(key?: string): string | null {
if (!key || typeof window === 'undefined') return null;
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
function saveComposerDraft(key: string | undefined, draft: string) {
if (!key || typeof window === 'undefined') return;
try {
if (draft) {
window.localStorage.setItem(key, draft);
} else {
window.localStorage.removeItem(key);
}
} catch {
// Storage can be unavailable in privacy modes; the composer should still work.
}
}
function looksLikeImage(name: string): boolean {
return /\.(png|jpe?g|gif|webp|svg|avif|bmp)$/i.test(name);
}

View file

@ -415,6 +415,9 @@ export function ChatPane({
(m) => m.role === 'assistant' && isActiveRunStatus(m.runStatus),
);
const retryAssistant = retryableAssistantMessage(messages, lastAssistantId, streaming);
const composerDraftStorageKey = projectId && activeConversationId
? `od:chat-composer:draft:${projectId}:${activeConversationId}`
: undefined;
// Only the first user message gets the active-plugin chip — the
// plugin is project-scoped so re-stamping it on every reply would be
// noise. Subsequent messages still run under the same snapshot.
@ -1165,6 +1168,7 @@ export function ChatPane({
streaming={streaming}
sendDisabled={sendDisabled}
initialDraft={initialDraft}
draftStorageKey={composerDraftStorageKey}
onEnsureProject={onEnsureProject}
commentAttachments={commentsToAttachments(attachedComments)}
onRemoveCommentAttachment={onDetachComment}

View file

@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { ComponentProps } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@ -50,6 +50,7 @@ beforeEach(() => {
afterEach(() => {
vi.unstubAllGlobals();
window.localStorage.clear();
cleanup();
});
@ -78,6 +79,37 @@ describe('ChatComposer infinite re-render regression (#2097)', () => {
expect(onSend).toHaveBeenCalledWith('change the font', [], [], undefined);
});
it('restores a saved draft for the active conversation', () => {
window.localStorage.setItem('od:chat-composer:draft:project-1:conv-1', 'draft before refresh');
renderComposer({
draftStorageKey: 'od:chat-composer:draft:project-1:conv-1',
});
expect((screen.getByTestId('chat-composer-input') as HTMLTextAreaElement).value).toBe(
'draft before refresh',
);
});
it('clears the saved draft after submitting it', async () => {
const key = 'od:chat-composer:draft:project-1:conv-1';
const onSend = vi.fn();
renderComposer({
draftStorageKey: key,
onSend,
});
const textarea = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement;
fireEvent.change(textarea, {
target: { value: 'send then clear', selectionStart: 15, selectionEnd: 15 },
});
await waitFor(() => expect(window.localStorage.getItem(key)).toBe('send then clear'));
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true });
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
await waitFor(() => expect(window.localStorage.getItem(key)).toBeNull());
});
it('does not re-sync the composer scroll offset on every plain-text keystroke', () => {
const scrollTopGetter = vi.fn(() => 0);
const original = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'scrollTop');