import { useEffect, useMemo, useRef, useState } from 'react' import type { Plan, Task, Message, QueueItem } from '../lib/api' import { api } from '../lib/api' import { relativeTime, absoluteTime } from '../lib/time' import { InlineCommentBox } from './InlineCommentBox' import { EditableComment } from './EditableComment' function duration(fromIso: string, toIso?: string): string { const from = new Date(fromIso).getTime() const to = toIso ? new Date(toIso).getTime() : Date.now() const sec = Math.max(0, Math.floor((to - from) / 1000)) if (sec < 60) return `${sec}s` const min = Math.floor(sec / 60) if (min < 60) return `${min}m ${sec % 60}s` const hr = Math.floor(min / 60) if (hr < 24) return `${hr}h ${min % 60}m` const day = Math.floor(hr / 24) return `${day}d ${hr % 24}h` } const taskStatusGlyph: Record = { queued: '○', in_progress: '◐', blocked: '■', complete: '●', returned: '↩', } const taskStatusColor: Record = { queued: 'text-gray-400', in_progress: 'text-blue-500', blocked: 'text-yellow-500', complete: 'text-green-500', returned: 'text-red-500', } const roleColor: Record = { director: 'text-blue-600 dark:text-blue-400', lead: 'text-emerald-600 dark:text-emerald-400', agent: 'text-amber-600 dark:text-amber-400', system: 'text-gray-500', } const priorityBadge: Record = { critical: 'bg-red-600 text-white', '1': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', '2': 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', '3': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', '4': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200', '5': 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400', backlog: 'bg-gray-50 text-gray-500 dark:bg-gray-800 dark:text-gray-500', } function MetaItem({ label, value, title }: { label: string; value: string; title?: string }) { return (
{label} {value}
) } // Wrapper that makes any region click-to-comment with refined hover affordance. // The whole region is clickable; a "+ comment" chip appears top-right on hover // as a secondary visual signal. Children stop propagation if they need to. function Commentable({ active, onActivate, children, className = '', chipLabel = 'comment', chipPosition = 'top-right', }: { active: boolean onActivate: () => void children: React.ReactNode className?: string chipLabel?: string chipPosition?: 'top-right' | 'inline' }) { return (
{ if (active) return // Don't activate if user is selecting text const sel = window.getSelection?.() if (sel && sel.toString().length > 0) return // Ignore clicks that originated on links or buttons const target = e.target as HTMLElement if (target.closest('button, a, textarea, input, [data-no-comment]')) return onActivate() }} className={`relative group/c rounded-md transition-colors cursor-text ${ !active ? 'hover:bg-blue-50/50 dark:hover:bg-blue-950/20' : '' } ${className}`} > {/* Subtle left accent on hover */} {!active && ( )} {!active && chipPosition === 'top-right' && ( + {chipLabel} )} {children}
) } // Single message rendered as a card. If it's a director message, the body is editable. function MessageCard({ message, replies, activeReplyTarget, setActiveReplyTarget, postReply, editMessage, }: { message: Message replies: Message[] activeReplyTarget: string | null setActiveReplyTarget: (key: string | null) => void postReply: (replyToId: number, text: string) => Promise editMessage: (id: number, next: string) => Promise }) { const replyKey = `msg:${message.id}` const isOpen = activeReplyTarget === replyKey const isDirector = message.from_role === 'director' const inner = (
{message.from_role} {message.from_id} {relativeTime(message.created_at)} {isDirector && ( · editable )}
{isDirector ? (
editMessage(message.id, next)} />
) : (
{message.message}
)} {isOpen && (
setActiveReplyTarget(null)} onSubmit={async (text) => { await postReply(message.id, text) setActiveReplyTarget(null) }} />
)}
) return (
{isDirector ? ( // Director messages: not click-to-comment (clicking edits via EditableComment) // but agents can still be replied to via the structure below.
{inner}
) : ( setActiveReplyTarget(replyKey)} chipLabel="reply" > {inner} )} {replies.length > 0 && (
{replies.map((reply) => ( ))}
)}
) } interface PlanDetailProps { plan: Plan onClose: () => void reviewItem?: QueueItem onReviewActioned?: () => void } export function PlanDetail({ plan: initial, onClose, reviewItem, onReviewActioned }: PlanDetailProps) { const [plan, setPlan] = useState(initial) const [tasks, setTasks] = useState([]) const [messages, setMessages] = useState([]) const [actionBusy, setActionBusy] = useState(false) const [sendBackMode, setSendBackMode] = useState(false) const [sendBackText, setSendBackText] = useState('') const [activeReplyTarget, setActiveReplyTarget] = useState(null) const verificationRef = useRef(null) function refresh() { api.plans.get(initial.id).then(setPlan).catch(() => {}) api.tasks.list(initial.id).then(setTasks) api.messages.list(initial.id).then(setMessages) } useEffect(() => { refresh() // eslint-disable-next-line react-hooks/exhaustive-deps }, [initial.id]) useEffect(() => { if (reviewItem && verificationRef.current) { verificationRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }) } }, [reviewItem]) // Esc closes any open inline editor useEffect(() => { function handler(e: KeyboardEvent) { if (e.key === 'Escape' && activeReplyTarget) { setActiveReplyTarget(null) } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, [activeReplyTarget]) const totalDuration = plan.completed_at && plan.started_at ? duration(plan.started_at, plan.completed_at) : plan.started_at ? duration(plan.started_at) : null const { topLevelMessages, repliesByMessageId, messagesByTaskId, verificationComments } = useMemo(() => { const byMsg = new Map() const byTask = new Map() const verif: Message[] = [] const top: Message[] = [] for (const m of messages) { if (m.reply_to_id != null) { const arr = byMsg.get(m.reply_to_id) ?? [] arr.push(m) byMsg.set(m.reply_to_id, arr) } else if (m.task_id != null) { const arr = byTask.get(m.task_id) ?? [] arr.push(m) byTask.set(m.task_id, arr) } else if (m.message_type === 'verification_comment') { verif.push(m) } else { top.push(m) } } return { topLevelMessages: top, repliesByMessageId: byMsg, messagesByTaskId: byTask, verificationComments: verif, } }, [messages]) async function postComment(payload: { text: string taskId?: number replyToId?: number messageType?: string }) { await api.messages.create(plan.id, { from_role: 'director', from_id: 'joe', message: payload.text, message_type: payload.messageType ?? 'comment', task_id: payload.taskId, reply_to_id: payload.replyToId, }) refresh() } async function editMessage(id: number, next: string) { await api.messages.update(id, next) refresh() } async function handleApprove() { if (!reviewItem || actionBusy) return setActionBusy(true) try { await api.queue.update(reviewItem.id, { status: 'approved' }) onReviewActioned?.() onClose() } catch (err) { console.error('approve failed', err) } finally { setActionBusy(false) } } async function handleSendBack() { if (!reviewItem || actionBusy || !sendBackText.trim()) return setActionBusy(true) try { await postComment({ text: `Sent back: ${sendBackText.trim()}` }) await api.queue.update(reviewItem.id, { status: 'redirected', director_notes: sendBackText.trim(), }) setSendBackText('') setSendBackMode(false) onReviewActioned?.() onClose() } catch (err) { console.error('send back failed', err) } finally { setActionBusy(false) } } return (
e.stopPropagation()} > {reviewItem && (
⊙ Review mode · click any item to comment · click your own comments to edit
)} {/* Header */}

{plan.title}

{plan.priority === 'critical' ? 'crit' : `p${plan.priority}`}

{plan.description}

{plan.approved_at && } {plan.started_at && } {plan.completed_at && } {totalDuration && }
{/* Verification (review mode only) */} {reviewItem && (

Verification

{reviewItem.task_title ? `task: ${reviewItem.task_title}` : 'plan-level'}
setActiveReplyTarget('verification')} className="-mx-2 px-2 py-1.5" > {reviewItem.verification_notes ? (
{reviewItem.verification_notes}
) : (
No verification notes recorded
)}
submitted {relativeTime(reviewItem.submitted_at)}
{activeReplyTarget === 'verification' && (
setActiveReplyTarget(null)} onSubmit={async (text) => { await postComment({ text, taskId: reviewItem.task_id, messageType: 'verification_comment', }) setActiveReplyTarget(null) }} />
)}
{verificationComments.length > 0 && (
{verificationComments.map((c) => ( { await postComment({ text, replyToId: rid }) }} editMessage={editMessage} /> ))}
)}
)} {/* Tasks */}

