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 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-03-26 17:05:36 +00:00
parent d8e62a7108
commit 0983c1ac9e

View File

@@ -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<TaskResponse[]>(() => [
...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<number>(() => {
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<TaskResponse>) => {
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 (
<div
className="relative bg-sidebar border-l border-default flex flex-col shrink-0 animate-in slide-in-from-right-4 duration-200"
style={{ width: panelWidth }}
>
{/* Resize grip handle */}
<div
onMouseDown={handleMouseDown}
className="absolute left-0 top-0 bottom-0 w-[6px] cursor-col-resize z-20 flex items-center justify-center group hover:bg-accent/10 transition-colors"
>
<div className="flex flex-col gap-[3px] opacity-0 group-hover:opacity-100 transition-opacity">
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
</div>
</div>
{/* Header */}
<div className="px-4 py-3 border-b border-default flex items-center justify-between shrink-0">
<h3 className="font-heading text-sm font-bold text-heading flex items-center gap-2">
Tasks
<span className={cn(
'text-[10px] font-semibold px-2 py-0.5 rounded-full',
allHandled
? 'bg-success-dim text-success'
: 'bg-accent-dim text-accent-text'
)}>
{allHandled ? '✓ Ready' : `${doneCount}/${totalCount}`}
</span>
</h3>
<button onClick={onClose} className="text-muted-foreground hover:text-heading transition-colors p-1">
<X size={16} />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-3 space-y-4">
{/* ── Questions Section ── */}
{questionTasks.length > 0 && (
<section>
<div className="sticky top-0 z-10 bg-sidebar pb-2">
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted pl-0.5">
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
Questions
{questionTasks.every(q => q.state === 'done' || q.state === 'skipped') && (
<Check size={10} className="text-success" />
)}
</div>
</div>
{tasks.map((task, idx) => {
if (task.type !== 'question') return null
const q = task as QuestionResponse
if (q.state === 'done') {
return (
<div key={idx} className="rounded-lg border border-success/20 bg-success-dim/20 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
<div className="text-[0.8125rem] text-muted-foreground">{q.text}</div>
<div className="text-[0.75rem] text-muted-foreground mt-1 italic">"{q.value}"</div>
</div>
)
}
if (q.state === 'skipped') {
return (
<div key={idx} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-50">
<div className="flex justify-between">
<div className="text-[0.8125rem] text-muted-foreground line-through">{q.text}</div>
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
</div>
</div>
)
}
return (
<div key={idx} className="rounded-lg border border-default bg-card p-3 mb-2">
<div className="text-[0.8125rem] text-heading leading-relaxed">{q.text}</div>
{q.context && (
<div className="text-[0.6875rem] text-muted-foreground mt-1">{q.context}</div>
)}
{q.state === 'active' ? (
<div className="mt-2">
<textarea
autoFocus
value={q.value}
onChange={e => updateTask(idx, { value: e.target.value })}
placeholder="Type your answer..."
className="w-full rounded-md border border-default bg-input px-3 py-2 text-[0.8125rem] text-heading placeholder:text-muted-foreground resize-y min-h-[48px] max-h-[150px] focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
rows={2}
/>
<div className="mt-1.5 flex items-center gap-2">
<button
onClick={() => updateTask(idx, { state: 'done' })}
disabled={!q.value.trim()}
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
>
<Check size={11} /> Answer
</button>
<button
onClick={() => updateTask(idx, { state: 'pending', value: '' })}
className="text-[0.75rem] text-muted-foreground hover:text-heading"
>
Cancel
</button>
</div>
</div>
) : (
<div className="mt-2 flex items-center gap-2">
<button
onClick={() => updateTask(idx, { state: 'active' })}
className="flex items-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-1.5 text-[0.75rem] font-medium text-accent-text hover:bg-accent-dim/50 transition-colors"
>
<MessageCircleQuestion size={11} /> Answer
</button>
<button
onClick={() => updateTask(idx, { state: 'skipped' })}
className="flex items-center gap-1 rounded-md px-2.5 py-1.5 text-[0.75rem] text-muted-foreground hover:text-heading"
>
<SkipForward size={11} /> Skip
</button>
</div>
)}
</div>
)
})}
</section>
)}
{/* ── Checks Section ── */}
{actionTasks.length > 0 && (
<section>
<div className="sticky top-0 z-10 bg-sidebar pb-2">
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted pl-0.5">
<span className="w-1.5 h-1.5 rounded-full bg-[#60a5fa]" />
Diagnostic Checks
{actionTasks.every(a => a.state === 'done' || a.state === 'skipped') && (
<Check size={10} className="text-success" />
)}
</div>
</div>
{/* Run All */}
{commandActions.length > 1 && (
<div className="mb-2">
<button
onClick={() => setShowRunAll(!showRunAll)}
className="flex items-center gap-1.5 text-[0.75rem] font-medium text-accent-text hover:text-accent transition-colors pl-0.5"
>
<Terminal size={12} />
Run All ({commandActions.length} commands)
{showRunAll ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
{showRunAll && (
<div className="mt-2 rounded-lg border border-default bg-code p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Combined script</span>
<button
onClick={() => handleCopy(combinedScript)}
className="flex items-center gap-1 text-[0.75rem] text-muted-foreground hover:text-heading"
>
<Copy size={11} /> Copy
</button>
</div>
<pre className="text-[0.75rem] font-mono text-heading whitespace-pre-wrap overflow-x-auto">{combinedScript}</pre>
</div>
)}
</div>
)}
{tasks.map((task, idx) => {
if (task.type !== 'action') return null
const a = task as ActionResponse
if (a.state === 'done') {
return (
<div key={idx} className="rounded-lg border border-success/20 bg-success-dim/20 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
<div className="flex justify-between">
<div className="text-[0.8125rem] font-medium text-heading">{a.label}</div>
<span className="text-[10px] font-semibold uppercase tracking-wider text-success"> Done</span>
</div>
</div>
)
}
if (a.state === 'skipped') {
return (
<div key={idx} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-50">
<div className="flex justify-between">
<div className="text-[0.8125rem] text-muted-foreground line-through">{a.label}</div>
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
</div>
</div>
)
}
return (
<div key={idx} className="rounded-lg border border-default bg-card p-3 mb-2 hover:border-hover transition-colors">
<div className="text-[0.8125rem] font-medium text-heading">{a.label}</div>
{a.description && (
<div className="text-[0.6875rem] text-muted-foreground mt-0.5 leading-relaxed">{a.description}</div>
)}
{a.command && (
<div className="mt-2 flex items-center gap-2 rounded bg-code px-2.5 py-1.5">
<code className="flex-1 text-[0.6875rem] font-mono text-heading truncate">{a.command}</code>
<button onClick={() => handleCopy(a.command!)} className="shrink-0 text-muted-foreground hover:text-heading" title="Copy">
<Copy size={11} />
</button>
</div>
)}
{a.state === 'active' ? (
<div className="mt-2">
<textarea
autoFocus
value={a.value}
onChange={e => updateTask(idx, { value: e.target.value })}
placeholder="Paste command output here..."
className="w-full rounded-md border border-default bg-input px-3 py-2 text-[0.8125rem] text-heading placeholder:text-muted-foreground font-mono resize-y min-h-[60px] max-h-[200px] overflow-y-auto focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
rows={3}
/>
<div className="mt-1.5 flex items-center gap-2">
<button
onClick={() => updateTask(idx, { state: 'done' })}
disabled={!a.value.trim()}
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
>
<Check size={11} /> Done
</button>
<button
onClick={() => updateTask(idx, { state: 'pending', value: '' })}
className="text-[0.75rem] text-muted-foreground hover:text-heading"
>
Cancel
</button>
</div>
</div>
) : (
<div className="mt-2 flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<button
onClick={() => updateTask(idx, { state: 'active' })}
className="flex items-center justify-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-1.5 text-[0.75rem] font-medium text-accent-text hover:bg-accent-dim/50 transition-colors"
>
<Clipboard size={11} /> Paste Result
</button>
<button
onClick={() => updateTask(idx, { state: 'active' })}
className="flex items-center justify-center gap-1 rounded-md border border-default bg-elevated/50 px-2.5 py-1.5 text-[0.75rem] font-medium text-heading hover:bg-elevated transition-colors"
>
Type Answer
</button>
<button
onClick={() => updateTask(idx, { state: 'skipped' })}
className="flex items-center justify-center gap-1 rounded-md px-2.5 py-1.5 text-[0.75rem] text-muted-foreground hover:text-heading"
>
<SkipForward size={11} /> Skip
</button>
</div>
)}
</div>
)
})}
</section>
)}
</div>
{/* Footer */}
<div className="p-3 border-t border-default shrink-0">
{/* Progress bar */}
<div className="flex gap-1 mb-2">
{tasks.map((t, i) => (
<div
key={i}
className={cn(
'flex-1 h-[3px] rounded-full',
t.state === 'done' ? 'bg-success' :
t.state === 'skipped' ? 'bg-muted' :
t.state === 'active' ? 'bg-accent' :
'bg-elevated'
)}
/>
))}
</div>
{/* Collapsible preview */}
{anyHandled && (
<div className="mb-2">
<button
onClick={() => setShowPreview(!showPreview)}
className="flex items-center gap-1.5 text-[0.75rem] font-medium text-muted-foreground hover:text-heading transition-colors mb-1"
>
<Eye size={12} />
Preview ({handledCount}/{totalCount} done)
{showPreview ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
{showPreview && (
<div className="rounded-lg border border-default bg-code p-2.5 max-h-[150px] overflow-y-auto">
<pre className="text-[0.6875rem] font-mono text-heading whitespace-pre-wrap">{buildPreviewText()}</pre>
</div>
)}
</div>
)}
<button
onClick={handleSubmit}
disabled={!anyHandled || loading || submitting}
className={cn(
'w-full flex items-center justify-center gap-1.5 rounded-lg px-4 py-2.5 text-[0.8125rem] font-semibold transition-colors',
anyHandled && !submitting
? 'bg-accent text-white hover:bg-accent-hover'
: 'bg-elevated text-muted-foreground border border-default cursor-not-allowed'
)}
>
{submitting ? (
<><Loader2 size={14} className="animate-spin" /> Sending...</>
) : (
<><Send size={14} /> {allHandled ? 'Send All Responses' : `Send ${handledCount} of ${totalCount} Responses`}</>
)}
</button>
</div>
</div>
)
}