import { useState, useEffect, useRef, useCallback } from 'react' import { Copy, Check, SkipForward, Terminal, ChevronDown, ChevronUp, Send, Clipboard, Loader2, PanelRightClose, Pencil, HelpCircle, 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 DiagnosticHelp { what: string lookFor: string usefulWhen: string } function getDiagnosticHelp(action: ActionResponse): DiagnosticHelp { const command = (action.command || '').toLowerCase() if (command.includes('test-netconnection') || command.includes('ping ')) { return { what: action.description || 'Checks whether the target is reachable over the network.', lookFor: 'Successful replies, low packet loss, and whether the expected port shows as open.', usefulWhen: 'Use it when you need to separate a service problem from a basic connectivity problem.', } } if (command.includes('nslookup') || command.includes('resolve-dnsname')) { return { what: action.description || 'Checks how DNS resolves the hostname or record.', lookFor: 'Wrong IPs, NXDOMAIN responses, timeout errors, or different answers from different resolvers.', usefulWhen: 'Use it when names fail but direct IP access may still work.', } } if (command.includes('ipconfig') || command.includes('get-netipconfiguration')) { return { what: action.description || 'Shows local IP, gateway, DNS, and adapter configuration.', lookFor: 'APIPA addresses, missing gateways, wrong DNS servers, disconnected adapters, or stale leases.', usefulWhen: 'Use it early when the symptom may be local network configuration.', } } if (command.includes('get-eventlog') || command.includes('get-winevent') || command.includes('eventlog')) { return { what: action.description || 'Reads Windows event logs for recent errors or warnings.', lookFor: 'Events matching the failure time, repeated error IDs, service crashes, or permission failures.', usefulWhen: 'Use it when the UI only shows a generic error and you need system-level evidence.', } } if (command.includes('get-service') || command.includes('restart-service')) { return { what: action.description || 'Checks service state on the affected machine.', lookFor: 'Stopped services, restart loops, disabled startup types, or dependency failures.', usefulWhen: 'Use it when a feature depends on a Windows service or background agent.', } } return { what: action.description || 'Runs the diagnostic check suggested by FlowPilot.', lookFor: 'Errors, unexpected values, failed checks, or output that differs from a known-good machine.', usefulWhen: 'Use it when you need evidence before choosing the next troubleshooting step.', } } interface TaskLaneProps { questions: QuestionItem[] actions: ActionItem[] sessionId?: string | null onSubmit: (responses: TaskResponse[]) => void onClose: () => void loading?: boolean // Slot for the FlowPilot Phase 2 "What we know" section. Rendered above // Questions in the body (per FLOWPILOT-MIGRATION.md Section 3.1). The slot // shape lets the parent own fact-fetching and state-version polling without // pulling that concern into TaskLane. whatWeKnowSlot?: React.ReactNode // Phase 3: bottom-of-lane slot for the Resolve action bar + preview popover // (parent owns state). Renders inside the scrollable body so the popover // stays anchored as the lane scrolls. bottomSlot?: React.ReactNode // Phase 7: `'drawer'` renders the lane as a full-width, bottom-anchored // sheet (no resize handle, no left border) — used on viewports <1200px. // Default `'side'` keeps the existing resizable right-side panel. variant?: 'side' | 'drawer' } // ── 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 } } // eslint-disable-next-line react-refresh/only-export-components 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, whatWeKnowSlot, bottomSlot, variant = 'side' }: TaskLaneProps) { const isDrawer = variant === 'drawer' 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) const [copiedKey, setCopiedKey] = useState(null) const [expandedHelpKey, setExpandedHelpKey] = useState(null) // ── 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]) // 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 task UI from persisted session state setTasks(saved) return } } 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, sessionId]) const updateTask = (idx: number, updates: Partial) => { setTasks(prev => prev.map((t, i) => i === idx ? { ...t, ...updates } as TaskResponse : t)) } // Mark `idx` done and advance focus to the next pending task. If none are // left, focus the Send button so the engineer can fire the batch with one // more keystroke. Powers both keyboard submit (Enter / Cmd+Enter) and the // mouse path on the Answer / Done buttons. const sendButtonRef = useRef(null) const submitAndAdvance = (idx: number, value: string) => { if (!value.trim()) return const nextIdx = tasks.findIndex((t, i) => i > idx && t.state === 'pending') setTasks(prev => prev.map((t, i) => { if (i === idx) return { ...t, state: 'done' } as TaskResponse if (nextIdx !== -1 && i === nextIdx) return { ...t, state: 'active' } as TaskResponse return t })) if (nextIdx === -1) { setTimeout(() => sendButtonRef.current?.focus(), 50) } } 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 = async (text: string) => { try { await navigator.clipboard.writeText(text) } catch { // Fallback for HTTP or focus-restricted contexts try { const el = document.createElement('textarea') el.value = text el.style.cssText = 'position:fixed;opacity:0;pointer-events:none' document.body.appendChild(el) el.select() document.execCommand('copy') document.body.removeChild(el) } catch { toast.error('Copy failed — select the text and copy manually') return } } setCopiedKey(text) setTimeout(() => setCopiedKey(k => k === text ? null : k), 1500) 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 — side variant only. Drawer variant has no horizontal neighbor to resize against. */} {!isDrawer && (
{Array.from({ length: 6 }).map((_, i) => (
))}
)} {/* Header */}

Tasks {allHandled ? ( Ready ) : ( {doneCount}/{totalCount} )} {loading && ( thinking )}

{/* Body */}
{/* ── What we know (Phase 2) ── */} {whatWeKnowSlot} {/* ── 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' ? (