Initial commit — Director app (API + UI)
This commit is contained in:
670
ui/src/components/PlanDetail.tsx
Normal file
670
ui/src/components/PlanDetail.tsx
Normal file
@@ -0,0 +1,670 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user