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' import { RotateCcw, AlertTriangle } from 'lucide-react'
export interface PromptEditorProps { export interface PromptEditorProps {
profileName: string profileName: string
prompts: Record<string, string> prompts: Record<string, string>
hasChanges: boolean
isSaving: boolean isSaving: boolean
onUpdate: (step: string, template: string) => void onUpdate: (step: string, template: string) => void
onSave: () => void onSave: () => void
@ -33,6 +34,7 @@ const findUnknownPlaceholders = (template: string, stepKey: string): string[] =>
export const PromptEditor: React.FC<PromptEditorProps> = ({ export const PromptEditor: React.FC<PromptEditorProps> = ({
profileName, profileName,
prompts, prompts,
hasChanges,
isSaving, isSaving,
onUpdate, onUpdate,
onSave, onSave,
@ -40,9 +42,9 @@ export const PromptEditor: React.FC<PromptEditorProps> = ({
onResetAll, onResetAll,
onCancel, onCancel,
}) => { }) => {
const [localPrompts, setLocalPrompts] = useState<Record<string, string>>({}) const [localPrompts, setLocalPrompts] = useState<Record<string, string>>({ ...prompts })
useEffect(() => { React.useEffect(() => {
setLocalPrompts({ ...prompts }) setLocalPrompts({ ...prompts })
}, [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 ( return (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6 space-y-6"> <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"> <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 <button
type="button" type="button"
onClick={onSave} 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" 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'} {isSaving ? 'Saving...' : 'Save Changes'}

View File

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

View File

@ -11,6 +11,7 @@ const mockPrompts: Record<string, string> = {
const defaultProps = { const defaultProps = {
profileName: 'A', profileName: 'A',
prompts: mockPrompts, prompts: mockPrompts,
hasChanges: false,
isSaving: false, isSaving: false,
onUpdate: vi.fn(), onUpdate: vi.fn(),
onSave: vi.fn(), onSave: vi.fn(),
@ -85,11 +86,7 @@ describe('PromptEditor', () => {
}) })
it('calls onSave when "Save Changes" button is clicked', () => { it('calls onSave when "Save Changes" button is clicked', () => {
render(<PromptEditor {...defaultProps} />) render(<PromptEditor {...defaultProps} hasChanges={true} />)
// 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' } })
const saveButton = screen.getByRole('button', { name: /save changes/i }) const saveButton = screen.getByRole('button', { name: /save changes/i })
expect(saveButton).not.toBeDisabled() expect(saveButton).not.toBeDisabled()