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 && (