mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
07fad5d56a
commit
7f8d750d8a
3 changed files with 66 additions and 2 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue