Files
resolutionflow/frontend/src/components/assistant/TaskLane.tsx
chihlasm 80af408f2d feat: persist task lane user responses to backend
Add PUT /ai-sessions/{id}/task-lane endpoint that saves the full task
lane state (AI questions/actions + user's in-progress responses) to
the pending_task_lane JSONB column. TaskLane debounce-saves to the
backend every 2s after changes. On session load, user responses are
restored from the backend into sessionStorage so TaskLane picks them
up on mount. Users can now close the browser, come back later, and
find their task lane exactly where they left it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 19:11:13 +00:00

542 lines
24 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from 'react'
import {
Copy, Check, SkipForward, Terminal, ChevronDown, ChevronUp,
Send, Clipboard, Loader2, X, 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<TaskResponse[]>(() => {
// 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<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])
// Save task state to sessionStorage on every change + debounce to backend
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | 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: questions.map(q => ({ text: q.text, context: q.context })),
actions: actions.map(a => ({ label: a.label, command: a.command, description: a.description })),
responses: tasks as unknown as Array<Record<string, unknown>>,
}).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
useEffect(() => {
// 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])
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)
// 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 (
<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>
)
}