feat(frontend): streamline task creation and preview workflows
- remove prompt and preview generation from task creation - create tasks as inactive and route directly to edit page - add generated message history UX to edit task - update entity/task views and related test coverage
This commit is contained in:
@@ -229,6 +229,7 @@ TASK DETAILS
|
||||
|
||||
INSTRUCTIONS
|
||||
- Write exactly one email message.
|
||||
- The message must be written by Milton Fiscal (Director of Ceremonial Logistics) as the sender persona.
|
||||
- Use an extremely formal corporate tone.
|
||||
- Keep the content casual, mundane, and slightly nonsensical.
|
||||
- Reflect the entity personality and task prompt faithfully.
|
||||
|
||||
@@ -39,11 +39,6 @@ const mockEntity = {
|
||||
|
||||
describe('CreateTaskPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(tasksApi.getEmailLookbackLabel).mockReturnValue('Last week')
|
||||
vi.mocked(tasksApi.buildTaskPreviewPrompt).mockImplementation(
|
||||
(entity, task) =>
|
||||
`PROMPT FOR ${entity.name}: ${task.name} | ${task.prompt} | ${task.scheduleCron} | ${task.emailLookback}`
|
||||
)
|
||||
vi.mocked(entitiesApi.getEntity).mockResolvedValue(mockEntity)
|
||||
mockNavigate.mockClear()
|
||||
})
|
||||
@@ -54,25 +49,36 @@ describe('CreateTaskPage', () => {
|
||||
await screen.findByRole('link', { name: /back to entity a/i })
|
||||
|
||||
expect(screen.getByLabelText(/task name/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/task prompt/i)).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText(/task prompt/i)).not.toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/^Email Period$/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/^Minute$/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/^Hour$/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/^Day of Month$/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/^Month$/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/^Day of Week$/i)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /generate test message/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/Final Prompt/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should_generatePreviewAndCreateTask_when_formSubmitted', async () => {
|
||||
vi.mocked(tasksApi.generateTaskPreview).mockResolvedValue('SUBJECT: Preview\nBODY:\nFormal nonsense')
|
||||
it('should_createInactiveTaskAndNavigateToEditTask_when_formSubmitted', async () => {
|
||||
vi.mocked(tasksApi.createTask).mockResolvedValue({
|
||||
id: 'task-2',
|
||||
entityId: 'entity-1',
|
||||
name: 'Morning Blast',
|
||||
prompt: 'Talk about coffee',
|
||||
prompt: '',
|
||||
scheduleCron: '0 8 * * 1-5',
|
||||
emailLookback: 'last_week',
|
||||
active: true,
|
||||
active: false,
|
||||
createdAt: '2026-03-26T10:00:00Z',
|
||||
})
|
||||
vi.mocked(tasksApi.inactivateTask).mockResolvedValue({
|
||||
id: 'task-2',
|
||||
entityId: 'entity-1',
|
||||
name: 'Morning Blast',
|
||||
prompt: '',
|
||||
scheduleCron: '0 8 * * 1-5',
|
||||
emailLookback: 'last_week',
|
||||
active: false,
|
||||
createdAt: '2026-03-26T10:00:00Z',
|
||||
})
|
||||
|
||||
@@ -97,45 +103,10 @@ describe('CreateTaskPage', () => {
|
||||
expect(screen.getByRole('button', { name: /Monthly/i })).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/task name/i), { target: { value: 'Morning Blast' } })
|
||||
fireEvent.change(screen.getByLabelText(/task prompt/i), { target: { value: 'Talk about coffee' } })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Weekdays/i }))
|
||||
fireEvent.change(screen.getByLabelText(/^Hour$/i), { target: { value: '8' } })
|
||||
|
||||
expect(screen.getByText(/Final Prompt/i)).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('PROMPT FOR Entity A: Morning Blast | Talk about coffee | 0 8 * * 1-5 | last_week')
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /generate test message/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(tasksApi.generateTaskPreview).toHaveBeenCalled()
|
||||
expect(vi.mocked(tasksApi.buildTaskPreviewPrompt)).toHaveBeenCalledWith(
|
||||
mockEntity,
|
||||
expect.objectContaining({
|
||||
entityId: 'entity-1',
|
||||
name: 'Morning Blast',
|
||||
prompt: 'Talk about coffee',
|
||||
scheduleCron: '0 8 * * 1-5',
|
||||
emailLookback: 'last_week',
|
||||
})
|
||||
)
|
||||
expect(vi.mocked(tasksApi.generateTaskPreview).mock.calls[0][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
entity: mockEntity,
|
||||
task: expect.objectContaining({
|
||||
entityId: 'entity-1',
|
||||
name: 'Morning Blast',
|
||||
prompt: 'Talk about coffee',
|
||||
scheduleCron: '0 8 * * 1-5',
|
||||
emailLookback: 'last_week',
|
||||
}),
|
||||
})
|
||||
)
|
||||
expect(screen.getByText(/Formal nonsense/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /create task/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -144,32 +115,13 @@ describe('CreateTaskPage', () => {
|
||||
expect.objectContaining({
|
||||
entityId: 'entity-1',
|
||||
name: 'Morning Blast',
|
||||
prompt: 'Talk about coffee',
|
||||
prompt: '',
|
||||
scheduleCron: '0 8 * * 1-5',
|
||||
emailLookback: 'last_week',
|
||||
})
|
||||
)
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/entities/entity-1')
|
||||
})
|
||||
})
|
||||
|
||||
it('should_showReadablePreviewError_when_generationFails', async () => {
|
||||
vi.mocked(tasksApi.generateTaskPreview).mockRejectedValue(
|
||||
new Error('Unable to generate a test message from the local model. Connection refused')
|
||||
)
|
||||
|
||||
render(<CreateTaskPage />, { wrapper })
|
||||
await screen.findByRole('link', { name: /back to entity a/i })
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/task name/i), { target: { value: 'Morning Blast' } })
|
||||
fireEvent.change(screen.getByLabelText(/task prompt/i), { target: { value: 'Talk about coffee' } })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /generate test message/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Unable to generate a test message from the local model. Connection refused/i)
|
||||
).toBeInTheDocument()
|
||||
expect(tasksApi.inactivateTask).toHaveBeenCalledWith('task-2')
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/entities/entity-1/tasks/task-2')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -186,6 +186,59 @@ describe('EditTaskPage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should_renderGeneratedMessagePanels_when_pageLoads', async () => {
|
||||
renderPage()
|
||||
|
||||
await screen.findByRole('link', { name: /back to entity a/i })
|
||||
|
||||
expect(screen.getByText(/Final Prompt/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/^Generated Message$/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/^Generated Message History$/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Generate a message and it will appear here./i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('list', { name: /generated message history/i })).toBeInTheDocument()
|
||||
expect(screen.getByText(/No generated messages yet./i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should_showAndManageGeneratedMessageHistory_when_multipleMessagesGenerated', async () => {
|
||||
vi.mocked(tasksApi.generateTaskPreview)
|
||||
.mockResolvedValueOnce('SUBJECT: First\nBODY:\nFirst output')
|
||||
.mockResolvedValueOnce('SUBJECT: Second\nBODY:\nSecond output')
|
||||
|
||||
renderPage()
|
||||
await screen.findByRole('link', { name: /back to entity a/i })
|
||||
|
||||
const generateButton = screen.getByRole('button', { name: /generate test message/i })
|
||||
|
||||
fireEvent.click(generateButton)
|
||||
await screen.findByText(/First output/i)
|
||||
|
||||
fireEvent.click(generateButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Second output/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const history = screen.getByRole('list', { name: /generated message history/i })
|
||||
expect(history).toBeInTheDocument()
|
||||
|
||||
const firstMessageHistoryItem = screen.getByRole('button', { name: /^message #1$/i })
|
||||
const secondMessageHistoryItem = screen.getByRole('button', { name: /^message #2$/i })
|
||||
|
||||
fireEvent.click(firstMessageHistoryItem)
|
||||
expect(screen.getByText(/First output/i)).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: /delete message #1/i,
|
||||
})
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(firstMessageHistoryItem).not.toBeInTheDocument()
|
||||
expect(secondMessageHistoryItem).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should_renderActivateButton_when_taskIsInactive', async () => {
|
||||
renderPage({ task: { ...mockTask, active: false } })
|
||||
|
||||
|
||||
@@ -41,6 +41,37 @@ describe('EntitiesPage', () => {
|
||||
fireEvent.click(addButton)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText(/default email context window/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should_submitDefaultContextWindow_when_createEntitySubmitted', async () => {
|
||||
vi.mocked(entitiesApi.getEntities).mockResolvedValue([])
|
||||
vi.mocked(entitiesApi.createEntity).mockResolvedValue(mockEntity)
|
||||
|
||||
render(<EntitiesPage />, { wrapper })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /add|create|new/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog', { name: /create entity/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/entity name/i), { target: { value: 'Test Entity' } })
|
||||
fireEvent.change(screen.getByLabelText(/sender email/i), { target: { value: 'test@condado.com' } })
|
||||
fireEvent.change(screen.getByLabelText(/job title/i), { target: { value: 'Tester' } })
|
||||
fireEvent.change(screen.getByLabelText(/personality notes/i), { target: { value: 'Formal' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /create/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(entitiesApi.createEntity).toHaveBeenCalled()
|
||||
expect(vi.mocked(entitiesApi.createEntity).mock.calls[0]?.[0]).toEqual({
|
||||
name: 'Test Entity',
|
||||
email: 'test@condado.com',
|
||||
jobTitle: 'Tester',
|
||||
personality: 'Formal',
|
||||
contextWindowDays: 3,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
@@ -20,6 +20,84 @@ const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
)
|
||||
|
||||
describe('EntityDetailPage', () => {
|
||||
it('should_updateEntity_when_editSubmittedFromDetailPage', async () => {
|
||||
vi.mocked(tasksApi.getEmailLookbackLabel).mockReturnValue('Last week')
|
||||
|
||||
vi.mocked(entitiesApi.getEntity)
|
||||
.mockResolvedValueOnce({
|
||||
id: 'entity-1',
|
||||
name: 'Entity A',
|
||||
email: 'a@a.com',
|
||||
jobTitle: 'Ops',
|
||||
personality: 'Formal',
|
||||
scheduleCron: '0 9 * * 1',
|
||||
contextWindowDays: 3,
|
||||
active: true,
|
||||
createdAt: '',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'entity-1',
|
||||
name: 'Entity A Updated',
|
||||
email: 'updated@a.com',
|
||||
jobTitle: 'Operations Lead',
|
||||
personality: 'Still formal',
|
||||
scheduleCron: '0 9 * * 1',
|
||||
contextWindowDays: 5,
|
||||
active: true,
|
||||
createdAt: '',
|
||||
})
|
||||
|
||||
vi.mocked(entitiesApi.updateEntity).mockResolvedValue({
|
||||
id: 'entity-1',
|
||||
name: 'Entity A Updated',
|
||||
email: 'updated@a.com',
|
||||
jobTitle: 'Operations Lead',
|
||||
personality: 'Still formal',
|
||||
scheduleCron: '0 9 * * 1',
|
||||
contextWindowDays: 5,
|
||||
active: true,
|
||||
createdAt: '',
|
||||
})
|
||||
vi.mocked(tasksApi.getTasksByEntity).mockResolvedValue([])
|
||||
|
||||
render(<EntityDetailPage />, { wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Entity A/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit entity/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog', { name: /edit entity/i })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/entity name/i)).toHaveValue('Entity A')
|
||||
expect(screen.getByLabelText(/sender email/i)).toHaveValue('a@a.com')
|
||||
expect(screen.getByLabelText(/job title/i)).toHaveValue('Ops')
|
||||
expect(screen.getByLabelText(/personality notes/i)).toHaveValue('Formal')
|
||||
expect(screen.queryByLabelText(/default email context window/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/entity name/i), { target: { value: 'Entity A Updated' } })
|
||||
fireEvent.change(screen.getByLabelText(/sender email/i), { target: { value: 'updated@a.com' } })
|
||||
fireEvent.change(screen.getByLabelText(/job title/i), { target: { value: 'Operations Lead' } })
|
||||
fireEvent.change(screen.getByLabelText(/personality notes/i), { target: { value: 'Still formal' } })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(entitiesApi.updateEntity).toHaveBeenCalledWith('entity-1', {
|
||||
name: 'Entity A Updated',
|
||||
email: 'updated@a.com',
|
||||
jobTitle: 'Operations Lead',
|
||||
personality: 'Still formal',
|
||||
contextWindowDays: 3,
|
||||
})
|
||||
expect(screen.queryByRole('dialog', { name: /edit entity/i })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: /Entity A Updated/i })).toBeInTheDocument()
|
||||
expect(screen.getByText(/Operations Lead - updated@a.com/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should_renderEntityAndTasks_when_pageLoads', async () => {
|
||||
vi.mocked(tasksApi.getEmailLookbackLabel).mockReturnValue('Last week')
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ export function buildTaskPreviewPrompt(
|
||||
'',
|
||||
'INSTRUCTIONS',
|
||||
'- Write exactly one email message.',
|
||||
`- The message must be written by ${entity.name} (${entity.jobTitle}) as the sender persona.`,
|
||||
'- Use an extremely formal corporate tone.',
|
||||
'- Keep the content casual, mundane, and slightly nonsensical.',
|
||||
'- Reflect the entity personality and task prompt faithfully.',
|
||||
|
||||
@@ -3,15 +3,13 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { getEntity } from '../api/entitiesApi'
|
||||
import {
|
||||
buildTaskPreviewPrompt,
|
||||
createTask,
|
||||
generateTaskPreview,
|
||||
inactivateTask,
|
||||
type EmailLookback,
|
||||
} from '../api/tasksApi'
|
||||
|
||||
interface TaskFormState {
|
||||
name: string
|
||||
prompt: string
|
||||
scheduleCron: string
|
||||
emailLookback: EmailLookback
|
||||
}
|
||||
@@ -72,7 +70,6 @@ function buildCron(parts: CronParts): string {
|
||||
|
||||
const DEFAULT_TASK_FORM: TaskFormState = {
|
||||
name: '',
|
||||
prompt: '',
|
||||
scheduleCron: buildCron(DEFAULT_CRON_PARTS),
|
||||
emailLookback: 'last_week',
|
||||
}
|
||||
@@ -83,8 +80,6 @@ export default function CreateTaskPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [cronParts, setCronParts] = useState<CronParts>(DEFAULT_CRON_PARTS)
|
||||
const [taskForm, setTaskForm] = useState<TaskFormState>(DEFAULT_TASK_FORM)
|
||||
const [preview, setPreview] = useState('')
|
||||
const [previewError, setPreviewError] = useState('')
|
||||
|
||||
const { data: entity, isLoading } = useQuery({
|
||||
queryKey: ['entity', entityId],
|
||||
@@ -94,51 +89,18 @@ export default function CreateTaskPage() {
|
||||
|
||||
const createTaskMutation = useMutation({
|
||||
mutationFn: createTask,
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (createdTask) => {
|
||||
await inactivateTask(createdTask.id)
|
||||
await queryClient.invalidateQueries({ queryKey: ['entity-tasks', entityId] })
|
||||
navigate(`/entities/${entityId}`)
|
||||
},
|
||||
})
|
||||
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: generateTaskPreview,
|
||||
onMutate: () => {
|
||||
setPreview('')
|
||||
setPreviewError('')
|
||||
},
|
||||
onSuccess: (value) => setPreview(value),
|
||||
onError: (error) => {
|
||||
setPreviewError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Unable to generate a test message from the local model.'
|
||||
)
|
||||
await queryClient.invalidateQueries({ queryKey: ['entity-task', createdTask.id] })
|
||||
navigate(`/entities/${entityId}/tasks/${createdTask.id}`)
|
||||
},
|
||||
})
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
const hasFilledCronParts = Object.values(cronParts).every((value) => value.trim().length > 0)
|
||||
return Boolean(taskForm.name.trim() && taskForm.prompt.trim() && hasFilledCronParts)
|
||||
}, [cronParts, taskForm.name, taskForm.prompt])
|
||||
|
||||
const currentTask = useMemo(
|
||||
() => ({
|
||||
entityId,
|
||||
name: taskForm.name,
|
||||
prompt: taskForm.prompt,
|
||||
scheduleCron: taskForm.scheduleCron,
|
||||
emailLookback: taskForm.emailLookback,
|
||||
}),
|
||||
[entityId, taskForm.emailLookback, taskForm.name, taskForm.prompt, taskForm.scheduleCron]
|
||||
)
|
||||
|
||||
const finalPrompt = useMemo(() => {
|
||||
if (!entity) {
|
||||
return 'Entity details unavailable.'
|
||||
}
|
||||
|
||||
return buildTaskPreviewPrompt(entity, currentTask)
|
||||
}, [currentTask, entity])
|
||||
return Boolean(taskForm.name.trim() && hasFilledCronParts)
|
||||
}, [cronParts, taskForm.name])
|
||||
|
||||
const applyCronParts = (nextCronParts: CronParts) => {
|
||||
setCronParts(nextCronParts)
|
||||
@@ -186,7 +148,7 @@ export default function CreateTaskPage() {
|
||||
createTaskMutation.mutate({
|
||||
entityId,
|
||||
name: taskForm.name,
|
||||
prompt: taskForm.prompt,
|
||||
prompt: '',
|
||||
scheduleCron: taskForm.scheduleCron,
|
||||
emailLookback: taskForm.emailLookback,
|
||||
})
|
||||
@@ -205,21 +167,6 @@ export default function CreateTaskPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="task-prompt" className="text-sm font-medium text-slate-200">
|
||||
Task Prompt
|
||||
</label>
|
||||
<textarea
|
||||
id="task-prompt"
|
||||
value={taskForm.prompt}
|
||||
onChange={(event) =>
|
||||
setTaskForm((prev) => ({ ...prev, prompt: event.target.value }))
|
||||
}
|
||||
className="mt-1 min-h-36 w-full rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="task-lookback" className="text-sm font-medium text-slate-200">
|
||||
Email Period
|
||||
@@ -340,47 +287,6 @@ export default function CreateTaskPage() {
|
||||
<p className="mt-1 text-xs text-slate-500">Cron: {taskForm.scheduleCron}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-slate-800 bg-slate-900 p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!canSubmit) return
|
||||
if (!entity) return
|
||||
previewMutation.mutate({ entity, task: currentTask })
|
||||
}}
|
||||
disabled={!canSubmit || previewMutation.isPending}
|
||||
className="rounded-md border border-cyan-500 px-3 py-2 text-sm font-medium text-cyan-300 hover:bg-cyan-500/10 disabled:opacity-50"
|
||||
>
|
||||
{previewMutation.isPending ? 'Generating…' : 'Generate Test Message'}
|
||||
</button>
|
||||
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
Final Prompt
|
||||
</p>
|
||||
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
|
||||
{finalPrompt}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{previewError && (
|
||||
<p role="alert" className="mt-4 text-sm text-red-300">
|
||||
{previewError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
Generated Message
|
||||
</p>
|
||||
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
|
||||
{preview}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pb-8">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { getEntity } from '../api/entitiesApi'
|
||||
@@ -35,6 +35,12 @@ interface RegularitySuggestion {
|
||||
cronParts: CronParts
|
||||
}
|
||||
|
||||
interface GeneratedMessageItem {
|
||||
id: string
|
||||
label: string
|
||||
content: string
|
||||
}
|
||||
|
||||
const DEFAULT_CRON_PARTS: CronParts = {
|
||||
minute: '0',
|
||||
hour: '9',
|
||||
@@ -115,8 +121,10 @@ export default function EditTaskPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [cronParts, setCronParts] = useState<CronParts>(DEFAULT_CRON_PARTS)
|
||||
const [taskForm, setTaskForm] = useState<TaskFormState>(DEFAULT_TASK_FORM)
|
||||
const [preview, setPreview] = useState('')
|
||||
const [generatedMessages, setGeneratedMessages] = useState<GeneratedMessageItem[]>([])
|
||||
const [selectedMessageId, setSelectedMessageId] = useState('')
|
||||
const [previewError, setPreviewError] = useState('')
|
||||
const generatedMessageCounter = useRef(0)
|
||||
|
||||
const { data: entity, isLoading: isLoadingEntity } = useQuery({
|
||||
queryKey: ['entity', entityId],
|
||||
@@ -187,10 +195,19 @@ export default function EditTaskPage() {
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: generateTaskPreview,
|
||||
onMutate: () => {
|
||||
setPreview('')
|
||||
setPreviewError('')
|
||||
},
|
||||
onSuccess: (value) => setPreview(value),
|
||||
onSuccess: (value) => {
|
||||
generatedMessageCounter.current += 1
|
||||
const nextMessage: GeneratedMessageItem = {
|
||||
id: `message-${generatedMessageCounter.current}`,
|
||||
label: `Message #${generatedMessageCounter.current}`,
|
||||
content: value,
|
||||
}
|
||||
|
||||
setGeneratedMessages((prev) => [nextMessage, ...prev])
|
||||
setSelectedMessageId(nextMessage.id)
|
||||
},
|
||||
onError: (error) => {
|
||||
setPreviewError(
|
||||
error instanceof Error
|
||||
@@ -224,6 +241,11 @@ export default function EditTaskPage() {
|
||||
return buildTaskPreviewPrompt(entity, currentTask)
|
||||
}, [currentTask, entity])
|
||||
|
||||
const selectedMessage = useMemo(
|
||||
() => generatedMessages.find((message) => message.id === selectedMessageId),
|
||||
[generatedMessages, selectedMessageId]
|
||||
)
|
||||
|
||||
const applyCronParts = (nextCronParts: CronParts) => {
|
||||
setCronParts(nextCronParts)
|
||||
setTaskForm((prev) => ({ ...prev, scheduleCron: buildCron(nextCronParts) }))
|
||||
@@ -256,7 +278,7 @@ export default function EditTaskPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen overflow-y-auto bg-slate-950 py-8">
|
||||
<div className="mx-auto max-w-2xl px-4 sm:px-6">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6">
|
||||
<nav className="mb-6">
|
||||
<Link
|
||||
to={`/entities/${entityId}`}
|
||||
@@ -443,13 +465,72 @@ export default function EditTaskPage() {
|
||||
{previewMutation.isPending ? 'Generating…' : 'Generate Test Message'}
|
||||
</button>
|
||||
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
Final Prompt
|
||||
</p>
|
||||
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
|
||||
{finalPrompt}
|
||||
</pre>
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
Final Prompt
|
||||
</p>
|
||||
<pre className="mt-2 h-full min-h-56 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
|
||||
{finalPrompt}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
Generated Message
|
||||
</p>
|
||||
<pre className="mt-2 min-h-56 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
|
||||
{selectedMessage?.content ?? 'Generate a message and it will appear here.'}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
Generated Message History
|
||||
</p>
|
||||
<ul
|
||||
aria-label="Generated message history"
|
||||
className="mt-2 space-y-2 rounded-md border border-slate-800 bg-slate-950 p-3"
|
||||
>
|
||||
{generatedMessages.length === 0 && (
|
||||
<li className="text-xs text-slate-400">No generated messages yet.</li>
|
||||
)}
|
||||
{generatedMessages.map((message) => (
|
||||
<li
|
||||
key={message.id}
|
||||
className="flex items-start gap-2 rounded-md border border-slate-800 bg-slate-900 p-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedMessageId(message.id)}
|
||||
className="flex-1 text-left text-xs text-slate-200 hover:text-cyan-300"
|
||||
aria-label={message.label}
|
||||
>
|
||||
<span className="block font-medium">{message.label}</span>
|
||||
<span className="mt-1 block line-clamp-2 text-slate-400">
|
||||
{message.content.split('\n')[0]}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setGeneratedMessages((prev) => {
|
||||
const nextMessages = prev.filter((item) => item.id !== message.id)
|
||||
if (selectedMessageId === message.id) {
|
||||
setSelectedMessageId(nextMessages[0]?.id ?? '')
|
||||
}
|
||||
return nextMessages
|
||||
})
|
||||
}}
|
||||
className="rounded border border-red-500/40 px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-red-300 hover:bg-red-500/10"
|
||||
aria-label={`Delete ${message.label.toLowerCase()}`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{previewError && (
|
||||
@@ -458,16 +539,6 @@ export default function EditTaskPage() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
Generated Message
|
||||
</p>
|
||||
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
|
||||
{preview}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pb-8">
|
||||
|
||||
@@ -8,25 +8,29 @@ import {
|
||||
VirtualEntityCreateDto,
|
||||
} from '../api/entitiesApi'
|
||||
|
||||
const DEFAULT_CONTEXT_WINDOW_DAYS = 3
|
||||
|
||||
const initialFormState: VirtualEntityCreateDto = {
|
||||
name: '',
|
||||
email: '',
|
||||
jobTitle: '',
|
||||
personality: '',
|
||||
contextWindowDays: DEFAULT_CONTEXT_WINDOW_DAYS,
|
||||
}
|
||||
|
||||
export default function EntitiesPage() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: entities = [] } = useQuery({ queryKey: ['entities'], queryFn: getEntities })
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [form, setForm] = useState<VirtualEntityCreateDto>({
|
||||
name: '',
|
||||
email: '',
|
||||
jobTitle: '',
|
||||
personality: '',
|
||||
contextWindowDays: 3,
|
||||
})
|
||||
const [form, setForm] = useState<VirtualEntityCreateDto>(initialFormState)
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createEntity,
|
||||
onSuccess: (createdEntity) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['entities'] })
|
||||
setDialogOpen(false)
|
||||
setForm({ name: '', email: '', jobTitle: '', personality: '', contextWindowDays: 3 })
|
||||
setForm(initialFormState)
|
||||
navigate(`/entities/${createdEntity.id}`)
|
||||
},
|
||||
})
|
||||
@@ -140,20 +144,6 @@ export default function EntitiesPage() {
|
||||
className="block w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="entity-context-window" className="mb-1 block text-sm font-medium text-slate-200">
|
||||
Default Email Context Window (days)
|
||||
</label>
|
||||
<input
|
||||
id="entity-context-window"
|
||||
type="number"
|
||||
value={form.contextWindowDays}
|
||||
onChange={(e) => setForm({ ...form, contextWindowDays: Number(e.target.value) })}
|
||||
className="block w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
||||
min={1}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { getEntity } from '../api/entitiesApi'
|
||||
import { getEntity, updateEntity, VirtualEntityCreateDto, VirtualEntityResponse } from '../api/entitiesApi'
|
||||
import { getEmailLookbackLabel, getTasksByEntity } from '../api/tasksApi'
|
||||
|
||||
function toEntityForm(entity: VirtualEntityResponse): VirtualEntityCreateDto {
|
||||
return {
|
||||
name: entity.name,
|
||||
email: entity.email,
|
||||
jobTitle: entity.jobTitle,
|
||||
personality: entity.personality ?? '',
|
||||
contextWindowDays: entity.contextWindowDays,
|
||||
}
|
||||
}
|
||||
|
||||
export default function EntityDetailPage() {
|
||||
const { entityId = '' } = useParams()
|
||||
const queryClient = useQueryClient()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [form, setForm] = useState<VirtualEntityCreateDto | null>(null)
|
||||
|
||||
const { data: entity, isLoading: isLoadingEntity } = useQuery({
|
||||
queryKey: ['entity', entityId],
|
||||
@@ -18,6 +32,18 @@ export default function EntityDetailPage() {
|
||||
enabled: Boolean(entityId),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: VirtualEntityCreateDto) => updateEntity(entityId, data),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['entity', entityId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['entities'] }),
|
||||
])
|
||||
setDialogOpen(false)
|
||||
setForm(null)
|
||||
},
|
||||
})
|
||||
|
||||
if (!entityId) {
|
||||
return <div className="p-8 text-sm text-slate-300">Entity identifier is missing.</div>
|
||||
}
|
||||
@@ -49,12 +75,24 @@ export default function EntityDetailPage() {
|
||||
{entity.jobTitle} - {entity.email}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to={`/entities/${entityId}/tasks/new`}
|
||||
className="rounded-md bg-cyan-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-cyan-400"
|
||||
>
|
||||
New Task
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setForm(toEntityForm(entity))
|
||||
setDialogOpen(true)
|
||||
}}
|
||||
className="rounded-md border border-slate-700 px-4 py-2 text-sm font-semibold text-slate-100 hover:border-cyan-500 hover:text-cyan-300"
|
||||
>
|
||||
Edit Entity
|
||||
</button>
|
||||
<Link
|
||||
to={`/entities/${entityId}/tasks/new`}
|
||||
className="rounded-md bg-cyan-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-cyan-400"
|
||||
>
|
||||
New Task
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
@@ -100,6 +138,93 @@ export default function EntityDetailPage() {
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{dialogOpen && form && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Edit Entity"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/70"
|
||||
>
|
||||
<div className="w-full max-w-md rounded-lg border border-slate-800 bg-slate-950 p-6 shadow-lg">
|
||||
<h2 className="mb-4 text-lg font-semibold text-slate-100">Edit Entity</h2>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
updateMutation.mutate(form)
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="entity-name" className="mb-1 block text-sm font-medium text-slate-200">
|
||||
Entity Name
|
||||
</label>
|
||||
<input
|
||||
id="entity-name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="block w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="entity-email" className="mb-1 block text-sm font-medium text-slate-200">
|
||||
Sender Email
|
||||
</label>
|
||||
<input
|
||||
id="entity-email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
className="block w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="entity-job-title" className="mb-1 block text-sm font-medium text-slate-200">
|
||||
Job Title
|
||||
</label>
|
||||
<input
|
||||
id="entity-job-title"
|
||||
value={form.jobTitle}
|
||||
onChange={(e) => setForm({ ...form, jobTitle: e.target.value })}
|
||||
className="block w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="entity-personality" className="mb-1 block text-sm font-medium text-slate-200">
|
||||
Personality Notes
|
||||
</label>
|
||||
<textarea
|
||||
id="entity-personality"
|
||||
value={form.personality}
|
||||
onChange={(e) => setForm({ ...form, personality: e.target.value })}
|
||||
className="block w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDialogOpen(false)
|
||||
setForm(null)
|
||||
}}
|
||||
className="rounded border border-slate-700 px-4 py-2 text-sm text-slate-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-cyan-500 px-4 py-2 text-sm font-medium text-slate-950 hover:bg-cyan-400"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user