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

@@ -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"

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 { 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">

View File

@@ -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"

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 { 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>
)
}