671 lines
28 KiB
TypeScript
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">×</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>
|
|
)
|
|
}
|