task-sheet.tsx
1 'use client' 2 3 import { useEffect, useMemo, useRef, useState } from 'react' 4 import { useRouter } from 'next/navigation' 5 import ReactMarkdown from 'react-markdown' 6 import remarkGfm from 'remark-gfm' 7 import { useAppStore } from '@/stores/use-app-store' 8 import { useAgentsQuery } from '@/features/agents/queries' 9 import { 10 useAppendTaskCommentMutation, 11 useCreateTaskMutation, 12 useTasksQuery, 13 useUpdateTaskMutation, 14 } from '@/features/tasks/queries' 15 import { useProjectsQuery } from '@/features/projects/queries' 16 import { useAppSettingsQuery } from '@/features/settings/queries' 17 import { useProtocolRunsQuery } from '@/features/protocols/queries' 18 import { BottomSheet } from '@/components/shared/bottom-sheet' 19 import { AgentPickerList } from '@/components/shared/agent-picker-list' 20 import { DirBrowser } from '@/components/shared/dir-browser' 21 import { SheetFooter } from '@/components/shared/sheet-footer' 22 import { inputClass } from '@/components/shared/form-styles' 23 import { StructuredSessionLauncher } from '@/components/protocols/structured-session-launcher' 24 import type { BoardTask, TaskComment, TaskQualityGateConfig } from '@/types' 25 import { dedup, errorMessage } from '@/lib/shared-utils' 26 import { SectionLabel } from '@/components/shared/section-label' 27 import { AgentAvatar } from '@/components/agents/agent-avatar' 28 29 function fmtTime(ts: number) { 30 const d = new Date(ts) 31 const now = new Date() 32 const isToday = d.toDateString() === now.toDateString() 33 if (isToday) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) 34 return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) 35 } 36 37 function normalizeGateNumber(value: unknown, fallback: number, min: number, max: number): number { 38 const parsed = typeof value === 'number' 39 ? value 40 : typeof value === 'string' 41 ? Number.parseInt(value, 10) 42 : Number.NaN 43 if (!Number.isFinite(parsed)) return fallback 44 return Math.max(min, Math.min(max, Math.trunc(parsed))) 45 } 46 47 export function TaskSheet() { 48 const router = useRouter() 49 const open = useAppStore((s) => s.taskSheetOpen) 50 const setOpen = useAppStore((s) => s.setTaskSheetOpen) 51 const editingId = useAppStore((s) => s.editingTaskId) 52 const setEditingId = useAppStore((s) => s.setEditingTaskId) 53 const activeProjectFilter = useAppStore((s) => s.activeProjectFilter) 54 55 const viewOnly = useAppStore((s) => s.taskSheetViewOnly) 56 const setViewOnly = useAppStore((s) => s.setTaskSheetViewOnly) 57 const { data: tasks = {}, isLoading: tasksLoading } = useTasksQuery({ includeArchived: true, enabled: open }) 58 const { data: agents = {}, isLoading: agentsLoading } = useAgentsQuery({ enabled: open }) 59 const { data: projects = {}, isLoading: projectsLoading } = useProjectsQuery({ enabled: open }) 60 const { data: appSettings = {}, isLoading: settingsLoading } = useAppSettingsQuery({ enabled: open }) 61 const createTaskMutation = useCreateTaskMutation() 62 const updateTaskMutation = useUpdateTaskMutation() 63 const appendCommentMutation = useAppendTaskCommentMutation() 64 65 const [title, setTitle] = useState('') 66 const [description, setDescription] = useState('') 67 const [agentId, setAgentId] = useState('') 68 const [commentText, setCommentText] = useState('') 69 const [images, setImages] = useState<string[]>([]) 70 const [uploading, setUploading] = useState(false) 71 const [cwd, setCwd] = useState('') 72 const [file, setFile] = useState<string | null>(null) 73 const [projectId, setProjectId] = useState('') 74 const [tags, setTags] = useState<string[]>([]) 75 const [tagInput, setTagInput] = useState('') 76 const [blockedBy, setBlockedBy] = useState<string[]>([]) 77 const [depSearch, setDepSearch] = useState('') 78 const [depError, setDepError] = useState<string | null>(null) 79 const [dueAt, setDueAt] = useState<string>('') 80 const [customFields, setCustomFields] = useState<Record<string, string | number | boolean>>({}) 81 const [priority, setPriority] = useState<'low' | 'medium' | 'high' | 'critical' | ''>('') 82 const [qualityGateEnabled, setQualityGateEnabled] = useState(true) 83 const [qualityGateMinResultChars, setQualityGateMinResultChars] = useState(80) 84 const [qualityGateMinEvidenceItems, setQualityGateMinEvidenceItems] = useState(2) 85 const [qualityGateRequireVerification, setQualityGateRequireVerification] = useState(false) 86 const [qualityGateRequireArtifact, setQualityGateRequireArtifact] = useState(false) 87 const [qualityGateRequireReport, setQualityGateRequireReport] = useState(false) 88 const [structuredSessionOpen, setStructuredSessionOpen] = useState(false) 89 const formInitRef = useRef<string | null>(null) 90 91 const editing = editingId ? tasks[editingId] : null 92 const agentList = useMemo( 93 () => Object.values(agents).sort((a, b) => a.name.localeCompare(b.name)), 94 [agents], 95 ) 96 const { data: linkedProtocolRuns = [] } = useProtocolRunsQuery({ 97 taskId: editing?.id || null, 98 limit: 6, 99 enabled: open && !!editing?.id, 100 }) 101 const activeStructuredRunId = 102 linkedProtocolRuns.find((run) => !['completed', 'failed', 'cancelled', 'archived'].includes(run.status))?.id || null 103 104 useEffect(() => { 105 if (!open) return 106 if (tasksLoading || agentsLoading || projectsLoading || settingsLoading) return 107 108 const initKey = editingId ?? '__new__' 109 if (formInitRef.current === initKey) return 110 111 const defaultGateEnabled = appSettings.taskQualityGateEnabled ?? true 112 const defaultGateMinResult = normalizeGateNumber(appSettings.taskQualityGateMinResultChars, 80, 10, 2000) 113 const defaultGateMinEvidence = normalizeGateNumber(appSettings.taskQualityGateMinEvidenceItems, 2, 0, 8) 114 const defaultGateRequireVerification = appSettings.taskQualityGateRequireVerification ?? false 115 const defaultGateRequireArtifact = appSettings.taskQualityGateRequireArtifact ?? false 116 const defaultGateRequireReport = appSettings.taskQualityGateRequireReport ?? false 117 118 if (editingId && !editing) return 119 120 if (editing) { 121 setTitle(editing.title) 122 setDescription(editing.description) 123 setAgentId(editing.agentId) 124 setProjectId(editing.projectId || '') 125 setImages(editing.images || []) 126 setCwd(editing.cwd || '') 127 setFile(editing.file || null) 128 setTags(editing.tags || []) 129 setBlockedBy(editing.blockedBy || []) 130 setDepSearch('') 131 setDepError(null) 132 setDueAt(editing.dueAt ? new Date(editing.dueAt).toISOString().slice(0, 10) : '') 133 setCustomFields(editing.customFields || {}) 134 setPriority(editing.priority || '') 135 const gate = (editing.qualityGate || null) as TaskQualityGateConfig | null 136 setQualityGateEnabled(gate?.enabled ?? defaultGateEnabled) 137 setQualityGateMinResultChars(normalizeGateNumber(gate?.minResultChars, defaultGateMinResult, 10, 2000)) 138 setQualityGateMinEvidenceItems(normalizeGateNumber(gate?.minEvidenceItems, defaultGateMinEvidence, 0, 8)) 139 setQualityGateRequireVerification(gate?.requireVerification ?? defaultGateRequireVerification) 140 setQualityGateRequireArtifact(gate?.requireArtifact ?? defaultGateRequireArtifact) 141 setQualityGateRequireReport(gate?.requireReport ?? defaultGateRequireReport) 142 formInitRef.current = initKey 143 return 144 } 145 146 setTitle('') 147 setDescription('') 148 setAgentId(agentList[0]?.id || '') 149 setProjectId(activeProjectFilter || '') 150 setImages([]) 151 setCwd('') 152 setFile(null) 153 setTags([]) 154 setBlockedBy([]) 155 setDepSearch('') 156 setDepError(null) 157 setDueAt('') 158 setCustomFields({}) 159 setPriority('') 160 setQualityGateEnabled(defaultGateEnabled) 161 setQualityGateMinResultChars(defaultGateMinResult) 162 setQualityGateMinEvidenceItems(defaultGateMinEvidence) 163 setQualityGateRequireVerification(defaultGateRequireVerification) 164 setQualityGateRequireArtifact(defaultGateRequireArtifact) 165 setQualityGateRequireReport(defaultGateRequireReport) 166 formInitRef.current = initKey 167 }, [ 168 activeProjectFilter, 169 agentList, 170 agentsLoading, 171 appSettings, 172 editing, 173 editingId, 174 open, 175 projectsLoading, 176 settingsLoading, 177 tasksLoading, 178 ]) 179 180 // Update default agent when agents load (only if no agent selected yet) 181 useEffect(() => { 182 if (open && !editing && !agentId && agentList.length) { 183 setAgentId(agentList[0].id) 184 } 185 }, [open, editing, agentId, agentList]) 186 187 const onClose = () => { 188 formInitRef.current = null 189 setDepError(null) 190 setOpen(false) 191 setEditingId(null) 192 } 193 194 const handleSave = async () => { 195 const qualityGate: TaskQualityGateConfig | null = qualityGateEnabled 196 ? { 197 enabled: true, 198 minResultChars: qualityGateMinResultChars, 199 minEvidenceItems: qualityGateMinEvidenceItems, 200 requireVerification: qualityGateRequireVerification, 201 requireArtifact: qualityGateRequireArtifact, 202 requireReport: qualityGateRequireReport, 203 } 204 : null 205 206 // projectId uses null (not undefined) so the API can distinguish "clear" from "not sent" 207 // projectId uses null (not undefined) so the API can distinguish "clear" from "not sent" 208 const payload = { 209 title: title.trim() || 'Untitled Task', description, agentId, projectId: projectId || null, images, 210 cwd: cwd || undefined, file: file || undefined, 211 tags, blockedBy, dueAt: dueAt ? new Date(dueAt).getTime() : null, 212 customFields: Object.keys(customFields).length > 0 ? customFields : undefined, 213 priority: priority || undefined, 214 qualityGate, 215 } as Partial<BoardTask> & { title: string; description: string; agentId: string } 216 try { 217 if (editing) { 218 const res = await updateTaskMutation.mutateAsync({ id: editing.id, patch: payload }) 219 const errMsg = res && typeof res === 'object' ? (res as unknown as Record<string, unknown>).error : undefined 220 if (typeof errMsg === 'string' && errMsg.trim()) { 221 setDepError(errMsg) 222 return 223 } 224 } else { 225 const res = await createTaskMutation.mutateAsync(payload) 226 const errMsg = res && typeof res === 'object' ? (res as unknown as Record<string, unknown>).error : undefined 227 if (typeof errMsg === 'string' && errMsg.trim()) { 228 setDepError(errMsg) 229 return 230 } 231 } 232 } catch (err: unknown) { 233 setDepError(errorMessage(err)) 234 return 235 } 236 setDepError(null) 237 onClose() 238 } 239 240 const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { 241 const file = e.target.files?.[0] 242 if (!file) return 243 setUploading(true) 244 try { 245 const res = await fetch('/api/upload', { 246 method: 'POST', 247 headers: { 'x-filename': file.name }, 248 body: await file.arrayBuffer(), 249 }) 250 const data = await res.json() 251 if (data.url) setImages((prev) => [...prev, data.url]) 252 } catch (err: unknown) { 253 console.error('Image upload failed:', errorMessage(err)) 254 } 255 setUploading(false) 256 e.target.value = '' 257 } 258 259 const handleArchive = async () => { 260 if (editing) { 261 await updateTaskMutation.mutateAsync({ id: editing.id, patch: { status: 'archived' } }) 262 onClose() 263 } 264 } 265 266 const handleUnarchive = async () => { 267 if (editing) { 268 await updateTaskMutation.mutateAsync({ id: editing.id, patch: { status: 'backlog' } }) 269 onClose() 270 } 271 } 272 273 const handleQueue = async () => { 274 if (editing && editing.status === 'backlog') { 275 await updateTaskMutation.mutateAsync({ id: editing.id, patch: { status: 'queued' } }) 276 onClose() 277 } 278 } 279 280 const handleAddComment = async () => { 281 if (!editing || !commentText.trim()) return 282 const c: TaskComment = { 283 id: Math.random().toString(36).slice(2, 10), 284 author: 'You', 285 text: commentText.trim(), 286 createdAt: Date.now(), 287 } 288 // Use atomic append to avoid race conditions with queue-added comments 289 await appendCommentMutation.mutateAsync({ id: editing.id, comment: c }) 290 setCommentText('') 291 } 292 293 const PRIORITY_STYLES: Record<string, string> = { 294 low: 'bg-sky-500/10 border-sky-500/20 text-sky-400', 295 medium: 'bg-amber-500/10 border-amber-500/20 text-amber-400', 296 high: 'bg-orange-500/10 border-orange-500/20 text-orange-400', 297 critical: 'bg-red-500/10 border-red-500/20 text-red-400', 298 } 299 const STATUS_STYLES: Record<string, string> = { 300 backlog: 'bg-white/[0.06] text-text-3', 301 queued: 'bg-amber-500/10 text-amber-400', 302 'in-progress': 'bg-sky-500/10 text-sky-400', 303 completed: 'bg-emerald-500/10 text-emerald-400', 304 failed: 'bg-red-500/10 text-red-400', 305 archived: 'bg-white/[0.04] text-text-3/60', 306 } 307 308 const taskAgent = editing ? agents[editing.agentId] : null 309 const taskProject = editing?.projectId ? projects[editing.projectId] : null 310 311 /* ───── View-only mode ───── */ 312 if (viewOnly && editing) { 313 return ( 314 <BottomSheet open={open} onClose={onClose}> 315 {/* Header: title + badges + timestamps */} 316 <div className="mb-8"> 317 <h2 className="font-display text-[28px] font-700 tracking-[-0.03em] mb-3"> 318 {editing.title} 319 </h2> 320 <div className="flex flex-wrap items-center gap-2 mb-3"> 321 <span className={`px-2.5 py-1 rounded-[8px] text-[12px] font-600 border border-transparent ${STATUS_STYLES[editing.status] || 'bg-white/[0.06] text-text-3'}`}> 322 {editing.status} 323 </span> 324 {editing.priority && ( 325 <span className={`px-2.5 py-1 rounded-[8px] text-[12px] font-600 border ${PRIORITY_STYLES[editing.priority] || ''}`}> 326 {editing.priority} 327 </span> 328 )} 329 </div> 330 <div className="flex flex-wrap gap-x-4 gap-y-1 text-[12px] text-text-3"> 331 <span>Created {fmtTime(editing.createdAt)}</span> 332 {editing.startedAt && <span>Started {fmtTime(editing.startedAt)}</span>} 333 {editing.completedAt && <span>Completed {fmtTime(editing.completedAt)}</span>} 334 </div> 335 </div> 336 337 {/* Description */} 338 {editing.description && ( 339 <div className="mb-8"> 340 <SectionLabel>Description</SectionLabel> 341 <div className="msg-content text-[14px] leading-[1.7] text-text-2 break-words p-4 rounded-[14px] border border-white/[0.06] bg-surface"> 342 <ReactMarkdown remarkPlugins={[remarkGfm]}>{editing.description}</ReactMarkdown> 343 </div> 344 </div> 345 )} 346 347 {editing.objective && ( 348 <div className="mb-8"> 349 <SectionLabel>Objective</SectionLabel> 350 <div className="rounded-[14px] border border-white/[0.06] bg-surface px-4 py-3"> 351 <div className="text-[14px] font-600 text-text">{editing.objective}</div> 352 </div> 353 </div> 354 )} 355 356 {/* Agent */} 357 {taskAgent && ( 358 <div className="mb-8"> 359 <SectionLabel>Agent</SectionLabel> 360 <div className="flex items-center gap-2.5 px-4 py-3 rounded-[14px] border border-white/[0.06] bg-surface"> 361 <AgentAvatar seed={taskAgent.avatarSeed || null} avatarUrl={taskAgent.avatarUrl} name={taskAgent.name} size={24} /> 362 <span className="text-[14px] font-600 text-text">{taskAgent.name}</span> 363 </div> 364 </div> 365 )} 366 367 {/* Project */} 368 {taskProject && ( 369 <div className="mb-8"> 370 <SectionLabel>Project</SectionLabel> 371 <span className="inline-flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/[0.06] bg-surface text-[13px] font-600 text-text-2"> 372 <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: taskProject.color || '#6366F1' }} /> 373 {taskProject.name} 374 </span> 375 </div> 376 )} 377 378 {/* Directory / File */} 379 {(editing.cwd || editing.file) && ( 380 <div className="mb-8"> 381 <SectionLabel>{editing.file ? 'File' : 'Directory'}</SectionLabel> 382 <code className="block px-4 py-3 rounded-[14px] border border-white/[0.06] bg-surface text-[13px] text-text-2 font-mono break-all"> 383 {editing.file || editing.cwd} 384 </code> 385 </div> 386 )} 387 388 {/* Tags */} 389 {editing.tags && editing.tags.length > 0 && ( 390 <div className="mb-8"> 391 <SectionLabel>Tags</SectionLabel> 392 <div className="flex flex-wrap gap-1.5"> 393 {editing.tags.map((tag) => ( 394 <span key={tag} className="px-2.5 py-1 rounded-[8px] bg-indigo-500/10 text-indigo-400 text-[12px] font-600"> 395 {tag} 396 </span> 397 ))} 398 </div> 399 </div> 400 )} 401 402 {/* Blocked By */} 403 {editing.blockedBy && editing.blockedBy.length > 0 && ( 404 <div className="mb-8"> 405 <SectionLabel>Blocked By</SectionLabel> 406 <div className="flex flex-wrap gap-1.5"> 407 {editing.blockedBy.map((bid) => { 408 const bt = tasks[bid] 409 return ( 410 <span key={bid} className="px-2.5 py-1 rounded-[8px] bg-white/[0.04] text-text-3 text-[12px] font-600"> 411 {bt ? bt.title : bid} 412 </span> 413 ) 414 })} 415 </div> 416 </div> 417 )} 418 419 {/* Blocks */} 420 {editing.blocks && editing.blocks.length > 0 && ( 421 <div className="mb-8"> 422 <SectionLabel>Blocks</SectionLabel> 423 <div className="flex flex-wrap gap-1.5"> 424 {editing.blocks.map((bid) => { 425 const bt = tasks[bid] 426 return bt ? ( 427 <span key={bid} className="px-2.5 py-1 rounded-[8px] bg-white/[0.04] text-text-3 text-[12px] font-600">{bt.title}</span> 428 ) : null 429 })} 430 </div> 431 </div> 432 )} 433 434 {/* Due Date */} 435 {editing.dueAt && ( 436 <div className="mb-8"> 437 <SectionLabel>Due Date</SectionLabel> 438 <span className="text-[14px] text-text-2">{new Date(editing.dueAt).toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })}</span> 439 </div> 440 )} 441 442 {/* Custom Fields */} 443 {editing.customFields && Object.keys(editing.customFields).length > 0 && ( 444 <div className="mb-8"> 445 <SectionLabel>Custom Fields</SectionLabel> 446 <div className="space-y-2"> 447 {Object.entries(editing.customFields).map(([key, val]) => { 448 const def = appSettings.taskCustomFieldDefs?.find((d) => d.key === key) 449 return ( 450 <div key={key} className="flex items-baseline gap-2"> 451 <span className="text-[12px] font-600 text-text-3">{def?.label || key}:</span> 452 <span className="text-[13px] text-text-2">{String(val)}</span> 453 </div> 454 ) 455 })} 456 </div> 457 </div> 458 )} 459 460 {editing.qualityGate?.enabled && ( 461 <div className="mb-8"> 462 <SectionLabel>Quality Gate</SectionLabel> 463 <div className="p-4 rounded-[14px] border border-white/[0.06] bg-surface space-y-1.5 text-[12px] text-text-2"> 464 <p>Min result chars: {editing.qualityGate.minResultChars ?? 80}</p> 465 <p>Min evidence signals: {editing.qualityGate.minEvidenceItems ?? 2}</p> 466 <p>Verification required: {(editing.qualityGate.requireVerification ?? false) ? 'Yes' : 'No'}</p> 467 <p>Artifact required: {(editing.qualityGate.requireArtifact ?? false) ? 'Yes' : 'No'}</p> 468 <p>Task report required: {(editing.qualityGate.requireReport ?? false) ? 'Yes' : 'No'}</p> 469 </div> 470 </div> 471 )} 472 473 {/* Images (thumbnails only, no remove/upload) */} 474 {editing.images && editing.images.length > 0 && ( 475 <div className="mb-8"> 476 <SectionLabel>Images</SectionLabel> 477 <div className="flex gap-2 flex-wrap"> 478 {editing.images.map((url, i) => ( 479 // eslint-disable-next-line @next/next/no-img-element 480 <img key={i} src={url} alt="" className="w-20 h-20 rounded-[10px] object-cover border border-white/[0.08]" /> 481 ))} 482 </div> 483 </div> 484 )} 485 486 {/* Result */} 487 {editing.result && ( 488 <div className="mb-8"> 489 <SectionLabel>Result</SectionLabel> 490 <div className="p-4 rounded-[14px] border border-white/[0.06] bg-surface text-[13px] text-text-2 whitespace-pre-wrap max-h-[200px] overflow-y-auto"> 491 {editing.result} 492 </div> 493 </div> 494 )} 495 496 {Array.isArray(editing.outputFiles) && editing.outputFiles.length > 0 && ( 497 <div className="mb-8"> 498 <SectionLabel>Output Files</SectionLabel> 499 <div className="flex flex-col gap-1.5"> 500 {editing.outputFiles.map((fileRef) => ( 501 <code key={fileRef} className="text-[12px] text-text-3 font-mono break-all"> 502 {fileRef} 503 </code> 504 ))} 505 </div> 506 </div> 507 )} 508 509 {editing.completionReportPath && ( 510 <div className="mb-8"> 511 <SectionLabel>Task Report</SectionLabel> 512 <code className="text-[12px] text-text-3 font-mono break-all">{editing.completionReportPath}</code> 513 </div> 514 )} 515 516 {/* CLI Sessions */} 517 {(editing.claudeResumeId || editing.codexResumeId || editing.opencodeResumeId || editing.geminiResumeId || editing.cliResumeId) && ( 518 <div className="mb-8"> 519 <SectionLabel>CLI Sessions</SectionLabel> 520 <div className="flex flex-wrap gap-2"> 521 {editing.claudeResumeId && ( 522 <div className="flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/[0.06] bg-surface"> 523 <span className="text-[11px] font-600 text-amber-400">Claude</span> 524 <code className="text-[11px] text-text-3 font-mono">{editing.claudeResumeId}</code> 525 </div> 526 )} 527 {editing.codexResumeId && ( 528 <div className="flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/[0.06] bg-surface"> 529 <span className="text-[11px] font-600 text-emerald-400">Codex</span> 530 <code className="text-[11px] text-text-3 font-mono">{editing.codexResumeId}</code> 531 </div> 532 )} 533 {editing.opencodeResumeId && ( 534 <div className="flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/[0.06] bg-surface"> 535 <span className="text-[11px] font-600 text-sky-400">OpenCode</span> 536 <code className="text-[11px] text-text-3 font-mono">{editing.opencodeResumeId}</code> 537 </div> 538 )} 539 {editing.geminiResumeId && ( 540 <div className="flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/[0.06] bg-surface"> 541 <span className="text-[11px] font-600 text-fuchsia-400">Gemini</span> 542 <code className="text-[11px] text-text-3 font-mono">{editing.geminiResumeId}</code> 543 </div> 544 )} 545 {!(editing.claudeResumeId || editing.codexResumeId || editing.opencodeResumeId || editing.geminiResumeId) && editing.cliResumeId && ( 546 <div className="flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/[0.06] bg-surface"> 547 <span className="text-[11px] font-600 text-text-2">{editing.cliProvider || 'CLI'}</span> 548 <code className="text-[11px] text-text-3 font-mono">{editing.cliResumeId}</code> 549 </div> 550 )} 551 </div> 552 </div> 553 )} 554 555 {/* Error / Retry notice */} 556 {editing.error && (() => { 557 const retryPending = 558 editing.status !== 'failed' && 559 !editing.deadLetteredAt && 560 (editing.retryScheduledAt != null || /^Retry scheduled after failure/i.test(editing.error)) 561 const label = retryPending ? 'Retry Pending' : 'Error' 562 const tone = retryPending 563 ? 'border-amber-500/15 bg-amber-500/[0.04] text-amber-300/80' 564 : 'border-red-500/10 bg-red-500/[0.03] text-red-400/80' 565 const labelTone = retryPending ? 'text-amber-400' : 'text-red-400' 566 return ( 567 <div className="mb-8"> 568 <label className={`block font-display text-[12px] font-600 uppercase tracking-[0.08em] mb-3 ${labelTone}`}>{label}</label> 569 <div className={`p-4 rounded-[14px] border text-[13px] whitespace-pre-wrap ${tone}`}> 570 {editing.error} 571 </div> 572 </div> 573 ) 574 })()} 575 576 {/* Comments (with input — adding comments from view mode is useful) */} 577 <div className="mb-8"> 578 <SectionLabel>Comments {editing.comments?.length ? `(${editing.comments.length})` : ''}</SectionLabel> 579 580 {editing.comments && editing.comments.length > 0 && ( 581 <div className="space-y-3 mb-4 max-h-[300px] overflow-y-auto"> 582 {editing.comments.map((c) => ( 583 <div key={c.id} className="p-3.5 rounded-[12px] border border-white/[0.06] bg-surface"> 584 <div className="flex items-center gap-2 mb-1.5"> 585 <span className={`text-[12px] font-600 ${c.agentId ? 'text-accent-bright' : 'text-text-2'}`}> 586 {c.author} 587 </span> 588 <span className="text-[10px] text-text-3/50 font-mono">{fmtTime(c.createdAt)}</span> 589 </div> 590 <p className="text-[13px] text-text-2 leading-[1.5] whitespace-pre-wrap">{c.text}</p> 591 </div> 592 ))} 593 </div> 594 )} 595 596 <div className="flex gap-2"> 597 <input 598 type="text" 599 value={commentText} 600 onChange={(e) => setCommentText(e.target.value)} 601 placeholder="Add a comment..." 602 className={`${inputClass} flex-1`} 603 style={{ fontFamily: 'inherit' }} 604 onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleAddComment() } }} 605 /> 606 <button 607 onClick={handleAddComment} 608 disabled={!commentText.trim()} 609 className="px-4 py-3 rounded-[14px] border-none bg-accent-soft text-accent-bright text-[13px] font-600 cursor-pointer disabled:opacity-30 hover:brightness-110 transition-all shrink-0" 610 style={{ fontFamily: 'inherit' }} 611 > 612 Post 613 </button> 614 </div> 615 </div> 616 617 {/* Footer: Edit + Close */} 618 <div className="flex gap-3 pt-2 border-t border-white/[0.04]"> 619 {activeStructuredRunId && ( 620 <button 621 onClick={() => router.push(`/protocols?runId=${encodeURIComponent(activeStructuredRunId)}`)} 622 className="flex-1 py-3.5 rounded-[14px] border border-sky-500/20 bg-sky-500/10 text-sky-100 text-[15px] font-600 cursor-pointer hover:bg-sky-500/14 transition-all" 623 style={{ fontFamily: 'inherit' }} 624 > 625 Open Session 626 </button> 627 )} 628 <button 629 onClick={() => setStructuredSessionOpen(true)} 630 className="flex-1 py-3.5 rounded-[14px] border border-accent-bright/20 bg-accent-bright/10 text-accent-bright text-[15px] font-600 cursor-pointer hover:bg-accent-bright/14 transition-all" 631 style={{ fontFamily: 'inherit' }} 632 > 633 {activeStructuredRunId ? 'Run Another Session' : 'Run Structured Session'} 634 </button> 635 <button 636 onClick={onClose} 637 className="flex-1 py-3.5 rounded-[14px] border border-white/[0.08] bg-transparent text-text-2 text-[15px] font-600 cursor-pointer hover:bg-surface-2 transition-all" 638 style={{ fontFamily: 'inherit' }} 639 > 640 Close 641 </button> 642 <button 643 onClick={() => setViewOnly(false)} 644 className="flex-1 py-3.5 rounded-[14px] border-none bg-accent-bright text-white text-[15px] font-600 cursor-pointer active:scale-[0.97] transition-all shadow-[0_4px_20px_rgba(99,102,241,0.25)] hover:brightness-110" 645 style={{ fontFamily: 'inherit' }} 646 > 647 Edit 648 </button> 649 </div> 650 </BottomSheet> 651 ) 652 } 653 654 /* ───── Edit / Create mode ───── */ 655 return ( 656 <BottomSheet open={open} onClose={onClose}> 657 <div className="mb-8"> 658 <h2 className="font-display text-[28px] font-700 tracking-[-0.03em] mb-2"> 659 {editing ? 'Edit Task' : 'New Task'} 660 </h2> 661 <p className="text-[14px] text-text-3"> 662 {editing ? `Status: ${editing.status}` : 'Create a task and assign an agent'} 663 </p> 664 </div> 665 666 <div className="mb-8"> 667 <SectionLabel>Title</SectionLabel> 668 <input 669 type="text" 670 value={title} 671 onChange={(e) => setTitle(e.target.value)} 672 placeholder="e.g. Run full site audit" 673 className={inputClass} 674 style={{ fontFamily: 'inherit' }} 675 /> 676 </div> 677 678 <div className="mb-8"> 679 <SectionLabel>Description</SectionLabel> 680 <textarea 681 value={description} 682 onChange={(e) => setDescription(e.target.value)} 683 placeholder="Detailed task instructions... Use @AgentName to auto-assign" 684 rows={4} 685 className={`${inputClass} resize-y min-h-[100px]`} 686 style={{ fontFamily: 'inherit' }} 687 /> 688 </div> 689 690 {editing?.objective && ( 691 <div className="mb-8"> 692 <SectionLabel>Objective</SectionLabel> 693 <div className="rounded-[14px] border border-white/[0.06] bg-surface px-4 py-3 text-[12px] leading-[1.7] text-text-3/75"> 694 <div className="font-600 text-text">{editing.objective}</div> 695 </div> 696 </div> 697 )} 698 699 {/* Priority */} 700 <div className="mb-8"> 701 <SectionLabel>Priority <span className="normal-case tracking-normal font-normal text-text-3">(optional)</span></SectionLabel> 702 <div className="flex flex-wrap gap-2"> 703 {([['', 'None', 'bg-surface border-white/[0.06] text-text-2'], 704 ['low', 'Low', 'bg-sky-500/10 border-sky-500/20 text-sky-400'], 705 ['medium', 'Medium', 'bg-amber-500/10 border-amber-500/20 text-amber-400'], 706 ['high', 'High', 'bg-orange-500/10 border-orange-500/20 text-orange-400'], 707 ['critical', 'Critical', 'bg-red-500/10 border-red-500/20 text-red-400'], 708 ] as const).map(([val, label, cls]) => ( 709 <button 710 key={val} 711 onClick={() => setPriority(val as typeof priority)} 712 className={`px-4 py-3 rounded-[12px] text-[14px] font-600 cursor-pointer transition-all border 713 ${priority === val 714 ? `${cls} ring-1 ring-current` 715 : 'bg-surface border-white/[0.06] text-text-2 hover:bg-surface-2'}`} 716 style={{ fontFamily: 'inherit' }} 717 > 718 {label} 719 </button> 720 ))} 721 </div> 722 </div> 723 724 {/* Images */} 725 <div className="mb-8"> 726 <SectionLabel>Images <span className="normal-case tracking-normal font-normal text-text-3">(optional — reference designs, mockups, etc.)</span></SectionLabel> 727 {images.length > 0 && ( 728 <div className="flex gap-2 flex-wrap mb-3"> 729 {images.map((url, i) => ( 730 <div key={i} className="relative group"> 731 <img src={url} alt="" className="w-20 h-20 rounded-[10px] object-cover border border-white/[0.08]" /> 732 <button 733 onClick={() => setImages((prev) => prev.filter((_, idx) => idx !== i))} 734 className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center text-[11px] font-700 cursor-pointer 735 opacity-0 group-hover:opacity-100 transition-opacity border-none" 736 > 737 x 738 </button> 739 </div> 740 ))} 741 </div> 742 )} 743 <label className={`inline-flex items-center gap-2 px-4 py-2.5 rounded-[12px] border border-white/[0.06] bg-surface text-text-3 text-[13px] font-600 cursor-pointer hover:bg-surface-2 transition-colors ${uploading ? 'opacity-50 pointer-events-none' : ''}`}> 744 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 745 <rect x="3" y="3" width="18" height="18" rx="2" /> 746 <circle cx="8.5" cy="8.5" r="1.5" /> 747 <polyline points="21 15 16 10 5 21" /> 748 </svg> 749 {uploading ? 'Uploading...' : 'Add Image'} 750 <input type="file" accept="image/*" onChange={handleImageUpload} className="hidden" /> 751 </label> 752 </div> 753 754 <div className="mb-8"> 755 <SectionLabel>Agent</SectionLabel> 756 <AgentPickerList 757 agents={agentList} 758 selected={agentId} 759 onSelect={(id) => setAgentId(id)} 760 /> 761 </div> 762 763 {/* Project (optional) */} 764 <div className="mb-8"> 765 <SectionLabel>Project <span className="normal-case tracking-normal font-normal text-text-3">(optional)</span></SectionLabel> 766 <div className="flex flex-wrap gap-2"> 767 <button 768 onClick={() => setProjectId('')} 769 className={`px-4 py-3 rounded-[12px] text-[14px] font-600 cursor-pointer transition-all border 770 ${!projectId 771 ? 'bg-accent-soft border-accent-bright/25 text-accent-bright' 772 : 'bg-surface border-white/[0.06] text-text-2 hover:bg-surface-2'}`} 773 style={{ fontFamily: 'inherit' }} 774 > 775 None 776 </button> 777 {Object.values(projects).map((p) => ( 778 <button 779 key={p.id} 780 onClick={() => setProjectId(p.id)} 781 className={`px-4 py-3 rounded-[12px] text-[14px] font-600 cursor-pointer transition-all border flex items-center gap-2 782 ${projectId === p.id 783 ? 'bg-accent-soft border-accent-bright/25 text-accent-bright' 784 : 'bg-surface border-white/[0.06] text-text-2 hover:bg-surface-2'}`} 785 style={{ fontFamily: 'inherit' }} 786 > 787 <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: p.color || '#6366F1' }} /> 788 {p.name} 789 </button> 790 ))} 791 </div> 792 </div> 793 794 {/* Directory (optional) */} 795 <div className="mb-8"> 796 <SectionLabel>Directory <span className="normal-case tracking-normal font-normal text-text-3">(optional — project to work in)</span></SectionLabel> 797 <DirBrowser 798 value={cwd || null} 799 file={file} 800 onChange={(dir, f) => { 801 setCwd(dir) 802 setFile(f ?? null) 803 if (!title) { 804 const dirName = dir.split('/').pop() || '' 805 setTitle(dirName) 806 } 807 }} 808 onClear={() => { setCwd(''); setFile(null) }} 809 /> 810 </div> 811 812 {/* Tags */} 813 <div className="mb-8"> 814 <SectionLabel>Tags <span className="normal-case tracking-normal font-normal text-text-3">(optional)</span></SectionLabel> 815 {tags.length > 0 && ( 816 <div className="flex flex-wrap gap-1.5 mb-3"> 817 {tags.map((tag) => ( 818 <span key={tag} className="inline-flex items-center gap-1 px-2 py-1 rounded-[8px] bg-indigo-500/10 text-indigo-400 text-[12px] font-600"> 819 {tag} 820 <button onClick={() => setTags((prev) => prev.filter((t) => t !== tag))} className="text-indigo-400/60 hover:text-indigo-400 cursor-pointer border-none bg-transparent p-0 text-[14px] leading-none">×</button> 821 </span> 822 ))} 823 </div> 824 )} 825 <div className="relative"> 826 <input 827 type="text" 828 value={tagInput} 829 onChange={(e) => setTagInput(e.target.value)} 830 onKeyDown={(e) => { 831 if (e.key === 'Enter' && tagInput.trim()) { 832 e.preventDefault() 833 const t = tagInput.trim().toLowerCase() 834 if (!tags.includes(t)) setTags((prev) => [...prev, t]) 835 setTagInput('') 836 } 837 }} 838 placeholder="Type and press Enter to add..." 839 className={inputClass} 840 style={{ fontFamily: 'inherit' }} 841 list="tag-suggestions" 842 /> 843 <datalist id="tag-suggestions"> 844 {dedup(Object.values(tasks).flatMap((t) => t.tags || [])) 845 .filter((t) => !tags.includes(t) && t.includes(tagInput.toLowerCase())) 846 .slice(0, 10) 847 .map((t) => <option key={t} value={t} />)} 848 </datalist> 849 </div> 850 </div> 851 852 {/* Dependencies */} 853 <div className="mb-8"> 854 <SectionLabel>Blocked By <span className="normal-case tracking-normal font-normal text-text-3">(tasks that must complete first)</span></SectionLabel> 855 {/* Selected blockers as removable chips */} 856 {blockedBy.length > 0 && ( 857 <div className="flex flex-wrap gap-1.5 mb-3"> 858 {blockedBy.map((bid) => { 859 const bt = tasks[bid] 860 return ( 861 <span key={bid} className="inline-flex items-center gap-1 px-2.5 py-1 rounded-[8px] bg-rose-500/10 text-rose-400 text-[12px] font-600"> 862 {bt ? bt.title : bid} 863 <button 864 onClick={() => setBlockedBy((prev) => prev.filter((b) => b !== bid))} 865 className="text-rose-400/60 hover:text-rose-400 cursor-pointer border-none bg-transparent p-0 text-[14px] leading-none" 866 > 867 × 868 </button> 869 </span> 870 ) 871 })} 872 </div> 873 )} 874 {/* Searchable dropdown for adding dependencies */} 875 <div className="relative"> 876 <input 877 type="text" 878 value={depSearch} 879 onChange={(e) => setDepSearch(e.target.value)} 880 placeholder="Search tasks to add as dependency..." 881 className={inputClass} 882 style={{ fontFamily: 'inherit' }} 883 /> 884 {depSearch.trim() && ( 885 <div className="absolute z-20 top-full left-0 right-0 mt-1 max-h-[200px] overflow-y-auto rounded-[12px] border border-white/[0.08] bg-surface shadow-xl"> 886 {Object.values(tasks) 887 .filter((t) => 888 t.id !== editingId && 889 t.status !== 'archived' && 890 !blockedBy.includes(t.id) && 891 t.title.toLowerCase().includes(depSearch.toLowerCase()) 892 ) 893 .slice(0, 10) 894 .map((t) => ( 895 <button 896 key={t.id} 897 onClick={() => { 898 setBlockedBy((prev) => [...prev, t.id]) 899 setDepSearch('') 900 }} 901 className="w-full text-left px-4 py-2.5 text-[13px] text-text-2 hover:bg-surface-2 cursor-pointer border-none bg-transparent transition-colors flex items-center gap-2" 902 style={{ fontFamily: 'inherit' }} 903 > 904 <span className="flex-1 truncate">{t.title}</span> 905 <span className="text-[10px] text-text-3 shrink-0">({t.status})</span> 906 </button> 907 ))} 908 {Object.values(tasks).filter((t) => 909 t.id !== editingId && 910 t.status !== 'archived' && 911 !blockedBy.includes(t.id) && 912 t.title.toLowerCase().includes(depSearch.toLowerCase()) 913 ).length === 0 && ( 914 <div className="px-4 py-3 text-[13px] text-text-3">No matching tasks</div> 915 )} 916 </div> 917 )} 918 </div> 919 {depError && ( 920 <p className="mt-2 text-[12px] text-red-400 font-600">{depError}</p> 921 )} 922 {editing && Array.isArray(editing.blocks) && editing.blocks.length > 0 && ( 923 <div className="mt-3"> 924 <span className="text-[11px] font-600 text-text-3 uppercase tracking-[0.06em]">Blocks:</span> 925 <div className="flex flex-wrap gap-1.5 mt-1.5"> 926 {editing.blocks.map((bid) => { 927 const bt = tasks[bid] 928 return bt ? ( 929 <span key={bid} className="px-2 py-1 rounded-[6px] bg-white/[0.04] text-text-3 text-[11px] font-600">{bt.title}</span> 930 ) : null 931 })} 932 </div> 933 </div> 934 )} 935 </div> 936 937 {/* Due Date */} 938 <div className="mb-8"> 939 <SectionLabel>Due Date <span className="normal-case tracking-normal font-normal text-text-3">(optional)</span></SectionLabel> 940 <input 941 type="date" 942 value={dueAt} 943 onChange={(e) => setDueAt(e.target.value)} 944 className={`${inputClass} appearance-none`} 945 style={{ fontFamily: 'inherit', colorScheme: 'dark' }} 946 /> 947 </div> 948 949 <div className="mb-8"> 950 <SectionLabel>Quality Gate</SectionLabel> 951 <p className="text-[12px] text-text-3 mb-3"> 952 Checks that must pass before this task can be marked completed. 953 </p> 954 <div className="p-4 rounded-[14px] border border-white/[0.06] bg-surface"> 955 <button 956 onClick={() => setQualityGateEnabled((prev) => !prev)} 957 className={`relative w-10 h-[22px] rounded-full transition-colors duration-200 cursor-pointer ${qualityGateEnabled ? 'bg-accent' : 'bg-white/[0.12]'}`} 958 > 959 <span className={`absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200 ${qualityGateEnabled ? 'translate-x-[18px]' : ''}`} /> 960 </button> 961 <span className="ml-2 text-[12px] text-text-2">{qualityGateEnabled ? 'Enabled' : 'Disabled'}</span> 962 963 {qualityGateEnabled && ( 964 <div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-4"> 965 <div> 966 <label className="block text-[11px] text-text-3 mb-1.5">Min Result Chars</label> 967 <input 968 type="number" 969 min={10} 970 max={2000} 971 value={qualityGateMinResultChars} 972 onChange={(e) => setQualityGateMinResultChars(normalizeGateNumber(e.target.value, 80, 10, 2000))} 973 className={inputClass} 974 style={{ fontFamily: 'inherit' }} 975 /> 976 </div> 977 <div> 978 <label className="block text-[11px] text-text-3 mb-1.5">Min Evidence Signals</label> 979 <input 980 type="number" 981 min={0} 982 max={8} 983 value={qualityGateMinEvidenceItems} 984 onChange={(e) => setQualityGateMinEvidenceItems(normalizeGateNumber(e.target.value, 2, 0, 8))} 985 className={inputClass} 986 style={{ fontFamily: 'inherit' }} 987 /> 988 </div> 989 <label className="flex items-center gap-2 text-[12px] text-text-2"> 990 <input 991 type="checkbox" 992 checked={qualityGateRequireVerification} 993 onChange={(e) => setQualityGateRequireVerification(e.target.checked)} 994 className="h-4 w-4 rounded border-white/20 accent-accent" 995 /> 996 Require verification evidence (tests/lint/build) 997 </label> 998 <label className="flex items-center gap-2 text-[12px] text-text-2"> 999 <input 1000 type="checkbox" 1001 checked={qualityGateRequireArtifact} 1002 onChange={(e) => setQualityGateRequireArtifact(e.target.checked)} 1003 className="h-4 w-4 rounded border-white/20 accent-accent" 1004 /> 1005 Require artifact evidence (upload URL or task artifacts) 1006 </label> 1007 <label className="flex items-center gap-2 text-[12px] text-text-2 md:col-span-2"> 1008 <input 1009 type="checkbox" 1010 checked={qualityGateRequireReport} 1011 onChange={(e) => setQualityGateRequireReport(e.target.checked)} 1012 className="h-4 w-4 rounded border-white/20 accent-accent" 1013 /> 1014 Require generated task report 1015 </label> 1016 </div> 1017 )} 1018 </div> 1019 </div> 1020 1021 {/* Custom Fields */} 1022 {appSettings.taskCustomFieldDefs && appSettings.taskCustomFieldDefs.length > 0 && ( 1023 <div className="mb-8"> 1024 <SectionLabel>Custom Fields</SectionLabel> 1025 <div className="space-y-4"> 1026 {appSettings.taskCustomFieldDefs.map((def) => ( 1027 <div key={def.key}> 1028 <label className="block text-[12px] text-text-3 mb-1.5">{def.label}</label> 1029 {def.type === 'select' ? ( 1030 <select 1031 value={String(customFields[def.key] ?? '')} 1032 onChange={(e) => setCustomFields((prev) => ({ ...prev, [def.key]: e.target.value }))} 1033 className={inputClass} 1034 style={{ fontFamily: 'inherit' }} 1035 > 1036 <option value="">—</option> 1037 {def.options?.map((opt) => <option key={opt} value={opt}>{opt}</option>)} 1038 </select> 1039 ) : ( 1040 <input 1041 type={def.type === 'number' ? 'number' : 'text'} 1042 value={String(customFields[def.key] ?? '')} 1043 onChange={(e) => setCustomFields((prev) => ({ 1044 ...prev, 1045 [def.key]: def.type === 'number' ? (e.target.value === '' ? '' : Number(e.target.value)) : e.target.value, 1046 }))} 1047 className={inputClass} 1048 style={{ fontFamily: 'inherit' }} 1049 /> 1050 )} 1051 </div> 1052 ))} 1053 </div> 1054 </div> 1055 )} 1056 1057 {editing?.result && ( 1058 <div className="mb-8"> 1059 <SectionLabel>Result</SectionLabel> 1060 <div className="p-4 rounded-[14px] border border-white/[0.06] bg-surface text-[13px] text-text-2 whitespace-pre-wrap max-h-[200px] overflow-y-auto"> 1061 {editing.result} 1062 </div> 1063 </div> 1064 )} 1065 1066 {editing && (editing.claudeResumeId || editing.codexResumeId || editing.opencodeResumeId || editing.geminiResumeId || editing.cliResumeId) && ( 1067 <div className="mb-8"> 1068 <SectionLabel>CLI Sessions</SectionLabel> 1069 <div className="flex flex-wrap gap-2"> 1070 {editing.claudeResumeId && ( 1071 <div className="flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/[0.06] bg-surface"> 1072 <span className="text-[11px] font-600 text-amber-400">Claude</span> 1073 <code className="text-[11px] text-text-3 font-mono">{editing.claudeResumeId}</code> 1074 </div> 1075 )} 1076 {editing.codexResumeId && ( 1077 <div className="flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/[0.06] bg-surface"> 1078 <span className="text-[11px] font-600 text-emerald-400">Codex</span> 1079 <code className="text-[11px] text-text-3 font-mono">{editing.codexResumeId}</code> 1080 </div> 1081 )} 1082 {editing.opencodeResumeId && ( 1083 <div className="flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/[0.06] bg-surface"> 1084 <span className="text-[11px] font-600 text-sky-400">OpenCode</span> 1085 <code className="text-[11px] text-text-3 font-mono">{editing.opencodeResumeId}</code> 1086 </div> 1087 )} 1088 {editing.geminiResumeId && ( 1089 <div className="flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/[0.06] bg-surface"> 1090 <span className="text-[11px] font-600 text-fuchsia-400">Gemini</span> 1091 <code className="text-[11px] text-text-3 font-mono">{editing.geminiResumeId}</code> 1092 </div> 1093 )} 1094 {!(editing.claudeResumeId || editing.codexResumeId || editing.opencodeResumeId || editing.geminiResumeId) && editing.cliResumeId && ( 1095 <div className="flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/[0.06] bg-surface"> 1096 <span className="text-[11px] font-600 text-text-2">{editing.cliProvider || 'CLI'}</span> 1097 <code className="text-[11px] text-text-3 font-mono">{editing.cliResumeId}</code> 1098 </div> 1099 )} 1100 </div> 1101 </div> 1102 )} 1103 1104 {editing?.error && ( 1105 <div className="mb-8"> 1106 <label className="block font-display text-[12px] font-600 text-red-400 uppercase tracking-[0.08em] mb-3">Error</label> 1107 <div className="p-4 rounded-[14px] border border-red-500/10 bg-red-500/[0.03] text-[13px] text-red-400/80 whitespace-pre-wrap"> 1108 {editing.error} 1109 </div> 1110 </div> 1111 )} 1112 1113 {/* Comments */} 1114 {editing && ( 1115 <div className="mb-8"> 1116 <SectionLabel>Comments {editing.comments?.length ? `(${editing.comments.length})` : ''}</SectionLabel> 1117 1118 {editing.comments && editing.comments.length > 0 && ( 1119 <div className="space-y-3 mb-4 max-h-[300px] overflow-y-auto"> 1120 {editing.comments.map((c) => ( 1121 <div key={c.id} className="p-3.5 rounded-[12px] border border-white/[0.06] bg-surface"> 1122 <div className="flex items-center gap-2 mb-1.5"> 1123 <span className={`text-[12px] font-600 ${c.agentId ? 'text-accent-bright' : 'text-text-2'}`}> 1124 {c.author} 1125 </span> 1126 <span className="text-[10px] text-text-3/50 font-mono">{fmtTime(c.createdAt)}</span> 1127 </div> 1128 <p className="text-[13px] text-text-2 leading-[1.5] whitespace-pre-wrap">{c.text}</p> 1129 </div> 1130 ))} 1131 </div> 1132 )} 1133 1134 <div className="flex gap-2"> 1135 <input 1136 type="text" 1137 value={commentText} 1138 onChange={(e) => setCommentText(e.target.value)} 1139 placeholder="Add a comment..." 1140 className={`${inputClass} flex-1`} 1141 style={{ fontFamily: 'inherit' }} 1142 onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleAddComment() } }} 1143 /> 1144 <button 1145 onClick={handleAddComment} 1146 disabled={!commentText.trim()} 1147 className="px-4 py-3 rounded-[14px] border-none bg-accent-soft text-accent-bright text-[13px] font-600 cursor-pointer disabled:opacity-30 hover:brightness-110 transition-all shrink-0" 1148 style={{ fontFamily: 'inherit' }} 1149 > 1150 Post 1151 </button> 1152 </div> 1153 </div> 1154 )} 1155 1156 <SheetFooter 1157 onCancel={onClose} 1158 onSave={handleSave} 1159 saveLabel={editing ? 'Save' : 'Create'} 1160 saveDisabled={!title.trim() || !agentId} 1161 left={<> 1162 {editing && activeStructuredRunId && ( 1163 <button onClick={() => router.push(`/protocols?runId=${encodeURIComponent(activeStructuredRunId)}`)} className="py-3.5 px-6 rounded-[14px] border border-sky-500/20 bg-transparent text-sky-100 text-[15px] font-600 cursor-pointer hover:bg-sky-500/10 transition-all" style={{ fontFamily: 'inherit' }}> 1164 Open Session 1165 </button> 1166 )} 1167 {editing && ( 1168 <button onClick={() => setStructuredSessionOpen(true)} className="py-3.5 px-6 rounded-[14px] border border-accent-bright/20 bg-transparent text-accent-bright text-[15px] font-600 cursor-pointer hover:bg-accent-bright/10 transition-all" style={{ fontFamily: 'inherit' }}> 1169 {activeStructuredRunId ? 'Run Another Session' : 'Run Structured Session'} 1170 </button> 1171 )} 1172 {editing && editing.status !== 'archived' && ( 1173 <button onClick={handleArchive} className="py-3.5 px-6 rounded-[14px] border border-white/[0.08] bg-transparent text-text-3 text-[15px] font-600 cursor-pointer hover:bg-white/[0.04] transition-all" style={{ fontFamily: 'inherit' }}> 1174 Archive 1175 </button> 1176 )} 1177 {editing && editing.status === 'archived' && ( 1178 <button onClick={handleUnarchive} className="py-3.5 px-6 rounded-[14px] border border-accent-bright/20 bg-transparent text-accent-bright text-[15px] font-600 cursor-pointer hover:bg-accent-bright/10 transition-all" style={{ fontFamily: 'inherit' }}> 1179 Unarchive 1180 </button> 1181 )} 1182 {editing && editing.status === 'backlog' && ( 1183 <button onClick={handleQueue} className="py-3.5 px-6 rounded-[14px] border border-amber-500/20 bg-transparent text-amber-400 text-[15px] font-600 cursor-pointer hover:bg-amber-500/10 transition-all" style={{ fontFamily: 'inherit' }}> 1184 Queue 1185 </button> 1186 )} 1187 </>} 1188 /> 1189 <StructuredSessionLauncher 1190 open={structuredSessionOpen} 1191 onClose={() => setStructuredSessionOpen(false)} 1192 onCreated={(run) => { 1193 router.push(`/protocols?runId=${encodeURIComponent(run.id)}`) 1194 }} 1195 initialContext={{ 1196 taskId: editing?.id || null, 1197 taskLabel: editing?.title || null, 1198 participantAgentIds: editing?.agentId ? [editing.agentId] : agentId ? [agentId] : [], 1199 facilitatorAgentId: editing?.agentId || agentId || null, 1200 title: editing ? `Structured session: ${editing.title}` : title ? `Structured session: ${title}` : null, 1201 goal: editing?.description || description || title || null, 1202 }} 1203 /> 1204 </BottomSheet> 1205 ) 1206 }