Two small ergonomic fixes after the impeccable pass: - TaskLane keyboard hints (⏎ submit · ⇧⏎ newline) under each open input were rendered at text-muted-foreground/70, just shy of legible at a glance. Drop the /70 opacity modifier so they read at full muted weight on first look without becoming visually loud. - 12 sites across the session screen had explicit font-sans utilities, but the body default is already IBM Plex Sans (via --font-sans in index.css and Tailwind v4's default-sans binding). None of the call sites sit inside a font-heading or font-mono cascade, so every font-sans there was a no-op. Drop them. ConcludeSessionModal also had three "text-xs font-sans text-xs" triplets — drop both the redundant font-sans and the doubled text-xs in one pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
782 lines
35 KiB
TypeScript
782 lines
35 KiB
TypeScript
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<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)
|
|
const [copiedKey, setCopiedKey] = useState<string | null>(null)
|
|
const [expandedHelpKey, setExpandedHelpKey] = useState<string | null>(null)
|
|
|
|
// ── 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])
|
|
|
|
// 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<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: 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<Record<string, unknown>>,
|
|
}).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<TaskResponse>) => {
|
|
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<HTMLButtonElement>(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 (
|
|
<div
|
|
className={cn(
|
|
'relative flex flex-col',
|
|
isDrawer
|
|
? 'w-full h-full border-t border-default animate-slide-in-bottom'
|
|
: 'border-l border-default shrink-0 animate-slide-in-right',
|
|
)}
|
|
style={{
|
|
background: 'var(--color-bg-page)',
|
|
...(isDrawer ? {} : { width: panelWidth }),
|
|
}}
|
|
>
|
|
{/* Resize grip handle — side variant only. Drawer variant has no
|
|
horizontal neighbor to resize against. */}
|
|
{!isDrawer && (
|
|
<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">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<div key={i} 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
|
|
{allHandled ? (
|
|
<span className="flex items-center gap-1 text-[0.625rem] font-semibold uppercase tracking-wider text-success">
|
|
<Check size={10} /> Ready
|
|
</span>
|
|
) : (
|
|
<span className="text-[0.625rem] font-medium tabular-nums text-muted-foreground">
|
|
{doneCount}/{totalCount}
|
|
</span>
|
|
)}
|
|
{loading && (
|
|
<span
|
|
className="flex items-center gap-1 text-[0.625rem] font-medium text-muted-foreground"
|
|
title="AI is thinking"
|
|
>
|
|
<Loader2 size={10} className="animate-spin" />
|
|
thinking
|
|
</span>
|
|
)}
|
|
</h3>
|
|
<button onClick={onClose} className="text-muted-foreground hover:text-heading transition-colors p-1" title="Collapse tasks">
|
|
<PanelRightClose size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="flex-1 overflow-y-auto p-3 space-y-4">
|
|
|
|
{/* ── What we know (Phase 2) ── */}
|
|
{whatWeKnowSlot}
|
|
|
|
{/* ── Questions Section ── */}
|
|
{questionTasks.length > 0 && (
|
|
<section>
|
|
<div className="pb-2">
|
|
<div className="flex items-center gap-2 text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-muted-foreground 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} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/40 p-3 mb-2 cursor-pointer hover:border-default transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
|
|
<div className="flex items-center gap-1.5">
|
|
<Check size={12} className="text-success shrink-0" />
|
|
<span className="text-[0.8125rem] text-muted-foreground">{q.text}</span>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground/80 mt-1 pl-5 italic truncate">"{q.value}"</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (q.state === 'skipped') {
|
|
return (
|
|
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore">
|
|
<div className="flex justify-between">
|
|
<div className="text-[0.8125rem] text-muted-foreground line-through">{q.text}</div>
|
|
<span className="text-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div key={idx} id={`task-lane-card-${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 })}
|
|
onKeyDown={e => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault()
|
|
submitAndAdvance(idx, q.value)
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault()
|
|
updateTask(idx, { state: 'pending', 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 justify-between gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => submitAndAdvance(idx, q.value)}
|
|
disabled={!q.value.trim()}
|
|
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-xs 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-xs text-muted-foreground hover:text-heading"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
<span className="text-[0.625rem] text-muted-foreground tabular-nums">
|
|
⏎ submit · ⇧⏎ newline
|
|
</span>
|
|
</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-xs font-medium text-accent-text hover:bg-accent-dim/50 transition-colors"
|
|
>
|
|
<Pencil size={11} /> Answer
|
|
</button>
|
|
<button
|
|
onClick={() => updateTask(idx, { state: 'skipped' })}
|
|
className="p-1.5 rounded-md text-muted-foreground hover:text-heading hover:bg-elevated/50 transition-colors"
|
|
title="Skip this question"
|
|
>
|
|
<SkipForward size={13} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</section>
|
|
)}
|
|
|
|
{/* ── Checks Section ── */}
|
|
{actionTasks.length > 0 && (
|
|
<section>
|
|
<div className="pb-2">
|
|
<div className="flex items-center gap-2 text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
|
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-xs 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-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">Combined script</span>
|
|
<button
|
|
onClick={() => void handleCopy(combinedScript)}
|
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-heading transition-colors"
|
|
>
|
|
{copiedKey === combinedScript ? <Check size={11} className="text-success" /> : <Copy size={11} />}
|
|
{copiedKey === combinedScript ? 'Copied' : 'Copy'}
|
|
</button>
|
|
</div>
|
|
<pre className="text-xs 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} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/40 p-3 mb-2 cursor-pointer hover:border-default transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
|
|
<div className="flex items-center gap-1.5">
|
|
<Check size={12} className="text-success shrink-0" />
|
|
<span className="text-[0.8125rem] text-muted-foreground flex-1">{a.label}</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (a.state === 'skipped') {
|
|
return (
|
|
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore">
|
|
<div className="flex justify-between">
|
|
<div className="text-[0.8125rem] text-muted-foreground line-through">{a.label}</div>
|
|
<span className="text-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default bg-card p-3 mb-2 hover:border-hover transition-colors">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<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>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpandedHelpKey(expandedHelpKey === `${idx}` ? null : `${idx}`)}
|
|
className={cn(
|
|
'shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-elevated/50 hover:text-heading',
|
|
expandedHelpKey === `${idx}` && 'bg-accent-dim text-accent-text',
|
|
)}
|
|
title="Explain this check"
|
|
aria-label="Explain this diagnostic check"
|
|
aria-expanded={expandedHelpKey === `${idx}`}
|
|
>
|
|
<HelpCircle size={13} />
|
|
</button>
|
|
</div>
|
|
|
|
{expandedHelpKey === `${idx}` && (() => {
|
|
const help = getDiagnosticHelp(a)
|
|
return (
|
|
<div className="mt-2 rounded-lg border border-info/20 bg-info-dim/20 p-2.5 text-[0.6875rem] leading-relaxed">
|
|
<div className="space-y-1.5">
|
|
<p>
|
|
<span className="font-semibold text-heading">What it checks: </span>
|
|
<span className="text-muted-foreground">{help.what}</span>
|
|
</p>
|
|
<p>
|
|
<span className="font-semibold text-heading">What to look for: </span>
|
|
<span className="text-muted-foreground">{help.lookFor}</span>
|
|
</p>
|
|
<p>
|
|
<span className="font-semibold text-heading">When to use it: </span>
|
|
<span className="text-muted-foreground">{help.usefulWhen}</span>
|
|
</p>
|
|
</div>
|
|
</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 whitespace-pre-wrap break-all">{a.command}</code>
|
|
<button
|
|
onClick={() => void handleCopy(a.command!)}
|
|
className="shrink-0 text-muted-foreground hover:text-heading transition-colors p-0.5 rounded"
|
|
title={copiedKey === a.command ? 'Copied!' : 'Copy command'}
|
|
>
|
|
{copiedKey === a.command
|
|
? <Check size={11} className="text-success" />
|
|
: <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 })}
|
|
onKeyDown={e => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault()
|
|
submitAndAdvance(idx, a.value)
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault()
|
|
updateTask(idx, { state: 'pending', 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 justify-between gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => submitAndAdvance(idx, a.value)}
|
|
disabled={!a.value.trim()}
|
|
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-xs 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-xs text-muted-foreground hover:text-heading"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
<span className="text-[0.625rem] text-muted-foreground tabular-nums">
|
|
⏎ submit · ⇧⏎ newline
|
|
</span>
|
|
</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-xs font-medium text-accent-text hover:bg-accent-dim/50 transition-colors"
|
|
>
|
|
<Clipboard size={11} /> {a.command ? 'Paste Output' : 'Answer'}
|
|
</button>
|
|
<button
|
|
onClick={() => updateTask(idx, { state: 'skipped' })}
|
|
className="p-1.5 rounded-md text-muted-foreground hover:text-heading hover:bg-elevated/50 transition-colors"
|
|
title="Skip this check"
|
|
>
|
|
<SkipForward size={13} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</section>
|
|
)}
|
|
|
|
{/* Quiet-state hint: lane is open (facts exist), but AI hasn't
|
|
proposed a next step yet. Keeps the lane from feeling "finished"
|
|
when the engineer still expects a question / fix to arrive. */}
|
|
{questionTasks.length === 0
|
|
&& actionTasks.length === 0
|
|
&& !loading && (
|
|
<div className="text-[0.6875rem] italic text-muted-foreground px-1 py-2">
|
|
No open questions — send a message or add a note; the AI will follow up.
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Resolve action bar + preview popover (Phase 3) ── */}
|
|
{bottomSlot}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="p-3 border-t border-default shrink-0">
|
|
{/* Progress bar */}
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<div className="flex gap-1 flex-1">
|
|
{tasks.map((t, i) => (
|
|
<div
|
|
key={i}
|
|
className={cn(
|
|
'flex-1 h-[5px] rounded-full transition-colors',
|
|
t.state === 'done' ? 'bg-success' :
|
|
t.state === 'skipped' ? 'bg-muted-foreground/30' :
|
|
t.state === 'active' ? 'bg-accent' :
|
|
'bg-border-default'
|
|
)}
|
|
/>
|
|
))}
|
|
</div>
|
|
<span className="text-[0.625rem] font-medium text-muted-foreground tabular-nums shrink-0">
|
|
{handledCount}/{totalCount}
|
|
</span>
|
|
</div>
|
|
{/* Collapsible preview */}
|
|
{anyHandled && (
|
|
<div className="mb-2">
|
|
<button
|
|
onClick={() => setShowPreview(!showPreview)}
|
|
className="flex items-center gap-1.5 text-xs 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
|
|
ref={sendButtonRef}
|
|
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>
|
|
)
|
|
}
|