diff --git a/client/src/components/SidePanel/Agents/AgentPanel.test.tsx b/client/src/components/SidePanel/Agents/AgentPanel.test.tsx index 1fa3f62009..a429559519 100644 --- a/client/src/components/SidePanel/Agents/AgentPanel.test.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanel.test.tsx @@ -10,6 +10,7 @@ import type { AgentForm } from '~/common'; // Mock toast context - define this after all mocks let mockShowToast: jest.Mock; +const MOCK_USER_ID = 'user-123'; // Mock notification severity enum before other imports jest.mock('~/common/types', () => ({ @@ -99,7 +100,7 @@ jest.mock('~/utils', () => ({ jest.mock('~/hooks', () => ({ useSelectAgent: () => ({ onSelect: jest.fn() }), useLocalize: () => (key: string) => key, - useAuthContext: () => ({ user: { id: 'user-123', role: 'USER' } }), + useAuthContext: () => ({ user: { id: MOCK_USER_ID, role: 'USER' } }), })); jest.mock('~/hooks/useResourcePermissions', () => ({ @@ -414,26 +415,30 @@ describe('AgentPanel - Update Agent Toast Messages', () => { }); mockUpdateAgent.mockResolvedValue(createMockAgent({ name: 'Test Agent', version: 2 })); - saveAgentDraft('agent-123', { - id: 'agent-123', - name: 'Unsaved Agent', - description: '', - instructions: 'Unsaved instructions', - model: 'gpt-4', - model_parameters: {}, - provider: 'openai', - category: 'general', - execute_code: false, - file_search: false, - web_search: false, - } as AgentForm); + saveAgentDraft( + 'agent-123', + { + id: 'agent-123', + name: 'Unsaved Agent', + description: '', + instructions: 'Unsaved instructions', + model: 'gpt-4', + model_parameters: {}, + provider: 'openai', + category: 'general', + execute_code: false, + file_search: false, + web_search: false, + } as AgentForm, + MOCK_USER_ID, + ); - expect(getAgentDraft('agent-123')).toBeDefined(); + expect(getAgentDraft('agent-123', MOCK_USER_ID)).toBeDefined(); await renderAndSubmitForm(); await waitFor(() => { - expect(getAgentDraft('agent-123')).toBeUndefined(); + expect(getAgentDraft('agent-123', MOCK_USER_ID)).toBeUndefined(); }); }); }); diff --git a/client/src/components/SidePanel/Agents/AgentPanel.tsx b/client/src/components/SidePanel/Agents/AgentPanel.tsx index 9d4f0eb61d..d48e2e03f7 100644 --- a/client/src/components/SidePanel/Agents/AgentPanel.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanel.tsx @@ -266,7 +266,11 @@ export default function AgentPanel() { const agentQuery = canEdit && expandedAgentQuery.data ? expandedAgentQuery : basicAgentQuery; const models = useMemo(() => modelsQuery.data ?? {}, [modelsQuery.data]); - const draftValues = useMemo(() => getAgentDraft(current_agent_id), [current_agent_id]); + const draftUserId = user?.id; + const draftValues = useMemo( + () => getAgentDraft(current_agent_id, draftUserId), + [current_agent_id, draftUserId], + ); const defaultValues = useMemo(() => getDraftFormValues(draftValues), [draftValues]); const methods = useForm({ defaultValues, @@ -284,12 +288,13 @@ export default function AgentPanel() { formState: { dirtyFields }, } = methods; const currentAgentIdRef = useRef(current_agent_id); + const draftUserIdRef = useRef(draftUserId); const [hasDraft, setHasDraft] = useState(draftValues != null); const shouldPersistDraftRef = useRef(hasDraft); const [isAvatarUploadInFlight, setIsAvatarUploadInFlight] = useState(false); const persistAgentDraft = useCallback((agentId: string | undefined, values: AgentForm) => { - saveAgentDraft(agentId, values); + saveAgentDraft(agentId, values, draftUserIdRef.current); if (agentId === currentAgentIdRef.current) { setHasDraft(true); } @@ -297,8 +302,8 @@ export default function AgentPanel() { const clearDraftsForAgentIds = useCallback((agentIds: Array) => { shouldPersistDraftRef.current = false; - clearAgentDrafts(agentIds); - setHasDraft(getAgentDraft(currentAgentIdRef.current) != null); + clearAgentDrafts(agentIds, draftUserIdRef.current); + setHasDraft(getAgentDraft(currentAgentIdRef.current, draftUserIdRef.current) != null); }, []); const handleAgentChange = useCallback( @@ -328,23 +333,32 @@ export default function AgentPanel() { ); useEffect(() => { - if (currentAgentIdRef.current === current_agent_id) { + const agentChanged = currentAgentIdRef.current !== current_agent_id; + const userChanged = draftUserIdRef.current !== draftUserId; + + if (!agentChanged && !userChanged) { return; } - if (shouldPersistDraftRef.current) { - saveAgentDraft(currentAgentIdRef.current, getValues()); + if (agentChanged && shouldPersistDraftRef.current) { + saveAgentDraft(currentAgentIdRef.current, getValues(), draftUserIdRef.current); } currentAgentIdRef.current = current_agent_id; - const nextDraft = getAgentDraft(current_agent_id); + draftUserIdRef.current = draftUserId; + const nextDraft = getAgentDraft(current_agent_id, draftUserId); const nextHasDraft = nextDraft != null; shouldPersistDraftRef.current = nextHasDraft; setHasDraft(nextHasDraft); if (nextDraft) { reset(getDraftFormValues(nextDraft)); + return; } - }, [current_agent_id, getValues, reset]); + + if (userChanged) { + reset(getDefaultAgentFormValues()); + } + }, [current_agent_id, draftUserId, getValues, reset]); useEffect(() => { if (hasDraft) { @@ -373,7 +387,7 @@ export default function AgentPanel() { return () => { if (shouldPersistDraftRef.current) { - saveAgentDraft(currentAgentIdRef.current, getValues()); + saveAgentDraft(currentAgentIdRef.current, getValues(), draftUserIdRef.current); } subscription.unsubscribe(); }; diff --git a/client/src/components/SidePanel/Agents/DeleteButton.tsx b/client/src/components/SidePanel/Agents/DeleteButton.tsx index 5e1ab2d525..e958e95968 100644 --- a/client/src/components/SidePanel/Agents/DeleteButton.tsx +++ b/client/src/components/SidePanel/Agents/DeleteButton.tsx @@ -15,7 +15,7 @@ import type { UseMutationResult } from '@tanstack/react-query'; import { logger, getDefaultAgentFormValues } from '~/utils'; import { useDeleteAgentMutation } from '~/data-provider'; import { isEphemeralAgent } from '~/common'; -import { useLocalize } from '~/hooks'; +import { useAuthContext, useLocalize } from '~/hooks'; import store from '~/store'; import { clearAgentDraft } from './drafts'; @@ -29,6 +29,7 @@ function DeleteButton({ createMutation: UseMutationResult; }) { const localize = useLocalize(); + const { user } = useAuthContext(); const { reset } = useFormContext(); const { showToast } = useToastContext(); const setConversation = useSetRecoilState(store.conversationByIndex(0)); @@ -45,7 +46,7 @@ function DeleteButton({ message: localize('com_ui_agent_deleted'), status: 'success', }); - clearAgentDraft(vars.agent_id); + clearAgentDraft(vars.agent_id, user?.id); if (createMutation.data?.id ?? '') { logger.log('agents', 'resetting createMutation'); diff --git a/client/src/components/SidePanel/Agents/__tests__/AgentPanel.drafts.spec.tsx b/client/src/components/SidePanel/Agents/__tests__/AgentPanel.drafts.spec.tsx index 9af2dedfa3..f946e39d50 100644 --- a/client/src/components/SidePanel/Agents/__tests__/AgentPanel.drafts.spec.tsx +++ b/client/src/components/SidePanel/Agents/__tests__/AgentPanel.drafts.spec.tsx @@ -12,6 +12,7 @@ import { getAgentDraft, saveAgentDraft, clearAllAgentDrafts } from '../drafts'; let mockCurrentAgentId: string | undefined; let mockLastAgentSelectHasDraft: boolean | undefined; +let mockUserId: string | undefined; const AGENT_SELECT_LABEL = 'Agent Select'; const ADVANCED_PANEL_LABEL = 'Advanced Panel'; @@ -22,6 +23,7 @@ const AGENTS_BUTTON_LABEL = 'Agents'; const FILES_BUTTON_LABEL = 'Files'; const FILES_PANEL_LABEL = 'Files panel'; const PROGRAMMATIC_UPDATE_LABEL = 'Programmatic agent update'; +const MOCK_USER_ID = 'user-123'; type MockButtonProps = React.ButtonHTMLAttributes & { size?: string; @@ -78,7 +80,7 @@ jest.mock('~/data-provider', () => ({ jest.mock('~/hooks', () => ({ useSelectAgent: () => ({ onSelect: jest.fn() }), useLocalize: () => (key: string) => key, - useAuthContext: () => ({ user: { id: 'user-123', role: 'USER' } }), + useAuthContext: () => ({ user: { id: mockUserId, role: 'USER' } }), })); jest.mock('~/hooks/useResourcePermissions', () => ({ @@ -241,6 +243,7 @@ describe('AgentPanel draft preservation', () => { localStorage.setItem('side:active-panel', 'agents'); mockCurrentAgentId = undefined; mockLastAgentSelectHasDraft = undefined; + mockUserId = MOCK_USER_ID; clearAllAgentDrafts(); }); @@ -269,21 +272,25 @@ describe('AgentPanel draft preservation', () => { it('clears an existing draft when Create new agent is clicked', () => { mockCurrentAgentId = 'agent-123'; - saveAgentDraft('agent-123', { - id: 'agent-123', - name: 'Unsaved saved-agent name', - description: '', - instructions: 'Unsaved saved-agent instructions', - model: 'gpt-4o', - model_parameters: {}, - provider: { label: 'OpenAI', value: 'openAI' }, - tools: [], - tool_options: {}, - category: 'general', - execute_code: false, - file_search: false, - web_search: false, - } as AgentForm); + saveAgentDraft( + 'agent-123', + { + id: 'agent-123', + name: 'Unsaved saved-agent name', + description: '', + instructions: 'Unsaved saved-agent instructions', + model: 'gpt-4o', + model_parameters: {}, + provider: { label: 'OpenAI', value: 'openAI' }, + tools: [], + tool_options: {}, + category: 'general', + execute_code: false, + file_search: false, + web_search: false, + } as AgentForm, + MOCK_USER_ID, + ); render(); @@ -295,8 +302,8 @@ describe('AgentPanel draft preservation', () => { expect(screen.getByLabelText('Draft name')).toHaveValue(''); expect(screen.getByLabelText('Draft instructions')).toHaveValue(''); expect(screen.getByLabelText('Draft model')).toHaveValue(''); - expect(getAgentDraft('agent-123')).toBeUndefined(); - expect(getAgentDraft(undefined)).toBeUndefined(); + expect(getAgentDraft('agent-123', MOCK_USER_ID)).toBeUndefined(); + expect(getAgentDraft(undefined, MOCK_USER_ID)).toBeUndefined(); expect(mockLastAgentSelectHasDraft).toBe(false); }); @@ -310,7 +317,7 @@ describe('AgentPanel draft preservation', () => { await waitFor(() => { expect(screen.getByLabelText('Draft name')).toHaveValue('Saved from API'); }); - expect(getAgentDraft('agent-123')).toBeUndefined(); + expect(getAgentDraft('agent-123', MOCK_USER_ID)).toBeUndefined(); expect(mockLastAgentSelectHasDraft).toBe(false); }); @@ -321,21 +328,25 @@ describe('AgentPanel draft preservation', () => { fireEvent.change(screen.getByLabelText('Draft name'), { target: { value: 'Previous agent draft' }, }); - saveAgentDraft('agent-456', { - id: 'agent-456', - name: 'Mounted switch draft', - description: '', - instructions: 'Draft for the switched agent', - model: 'gpt-4o', - model_parameters: {}, - provider: { label: 'OpenAI', value: 'openAI' }, - tools: [], - tool_options: {}, - category: 'general', - execute_code: false, - file_search: false, - web_search: false, - } as AgentForm); + saveAgentDraft( + 'agent-456', + { + id: 'agent-456', + name: 'Mounted switch draft', + description: '', + instructions: 'Draft for the switched agent', + model: 'gpt-4o', + model_parameters: {}, + provider: { label: 'OpenAI', value: 'openAI' }, + tools: [], + tool_options: {}, + category: 'general', + execute_code: false, + file_search: false, + web_search: false, + } as AgentForm, + MOCK_USER_ID, + ); mockCurrentAgentId = 'agent-456'; rerender(); @@ -344,7 +355,25 @@ describe('AgentPanel draft preservation', () => { expect(screen.getByLabelText('Draft name')).toHaveValue('Mounted switch draft'); }); expect(screen.getByLabelText('Draft instructions')).toHaveValue('Draft for the switched agent'); - expect(getAgentDraft('agent-123')?.name).toBe('Previous agent draft'); + expect(getAgentDraft('agent-123', MOCK_USER_ID)?.name).toBe('Previous agent draft'); expect(mockLastAgentSelectHasDraft).toBe(true); }); + + it('does not carry drafts across user changes while mounted', async () => { + const { rerender } = render(); + + fireEvent.change(screen.getByLabelText('Draft name'), { + target: { value: 'First user draft' }, + }); + expect(getAgentDraft(undefined, MOCK_USER_ID)?.name).toBe('First user draft'); + + mockUserId = 'user-456'; + rerender(); + + await waitFor(() => { + expect(screen.getByLabelText('Draft name')).toHaveValue(''); + }); + expect(getAgentDraft(undefined, 'user-456')).toBeUndefined(); + expect(getAgentDraft(undefined, MOCK_USER_ID)?.name).toBe('First user draft'); + }); }); diff --git a/client/src/components/SidePanel/Agents/__tests__/drafts.spec.ts b/client/src/components/SidePanel/Agents/__tests__/drafts.spec.ts index a5860a9a5f..c1109fcb64 100644 --- a/client/src/components/SidePanel/Agents/__tests__/drafts.spec.ts +++ b/client/src/components/SidePanel/Agents/__tests__/drafts.spec.ts @@ -80,4 +80,17 @@ describe('agent drafts', () => { expect(getAgentDraft('agent-a')).toBeUndefined(); expect(getAgentDraft('agent-b')?.name).toBe('Agent B'); }); + + it('keeps drafts isolated by user id', () => { + saveAgentDraft(undefined, createForm({ name: 'User A new draft' }), 'user-a'); + saveAgentDraft(undefined, createForm({ name: 'User B new draft' }), 'user-b'); + saveAgentDraft('agent-1', createForm({ name: 'User A saved-agent draft' }), 'user-a'); + + clearAgentDraft(undefined, 'user-a'); + + expect(getAgentDraft(undefined, 'user-a')).toBeUndefined(); + expect(getAgentDraft(undefined, 'user-b')?.name).toBe('User B new draft'); + expect(getAgentDraft('agent-1', 'user-a')?.name).toBe('User A saved-agent draft'); + expect(getAgentDraft('agent-1', 'user-b')).toBeUndefined(); + }); }); diff --git a/client/src/components/SidePanel/Agents/drafts.ts b/client/src/components/SidePanel/Agents/drafts.ts index 05f4df9018..56d15499e2 100644 --- a/client/src/components/SidePanel/Agents/drafts.ts +++ b/client/src/components/SidePanel/Agents/drafts.ts @@ -6,8 +6,8 @@ export type AgentDraftValues = Partial; const drafts = new Map(); -export function getAgentDraftKey(agentId?: string | null): string { - return agentId || NEW_AGENT_DRAFT_KEY; +export function getAgentDraftKey(agentId?: string | null, userId?: string | null): string { + return `${userId || 'anonymous'}:${agentId || NEW_AGENT_DRAFT_KEY}`; } const sanitizeFile = ({ file: _file, ...value }: ExtendedFile): ExtendedFile => value; @@ -37,20 +37,30 @@ export function sanitizeAgentDraft(values: AgentForm): AgentDraftValues { return draft; } -export function getAgentDraft(agentId?: string | null): AgentDraftValues | undefined { - return drafts.get(getAgentDraftKey(agentId)); +export function getAgentDraft( + agentId?: string | null, + userId?: string | null, +): AgentDraftValues | undefined { + return drafts.get(getAgentDraftKey(agentId, userId)); } -export function saveAgentDraft(agentId: string | null | undefined, values: AgentForm): void { - drafts.set(getAgentDraftKey(agentId), sanitizeAgentDraft(values)); +export function saveAgentDraft( + agentId: string | null | undefined, + values: AgentForm, + userId?: string | null, +): void { + drafts.set(getAgentDraftKey(agentId, userId), sanitizeAgentDraft(values)); } -export function clearAgentDraft(agentId?: string | null): void { - drafts.delete(getAgentDraftKey(agentId)); +export function clearAgentDraft(agentId?: string | null, userId?: string | null): void { + drafts.delete(getAgentDraftKey(agentId, userId)); } -export function clearAgentDrafts(agentIds: Array): void { - agentIds.forEach(clearAgentDraft); +export function clearAgentDrafts( + agentIds: Array, + userId?: string | null, +): void { + agentIds.forEach((agentId) => clearAgentDraft(agentId, userId)); } export function clearAllAgentDrafts(): void {