diff --git a/apps/web/src/components/settings/PersonalizationSection.test.tsx b/apps/web/src/components/settings/PersonalizationSection.test.tsx index b789d05fe..2c9c306db 100644 --- a/apps/web/src/components/settings/PersonalizationSection.test.tsx +++ b/apps/web/src/components/settings/PersonalizationSection.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; const mocks = vi.hoisted(() => ({ mutate: vi.fn(), @@ -6,7 +6,14 @@ const mocks = vi.hoisted(() => ({ refetch: vi.fn(), setQueryData: vi.fn(), updateOptions: undefined as - | { onError: (error: { message: string }) => void } + | { + onError: (error: { message: string }) => void; + onSuccess: (settings: { + instructions: string; + learnFromConversations: boolean; + version: number; + }) => void; + } | undefined, query: { data: undefined as unknown, @@ -174,6 +181,94 @@ describe('PersonalizationSection', () => { ); }); + it('preserves a dirty draft across background refreshes and saves with the refreshed version', () => { + const { rerender } = render(); + const editor = screen.getByRole('textbox'); + fireEvent.change(editor, { target: { value: 'Keep my unsaved draft.' } }); + + mocks.query.data = { + ...mocks.settings, + instructions: 'Saved in another tab.', + version: 4, + }; + rerender(); + + expect(editor).toHaveValue('Keep my unsaved draft.'); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(mocks.mutate).toHaveBeenCalledWith( + { expectedVersion: 4, instructions: 'Keep my unsaved draft.' }, + expect.any(Object), + ); + }); + + it('preserves an empty draft when a failed save refreshes the server settings', () => { + const { rerender } = render(); + fireEvent.change(screen.getByRole('textbox'), { target: { value: '' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + act(() => mocks.updateOptions?.onError({ message: 'Conflict' })); + mocks.query.data = { + ...mocks.settings, + instructions: 'New server text.', + version: 4, + }; + rerender(); + + expect(screen.getByRole('textbox')).toHaveValue(''); + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled(); + }); + + it('follows background updates while clean, including after reverting a draft', () => { + const { rerender } = render(); + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'Draft' }, + }); + fireEvent.change(screen.getByRole('textbox'), { + target: { value: mocks.settings.instructions }, + }); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + + mocks.query.data = { + ...mocks.settings, + instructions: 'New server text.', + version: 4, + }; + rerender(); + expect(screen.getByRole('textbox')).toHaveValue('New server text.'); + }); + + it.each(['save', 'reset'])( + 'clears the draft after successful %s and follows later updates', + (action) => { + const { rerender } = render(); + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'Draft' }, + }); + fireEvent.click( + screen.getByRole('button', { + name: action === 'save' ? 'Save' : 'Reset', + }), + ); + const saved = { + ...mocks.settings, + instructions: action === 'save' ? 'Draft' : '', + version: 4, + }; + mocks.query.data = saved; + act(() => mocks.updateOptions?.onSuccess(saved)); + rerender(); + expect(screen.getByRole('textbox')).toHaveValue(saved.instructions); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + + mocks.query.data = { + ...saved, + instructions: 'Later update.', + version: 5, + }; + rerender(); + expect(screen.getByRole('textbox')).toHaveValue('Later update.'); + }, + ); + it('updates learning separately and leaves the blob untouched', () => { render(); diff --git a/apps/web/src/components/settings/PersonalizationSection.tsx b/apps/web/src/components/settings/PersonalizationSection.tsx index 456c50503..fd6000c00 100644 --- a/apps/web/src/components/settings/PersonalizationSection.tsx +++ b/apps/web/src/components/settings/PersonalizationSection.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; @@ -28,13 +28,14 @@ export function PersonalizationSection() { const personalization = useQuery( trpc.preferences.getPersonalization.queryOptions(), ); - const [instructions, setInstructions] = useState(''); + const [draft, setDraft] = useState(null); + const settings = personalization.data; + const instructions = draft ?? settings?.instructions ?? ''; - useEffect(() => { - if (personalization.data) { - setInstructions(personalization.data.instructions); - } - }, [personalization.data]); + // Once saved data matches the draft, follow subsequent clean refreshes again. + if (draft !== null && draft === settings?.instructions) { + setDraft(null); + } const update = useMutation( trpc.preferences.updatePersonalization.mutationOptions({ @@ -43,7 +44,7 @@ export function PersonalizationSection() { queryKey, settings, ); - setInstructions(settings.instructions); + setDraft(null); }, onError: (error) => { toast.error(error.message); @@ -52,7 +53,6 @@ export function PersonalizationSection() { }), ); - const settings = personalization.data; const isBusy = personalization.isPending || update.isPending; const hasChanges = settings?.instructions !== instructions; @@ -80,7 +80,7 @@ export function PersonalizationSection() { rows={7} className="md:min-h-48" placeholder="For example: Keep answers concise, lead with a recommendation, and use examples when explaining unfamiliar concepts." - onChange={(event) => setInstructions(event.target.value)} + onChange={(event) => setDraft(event.target.value)} /> {instructions.length > MAX_INSTRUCTIONS_LENGTH * 0.8 && (