Files
director-app/ui/src/components/PlanDetail.tsx

671 lines
28 KiB
TypeScript

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<string, string> = {
queued: '○',
in_progress: '◐',
blocked: '■',
complete: '●',
returned: '↩',
}
const taskStatusColor: Record<string, string> = {
queued: 'text-gray-400',
in_progress: 'text-blue-500',
blocked: 'text-yellow-500',
complete: 'text-green-500',
returned: 'text-red-500',
}
const roleColor: Record<string, string> = {
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<string, string> = {
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 (
<div className="flex flex-col gap-0.5" title={title}>
<span className="font-mono text-[9px] uppercase tracking-[0.12em] text-gray-400 dark:text-gray-500">{label}</span>
<span className="font-mono text-xs tabular text-gray-900 dark:text-gray-100">{value}</span>
</div>
)
}
// 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 (
<div
onClick={(e) => {
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 && (
<span
aria-hidden
className="pointer-events-none absolute left-0 top-1.5 bottom-1.5 w-[2px] rounded-full bg-blue-400/0 group-hover/c:bg-blue-400/60 dark:group-hover/c:bg-blue-500/60 transition-colors"
/>
)}
{!active && chipPosition === 'top-right' && (
<span className="pointer-events-none absolute top-2 right-2 font-mono text-[9px] uppercase tracking-wider text-blue-500/0 group-hover/c:text-blue-500 dark:group-hover/c:text-blue-400 transition-colors">
+ {chipLabel}
</span>
)}
{children}
</div>
)
}
// 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<void>
editMessage: (id: number, next: string) => Promise<void>
}) {
const replyKey = `msg:${message.id}`
const isOpen = activeReplyTarget === replyKey
const isDirector = message.from_role === 'director'
const inner = (
<div className="border-l-2 border-gray-200 dark:border-gray-700 pl-3 py-1.5 pr-2">
<div className="flex items-baseline gap-2 mb-1">
<span className={`font-mono text-[10px] uppercase tracking-wider font-semibold ${roleColor[message.from_role] ?? 'text-gray-700'}`}>
{message.from_role}
</span>
<span className="font-mono text-[10px] text-gray-500 dark:text-gray-400">{message.from_id}</span>
<span className="font-mono text-[10px] tabular text-gray-400 dark:text-gray-500" title={absoluteTime(message.created_at)}>
{relativeTime(message.created_at)}
</span>
{isDirector && (
<span className="font-mono text-[9px] uppercase tracking-wider text-gray-400 ml-1">· editable</span>
)}
</div>
{isDirector ? (
<div className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">
<EditableComment text={message.message} onSave={(next) => editMessage(message.id, next)} />
</div>
) : (
<div className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-line">
{message.message}
</div>
)}
{isOpen && (
<div data-no-comment>
<InlineCommentBox
placeholder="Reply to this message…"
buttonLabel="reply"
onCancel={() => setActiveReplyTarget(null)}
onSubmit={async (text) => {
await postReply(message.id, text)
setActiveReplyTarget(null)
}}
/>
</div>
)}
</div>
)
return (
<div>
{isDirector ? (
// Director messages: not click-to-comment (clicking edits via EditableComment)
// but agents can still be replied to via the structure below.
<div className="rounded-md">{inner}</div>
) : (
<Commentable
active={isOpen}
onActivate={() => setActiveReplyTarget(replyKey)}
chipLabel="reply"
>
{inner}
</Commentable>
)}
{replies.length > 0 && (
<div className="ml-5 mt-2 space-y-2">
{replies.map((reply) => (
<MessageCard
key={reply.id}
message={reply}
replies={[]}
activeReplyTarget={activeReplyTarget}
setActiveReplyTarget={setActiveReplyTarget}
postReply={postReply}
editMessage={editMessage}
/>
))}
</div>
)}
</div>
)
}
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<Task[]>([])
const [messages, setMessages] = useState<Message[]>([])
const [actionBusy, setActionBusy] = useState(false)
const [sendBackMode, setSendBackMode] = useState(false)
const [sendBackText, setSendBackText] = useState('')
const [activeReplyTarget, setActiveReplyTarget] = useState<string | null>(null)
const verificationRef = useRef<HTMLDivElement>(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<number, Message[]>()
const byTask = new Map<number, Message[]>()
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 (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={onClose}>
<div
className="bg-white dark:bg-gray-900 rounded-xl w-full max-w-3xl max-h-[92vh] overflow-auto shadow-2xl border border-gray-200 dark:border-gray-700"
onClick={(e) => e.stopPropagation()}
>
{reviewItem && (
<div className="bg-gradient-to-r from-blue-50 to-blue-50/50 dark:from-blue-950/30 dark:to-blue-950/10 border-b border-blue-200 dark:border-blue-900 px-7 py-3 sticky top-0 z-10 backdrop-blur-sm">
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] uppercase tracking-wider font-semibold text-blue-700 dark:text-blue-400">
Review mode
</span>
<span className="font-mono text-[10px] text-blue-600/70 dark:text-blue-400/70">
· click any item to comment · click your own comments to edit
</span>
</div>
</div>
)}
{/* Header */}
<div className="px-7 pt-6 pb-5 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-start justify-between gap-4 mb-3">
<h2 className="font-display text-2xl font-medium leading-tight text-gray-900 dark:text-gray-100">
{plan.title}
</h2>
<div className="flex items-center gap-2 shrink-0">
<span className={`text-[10px] font-mono font-medium uppercase tracking-wider px-2 py-1 rounded ${priorityBadge[plan.priority] ?? ''}`}>
{plan.priority === 'critical' ? 'crit' : `p${plan.priority}`}
</span>
<button onClick={onClose} className="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 text-2xl leading-none w-7 h-7 flex items-center justify-center cursor-pointer">&times;</button>
</div>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 leading-relaxed mb-5">{plan.description}</p>
<div className="flex flex-wrap gap-x-7 gap-y-3 pt-4 border-t border-gray-100 dark:border-gray-800">
<MetaItem label="Plan #" value={String(plan.id)} />
<MetaItem label="Team" value={plan.team} />
<MetaItem label="Status" value={plan.status.replace(/_/g, ' ')} />
<MetaItem label="Created" value={relativeTime(plan.created_at)} title={absoluteTime(plan.created_at)} />
{plan.approved_at && <MetaItem label="Approved" value={relativeTime(plan.approved_at)} title={absoluteTime(plan.approved_at)} />}
{plan.started_at && <MetaItem label="Picked up" value={relativeTime(plan.started_at)} title={absoluteTime(plan.started_at)} />}
{plan.completed_at && <MetaItem label="Completed" value={relativeTime(plan.completed_at)} title={absoluteTime(plan.completed_at)} />}
{totalDuration && <MetaItem label={plan.completed_at ? 'Took' : 'Running'} value={totalDuration} />}
</div>
</div>
{/* Verification (review mode only) */}
{reviewItem && (
<div ref={verificationRef} className="px-7 py-5 border-b border-gray-200 dark:border-gray-700 bg-blue-50/30 dark:bg-blue-950/10">
<div className="flex items-baseline justify-between mb-2">
<h3 className="font-display text-base font-medium text-gray-900 dark:text-gray-100">Verification</h3>
<span className="font-mono text-[10px] uppercase tracking-wider text-blue-600 dark:text-blue-400">
{reviewItem.task_title ? `task: ${reviewItem.task_title}` : 'plan-level'}
</span>
</div>
<Commentable
active={activeReplyTarget === 'verification'}
onActivate={() => setActiveReplyTarget('verification')}
className="-mx-2 px-2 py-1.5"
>
{reviewItem.verification_notes ? (
<div className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">
{reviewItem.verification_notes}
</div>
) : (
<div className="font-mono text-xs text-gray-500 italic">No verification notes recorded</div>
)}
<div className="mt-2 font-mono text-[10px] tabular text-gray-500 dark:text-gray-400">
submitted {relativeTime(reviewItem.submitted_at)}
</div>
{activeReplyTarget === 'verification' && (
<div data-no-comment>
<InlineCommentBox
placeholder="Comment on the verification…"
buttonLabel="post"
onCancel={() => setActiveReplyTarget(null)}
onSubmit={async (text) => {
await postComment({
text,
taskId: reviewItem.task_id,
messageType: 'verification_comment',
})
setActiveReplyTarget(null)
}}
/>
</div>
)}
</Commentable>
{verificationComments.length > 0 && (
<div className="mt-4 space-y-2">
{verificationComments.map((c) => (
<MessageCard
key={c.id}
message={c}
replies={repliesByMessageId.get(c.id) ?? []}
activeReplyTarget={activeReplyTarget}
setActiveReplyTarget={setActiveReplyTarget}
postReply={async (rid, text) => { await postComment({ text, replyToId: rid }) }}
editMessage={editMessage}
/>
))}
</div>
)}
</div>
)}
{/* Tasks */}
<section className="px-7 py-5 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-baseline justify-between mb-3">
<h3 className="font-display text-base font-medium text-gray-900 dark:text-gray-100">Tasks</h3>
<span className="font-mono text-[10px] uppercase tracking-wider text-gray-400">
{tasks.filter((t) => t.status === 'complete').length} / {tasks.length} complete
</span>
</div>
{tasks.length === 0 ? (
<p className="font-mono text-xs text-gray-500 italic">No tasks yet awaiting team lead decomposition</p>
) : (
<div className="space-y-3">
{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 (
<div
key={task.id}
className={isReviewedTask ? 'rounded-md ring-1 ring-blue-200 dark:ring-blue-900 bg-blue-50/40 dark:bg-blue-950/20' : ''}
>
<Commentable
active={activeReplyTarget === taskKey}
onActivate={() => setActiveReplyTarget(taskKey)}
className="px-3 py-2"
>
<div className="flex items-start gap-3">
<span className={`text-base leading-tight pt-0.5 ${taskStatusColor[task.status] ?? 'text-gray-400'}`} title={task.status}>
{taskStatusGlyph[task.status] ?? '○'}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="text-sm text-gray-900 dark:text-gray-100">{task.title}</span>
{task.assigned_agent && (
<span className="font-mono text-[10px] text-gray-400 dark:text-gray-500">
{task.assigned_agent}
</span>
)}
</div>
{task.description && (
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 leading-relaxed">{task.description}</div>
)}
{task.progress_note && (
<div className="font-mono text-[10px] text-gray-400 dark:text-gray-500 mt-1 italic">
{task.progress_note}
</div>
)}
<div className="flex items-center gap-3 mt-1 font-mono text-[10px] tabular text-gray-400 dark:text-gray-500">
{task.started_at && (
<span title={absoluteTime(task.started_at)}>started {relativeTime(task.started_at)}</span>
)}
{took && (
<span className={task.completed_at ? 'text-gray-500 dark:text-gray-400' : 'text-blue-500'}>
{task.completed_at ? `took ${took}` : `${took} elapsed`}
</span>
)}
</div>
{activeReplyTarget === taskKey && (
<div data-no-comment>
<InlineCommentBox
placeholder={`Comment on "${task.title}"…`}
buttonLabel="post"
onCancel={() => setActiveReplyTarget(null)}
onSubmit={async (text) => {
await postComment({ text, taskId: task.id })
setActiveReplyTarget(null)
}}
/>
</div>
)}
{taskComments.length > 0 && (
<div className="mt-3 space-y-2 border-l border-gray-200 dark:border-gray-700 pl-3" data-no-comment>
{taskComments.map((c) => (
<MessageCard
key={c.id}
message={c}
replies={repliesByMessageId.get(c.id) ?? []}
activeReplyTarget={activeReplyTarget}
setActiveReplyTarget={setActiveReplyTarget}
postReply={async (rid, text) => { await postComment({ text, replyToId: rid }) }}
editMessage={editMessage}
/>
))}
</div>
)}
</div>
</div>
</Commentable>
</div>
)
})}
</div>
)}
</section>
{/* Activity */}
<section className="px-7 py-5">
<div className="flex items-baseline justify-between mb-3">
<h3 className="font-display text-base font-medium text-gray-900 dark:text-gray-100">Activity</h3>
<span className="font-mono text-[10px] uppercase tracking-wider text-gray-400">
{messages.length} {messages.length === 1 ? 'message' : 'messages'}
</span>
</div>
{topLevelMessages.length === 0 ? (
<p className="font-mono text-xs text-gray-500 italic">No plan-level messages</p>
) : (
<div className="space-y-3">
{topLevelMessages.map((msg) => (
<MessageCard
key={msg.id}
message={msg}
replies={repliesByMessageId.get(msg.id) ?? []}
activeReplyTarget={activeReplyTarget}
setActiveReplyTarget={setActiveReplyTarget}
postReply={async (rid, text) => { await postComment({ text, replyToId: rid }) }}
editMessage={editMessage}
/>
))}
</div>
)}
<div className="mt-5 pt-4 border-t border-gray-100 dark:border-gray-800">
{activeReplyTarget === 'plan' ? (
<InlineCommentBox
placeholder="Add a general note to this plan…"
buttonLabel="post"
onCancel={() => setActiveReplyTarget(null)}
onSubmit={async (text) => {
await postComment({ text })
setActiveReplyTarget(null)
}}
/>
) : (
<button
onClick={() => setActiveReplyTarget('plan')}
className="font-mono text-[10px] uppercase tracking-wider text-gray-500 hover:text-blue-600 dark:hover:text-blue-400 cursor-pointer"
>
+ add plan-level comment
</button>
)}
</div>
</section>
{/* Sticky review actions */}
{reviewItem && (
<div className="sticky bottom-0 bg-white/95 dark:bg-gray-900/95 backdrop-blur-sm border-t border-gray-200 dark:border-gray-700 px-7 py-4">
{sendBackMode ? (
<div>
<div className="flex items-baseline gap-2 mb-2">
<span className="font-mono text-[10px] uppercase tracking-wider font-semibold text-orange-600 dark:text-orange-400">
sending back
</span>
<span className="font-mono text-[10px] text-orange-600/70 dark:text-orange-400/70">
explain what needs to change
</span>
</div>
<textarea
value={sendBackText}
onChange={(e) => setSendBackText(e.target.value)}
rows={3}
autoFocus
placeholder="Required: explain why and what to fix"
className="w-full px-3 py-2 rounded border border-orange-300 dark:border-orange-800 bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-orange-200 dark:focus:ring-orange-900"
/>
<div className="flex justify-end gap-2 mt-3">
<button
onClick={() => { setSendBackMode(false); setSendBackText('') }}
disabled={actionBusy}
className="px-3 py-1.5 rounded text-xs font-mono uppercase tracking-wider text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 cursor-pointer"
>
cancel
</button>
<button
onClick={handleSendBack}
disabled={actionBusy || !sendBackText.trim()}
className="px-4 py-1.5 rounded text-xs font-mono uppercase tracking-wider bg-orange-600 hover:bg-orange-700 text-white disabled:opacity-50 cursor-pointer"
>
{actionBusy ? 'sending…' : 'confirm send back'}
</button>
</div>
</div>
) : (
<div className="flex items-center justify-between">
<span className="font-mono text-[10px] text-gray-500 dark:text-gray-400">
ready to decide?
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setSendBackMode(true)}
disabled={actionBusy}
className="px-4 py-1.5 rounded text-xs font-mono uppercase tracking-wider border border-orange-300 dark:border-orange-800 text-orange-700 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-950/30 cursor-pointer"
>
send back
</button>
<button
onClick={handleApprove}
disabled={actionBusy}
className="px-5 py-1.5 rounded text-xs font-mono uppercase tracking-wider bg-emerald-600 hover:bg-emerald-700 text-white disabled:opacity-50 cursor-pointer"
>
{actionBusy ? 'approving…' : 'approve'}
</button>
</div>
</div>
)}
</div>
)}
</div>
</div>
)
}