diff --git a/frontend/src/components/settings/AgentsMdEditor.test.tsx b/frontend/src/components/settings/AgentsMdEditor.test.tsx new file mode 100644 index 00000000..e7be3013 --- /dev/null +++ b/frontend/src/components/settings/AgentsMdEditor.test.tsx @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { AgentsMdEditor } from './AgentsMdEditor' + +vi.mock('@/api/settings', () => ({ + settingsApi: { + getAgentsMd: vi.fn().mockResolvedValue({ content: '# Rules\n\nline two\nline three\n' }), + updateAgentsMd: vi.fn().mockResolvedValue(undefined), + getDefaultAgentsMd: vi.fn().mockResolvedValue({ content: '# Default' }), + }, +})) + +const createWrapper = () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return ({ children }: { children: React.ReactNode }) => ( + {children} + ) +} + +describe('AgentsMdEditor', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('loads the current AGENTS.md into the editor', async () => { + render(, { wrapper: createWrapper() }) + await waitFor(() => + expect(screen.getByLabelText('AGENTS.md content')).toHaveValue('# Rules\n\nline two\nline three\n'), + ) + }) + + it('renders a line number gutter', async () => { + render(, { wrapper: createWrapper() }) + await waitFor(() => expect(screen.getByLabelText('AGENTS.md content')).toBeInTheDocument()) + const numbers = Array.from(document.body.querySelectorAll('[data-line-number]')) + expect(numbers.map((n) => n.textContent?.trim())).toEqual(['1', '2', '3', '4', '5']) + }) + + it('finds matches in the content', async () => { + const user = userEvent.setup() + render(, { wrapper: createWrapper() }) + await waitFor(() => expect(screen.getByLabelText('AGENTS.md content')).toBeInTheDocument()) + const findInput = screen.getByLabelText('Find in content') + await user.type(findInput, 'line') + expect(screen.getByTestId('find-match-count')).toHaveTextContent('1 of 2') + const marks = document.body.querySelectorAll('mark') + expect(marks.length).toBe(2) + }) + + it('keeps Save disabled until the content changes', async () => { + const user = userEvent.setup() + render(, { wrapper: createWrapper() }) + const saveButton = await screen.findByRole('button', { name: 'Save' }) + expect(saveButton).toBeDisabled() + const textarea = screen.getByLabelText('AGENTS.md content') as HTMLTextAreaElement + await user.type(textarea, '!') + expect(saveButton).toBeEnabled() + }) + + it('saves the edited content', async () => { + const user = userEvent.setup() + const { settingsApi } = await import('@/api/settings') + render(, { wrapper: createWrapper() }) + const textarea = (await screen.findByLabelText('AGENTS.md content')) as HTMLTextAreaElement + const edited = '# Rules\n\nline two\nline three\n# new section\n' + fireEvent.change(textarea, { target: { value: edited } }) + const saveButton = screen.getByRole('button', { name: 'Save' }) + await user.click(saveButton) + await waitFor(() => expect(settingsApi.updateAgentsMd).toHaveBeenCalled()) + expect(settingsApi.updateAgentsMd).toHaveBeenCalledWith(edited) + }) + + it('uses a 16px editor font on mobile', async () => { + render(, { wrapper: createWrapper() }) + const textarea = await screen.findByLabelText('AGENTS.md content') + expect(textarea.className).toContain('text-[16px]') + expect(textarea.className).toContain('min-[769px]:text-sm') + expect(textarea.className).not.toContain('text-xs') + }) + + it('locks the editor while a save is pending and preserves submitted content', async () => { + const user = userEvent.setup() + const { settingsApi } = await import('@/api/settings') + let resolveSave: () => void = () => {} + ;(settingsApi.updateAgentsMd as ReturnType).mockReturnValueOnce( + new Promise((resolve) => { + resolveSave = resolve + }), + ) + render(, { wrapper: createWrapper() }) + const textarea = (await screen.findByLabelText('AGENTS.md content')) as HTMLTextAreaElement + const edited = '# Rules\n\nline two\nline three\nedit\n' + fireEvent.change(textarea, { target: { value: edited } }) + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(textarea).toBeDisabled()) + try { + await user.type(textarea, 'lost edit') + } catch { + // user-event refuses to type into a disabled element; expected + } + expect(textarea).toHaveValue(edited) + resolveSave() + await waitFor(() => expect(textarea).not.toBeDisabled()) + expect(textarea).toHaveValue(edited) + }) + + it('preserves a post-save edit when a deferred refetch arrives with the saved value', async () => { + const user = userEvent.setup() + const { settingsApi } = await import('@/api/settings') + ;(settingsApi.getAgentsMd as ReturnType) + .mockResolvedValueOnce({ content: 'A' }) + .mockResolvedValueOnce({ content: 'B' }) + render(, { wrapper: createWrapper() }) + const textarea = (await screen.findByLabelText('AGENTS.md content')) as HTMLTextAreaElement + await waitFor(() => expect(textarea).toHaveValue('A')) + fireEvent.change(textarea, { target: { value: 'B' } }) + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(settingsApi.updateAgentsMd).toHaveBeenCalledWith('B')) + fireEvent.change(textarea, { target: { value: 'A' } }) + await waitFor(() => expect(settingsApi.getAgentsMd).toHaveBeenCalledTimes(2)) + expect(textarea).toHaveValue('A') + }) +}) diff --git a/frontend/src/components/settings/AgentsMdEditor.tsx b/frontend/src/components/settings/AgentsMdEditor.tsx index 3c7f9c38..d2899bff 100644 --- a/frontend/src/components/settings/AgentsMdEditor.tsx +++ b/frontend/src/components/settings/AgentsMdEditor.tsx @@ -1,15 +1,22 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Loader2, Save, RotateCcw } from 'lucide-react' import { Button } from '@/components/ui/button' -import { Textarea } from '@/components/ui/textarea' +import { CodeEditor } from '@/components/ui/code-editor' +import { EditorFindBar } from '@/components/ui/editor-find-bar' +import { useFindInText } from '@/lib/useFindInText' import { settingsApi } from '@/api/settings' import { showToast } from '@/lib/toast' export function AgentsMdEditor() { const queryClient = useQueryClient() const [content, setContent] = useState('') - const [hasChanges, setHasChanges] = useState(false) + const [savedContent, setSavedContent] = useState('') + const hasChanges = content !== savedContent + const hasChangesRef = useRef(false) + hasChangesRef.current = hasChanges + const savedContentRef = useRef('') + savedContentRef.current = savedContent const { data, isLoading, error } = useQuery({ queryKey: ['agents-md'], @@ -17,18 +24,19 @@ export function AgentsMdEditor() { }) useEffect(() => { - if (data?.content !== undefined) { - setContent(data.content) - setHasChanges(false) - } + if (data?.content === undefined) return + if (hasChangesRef.current) return + if (data.content === savedContentRef.current) return + setContent(data.content) + setSavedContent(data.content) }, [data?.content]) const updateMutation = useMutation({ mutationFn: (newContent: string) => settingsApi.updateAgentsMd(newContent), - onSuccess: () => { + onSuccess: (_data, newContent) => { + setSavedContent(newContent) queryClient.invalidateQueries({ queryKey: ['agents-md'] }) queryClient.invalidateQueries({ queryKey: ['opencode', 'agents'] }) - setHasChanges(false) showToast.success('AGENTS.md saved and server restarted') }, onError: () => { @@ -45,7 +53,7 @@ export function AgentsMdEditor() { onSuccess: (defaultContent) => { queryClient.invalidateQueries({ queryKey: ['agents-md'] }) setContent(defaultContent) - setHasChanges(false) + setSavedContent(defaultContent) showToast.success('AGENTS.md reset to default and server restarted') }, onError: () => { @@ -53,10 +61,7 @@ export function AgentsMdEditor() { }, }) - const handleContentChange = (value: string) => { - setContent(value) - setHasChanges(value !== data?.content) - } + const isSaving = updateMutation.isPending || resetToDefaultMutation.isPending const handleSave = () => { updateMutation.mutate(content) @@ -66,6 +71,8 @@ export function AgentsMdEditor() { resetToDefaultMutation.mutate() } + const { query, setQuery, matches, currentMatchIndex, hasMatches, next, prev } = useFindInText(content) + if (isLoading) { return (
@@ -84,18 +91,19 @@ export function AgentsMdEditor() { return (
-
+

Global instructions for AI agents. This file is merged with repository-specific AGENTS.md files.

-
+
- -