import { useState, useEffect, useRef, useCallback } from 'react' import { Copy, Check, SkipForward, Terminal, ChevronDown, ChevronUp, Send, Clipboard, Loader2, PanelRightClose, MessageCircleQuestion, Eye, } from 'lucide-react' import { cn } from '@/lib/utils' import { toast } from '@/lib/toast' import { aiSessionsApi } from '@/api/aiSessions' import type { ActionItem, QuestionItem } from '@/types/ai-session' // ── Types ── type TaskState = 'pending' | 'active' | 'done' | 'skipped' interface QuestionResponse { type: 'question' text: string context?: string state: TaskState value: string } interface ActionResponse { type: 'action' label: string command?: string | null description: string state: TaskState value: string } type TaskResponse = QuestionResponse | ActionResponse interface TaskLaneProps { questions: QuestionItem[] actions: ActionItem[] sessionId?: string | null onSubmit: (responses: TaskResponse[]) => void onClose: () => void loading?: boolean } // ── Storage helpers ── const TASK_LANE_STORAGE_KEY = 'rf-tasklane-state' function saveTaskState(sessionId: string, tasks: TaskResponse[]) { try { sessionStorage.setItem(`${TASK_LANE_STORAGE_KEY}:${sessionId}`, JSON.stringify(tasks)) } catch { /* quota exceeded — ignore */ } } function loadTaskState(sessionId: string): TaskResponse[] | null { try { const stored = sessionStorage.getItem(`${TASK_LANE_STORAGE_KEY}:${sessionId}`) return stored ? JSON.parse(stored) : null } catch { return null } } export function clearTaskState(sessionId: string) { try { sessionStorage.removeItem(`${TASK_LANE_STORAGE_KEY}:${sessionId}`) } catch { /* ignore */ } } // ── Component ── export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loading }: TaskLaneProps) { const [tasks, setTasks] = useState(() => { // Try to restore saved state for this session (preserves user's in-progress answers) if (sessionId) { const saved = loadTaskState(sessionId) if (saved && saved.length > 0) return saved } return [ ...questions.map((q): QuestionResponse => ({ type: 'question', text: q.text, context: q.context, state: 'pending', value: '', })), ...actions.map((a): ActionResponse => ({ type: 'action', label: a.label, command: a.command, description: a.description, state: 'pending', value: '', })), ] }) const [submitting, setSubmitting] = useState(false) const [showRunAll, setShowRunAll] = useState(false) const [showPreview, setShowPreview] = useState(false) // ── Resize state ── const DEFAULT_WIDTH = 340 const MIN_WIDTH = 280 const MAX_WIDTH_RATIO = 0.5 // 50vw const [panelWidth, setPanelWidth] = useState(() => { const stored = localStorage.getItem('rf-tasklane-width') return stored ? Math.max(MIN_WIDTH, parseInt(stored, 10) || DEFAULT_WIDTH) : DEFAULT_WIDTH }) const isDragging = useRef(false) const startX = useRef(0) const startWidth = useRef(0) const handleMouseMove = useCallback((e: MouseEvent) => { if (!isDragging.current) return const maxWidth = window.innerWidth * MAX_WIDTH_RATIO const deltaX = startX.current - e.clientX const newWidth = Math.min(maxWidth, Math.max(MIN_WIDTH, startWidth.current + deltaX)) setPanelWidth(newWidth) }, []) const handleMouseUp = useCallback(() => { if (!isDragging.current) return isDragging.current = false document.body.style.cursor = '' document.body.style.userSelect = '' localStorage.setItem('rf-tasklane-width', String(Math.round(panelWidth))) }, [panelWidth]) const handleMouseDown = useCallback((e: React.MouseEvent) => { e.preventDefault() isDragging.current = true startX.current = e.clientX startWidth.current = panelWidth document.body.style.cursor = 'col-resize' document.body.style.userSelect = 'none' }, [panelWidth]) useEffect(() => { document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mouseup', handleMouseUp) return () => { document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mouseup', handleMouseUp) } }, [handleMouseMove, handleMouseUp]) // Refs so the debounced save always uses the latest questions/actions/tasks const questionsRef = useRef(questions) const actionsRef = useRef(actions) const tasksRef = useRef(tasks) useEffect(() => { questionsRef.current = questions }, [questions]) useEffect(() => { actionsRef.current = actions }, [actions]) useEffect(() => { tasksRef.current = tasks }, [tasks]) // Save task state to sessionStorage on every change + debounce to backend const saveTimerRef = useRef | null>(null) useEffect(() => { if (!sessionId) return saveTaskState(sessionId, tasks) // Debounce save to backend (2s after last change) if (saveTimerRef.current) clearTimeout(saveTimerRef.current) saveTimerRef.current = setTimeout(() => { aiSessionsApi.saveTaskLane(sessionId, { questions: questionsRef.current.map(q => ({ text: q.text, context: q.context })), actions: actionsRef.current.map(a => ({ label: a.label, command: a.command, description: a.description })), responses: tasksRef.current as unknown as Array>, }).catch(() => { /* silent — best-effort save */ }) }, 2000) return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) } }, [sessionId, tasks]) // eslint-disable-line react-hooks/exhaustive-deps // Reset when new tasks come in from AI response — but preserve saved state useEffect(() => { if (sessionId) { const saved = loadTaskState(sessionId) if (saved && saved.length > 0) { // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncs derived state from prop changes setTasks(saved) return } } // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncs derived state from prop changes setTasks([ ...questions.map((q): QuestionResponse => ({ type: 'question', text: q.text, context: q.context, state: 'pending', value: '', })), ...actions.map((a): ActionResponse => ({ type: 'action', label: a.label, command: a.command, description: a.description, state: 'pending', value: '', })), ]) }, [questions, actions]) // eslint-disable-line react-hooks/exhaustive-deps const updateTask = (idx: number, updates: Partial) => { setTasks(prev => prev.map((t, i) => i === idx ? { ...t, ...updates } as TaskResponse : t)) } const questionTasks = tasks.filter(t => t.type === 'question') const actionTasks = tasks.filter(t => t.type === 'action') as ActionResponse[] const allHandled = tasks.every(t => t.state === 'done' || t.state === 'skipped') const anyHandled = tasks.some(t => t.state === 'done' || t.state === 'skipped') const handledCount = tasks.filter(t => t.state === 'done' || t.state === 'skipped').length const doneCount = tasks.filter(t => t.state === 'done').length const totalCount = tasks.length const commandActions = actionTasks.filter(a => a.command) const combinedScript = commandActions.map((a, i) => ( `# ── ${i + 1}. ${a.label} ──\n${a.command}` )).join('\n\n') const handleCopy = (text: string) => { navigator.clipboard.writeText(text) toast.success('Copied to clipboard') } const buildPreviewText = (): string => { const parts: string[] = [] for (const t of tasks) { if (t.type === 'question') { const q = t as QuestionResponse const name = `Q: ${q.text}` if (q.state === 'done' && q.value.trim()) { parts.push(`**${name}:**\n\`\`\`\n${q.value.trim()}\n\`\`\``) } else if (q.state === 'skipped') { parts.push(`**${name}:** _(skipped)_`) } } else { const a = t as ActionResponse const name = a.label || 'Check' if (a.state === 'done' && a.value.trim()) { parts.push(`**${name}:**\n\`\`\`\n${a.value.trim()}\n\`\`\``) } else if (a.state === 'skipped') { parts.push(`**${name}:** _(skipped)_`) } } } return parts.join('\n\n') || '(No responses yet)' } const handleSubmit = () => { setSubmitting(true) onSubmit(tasks) // Don't self-hide — parent controls visibility via showTaskLane. // The AI response will either send updated tasks (replacing these) // or send none (parent hides the lane). setSubmitting(false) } return (
{/* Resize grip handle */}
{Array.from({ length: 6 }).map((_, i) => (
))}
{/* Header */}

Tasks {allHandled ? '✓ Ready' : `${doneCount}/${totalCount}`}

{/* Body */}
{/* ── Questions Section ── */} {questionTasks.length > 0 && (
Questions {questionTasks.every(q => q.state === 'done' || q.state === 'skipped') && ( )}
{tasks.map((task, idx) => { if (task.type !== 'question') return null const q = task as QuestionResponse if (q.state === 'done') { return (
updateTask(idx, { state: 'active' })}>
{q.text}
"{q.value}"
) } if (q.state === 'skipped') { return (
updateTask(idx, { state: 'pending' })} title="Click to restore">
{q.text}
Skipped
) } return (
{q.text}
{q.context && (
{q.context}
)} {q.state === 'active' ? (