Tasks

{tasks.filter((t) => t.status === 'complete').length} / {tasks.length} complete
{tasks.length === 0 ? (

No tasks yet — awaiting team lead decomposition

) : (
{tasks.map((task) => { const took = task.completed_at && task.started_at ? duration(task.started_at, task.completed_at) : task.started_at ? duration(task.started_at) : null const isReviewedTask = task.id === reviewItem?.task_id const taskKey = `task:${task.id}` const taskComments = messagesByTaskId.get(task.id) ?? [] return (
setActiveReplyTarget(taskKey)} className="px-3 py-2" >
{taskStatusGlyph[task.status] ?? '○'}
{task.title} {task.assigned_agent && ( → {task.assigned_agent} )}
{task.description && (
{task.description}
)} {task.progress_note && (
{task.progress_note}
)}
{task.started_at && ( started {relativeTime(task.started_at)} )} {took && ( {task.completed_at ? `took ${took}` : `${took} elapsed`} )}
{activeReplyTarget === taskKey && (
setActiveReplyTarget(null)} onSubmit={async (text) => { await postComment({ text, taskId: task.id }) setActiveReplyTarget(null) }} />
)} {taskComments.length > 0 && (
{taskComments.map((c) => ( { await postComment({ text, replyToId: rid }) }} editMessage={editMessage} /> ))}
)}
) })}
)}
{/* Activity */}

Activity

{messages.length} {messages.length === 1 ? 'message' : 'messages'}
{topLevelMessages.length === 0 ? (

No plan-level messages

) : (
{topLevelMessages.map((msg) => ( { await postComment({ text, replyToId: rid }) }} editMessage={editMessage} /> ))}
)}
{activeReplyTarget === 'plan' ? ( setActiveReplyTarget(null)} onSubmit={async (text) => { await postComment({ text }) setActiveReplyTarget(null) }} /> ) : ( )}
{/* Sticky review actions */} {reviewItem && (
{sendBackMode ? (
sending back — explain what needs to change