fix(frontend): save button always disabled on System Prompts page

Root cause: PromptEditor useEffect synced localPrompts back to match prompts after every keystroke, making isDirty() always false.

- Delegate disabled control to parent via hasChanges prop (no local sync)

- Derive currentPrompts synchronously to avoid empty-textarea flash

- Add key={selectedProfile} for clean remount on profile switch

- Update PromptEditor tests for new hasChanges prop

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Woody 2026-04-26 18:48:52 +08:00
parent 9f41a328e3
commit 0d3e8ce0ce
3 changed files with 14 additions and 15 deletions

View File

@ -1,9 +1,10 @@
import React, { useState, useEffect, useCallback } from 'react'
import React, { useState } from 'react'
import { RotateCcw, AlertTriangle } from 'lucide-react'
export interface PromptEditorProps {
profileName: string
prompts: Record<string, string>
hasChanges: boolean
isSaving: boolean
onUpdate: (step: string, template: string) => void
onSave: () => void
@ -33,6 +34,7 @@ const findUnknownPlaceholders = (template: string, stepKey: string): string[] =>
export const PromptEditor: React.FC<PromptEditorProps> = ({
profileName,
prompts,
hasChanges,
isSaving,
onUpdate,
onSave,
@ -40,9 +42,9 @@ export const PromptEditor: React.FC<PromptEditorProps> = ({
onResetAll,
onCancel,
}) => {
const [localPrompts, setLocalPrompts] = useState<Record<string, string>>({})
const [localPrompts, setLocalPrompts] = useState<Record<string, string>>({ ...prompts })
useEffect(() => {
React.useEffect(() => {
setLocalPrompts({ ...prompts })
}, [prompts])
@ -63,10 +65,6 @@ export const PromptEditor: React.FC<PromptEditorProps> = ({
}
}
const isDirty = useCallback(() => {
return STEPS.some((step) => localPrompts[step.key] !== prompts[step.key])
}, [localPrompts, prompts])
return (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6 space-y-6">
<div className="flex items-center gap-2 border-b border-gray-200 pb-3">
@ -136,7 +134,7 @@ export const PromptEditor: React.FC<PromptEditorProps> = ({
<button
type="button"
onClick={onSave}
disabled={isSaving || !isDirty()}
disabled={isSaving || !hasChanges}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-blue-600 transition-all duration-200"
>
{isSaving ? 'Saving...' : 'Save Changes'}

View File

@ -50,7 +50,9 @@ export const SystemPromptsPage: React.FC = () => {
setEditPrompts(profileData.prompts)
setHasChanges(false)
}
}, [profileData])
}, [profileData]) // eslint-disable-line react-hooks/exhaustive-deps
const currentPrompts = hasChanges ? editPrompts : (profileData?.prompts ?? {})
const handleSelectProfile = (name: string) => {
if (selectedProfile === name) {
@ -214,8 +216,10 @@ export const SystemPromptsPage: React.FC = () => {
<>
<PlaceholderDocs />
<PromptEditor
key={selectedProfile}
profileName={selectedProfile}
prompts={editPrompts}
prompts={currentPrompts}
hasChanges={hasChanges}
isSaving={isSaving}
onUpdate={handleUpdate}
onSave={handleSave}

View File

@ -11,6 +11,7 @@ const mockPrompts: Record<string, string> = {
const defaultProps = {
profileName: 'A',
prompts: mockPrompts,
hasChanges: false,
isSaving: false,
onUpdate: vi.fn(),
onSave: vi.fn(),
@ -85,11 +86,7 @@ describe('PromptEditor', () => {
})
it('calls onSave when "Save Changes" button is clicked', () => {
render(<PromptEditor {...defaultProps} />)
// The Save button is disabled when not dirty, so we need to change something first
const textareas = screen.getAllByRole('textbox')
fireEvent.change(textareas[0], { target: { value: 'Modified decompose template' } })
render(<PromptEditor {...defaultProps} hasChanges={true} />)
const saveButton = screen.getByRole('button', { name: /save changes/i })
expect(saveButton).not.toBeDisabled()