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:
2026-03-27 02:23:56 -03:00
parent a83ea85857
commit f2a16b5cf6
10 changed files with 430 additions and 222 deletions

View File

@@ -229,6 +229,7 @@ TASK DETAILS
INSTRUCTIONS INSTRUCTIONS
- Write exactly one email message. - 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. - Use an extremely formal corporate tone.
- Keep the content casual, mundane, and slightly nonsensical. - Keep the content casual, mundane, and slightly nonsensical.
- Reflect the entity personality and task prompt faithfully. - Reflect the entity personality and task prompt faithfully.

View File

@@ -39,11 +39,6 @@ const mockEntity = {
describe('CreateTaskPage', () => { describe('CreateTaskPage', () => {
beforeEach(() => { 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) vi.mocked(entitiesApi.getEntity).mockResolvedValue(mockEntity)
mockNavigate.mockClear() mockNavigate.mockClear()
}) })
@@ -54,25 +49,36 @@ describe('CreateTaskPage', () => {
await screen.findByRole('link', { name: /back to entity a/i }) await screen.findByRole('link', { name: /back to entity a/i })
expect(screen.getByLabelText(/task name/i)).toBeInTheDocument() 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(/^Email Period$/i)).toBeInTheDocument()
expect(screen.getByLabelText(/^Minute$/i)).toBeInTheDocument() expect(screen.getByLabelText(/^Minute$/i)).toBeInTheDocument()
expect(screen.getByLabelText(/^Hour$/i)).toBeInTheDocument() expect(screen.getByLabelText(/^Hour$/i)).toBeInTheDocument()
expect(screen.getByLabelText(/^Day of Month$/i)).toBeInTheDocument() expect(screen.getByLabelText(/^Day of Month$/i)).toBeInTheDocument()
expect(screen.getByLabelText(/^Month$/i)).toBeInTheDocument() expect(screen.getByLabelText(/^Month$/i)).toBeInTheDocument()
expect(screen.getByLabelText(/^Day of Week$/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 () => { it('should_createInactiveTaskAndNavigateToEditTask_when_formSubmitted', async () => {
vi.mocked(tasksApi.generateTaskPreview).mockResolvedValue('SUBJECT: Preview\nBODY:\nFormal nonsense')
vi.mocked(tasksApi.createTask).mockResolvedValue({ vi.mocked(tasksApi.createTask).mockResolvedValue({
id: 'task-2', id: 'task-2',
entityId: 'entity-1', entityId: 'entity-1',
name: 'Morning Blast', name: 'Morning Blast',
prompt: 'Talk about coffee', prompt: '',
scheduleCron: '0 8 * * 1-5', scheduleCron: '0 8 * * 1-5',
emailLookback: 'last_week', 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', createdAt: '2026-03-26T10:00:00Z',
}) })
@@ -97,45 +103,10 @@ describe('CreateTaskPage', () => {
expect(screen.getByRole('button', { name: /Monthly/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /Monthly/i })).toBeInTheDocument()
fireEvent.change(screen.getByLabelText(/task name/i), { target: { value: 'Morning Blast' } }) 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.click(screen.getByRole('button', { name: /Weekdays/i }))
fireEvent.change(screen.getByLabelText(/^Hour$/i), { target: { value: '8' } }) 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 })) fireEvent.click(screen.getByRole('button', { name: /create task/i }))
await waitFor(() => { await waitFor(() => {
@@ -144,32 +115,13 @@ describe('CreateTaskPage', () => {
expect.objectContaining({ expect.objectContaining({
entityId: 'entity-1', entityId: 'entity-1',
name: 'Morning Blast', name: 'Morning Blast',
prompt: 'Talk about coffee', prompt: '',
scheduleCron: '0 8 * * 1-5', scheduleCron: '0 8 * * 1-5',
emailLookback: 'last_week', emailLookback: 'last_week',
}) })
) )
expect(mockNavigate).toHaveBeenCalledWith('/entities/entity-1') expect(tasksApi.inactivateTask).toHaveBeenCalledWith('task-2')
}) expect(mockNavigate).toHaveBeenCalledWith('/entities/entity-1/tasks/task-2')
})
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()
}) })
}) })

