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>
542 lines
24 KiB
TypeScript
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>
|
|
)
|
|
}
|