From 583d74b38d11aeaab8eccf4ea31025fcbf3f66be Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Sun, 1 Feb 2026 02:45:57 -0600 Subject: [PATCH] feat(v1.4): planning status + verification checklists (#40 #38) --- cli/src/commands/tasks.ts | 5 +- mcp/src/tools/tasks.ts | 8 +- server/src/__tests__/test-app.ts | 10 +- server/src/config/swagger.ts | 12 +- server/src/routes/task-verification.ts | 117 +++++++++++++ server/src/routes/tasks.ts | 4 +- server/src/routes/v1/index.ts | 2 + server/src/services/metrics/task-metrics.ts | 1 + shared/src/types/task.types.ts | 18 +- shared/src/utils/constants.ts | 15 +- .../components/board/BoardLoadingSkeleton.tsx | 7 +- web/src/components/board/BulkActionsBar.tsx | 8 + web/src/components/board/KanbanBoard.tsx | 5 +- web/src/components/board/KanbanColumn.tsx | 1 + .../components/dashboard/TasksDrillDown.tsx | 8 +- .../layout/KeyboardShortcutsDialog.tsx | 7 +- web/src/components/task/TaskCard.tsx | 63 ++++++- .../components/task/VerificationSection.tsx | 164 ++++++++++++++++++ .../components/task/detail/TaskDetailsTab.tsx | 106 ++++++----- .../task/detail/TaskMetadataSection.tsx | 1 + web/src/hooks/useKeyboard.tsx | 9 +- web/src/hooks/useTasks.ts | 48 +++++ web/src/lib/api/tasks.ts | 32 ++++ 23 files changed, 573 insertions(+), 78 deletions(-) create mode 100644 server/src/routes/task-verification.ts create mode 100644 web/src/components/task/VerificationSection.tsx diff --git a/cli/src/commands/tasks.ts b/cli/src/commands/tasks.ts index 6d4dafd2..d1d26a22 100644 --- a/cli/src/commands/tasks.ts +++ b/cli/src/commands/tasks.ts @@ -11,7 +11,10 @@ export function registerTaskCommands(program: Command): void { .command('list') .alias('ls') .description('List tasks') - .option('-s, --status ', 'Filter by status (todo, in-progress, blocked, done)') + .option( + '-s, --status ', + 'Filter by status (todo, planning, in-progress, blocked, done)' + ) .option('-t, --type ', 'Filter by type (code, research, content, automation)') .option('-p, --project ', 'Filter by project') .option('-v, --verbose', 'Show more details') diff --git a/mcp/src/tools/tasks.ts b/mcp/src/tools/tasks.ts index 9da7ef58..a8c16a64 100644 --- a/mcp/src/tools/tasks.ts +++ b/mcp/src/tools/tasks.ts @@ -5,7 +5,7 @@ import { Task } from '../utils/types.js'; // Tool input schemas const ListTasksSchema = z.object({ - status: z.enum(['todo', 'in-progress', 'blocked', 'done']).optional(), + status: z.enum(['todo', 'planning', 'in-progress', 'blocked', 'done']).optional(), type: z.enum(['code', 'research', 'content', 'automation']).optional(), project: z.string().optional(), }); @@ -22,7 +22,7 @@ const UpdateTaskSchema = z.object({ id: z.string().min(1), title: z.string().optional(), description: z.string().optional(), - status: z.enum(['todo', 'in-progress', 'blocked', 'done']).optional(), + status: z.enum(['todo', 'planning', 'in-progress', 'blocked', 'done']).optional(), type: z.enum(['code', 'research', 'content', 'automation']).optional(), priority: z.enum(['low', 'medium', 'high']).optional(), project: z.string().optional(), @@ -41,7 +41,7 @@ export const taskTools = [ properties: { status: { type: 'string', - enum: ['todo', 'in-progress', 'blocked', 'done'], + enum: ['todo', 'planning', 'in-progress', 'blocked', 'done'], description: 'Filter by task status', }, type: { @@ -122,7 +122,7 @@ export const taskTools = [ }, status: { type: 'string', - enum: ['todo', 'in-progress', 'blocked', 'done'], + enum: ['todo', 'planning', 'in-progress', 'blocked', 'done'], description: 'New status', }, type: { diff --git a/server/src/__tests__/test-app.ts b/server/src/__tests__/test-app.ts index 19c5094e..d840fe85 100644 --- a/server/src/__tests__/test-app.ts +++ b/server/src/__tests__/test-app.ts @@ -6,25 +6,27 @@ import express from 'express'; import { taskRoutes } from '../routes/tasks.js'; import { taskCommentRoutes } from '../routes/task-comments.js'; import { taskSubtaskRoutes } from '../routes/task-subtasks.js'; +import { taskVerificationRoutes } from '../routes/task-verification.js'; import { taskTimeRoutes } from '../routes/task-time.js'; import { agentStatusRoutes, updateAgentStatus, getAgentStatus } from '../routes/agent-status.js'; import { errorHandler } from '../middleware/error-handler.js'; export function createTestApp() { const app = express(); - + app.use(express.json({ limit: '1mb' })); - + // Mount routes app.use('/api/tasks', taskTimeRoutes); app.use('/api/tasks', taskCommentRoutes); app.use('/api/tasks', taskSubtaskRoutes); + app.use('/api/tasks', taskVerificationRoutes); app.use('/api/tasks', taskRoutes); app.use('/api/agent/status', agentStatusRoutes); - + // Error handler app.use(errorHandler); - + return app; } diff --git a/server/src/config/swagger.ts b/server/src/config/swagger.ts index ddc2c730..9fad18ea 100644 --- a/server/src/config/swagger.ts +++ b/server/src/config/swagger.ts @@ -46,7 +46,7 @@ const options: swaggerJsdoc.Options = { type: { type: 'string', example: 'feature' }, status: { type: 'string', - enum: ['todo', 'in-progress', 'blocked', 'done'], + enum: ['todo', 'planning', 'in-progress', 'blocked', 'done'], example: 'todo', }, priority: { @@ -85,7 +85,10 @@ const options: swaggerJsdoc.Options = { properties: { id: { type: 'string' }, title: { type: 'string' }, - status: { type: 'string', enum: ['todo', 'in-progress', 'blocked', 'done'] }, + status: { + type: 'string', + enum: ['todo', 'planning', 'in-progress', 'blocked', 'done'], + }, priority: { type: 'string', enum: ['low', 'medium', 'high'] }, type: { type: 'string' }, project: { type: 'string' }, @@ -118,7 +121,10 @@ const options: swaggerJsdoc.Options = { title: { type: 'string', minLength: 1, maxLength: 200 }, description: { type: 'string' }, type: { type: 'string' }, - status: { type: 'string', enum: ['todo', 'in-progress', 'blocked', 'done'] }, + status: { + type: 'string', + enum: ['todo', 'planning', 'in-progress', 'blocked', 'done'], + }, priority: { type: 'string', enum: ['low', 'medium', 'high'] }, project: { type: 'string' }, sprint: { type: 'string' }, diff --git a/server/src/routes/task-verification.ts b/server/src/routes/task-verification.ts new file mode 100644 index 00000000..8b374fde --- /dev/null +++ b/server/src/routes/task-verification.ts @@ -0,0 +1,117 @@ +import { Router, type Router as RouterType } from 'express'; +import { z } from 'zod'; +import { getTaskService } from '../services/task-service.js'; +import { asyncHandler } from '../middleware/async-handler.js'; +import { NotFoundError, ValidationError } from '../middleware/error-handler.js'; + +const router: RouterType = Router(); +const taskService = getTaskService(); + +// Validation schemas +const addVerificationStepSchema = z.object({ + description: z.string().min(1).max(500), +}); + +const updateVerificationStepSchema = z.object({ + description: z.string().min(1).max(500).optional(), + checked: z.boolean().optional(), +}); + +// POST /api/tasks/:id/verification - Add a verification step +router.post( + '/:id/verification', + asyncHandler(async (req, res) => { + let description: string; + try { + ({ description } = addVerificationStepSchema.parse(req.body)); + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError('Validation failed', error.errors); + } + throw error; + } + + const task = await taskService.getTask(req.params.id as string); + if (!task) { + throw new NotFoundError('Task not found'); + } + + const step = { + id: `vstep_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + description, + checked: false, + }; + + const verificationSteps = [...(task.verificationSteps || []), step]; + const updatedTask = await taskService.updateTask(req.params.id as string, { + verificationSteps, + }); + + res.status(201).json(updatedTask); + }) +); + +// PATCH /api/tasks/:id/verification/:stepId - Toggle or update a verification step +router.patch( + '/:id/verification/:stepId', + asyncHandler(async (req, res) => { + let updates; + try { + updates = updateVerificationStepSchema.parse(req.body); + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError('Validation failed', error.errors); + } + throw error; + } + + const task = await taskService.getTask(req.params.id as string); + if (!task) { + throw new NotFoundError('Task not found'); + } + + const verificationSteps = task.verificationSteps || []; + const stepIndex = verificationSteps.findIndex((s) => s.id === (req.params.stepId as string)); + if (stepIndex === -1) { + throw new NotFoundError('Verification step not found'); + } + + const existingStep = verificationSteps[stepIndex]; + const updatedStep = { ...existingStep, ...updates }; + + // Set/clear checkedAt timestamp when checked state changes + if (updates.checked !== undefined && updates.checked !== existingStep.checked) { + updatedStep.checkedAt = updates.checked ? new Date().toISOString() : undefined; + } + + verificationSteps[stepIndex] = updatedStep; + + const updatedTask = await taskService.updateTask(req.params.id as string, { + verificationSteps, + }); + + res.json(updatedTask); + }) +); + +// DELETE /api/tasks/:id/verification/:stepId - Remove a verification step +router.delete( + '/:id/verification/:stepId', + asyncHandler(async (req, res) => { + const task = await taskService.getTask(req.params.id as string); + if (!task) { + throw new NotFoundError('Task not found'); + } + + const verificationSteps = (task.verificationSteps || []).filter( + (s) => s.id !== (req.params.stepId as string) + ); + const updatedTask = await taskService.updateTask(req.params.id as string, { + verificationSteps, + }); + + res.json(updatedTask); + }) +); + +export { router as taskVerificationRoutes }; diff --git a/server/src/routes/tasks.ts b/server/src/routes/tasks.ts index a43deada..7858ba86 100644 --- a/server/src/routes/tasks.ts +++ b/server/src/routes/tasks.ts @@ -109,7 +109,7 @@ const updateTaskSchema = z.object({ title: z.string().min(1).max(200).optional(), description: z.string().optional(), type: z.string().optional(), - status: z.enum(['todo', 'in-progress', 'blocked', 'done']).optional(), + status: z.enum(['todo', 'planning', 'in-progress', 'blocked', 'done']).optional(), priority: z.enum(['low', 'medium', 'high']).optional(), project: z.string().optional(), sprint: z.string().optional(), @@ -123,6 +123,7 @@ const updateTaskSchema = z.object({ autoCompleteOnSubtasks: z.boolean().optional(), blockedBy: z.array(z.string()).optional(), blockedReason: blockedReasonSchema, + plan: z.string().optional(), automation: automationSchema, position: z.number().optional(), }); @@ -272,6 +273,7 @@ router.get( created: task.created, updated: task.updated, subtasks: task.subtasks, + verificationSteps: task.verificationSteps, blockedBy: task.blockedBy, blockedReason: task.blockedReason, position: task.position, diff --git a/server/src/routes/v1/index.ts b/server/src/routes/v1/index.ts index 4dd01089..b322b98b 100644 --- a/server/src/routes/v1/index.ts +++ b/server/src/routes/v1/index.ts @@ -23,6 +23,7 @@ import { taskTimeRoutes } from '../task-time.js'; import { taskRoutes } from '../tasks.js'; import { taskCommentRoutes } from '../task-comments.js'; import { taskSubtaskRoutes } from '../task-subtasks.js'; +import { taskVerificationRoutes } from '../task-verification.js'; import attachmentRoutes from '../attachments.js'; // Feature routes @@ -70,6 +71,7 @@ v1Router.use('/tasks', taskTimeRoutes); v1Router.use('/tasks', taskRoutes); v1Router.use('/tasks', taskCommentRoutes); v1Router.use('/tasks', taskSubtaskRoutes); +v1Router.use('/tasks', taskVerificationRoutes); // Attachment routes get the stricter upload rate limit (20 req/min) // applied BEFORE the route handler for upload (POST) requests. diff --git a/server/src/services/metrics/task-metrics.ts b/server/src/services/metrics/task-metrics.ts index 7dd4269a..eadd2656 100644 --- a/server/src/services/metrics/task-metrics.ts +++ b/server/src/services/metrics/task-metrics.ts @@ -36,6 +36,7 @@ export async function computeTaskMetrics( // Count by status const byStatus: Record = { todo: 0, + planning: 0, 'in-progress': 0, blocked: 0, done: 0, diff --git a/shared/src/types/task.types.ts b/shared/src/types/task.types.ts index b86d77a3..0e334ba4 100644 --- a/shared/src/types/task.types.ts +++ b/shared/src/types/task.types.ts @@ -1,7 +1,7 @@ // Task Types export type TaskType = string; -export type TaskStatus = 'todo' | 'in-progress' | 'blocked' | 'done'; +export type TaskStatus = 'todo' | 'planning' | 'in-progress' | 'blocked' | 'done'; export type TaskPriority = 'low' | 'medium' | 'high'; /** Built-in agent types. Custom agents use any string slug. */ export type BuiltInAgentType = 'claude-code' | 'amp' | 'copilot' | 'gemini' | 'veritas'; @@ -38,6 +38,13 @@ export interface Subtask { created: string; } +export interface VerificationStep { + id: string; + description: string; + checked: boolean; + checkedAt?: string; // ISO timestamp when checked +} + export interface TimeEntry { id: string; startTime: string; @@ -156,6 +163,9 @@ export interface Task { subtasks?: Subtask[]; autoCompleteOnSubtasks?: boolean; // Auto-complete parent when all subtasks done + // Verification checklist (done criteria) + verificationSteps?: VerificationStep[]; + // Dependencies blockedBy?: string[]; // Array of task IDs that block this task @@ -170,6 +180,9 @@ export interface Task { result?: string; // Result summary from sub-agent }; + // Planning phase + plan?: string; // Markdown content for the execution plan + // Time tracking timeTracking?: TimeTracking; @@ -229,8 +242,10 @@ export interface UpdateTaskInput { review?: ReviewState; subtasks?: Subtask[]; autoCompleteOnSubtasks?: boolean; + verificationSteps?: VerificationStep[]; blockedBy?: string[]; blockedReason?: BlockedReason | null; // null to clear + plan?: string; automation?: { sessionKey?: string; spawnedAt?: string; @@ -266,6 +281,7 @@ export interface TaskSummary { created: string; updated: string; subtasks?: Subtask[]; + verificationSteps?: VerificationStep[]; blockedBy?: string[]; blockedReason?: BlockedReason; position?: number; diff --git a/shared/src/utils/constants.ts b/shared/src/utils/constants.ts index eb281db7..5a11cb43 100644 --- a/shared/src/utils/constants.ts +++ b/shared/src/utils/constants.ts @@ -9,19 +9,20 @@ export const PRIORITY_LABELS: Record = { low: 'Low', medium: 'Medium', high: 'High', - critical: 'Critical' + critical: 'Critical', }; /** * Task status labels */ export const STATUS_LABELS: Record = { - 'todo': 'To Do', + todo: 'To Do', + planning: 'Planning', 'in-progress': 'In Progress', - 'review': 'Review', - 'done': 'Done', - 'blocked': 'Blocked', - 'cancelled': 'Cancelled' + review: 'Review', + done: 'Done', + blocked: 'Blocked', + cancelled: 'Cancelled', }; /** @@ -33,5 +34,5 @@ export const TYPE_LABELS: Record = { refactor: 'Refactor', docs: 'Documentation', test: 'Test', - chore: 'Chore' + chore: 'Chore', }; diff --git a/web/src/components/board/BoardLoadingSkeleton.tsx b/web/src/components/board/BoardLoadingSkeleton.tsx index 904be730..d3d3f23b 100644 --- a/web/src/components/board/BoardLoadingSkeleton.tsx +++ b/web/src/components/board/BoardLoadingSkeleton.tsx @@ -12,7 +12,7 @@ interface BoardLoadingSkeletonProps { export function BoardLoadingSkeleton({ columns }: BoardLoadingSkeletonProps) { return ( -
+
{columns.map((column) => (
{[1, 2, 3].map((i) => ( -
+
diff --git a/web/src/components/board/BulkActionsBar.tsx b/web/src/components/board/BulkActionsBar.tsx index 987345a4..86ee80ad 100644 --- a/web/src/components/board/BulkActionsBar.tsx +++ b/web/src/components/board/BulkActionsBar.tsx @@ -24,6 +24,12 @@ const STATUS_BUTTONS: { id: TaskStatus; label: string; color: string; activeColo color: 'border-slate-400 text-slate-600', activeColor: 'bg-slate-500 text-white border-slate-500', }, + { + id: 'planning', + label: 'Planning', + color: 'border-violet-400 text-violet-600', + activeColor: 'bg-violet-500 text-white border-violet-500', + }, { id: 'in-progress', label: 'In Progress', @@ -63,6 +69,7 @@ export function BulkActionsBar({ tasks }: BulkActionsBarProps) { const taskIdsByStatus = useMemo(() => { const map: Record = { todo: [], + planning: [], 'in-progress': [], blocked: [], done: [], @@ -217,6 +224,7 @@ export function BulkActionsBar({ tasks }: BulkActionsBarProps) { To Do + Planning In Progress Review Done diff --git a/web/src/components/board/KanbanBoard.tsx b/web/src/components/board/KanbanBoard.tsx index 213b7dac..5a3eeff3 100644 --- a/web/src/components/board/KanbanBoard.tsx +++ b/web/src/components/board/KanbanBoard.tsx @@ -30,6 +30,7 @@ const DashboardSection = lazy(() => const COLUMNS: { id: TaskStatus; title: string }[] = [ { id: 'todo', title: 'To Do' }, + { id: 'planning', title: 'Planning' }, { id: 'in-progress', title: 'In Progress' }, { id: 'blocked', title: 'Blocked' }, { id: 'done', title: 'Done' }, @@ -196,7 +197,7 @@ export function KanbanBoard() { onDragOver={handleDragOver} onDragEnd={handleDragEnd} > -
+
{COLUMNS.map((column) => ( ) : ( -
+
{COLUMNS.map((column) => ( = { todo: 'border-t-slate-500', + planning: 'border-t-violet-500', 'in-progress': 'border-t-blue-500', blocked: 'border-t-red-500', done: 'border-t-green-500', diff --git a/web/src/components/dashboard/TasksDrillDown.tsx b/web/src/components/dashboard/TasksDrillDown.tsx index 3bc6127b..d14d10c2 100644 --- a/web/src/components/dashboard/TasksDrillDown.tsx +++ b/web/src/components/dashboard/TasksDrillDown.tsx @@ -3,7 +3,7 @@ import { useTasks } from '@/hooks/useTasks'; import { useProjects } from '@/hooks/useProjects'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; -import { CheckCircle, Play, Ban, ListTodo } from 'lucide-react'; +import { CheckCircle, Play, Ban, ListTodo, FileEdit } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { TaskStatus, Task } from '@veritas-kanban/shared'; @@ -26,6 +26,11 @@ const statusConfig: Record< color: 'text-muted-foreground', label: 'To Do', }, + planning: { + icon: , + color: 'text-violet-500', + label: 'Planning', + }, 'in-progress': { icon: , color: 'text-blue-500', @@ -76,6 +81,7 @@ export function TasksDrillDown({ const statusCounts = useMemo(() => { const counts: Record = { todo: 0, + planning: 0, 'in-progress': 0, blocked: 0, done: 0, diff --git a/web/src/components/layout/KeyboardShortcutsDialog.tsx b/web/src/components/layout/KeyboardShortcutsDialog.tsx index 58d5837b..ddde85d0 100644 --- a/web/src/components/layout/KeyboardShortcutsDialog.tsx +++ b/web/src/components/layout/KeyboardShortcutsDialog.tsx @@ -22,9 +22,10 @@ const shortcuts: { category: string; items: Shortcut[] }[] = [ { keys: ['c'], description: 'Create new task' }, { keys: ['⌘⇧C'], description: 'Open agent chat' }, { keys: ['1'], description: 'Move to To Do' }, - { keys: ['2'], description: 'Move to In Progress' }, - { keys: ['3'], description: 'Move to Review' }, - { keys: ['4'], description: 'Move to Done' }, + { keys: ['2'], description: 'Move to Planning' }, + { keys: ['3'], description: 'Move to In Progress' }, + { keys: ['4'], description: 'Move to Blocked' }, + { keys: ['5'], description: 'Move to Done' }, ], }, { diff --git a/web/src/components/task/TaskCard.tsx b/web/src/components/task/TaskCard.tsx index 876ce457..62f61648 100644 --- a/web/src/components/task/TaskCard.tsx +++ b/web/src/components/task/TaskCard.tsx @@ -12,6 +12,7 @@ import { Loader2, Paperclip, ListChecks, + ShieldCheck, Zap, MessageSquare, Wrench, @@ -20,6 +21,7 @@ import { Play, CheckCircle, XCircle, + FileEdit, } from 'lucide-react'; import { useBulkActions } from '@/hooks/useBulkActions'; import { formatDuration } from '@/hooks/useTimeTracking'; @@ -103,6 +105,15 @@ function areTaskCardPropsEqual(prev: TaskCardProps, next: TaskCardProps): boolea for (let i = 0; i < pSubs.length; i++) { if (pSubs[i].completed !== nSubs[i].completed) return false; } + // Plan field — presence matters for the indicator + if ((pt.plan || '') !== (nt.plan || '')) return false; + // Verification steps — compare count and checked state + const pVSteps = pt.verificationSteps || []; + const nVSteps = nt.verificationSteps || []; + if (pVSteps.length !== nVSteps.length) return false; + for (let i = 0; i < pVSteps.length; i++) { + if (pVSteps[i].checked !== nVSteps[i].checked) return false; + } // Attachments — only count matters for the badge if ((pt.attachments?.length || 0) !== (nt.attachments?.length || 0)) return false; } @@ -226,6 +237,18 @@ export const TaskCard = memo(function TaskCard({ }; }, [task.subtasks]); + // Memoize verification progress + const { verificationTotal, verificationChecked, allVerificationDone } = useMemo(() => { + const steps = task.verificationSteps || []; + const total = steps.length; + const checked = steps.filter((s) => s.checked).length; + return { + verificationTotal: total, + verificationChecked: checked, + allVerificationDone: total > 0 && checked === total, + }; + }, [task.verificationSteps]); + // Suppress the outer card tooltip entirely during any drag operation const suppressCardTooltip = isDragActive || isDragging || isCurrentlyDragging; @@ -383,6 +406,19 @@ export const TaskCard = memo(function TaskCard({ {task.attachments.length} )} + {/* Plan indicator */} + {task.plan && ( + + + + + + + +

Has execution plan

+
+
+ )} {/* Right side: subtask count + time tracking */} {subtaskTotal > 0 && ( @@ -407,12 +443,37 @@ export const TaskCard = memo(function TaskCard({ )} + {/* Verification progress indicator */} + {verificationTotal > 0 && ( + + + + + {verificationChecked}/{verificationTotal} + + + +

Done Criteria

+

+ {verificationChecked} of {verificationTotal} verified +

+
+
+ )} {/* Time tracking indicator */} {(task.timeTracking?.totalSeconds || task.timeTracking?.isRunning) && ( s.checked).length; + const totalCount = steps.length; + const progress = totalCount > 0 ? (checkedCount / totalCount) * 100 : 0; + + const handleAddStep = async () => { + if (!newDescription.trim()) return; + + setIsAdding(true); + try { + await addStep.mutateAsync({ taskId: task.id, description: newDescription.trim() }); + setNewDescription(''); + } finally { + setIsAdding(false); + } + }; + + const handleToggleStep = async (step: VerificationStep) => { + await updateStep.mutateAsync({ + taskId: task.id, + stepId: step.id, + updates: { checked: !step.checked }, + }); + }; + + const handleDeleteStep = async (stepId: string) => { + await deleteStep.mutateAsync({ taskId: task.id, stepId }); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleAddStep(); + } + }; + + const formatTimestamp = (iso: string) => { + return new Date(iso).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + }; + + return ( +
+
+
+ + +
+ {totalCount > 0 && ( + + {checkedCount}/{totalCount} verified + + )} +
+ + {/* Progress bar */} + {totalCount > 0 && ( +
+
+
+ )} + + {/* Verification step list */} +
+ {steps.map((step) => ( +
+ handleToggleStep(step)} + className={cn( + 'flex-shrink-0 mt-0.5', + step.checked && + 'data-[state=checked]:bg-green-600 data-[state=checked]:border-green-600' + )} + /> +
+ + {step.description} + + {step.checked && step.checkedAt && ( +
+ + + {formatTimestamp(step.checkedAt)} + +
+ )} +
+ +
+ ))} +
+ + {/* Add verification step input */} +
+ setNewDescription(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Add verification step..." + className="text-sm" + disabled={isAdding} + /> + +
+
+ ); +} diff --git a/web/src/components/task/detail/TaskDetailsTab.tsx b/web/src/components/task/detail/TaskDetailsTab.tsx index 272a2ab5..2a167041 100644 --- a/web/src/components/task/detail/TaskDetailsTab.tsx +++ b/web/src/components/task/detail/TaskDetailsTab.tsx @@ -14,6 +14,7 @@ import { } from '@/components/ui/alert-dialog'; import { TaskMetadataSection } from './TaskMetadataSection'; import { SubtasksSection } from '../SubtasksSection'; +import { VerificationSection } from '../VerificationSection'; import { DependenciesSection } from '../DependenciesSection'; import { TimeTrackingSection } from '../TimeTrackingSection'; import { CommentsSection } from '../CommentsSection'; @@ -32,10 +33,10 @@ interface TaskDetailsTabProps { onRestore?: (taskId: string) => void; } -export function TaskDetailsTab({ - task, - onUpdate, - onClose, +export function TaskDetailsTab({ + task, + onUpdate, + onClose, readOnly = false, onRestore, }: TaskDetailsTabProps) { @@ -78,19 +79,39 @@ export function TaskDetailsTab({ )}
+ {/* Execution Plan (visible when status is planning or plan has content) */} + {(task.status === 'planning' || task.plan) && ( +
+ + {readOnly ? ( +
+ {sanitizeText(task.plan || '') || 'No plan defined'} +
+ ) : ( +