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:
74
frontend/src/components/assistant/FlowPilotAsks.tsx
Normal file
74
frontend/src/components/assistant/FlowPilotAsks.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
158
frontend/src/components/assistant/IncidentHeader.tsx
Normal file
158
frontend/src/components/assistant/IncidentHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
70
frontend/src/components/assistant/StepsPanel.tsx
Normal file
70
frontend/src/components/assistant/StepsPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
139
frontend/src/components/assistant/WhatWeKnow.tsx
Normal file
139
frontend/src/components/assistant/WhatWeKnow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user