feat: add cockpit work zone components (Phase 4)

- IncidentHeader: labelled fields with per-field edit popovers
- StepsPanel: ordered step checklist (✓/→/○) with script CTA
- FlowPilotAsks: quick-reply buttons or free-text input
- WhatWeKnow: evidence list with status toggle and inline editing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-04-01 22:43:15 +00:00
parent 036198b224
commit 23a7cee1f5
4 changed files with 441 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
import { useState } from 'react'
import { Send, HelpCircle } from 'lucide-react'
import type { QuestionItem } from '@/types/ai-session'
interface FlowPilotAsksProps {
questions: QuestionItem[]
onAnswer: (answer: string) => void
loading?: boolean
}
export function FlowPilotAsks({ questions, onAnswer, loading }: FlowPilotAsksProps) {
const [freeText, setFreeText] = useState('')
// Show first unanswered question
const question = questions.length > 0 ? questions[0] : null
if (!question) return null
const handleFreeTextSubmit = () => {
if (!freeText.trim()) return
onAnswer(freeText.trim())
setFreeText('')
}
return (
<div className="bg-card border border-default rounded-lg p-3">
<div className="text-[10px] uppercase tracking-wider text-warning font-semibold mb-2 flex items-center gap-1.5">
<HelpCircle size={11} />
FlowPilot Asks
</div>
<p className="text-sm text-primary leading-relaxed mb-2.5">
{question.text}
</p>
{question.context && (
<p className="text-xs text-muted-foreground mb-2.5 leading-relaxed">
{question.context}
</p>
)}
{question.options ? (
<div className="flex flex-wrap gap-1.5">
{question.options.map((option, idx) => (
<button
key={idx}
onClick={() => onAnswer(option)}
disabled={loading}
className="bg-elevated border border-hover rounded px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors disabled:opacity-50"
>
{option}
</button>
))}
</div>
) : (
<div className="flex gap-1.5">
<input
type="text"
value={freeText}
onChange={e => setFreeText(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleFreeTextSubmit()}
placeholder="Type your answer..."
disabled={loading}
className="flex-1 bg-input border border-default rounded px-2.5 py-1.5 text-sm text-primary outline-none focus:border-accent placeholder:text-muted-foreground/50"
/>
<button
onClick={handleFreeTextSubmit}
disabled={!freeText.trim() || loading}
className="bg-accent/15 border border-accent rounded px-2.5 py-1.5 text-accent hover:bg-accent/25 transition-colors disabled:opacity-50"
>
<Send size={12} />
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,158 @@
import { useState, useRef, useEffect } from 'react'
import { Pencil, X, Check, ExternalLink } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { TriageMeta } from '@/types/ai-session'
interface IncidentHeaderProps {
triageMeta: TriageMeta
psaTicketId: string | null
sessionId: string
onFieldSave: (field: keyof TriageMeta, value: string) => void
onResolve: () => void
onOverflow: () => void
}
interface EditPopoverProps {
value: string
onSave: (value: string) => void
onCancel: () => void
}
function EditPopover({ value, onSave, onCancel }: EditPopoverProps) {
const [editValue, setEditValue] = useState(value)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
inputRef.current?.focus()
inputRef.current?.select()
}, [])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') onSave(editValue)
if (e.key === 'Escape') onCancel()
}
return (
<div className="absolute top-full left-0 mt-1 z-50 bg-elevated border border-hover rounded-md p-2 shadow-lg flex gap-1.5 items-center min-w-[200px]">
<input
ref={inputRef}
type="text"
value={editValue}
onChange={e => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1 bg-input border border-default rounded px-2 py-1 text-sm text-primary outline-none focus:border-accent"
/>
<button onClick={() => onSave(editValue)} className="p-1 text-success hover:text-success/80">
<Check size={14} />
</button>
<button onClick={onCancel} className="p-1 text-muted-foreground hover:text-foreground">
<X size={14} />
</button>
</div>
)
}
interface HeaderFieldProps {
label: string
value: string | null
placeholder: string
onSave: (value: string) => void
isHypothesis?: boolean
}
function HeaderField({ label, value, placeholder, onSave, isHypothesis }: HeaderFieldProps) {
const [editing, setEditing] = useState(false)
return (
<div className="relative group flex flex-col gap-0.5 min-w-0">
<span className="text-[10px] uppercase tracking-wider text-muted font-semibold leading-none">
{label}
</span>
<div className="flex items-center gap-1 min-w-0">
<span
className={cn(
'text-sm truncate',
value ? (isHypothesis ? 'text-warning' : 'text-primary') : 'text-muted-foreground italic',
)}
>
{value || placeholder}
</span>
<button
onClick={() => setEditing(true)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5 text-muted-foreground hover:text-foreground flex-shrink-0"
>
<Pencil size={11} />
</button>
</div>
{editing && (
<EditPopover
value={value || ''}
onSave={(v) => { onSave(v); setEditing(false) }}
onCancel={() => setEditing(false)}
/>
)}
</div>
)
}
export function IncidentHeader({
triageMeta,
psaTicketId,
onFieldSave,
onResolve,
onOverflow,
}: IncidentHeaderProps) {
return (
<div className="bg-card border-b border-default px-4 py-2 flex items-center gap-4 flex-wrap">
<HeaderField
label="Client"
value={triageMeta.client_name}
placeholder="Unknown client"
onSave={v => onFieldSave('client_name', v)}
/>
<div className="w-px h-7 bg-elevated flex-shrink-0 hidden sm:block" />
<HeaderField
label="Device"
value={triageMeta.asset_name}
placeholder="No device"
onSave={v => onFieldSave('asset_name', v)}
/>
<div className="w-px h-7 bg-elevated flex-shrink-0 hidden sm:block" />
<HeaderField
label="Category"
value={triageMeta.issue_category}
placeholder="Uncategorized"
onSave={v => onFieldSave('issue_category', v)}
/>
<div className="w-px h-7 bg-elevated flex-shrink-0 hidden sm:block" />
<HeaderField
label="Hypothesis"
value={triageMeta.triage_hypothesis}
placeholder="No hypothesis yet"
onSave={v => onFieldSave('triage_hypothesis', v)}
isHypothesis
/>
<div className="flex items-center gap-2 ml-auto flex-shrink-0">
{psaTicketId && (
<span className="bg-elevated border border-default rounded px-2 py-0.5 text-xs text-muted-foreground flex items-center gap-1">
<ExternalLink size={10} />
CW #{psaTicketId}
</span>
)}
<button
onClick={onResolve}
className="bg-accent/15 border border-accent rounded px-3 py-1 text-xs font-medium text-accent hover:bg-accent/25 transition-colors"
>
Resolve
</button>
<button
onClick={onOverflow}
className="bg-elevated border border-default rounded px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,70 @@
import { Check, ArrowRight, Circle, Terminal } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { ActionItem } from '@/types/ai-session'
interface StepsPanelProps {
actions: ActionItem[]
activeIndex: number
onGenerateScript?: () => void
}
export function StepsPanel({ actions, activeIndex, onGenerateScript }: StepsPanelProps) {
if (actions.length === 0) {
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
No steps yet start troubleshooting
</div>
)
}
const showScriptCta = actions[activeIndex]?.command?.toLowerCase().includes('script') ||
actions[activeIndex]?.description?.toLowerCase().includes('script')
return (
<div className="flex flex-col h-full">
<div className="text-[10px] uppercase tracking-wider text-accent font-semibold mb-2">
Steps
</div>
<div className="flex-1 overflow-y-auto space-y-1.5 min-h-0">
{actions.map((action, idx) => {
const isCompleted = idx < activeIndex
const isActive = idx === activeIndex
const isPending = idx > activeIndex
return (
<div
key={idx}
className={cn(
'rounded px-3 py-2 text-sm flex items-start gap-2.5 transition-colors',
isCompleted && 'bg-elevated/50 text-muted-foreground',
isActive && 'bg-elevated border border-accent/30 text-primary',
isPending && 'bg-card text-muted-foreground/60',
)}
>
<span className="mt-0.5 flex-shrink-0">
{isCompleted && <Check size={14} className="text-success" />}
{isActive && <ArrowRight size={14} className="text-accent" />}
{isPending && <Circle size={14} className="text-muted" />}
</span>
<div className="min-w-0">
<span className={cn(isCompleted && 'line-through')}>{action.label}</span>
{isActive && action.description && (
<p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">{action.description}</p>
)}
</div>
</div>
)
})}
</div>
{showScriptCta && onGenerateScript && (
<button
onClick={onGenerateScript}
className="mt-2 flex items-center justify-center gap-1.5 bg-accent/15 border border-accent rounded px-3 py-1.5 text-xs font-medium text-accent hover:bg-accent/25 transition-colors"
>
<Terminal size={12} />
Generate Script
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,139 @@
import { useState } from 'react'
import { Check, X, HelpCircle, Plus } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { EvidenceItem } from '@/types/ai-session'
interface WhatWeKnowProps {
items: EvidenceItem[]
onAdd: (text: string, status: EvidenceItem['status']) => void
onEdit: (index: number, text: string, status: EvidenceItem['status']) => void
}
const STATUS_CONFIG = {
confirmed: { icon: Check, color: 'text-success', label: '✓' },
ruled_out: { icon: X, color: 'text-danger', label: '✗' },
pending: { icon: HelpCircle, color: 'text-muted-foreground', label: '?' },
} as const
const STATUS_CYCLE: EvidenceItem['status'][] = ['confirmed', 'ruled_out', 'pending']
export function WhatWeKnow({ items, onAdd, onEdit }: WhatWeKnowProps) {
const [addingText, setAddingText] = useState('')
const [showAddInput, setShowAddInput] = useState(false)
const [editingIdx, setEditingIdx] = useState<number | null>(null)
const [editText, setEditText] = useState('')
const handleAdd = () => {
if (!addingText.trim()) return
onAdd(addingText.trim(), 'pending')
setAddingText('')
setShowAddInput(false)
}
const handleStatusToggle = (idx: number) => {
const item = items[idx]
const currentIdx = STATUS_CYCLE.indexOf(item.status)
const nextStatus = STATUS_CYCLE[(currentIdx + 1) % STATUS_CYCLE.length]
onEdit(idx, item.text, nextStatus)
}
const handleEditStart = (idx: number) => {
setEditingIdx(idx)
setEditText(items[idx].text)
}
const handleEditSave = (idx: number) => {
if (editText.trim()) {
onEdit(idx, editText.trim(), items[idx].status)
}
setEditingIdx(null)
}
return (
<div className="bg-card border border-default rounded-lg p-3">
<div className="text-[10px] uppercase tracking-wider text-muted font-semibold mb-2">
What We Know
</div>
{items.length === 0 ? (
<p className="text-xs text-muted-foreground italic">No evidence collected yet</p>
) : (
<div className="space-y-1">
{items.map((item, idx) => {
const config = STATUS_CONFIG[item.status]
const Icon = config.icon
return (
<div key={idx} className="flex items-start gap-2 text-sm group">
<button
onClick={() => handleStatusToggle(idx)}
className={cn('mt-0.5 flex-shrink-0 transition-colors hover:opacity-70', config.color)}
title="Click to cycle status"
>
<Icon size={13} />
</button>
{editingIdx === idx ? (
<input
type="text"
value={editText}
onChange={e => setEditText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleEditSave(idx); if (e.key === 'Escape') setEditingIdx(null) }}
onBlur={() => handleEditSave(idx)}
autoFocus
className="flex-1 bg-input border border-default rounded px-1.5 py-0.5 text-xs text-primary outline-none focus:border-accent"
/>
) : (
<span
onClick={() => handleEditStart(idx)}
className={cn(
'text-xs leading-relaxed cursor-pointer hover:text-foreground transition-colors',
item.status === 'confirmed' && 'text-success/80',
item.status === 'ruled_out' && 'text-danger/80',
item.status === 'pending' && 'text-muted-foreground',
)}
>
{item.text}
</span>
)}
</div>
)
})}
</div>
)}
{showAddInput ? (
<div className="flex gap-1.5 mt-2">
<input
type="text"
value={addingText}
onChange={e => setAddingText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAdd(); if (e.key === 'Escape') setShowAddInput(false) }}
placeholder="New finding..."
autoFocus
className="flex-1 bg-input border border-default rounded px-2 py-1 text-xs text-primary outline-none focus:border-accent placeholder:text-muted-foreground/50"
/>
<button
onClick={handleAdd}
disabled={!addingText.trim()}
className="text-accent hover:text-accent/80 disabled:opacity-50 p-1"
>
<Check size={14} />
</button>
<button
onClick={() => { setShowAddInput(false); setAddingText('') }}
className="text-muted-foreground hover:text-foreground p-1"
>
<X size={14} />
</button>
</div>
) : (
<button
onClick={() => setShowAddInput(true)}
className="mt-2 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Plus size={12} />
Add finding
</button>
)}
</div>
)
}