Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions frontend/src/components/settings/AgentsMdEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}

describe('AgentsMdEditor', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('loads the current AGENTS.md into the editor', async () => {
render(<AgentsMdEditor />, { 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(<AgentsMdEditor />, { 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(<AgentsMdEditor />, { 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(<AgentsMdEditor />, { 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(<AgentsMdEditor />, { 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(<AgentsMdEditor />, { 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<typeof vi.fn>).mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveSave = resolve
}),
)
render(<AgentsMdEditor />, { 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<typeof vi.fn>)
.mockResolvedValueOnce({ content: 'A' })
.mockResolvedValueOnce({ content: 'B' })
render(<AgentsMdEditor />, { 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')
})
})
76 changes: 51 additions & 25 deletions frontend/src/components/settings/AgentsMdEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,42 @@
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'],
queryFn: () => settingsApi.getAgentsMd(),
})

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: () => {
Expand All @@ -45,18 +53,15 @@ 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: () => {
showToast.error('Failed to reset AGENTS.md')
},
})

const handleContentChange = (value: string) => {
setContent(value)
setHasChanges(value !== data?.content)
}
const isSaving = updateMutation.isPending || resetToDefaultMutation.isPending

const handleSave = () => {
updateMutation.mutate(content)
Expand All @@ -66,6 +71,8 @@ export function AgentsMdEditor() {
resetToDefaultMutation.mutate()
}

const { query, setQuery, matches, currentMatchIndex, hasMatches, next, prev } = useFindInText(content)

if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
Expand All @@ -84,18 +91,19 @@ export function AgentsMdEditor() {

return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="sticky top-0 z-10 flex flex-col gap-3 bg-background py-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm text-muted-foreground">
Global instructions for AI agents. This file is merged with repository-specific AGENTS.md files.
</p>
</div>
<div className="flex gap-2 flex-shrink-0">
<div className="flex flex-shrink-0 gap-2">
<Button
variant="outline"
size="sm"
onClick={handleResetToDefault}
disabled={updateMutation.isPending || resetToDefaultMutation.isPending}
disabled={isSaving}
className="flex-1 sm:flex-none"
>
{resetToDefaultMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
Expand All @@ -107,7 +115,8 @@ export function AgentsMdEditor() {
<Button
size="sm"
onClick={handleSave}
disabled={!hasChanges || updateMutation.isPending || resetToDefaultMutation.isPending}
disabled={!hasChanges || isSaving}
className="flex-1 sm:flex-none"
>
{updateMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
Expand All @@ -118,14 +127,31 @@ export function AgentsMdEditor() {
</Button>
</div>
</div>

<Textarea
value={content}
onChange={(e) => handleContentChange(e.target.value)}
className="font-mono text-xs sm:text-sm min-h-[300px] resize-y"
placeholder="# Agent Instructions&#10;&#10;Add global instructions for AI agents here..."

<EditorFindBar
query={query}
onQueryChange={setQuery}
matchCount={matches.length}
currentMatch={hasMatches ? currentMatchIndex + 1 : 0}
onPrev={prev}
onNext={next}
inputName="agents-md-find"
placeholder="Find in AGENTS.md..."
className="rounded-t-md border-x border-t"
/>


<div className="h-[55vh] min-h-[300px] overflow-hidden rounded-b-md border border-input">
<CodeEditor
ariaLabel="AGENTS.md content"
value={content}
onChange={setContent}
highlights={matches}
activeHighlightIndex={currentMatchIndex}
disabled={isSaving}
placeholder="# Agent Instructions&#10;&#10;Add global instructions for AI agents here..."
/>
</div>

{hasChanges && (
<p className="text-xs text-amber-500">You have unsaved changes</p>
)}
Expand Down
Loading