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
55 changes: 43 additions & 12 deletions components/settings/PromptPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest';
import PromptPanel from './PromptPanel';
import { SettingsModalProvider } from './SettingsModalContext';
import type { AppSettings } from '../../types';
import React from 'react';

const baseSettings: AppSettings = {
provider: 'Gemini',
Expand Down Expand Up @@ -38,20 +39,34 @@ vi.mock('../../store', () => ({
}));

const renderPanel = (overrides: Partial<AppSettings> = {}) => {
const ctxValue = {
currentSettings: { ...baseSettings, ...overrides },
handleSettingChange: vi.fn(),
parameterSupport: {},
setParameterSupport: vi.fn(),
novelMetadata: null,
handleNovelMetadataChange: vi.fn(),
const Wrapper: React.FC = () => {
const [currentSettings, setCurrentSettings] = React.useState<AppSettings>({
...baseSettings,
...overrides,
});

const ctxValue = React.useMemo(
() => ({
currentSettings,
handleSettingChange: <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
setCurrentSettings((prev) => ({ ...prev, [key]: value }));
},
parameterSupport: {},
setParameterSupport: vi.fn(),
novelMetadata: null,
handleNovelMetadataChange: vi.fn(),
}),
[currentSettings]
);

return (
<SettingsModalProvider value={ctxValue}>
<PromptPanel />
</SettingsModalProvider>
);
};

return render(
<SettingsModalProvider value={ctxValue}>
<PromptPanel />
</SettingsModalProvider>
);
return render(<Wrapper />);
};

describe('PromptPanel', () => {
Expand Down Expand Up @@ -82,4 +97,20 @@ describe('PromptPanel', () => {
expect(storeState.setActivePromptTemplate).toHaveBeenCalledWith('2');
expect(storeState.updateSettings).toHaveBeenCalledWith({ systemPrompt: 'B', activePromptId: '2' });
});

it('edits the active prompt content and saves it', async () => {
const user = userEvent.setup();
renderPanel();

const editButtons = screen.getAllByRole('button', { name: /^edit$/i });
await user.click(editButtons[0]);
const textarea = screen.getByLabelText(/system prompt text/i);
await user.clear(textarea);
await user.type(textarea, 'Updated content');
await user.click(screen.getAllByRole('button', { name: /save/i })[0]);

expect(storeState.updatePromptTemplate).toHaveBeenCalledWith(
expect.objectContaining({ id: '1', content: 'Updated content' })
);
});
});
73 changes: 71 additions & 2 deletions components/settings/PromptPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useSettingsModalContext } from './SettingsModalContext';
import { useAppStore } from '../../store';
import { useShallow } from 'zustand/react/shallow';
Expand Down Expand Up @@ -29,6 +29,15 @@ export const PromptPanel: React.FC = () => {
const [editingPrompt, setEditingPrompt] = useState<string | null>(null);
const [newPromptName, setNewPromptName] = useState('');
const [newPromptDescription, setNewPromptDescription] = useState('');
const promptTextareaRef = useRef<HTMLTextAreaElement | null>(null);

const isEditingActive = editingPrompt === activePromptTemplate?.id;

useEffect(() => {
if (isEditingActive && promptTextareaRef.current) {
promptTextareaRef.current.focus();
}
}, [isEditingActive]);

const handleCreatePrompt = async () => {
if (!newPromptName.trim()) return;
Expand All @@ -46,6 +55,7 @@ export const PromptPanel: React.FC = () => {
};

const handleSelectPrompt = async (templateId: string) => {
setEditingPrompt(null);
await setActivePromptTemplate(templateId);
const template = promptTemplates.find((t) => t.id === templateId);
if (template) {
Expand All @@ -54,7 +64,7 @@ export const PromptPanel: React.FC = () => {
handleSettingChange('activePromptId' as any, templateId as any);
requestAnimationFrame(() => {
const el = document.getElementById(`prompt-${templateId}`);
if (el) {
if (el && typeof el.scrollIntoView === 'function') {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('ring-2', 'ring-blue-400');
setTimeout(() => el.classList.remove('ring-2', 'ring-blue-400'), 1200);
Expand All @@ -77,6 +87,7 @@ export const PromptPanel: React.FC = () => {
...template,
content: currentSettings.systemPrompt,
});
updateSettings({ systemPrompt: currentSettings.systemPrompt, activePromptId: templateId });
}
setEditingPrompt(null);
};
Expand Down Expand Up @@ -104,6 +115,64 @@ export const PromptPanel: React.FC = () => {
</button>
</div>

<div className="border border-gray-200 dark:border-gray-700 rounded-md p-3 bg-white dark:bg-gray-900">
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-semibold text-gray-800 dark:text-gray-200">
Active prompt content
</h4>
<div className="space-x-2">
{activePromptTemplate && !isEditingActive && (
<button
onClick={() => setEditingPrompt(activePromptTemplate.id)}
className="px-2 py-1 bg-gray-500 text-white text-xs rounded-md hover:bg-gray-600 transition"
disabled={!activePromptTemplate}
>
Edit
</button>
)}
{isEditingActive && (
<>
<button
onClick={() => setEditingPrompt(null)}
className="px-2 py-1 bg-gray-400 text-white text-xs rounded-md hover:bg-gray-500 transition"
>
Cancel
</button>
<button
onClick={() => handleSavePromptEdit(activePromptTemplate!.id)}
className="px-2 py-1 bg-green-600 text-white text-xs rounded-md hover:bg-green-700 transition"
>
Save
</button>
</>
)}
</div>
</div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1" htmlFor="active-prompt-content">
System prompt text
</label>
<textarea
id="active-prompt-content"
ref={promptTextareaRef}
value={currentSettings.systemPrompt}
onChange={(e) => handleSettingChange('systemPrompt' as any, e.target.value as any)}
disabled={!activePromptTemplate}
className={`w-full h-40 p-3 text-sm rounded-md border ${
isEditingActive
? 'border-blue-400 focus:ring-2 focus:ring-blue-400'
: 'border-gray-300 dark:border-gray-700'
} bg-gray-50 dark:bg-gray-800 dark:text-gray-100`}
placeholder="Select or create a prompt to edit its content"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{activePromptTemplate
? isEditingActive
? 'Editing active prompt. Click Save to persist changes.'
: 'Click Edit to modify the active prompt content.'
: 'Select or create a prompt to start editing.'}
</p>
</div>

{showCreatePrompt && (
<div className="border border-gray-300 dark:border-gray-600 rounded-md p-4 bg-gray-50 dark:bg-gray-700">
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">Create New Prompt Template</h4>
Expand Down
5 changes: 5 additions & 0 deletions docs/WORKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -1124,6 +1124,11 @@ Next: After running with reduced logs, gather traces for 'Chapter not found' and
- Why: Keep session export using the DB ops layer (DiffOps), make EPUB image IDs stable with versioning, and tighten tests/selectors for more reliable validation.
- Tests: `npm test -- --run tests/current-system/export-import.test.ts tests/epub/assetResolver.test.ts tests/current-system/translation.test.ts tests/services/db/TranslationRepository.test.ts`

2025-12-21 18:10 UTC - Prompt UX: active prompt content editor
- Files: components/settings/PromptPanel.tsx; components/settings/PromptPanel.test.tsx; docs/WORKLOG.md
- Why: Make the active system prompt directly editable (edit/save/cancel) without switching templates or losing selection state.
- Tests: `npm test -- --run components/settings/PromptPanel.test.tsx`

2025-12-21 18:26 UTC - Test runner fixes: exclude Playwright specs + stabilize DB singleton test
- Files: vitest.config.ts; tests/adapters/providers/ClaudeAdapter.test.ts; services/db/core/connection.ts; docs/WORKLOG.md
- Why: Keep Playwright `tests/e2e/*.spec.ts` out of Vitest, fix Vitest mock hoisting in ClaudeAdapter tests, and remove an unreliable IndexedDB “probe open” that doubled open() calls.
Expand Down
Loading