View File

@@ -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 () => { it('should_renderActivateButton_when_taskIsInactive', async () => {
renderPage({ task: { ...mockTask, active: false } }) renderPage({ task: { ...mockTask, active: false } })

View File

@@ -41,6 +41,37 @@ describe('EntitiesPage', () => {
fireEvent.click(addButton) fireEvent.click(addButton)
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument() 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,
})
}) })
}) })

View File

@@ -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 { describe, it, expect, vi } from 'vitest'
import { MemoryRouter, Route, Routes } from 'react-router-dom' import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
@@ -20,6 +20,84 @@ const wrapper = ({ children }: { children: React.ReactNode }) => (
) )
describe('EntityDetailPage', () => { 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 () => { it('should_renderEntityAndTasks_when_pageLoads', async () => {
vi.mocked(tasksApi.getEmailLookbackLabel).mockReturnValue('Last week') vi.mocked(tasksApi.getEmailLookbackLabel).mockReturnValue('Last week')

View File

@@ -108,6 +108,7 @@ export function buildTaskPreviewPrompt(
'', '',
'INSTRUCTIONS', 'INSTRUCTIONS',
'- Write exactly one email message.', '- 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.', '- Use an extremely formal corporate tone.',
'- Keep the content casual, mundane, and slightly nonsensical.', '- Keep the content casual, mundane, and slightly nonsensical.',
'- Reflect the entity personality and task prompt faithfully.', '- Reflect the entity personality and task prompt faithfully.',

View File

@@ -3,15 +3,13 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import { getEntity } from '../api/entitiesApi' import { getEntity } from '../api/entitiesApi'
import { import {
buildTaskPreviewPrompt,
createTask, createTask,
generateTaskPreview, inactivateTask,
type EmailLookback, type EmailLookback,
} from '../api/tasksApi' } from '../api/tasksApi'
interface TaskFormState { interface TaskFormState {
name: string name: string
prompt: string
scheduleCron: string scheduleCron: string
emailLookback: EmailLookback emailLookback: EmailLookback
} }
@@ -72,7 +70,6 @@ function buildCron(parts: CronParts): string {
const DEFAULT_TASK_FORM: TaskFormState = { const DEFAULT_TASK_FORM: TaskFormState = {
name: '', name: '',
prompt: '',
scheduleCron: buildCron(DEFAULT_CRON_PARTS), scheduleCron: buildCron(DEFAULT_CRON_PARTS),
emailLookback: 'last_week', emailLookback: 'last_week',
} }
@@ -83,8 +80,6 @@ export default function CreateTaskPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [cronParts, setCronParts] = useState<CronParts>(DEFAULT_CRON_PARTS) const [cronParts, setCronParts] = useState<CronParts>(DEFAULT_CRON_PARTS)
const [taskForm, setTaskForm] = useState<TaskFormState>(DEFAULT_TASK_FORM) const [taskForm, setTaskForm] = useState<TaskFormState>(DEFAULT_TASK_FORM)
const [preview, setPreview] = useState('')
const [previewError, setPreviewError] = useState('')
const { data: entity, isLoading } = useQuery({ const { data: entity, isLoading } = useQuery({
queryKey: ['entity', entityId], queryKey: ['entity', entityId],
@@ -94,51 +89,18 @@ export default function CreateTaskPage() {
const createTaskMutation = useMutation({ const createTaskMutation = useMutation({
mutationFn: createTask, mutationFn: createTask,
onSuccess: async () => { onSuccess: async (createdTask) => {
await inactivateTask(createdTask.id)
await queryClient.invalidateQueries({ queryKey: ['entity-tasks', entityId] }) await queryClient.invalidateQueries({ queryKey: ['entity-tasks', entityId] })
navigate(`/entities/${entityId}`) await queryClient.invalidateQueries({ queryKey: ['entity-task', createdTask.id] })
}, navigate(`/entities/${entityId}/tasks/${createdTask.id}`)
})
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.'
)
}, },
}) })
const canSubmit = useMemo(() => { const canSubmit = useMemo(() => {
const hasFilledCronParts = Object.values(cronParts).every((value) => value.trim().length > 0) const hasFilledCronParts = Object.values(cronParts).every((value) => value.trim().length > 0)
return Boolean(taskForm.name.trim() && taskForm.prompt.trim() && hasFilledCronParts) return Boolean(taskForm.name.trim() && hasFilledCronParts)
}, [cronParts, taskForm.name, taskForm.prompt]) }, [cronParts, taskForm.name])
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])
const applyCronParts = (nextCronParts: CronParts) => { const applyCronParts = (nextCronParts: CronParts) => {
setCronParts(nextCronParts) setCronParts(nextCronParts)
@@ -186,7 +148,7 @@ export default function CreateTaskPage() {
createTaskMutation.mutate({ createTaskMutation.mutate({
entityId, entityId,
name: taskForm.name, name: taskForm.name,
prompt: taskForm.prompt, prompt: '',
scheduleCron: taskForm.scheduleCron, scheduleCron: taskForm.scheduleCron,
emailLookback: taskForm.emailLookback, emailLookback: taskForm.emailLookback,
}) })
@@ -205,21 +167,6 @@ export default function CreateTaskPage() {
/> />
</div> </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> <div>
<label htmlFor="task-lookback" className="text-sm font-medium text-slate-200"> <label htmlFor="task-lookback" className="text-sm font-medium text-slate-200">
Email Period Email Period
@@ -340,47 +287,6 @@ export default function CreateTaskPage() {
<p className="mt-1 text-xs text-slate-500">Cron: {taskForm.scheduleCron}</p> <p className="mt-1 text-xs text-slate-500">Cron: {taskForm.scheduleCron}</p>
</div> </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"> <div className="flex justify-end gap-3 pb-8">
<button <button
type="button" type="button"

View File

@@ -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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import { getEntity } from '../api/entitiesApi' import { getEntity } from '../api/entitiesApi'
@@ -35,6 +35,12 @@ interface RegularitySuggestion {
cronParts: CronParts cronParts: CronParts
} }
interface GeneratedMessageItem {
id: string
label: string
content: string
}
const DEFAULT_CRON_PARTS: CronParts = { const DEFAULT_CRON_PARTS: CronParts = {
minute: '0', minute: '0',
hour: '9', hour: '9',
@@ -115,8 +121,10 @@ export default function EditTaskPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [cronParts, setCronParts] = useState<CronParts>(DEFAULT_CRON_PARTS) const [cronParts, setCronParts] = useState<CronParts>(DEFAULT_CRON_PARTS)
const [taskForm, setTaskForm] = useState<TaskFormState>(DEFAULT_TASK_FORM) 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 [previewError, setPreviewError] = useState('')
const generatedMessageCounter = useRef(0)
const { data: entity, isLoading: isLoadingEntity } = useQuery({ const { data: entity, isLoading: isLoadingEntity } = useQuery({
queryKey: ['entity', entityId], queryKey: ['entity', entityId],
@@ -187,10 +195,19 @@ export default function EditTaskPage() {
const previewMutation = useMutation({ const previewMutation = useMutation({
mutationFn: generateTaskPreview, mutationFn: generateTaskPreview,
onMutate: () => { onMutate: () => {
setPreview('')
setPreviewError('') 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) => { onError: (error) => {
setPreviewError( setPreviewError(
error instanceof Error error instanceof Error
@@ -224,6 +241,11 @@ export default function EditTaskPage() {
return buildTaskPreviewPrompt(entity, currentTask) return buildTaskPreviewPrompt(entity, currentTask)
}, [currentTask, entity]) }, [currentTask, entity])
const selectedMessage = useMemo(
() => generatedMessages.find((message) => message.id === selectedMessageId),
[generatedMessages, selectedMessageId]
)
const applyCronParts = (nextCronParts: CronParts) => { const applyCronParts = (nextCronParts: CronParts) => {
setCronParts(nextCronParts) setCronParts(nextCronParts)
setTaskForm((prev) => ({ ...prev, scheduleCron: buildCron(nextCronParts) })) setTaskForm((prev) => ({ ...prev, scheduleCron: buildCron(nextCronParts) }))
@@ -256,7 +278,7 @@ export default function EditTaskPage() {
return ( return (
<div className="min-h-screen overflow-y-auto bg-slate-950 py-8"> <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"> <nav className="mb-6">
<Link <Link
to={`/entities/${entityId}`} to={`/entities/${entityId}`}
@@ -443,31 +465,80 @@ export default function EditTaskPage() {
{previewMutation.isPending ? 'Generating…' : 'Generate Test Message'} {previewMutation.isPending ? 'Generating…' : 'Generate Test Message'}
</button> </button>
<div className="mt-4"> <div className="mt-4 grid gap-4 lg:grid-cols-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400"> <p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
Final Prompt Final Prompt
</p> </p>
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200"> <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} {finalPrompt}
</pre> </pre>
</div> </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 && ( {previewError && (
<p role="alert" className="mt-4 text-sm text-red-300"> <p role="alert" className="mt-4 text-sm text-red-300">
{previewError} {previewError}
</p> </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>
<div className="flex justify-end gap-3 pb-8"> <div className="flex justify-end gap-3 pb-8">

View File

@@ -8,25 +8,29 @@ import {
VirtualEntityCreateDto, VirtualEntityCreateDto,
} from '../api/entitiesApi' } 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() { export default function EntitiesPage() {
const navigate = useNavigate() const navigate = useNavigate()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data: entities = [] } = useQuery({ queryKey: ['entities'], queryFn: getEntities }) const { data: entities = [] } = useQuery({ queryKey: ['entities'], queryFn: getEntities })
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [form, setForm] = useState<VirtualEntityCreateDto>({ const [form, setForm] = useState<VirtualEntityCreateDto>(initialFormState)
name: '',
email: '',
jobTitle: '',
personality: '',
contextWindowDays: 3,
})
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: createEntity, mutationFn: createEntity,
onSuccess: (createdEntity) => { onSuccess: (createdEntity) => {
queryClient.invalidateQueries({ queryKey: ['entities'] }) queryClient.invalidateQueries({ queryKey: ['entities'] })
setDialogOpen(false) setDialogOpen(false)
setForm({ name: '', email: '', jobTitle: '', personality: '', contextWindowDays: 3 }) setForm(initialFormState)
navigate(`/entities/${createdEntity.id}`) 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" className="block w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
/> />
</div> </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"> <div className="flex justify-end gap-2 pt-2">
<button <button
type="button" type="button"

View File

@@ -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 { 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' 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() { export default function EntityDetailPage() {
const { entityId = '' } = useParams() const { entityId = '' } = useParams()
const queryClient = useQueryClient()
const [dialogOpen, setDialogOpen] = useState(false)
const [form, setForm] = useState<VirtualEntityCreateDto | null>(null)
const { data: entity, isLoading: isLoadingEntity } = useQuery({ const { data: entity, isLoading: isLoadingEntity } = useQuery({
queryKey: ['entity', entityId], queryKey: ['entity', entityId],
@@ -18,6 +32,18 @@ export default function EntityDetailPage() {
enabled: Boolean(entityId), 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) { if (!entityId) {
return <div className="p-8 text-sm text-slate-300">Entity identifier is missing.</div> return <div className="p-8 text-sm text-slate-300">Entity identifier is missing.</div>
} }
@@ -49,6 +75,17 @@ export default function EntityDetailPage() {
{entity.jobTitle} - {entity.email} {entity.jobTitle} - {entity.email}
</p> </p>
</div> </div>
<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 <Link
to={`/entities/${entityId}/tasks/new`} 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" className="rounded-md bg-cyan-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-cyan-400"
@@ -56,6 +93,7 @@ export default function EntityDetailPage() {
New Task New Task
</Link> </Link>
</div> </div>
</div>
<section> <section>
<h2 className="text-lg font-semibold text-slate-100">Scheduled Tasks</h2> <h2 className="text-lg font-semibold text-slate-100">Scheduled Tasks</h2>
@@ -100,6 +138,93 @@ export default function EntityDetailPage() {
)} )}
</ul> </ul>
</section> </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> </div>
) )
} }