From 0983c1ac9e88150aabc1311389ac869b573b7e9f Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 26 Mar 2026 17:05:36 +0000 Subject: [PATCH] feat: TaskLane partial submit, edit done cards, preview, and resize - Enable submit when at least 1 item is answered (not all required) - Dynamic label: "Send 2 of 6 Responses" vs "Send All Responses" - Done cards are clickable to reopen for editing - Collapsible preview shows formatted message before sending - Resizable via left-edge grip handle, width persists to localStorage Co-Authored-By: Claude Opus 4.6 --- .../src/components/assistant/TaskLane.tsx | 496 ++++++++++++++++++ 1 file changed, 496 insertions(+) create mode 100644 frontend/src/components/assistant/TaskLane.tsx diff --git a/frontend/src/components/assistant/TaskLane.tsx b/frontend/src/components/assistant/TaskLane.tsx new file mode 100644 index 00000000..4fb4f09d --- /dev/null +++ b/frontend/src/components/assistant/TaskLane.tsx @@ -0,0 +1,496 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { + Copy, Check, SkipForward, Terminal, ChevronDown, ChevronUp, + Send, Clipboard, Loader2, X, MessageCircleQuestion, Wrench, + Eye, +} from 'lucide-react' +import { cn } from '@/lib/utils' +import { toast } from '@/lib/toast' +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[] + onSubmit: (responses: TaskResponse[]) => void + onClose: () => void + loading?: boolean +} + +// ── Component ── + +export function TaskLane({ questions, actions, onSubmit, onClose, loading }: TaskLaneProps) { + const [tasks, setTasks] = useState(() => [ + ...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 [submitted, setSubmitted] = 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]) + + // Reset when new tasks come in + useEffect(() => { + 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: '', + })), + ]) + setSubmitted(false) + }, [questions, actions]) + + 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) + setSubmitted(true) + setSubmitting(false) + } + + if (submitted) return null + + return ( +
+ {/* Resize grip handle */} +
+
+
+
+
+
+
+
+
+
+ {/* 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 ( +
+
+
{q.text}
+ Skipped +
+
+ ) + } + + return ( +
+
{q.text}
+ {q.context && ( +
{q.context}
+ )} + {q.state === 'active' ? ( +
+