feat: cockpit UX polish — conversation log, interactive steps, onboarding

- Redesign conversation log: proper role labels, left-border accents,
  larger text, CSS variable background, min-height guarantee
- Interactive steps panel: click-to-complete, click-to-select, progress
  bar with counter, hover-reveal descriptions, smooth transitions
- Replace noop overflow button with real dropdown menu (Pause, Copy Link,
  Close Case) with keyboard/click-outside dismiss
- Evidence cycling: right-click to reverse-cycle status, tooltips on icons
- First-run onboarding overlay labeling the three cockpit zones, auto-
  dismisses on first message or manual dismiss, persisted via localStorage
- Drag handle: taller, visible hover state, title tooltip
- Simplify input placeholder (remove redundant paste-log hint)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-04-02 00:13:46 +00:00
parent 63023d486d
commit 9462da8b80
4 changed files with 257 additions and 51 deletions

View File

@@ -1,6 +1,7 @@
import { useState, useRef, useEffect } from 'react'
import { Pencil, X, Check, ExternalLink } from 'lucide-react'
import { Pencil, X, Check, ExternalLink, Pause, XCircle, Link2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
import type { TriageMeta } from '@/types/ai-session'
interface IncidentHeaderProps {
@@ -9,7 +10,8 @@ interface IncidentHeaderProps {
sessionId: string
onFieldSave: (field: keyof TriageMeta, value: string) => void
onResolve: () => void
onOverflow: () => void
onPause?: () => void
onClose?: () => void
}
interface EditPopoverProps {
@@ -95,12 +97,81 @@ function HeaderField({ label, value, placeholder, onSave, isHypothesis }: Header
)
}
function OverflowMenu({ onPause, onClose, sessionId }: { onPause?: () => void; onClose?: () => void; sessionId: string }) {
const [open, setOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setOpen(false)
}
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleEsc)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleEsc)
}
}, [open])
const handleCopyLink = () => {
navigator.clipboard.writeText(`${window.location.origin}/assistant/${sessionId}`)
toast.success('Session link copied')
setOpen(false)
}
return (
<div ref={menuRef} className="relative">
<button
onClick={() => setOpen(!open)}
className="bg-elevated border border-default rounded px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
</button>
{open && (
<div className="absolute right-0 top-full mt-1 z-50 w-44 bg-elevated border border-hover rounded-md shadow-lg py-1">
{onPause && (
<button
onClick={() => { onPause(); setOpen(false) }}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-card transition-colors text-left"
>
<Pause size={12} />
Pause Case
</button>
)}
<button
onClick={handleCopyLink}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-card transition-colors text-left"
>
<Link2 size={12} />
Copy Link
</button>
{onClose && (
<button
onClick={() => { onClose(); setOpen(false) }}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-danger hover:text-danger hover:bg-danger-dim transition-colors text-left"
>
<XCircle size={12} />
Close Case
</button>
)}
</div>
)}
</div>
)
}
export function IncidentHeader({
triageMeta,
psaTicketId,
sessionId,
onFieldSave,
onResolve,
onOverflow,
onPause,
onClose,
}: IncidentHeaderProps) {
return (
<div className="bg-card border-b border-default px-4 py-2 flex items-center gap-4 flex-wrap">
@@ -146,12 +217,7 @@ export function IncidentHeader({
>
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>
<OverflowMenu onPause={onPause} onClose={onClose} sessionId={sessionId} />
</div>
</div>
)

View File

@@ -1,14 +1,24 @@
import { Check, ArrowRight, Circle, Terminal } from 'lucide-react'
import { Check, ArrowRight, Circle, Terminal, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { ActionItem } from '@/types/ai-session'
interface StepsPanelProps {
actions: ActionItem[]
activeIndex: number
completedSteps?: Set<number>
onStepComplete?: (index: number) => void
onStepSelect?: (index: number) => void
onGenerateScript?: () => void
}
export function StepsPanel({ actions, activeIndex, onGenerateScript }: StepsPanelProps) {
export function StepsPanel({
actions,
activeIndex,
completedSteps = new Set(),
onStepComplete,
onStepSelect,
onGenerateScript,
}: StepsPanelProps) {
if (actions.length === 0) {
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
@@ -17,45 +27,100 @@ export function StepsPanel({ actions, activeIndex, onGenerateScript }: StepsPane
)
}
const completedCount = completedSteps.size
const totalCount = actions.length
const progressPct = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0
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
{/* Header with progress */}
<div className="flex items-center justify-between mb-2">
<div className="text-[10px] uppercase tracking-wider text-accent font-semibold">
Steps
</div>
<span className="text-[10px] text-muted-foreground tabular-nums">
{completedCount}/{totalCount} complete
</span>
</div>
{/* Progress bar */}
<div className="h-1 bg-elevated rounded-full mb-3 overflow-hidden">
<div
className="h-full bg-accent rounded-full transition-all duration-500 ease-out"
style={{ width: `${progressPct}%` }}
/>
</div>
{/* Steps list */}
<div className="flex-1 overflow-y-auto space-y-1.5 min-h-0">
{actions.map((action, idx) => {
const isCompleted = idx < activeIndex
const isCompleted = completedSteps.has(idx)
const isActive = idx === activeIndex
const isPending = idx > activeIndex
const isPending = !isCompleted && !isActive
return (
<div
key={idx}
onClick={() => onStepSelect?.(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',
'rounded-md px-3 py-2 text-sm flex items-start gap-2.5 transition-all duration-200 cursor-pointer group',
isCompleted && 'bg-success/[0.06] text-muted-foreground hover:bg-success/[0.1]',
isActive && 'bg-elevated border border-accent/30 text-primary',
isPending && 'bg-card text-muted-foreground/60',
isPending && 'bg-card text-muted-foreground/60 hover:bg-elevated/50',
)}
>
<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>
{/* Status icon / complete button */}
<button
onClick={(e) => {
e.stopPropagation()
if (!isCompleted) onStepComplete?.(idx)
}}
className={cn(
'mt-0.5 flex-shrink-0 rounded-full transition-all duration-200',
isCompleted && 'text-success',
isActive && 'text-accent hover:text-success hover:scale-110',
isPending && 'text-muted hover:text-muted-foreground',
)}
title={isCompleted ? 'Completed' : 'Mark as done'}
>
{isCompleted && <Check size={14} />}
{isActive && (
<div className="relative">
<ArrowRight size={14} className="group-hover:hidden" />
<Check size={14} className="hidden group-hover:block" />
</div>
)}
{isPending && <Circle size={14} />}
</button>
<div className="min-w-0 flex-1">
<span className={cn(
'transition-colors duration-200',
isCompleted && 'line-through opacity-70',
)}>{action.label}</span>
{/* Show description for active step and on hover for others */}
{action.description && (
<p className={cn(
'text-xs text-muted-foreground mt-0.5 leading-relaxed transition-all duration-200',
isActive ? 'opacity-100 max-h-20' : 'opacity-0 max-h-0 group-hover:opacity-70 group-hover:max-h-20 overflow-hidden',
)}>
{action.description}
</p>
)}
</div>
{/* Active indicator */}
{isActive && !isCompleted && (
<ChevronRight size={12} className="text-accent/50 flex-shrink-0 mt-1" />
)}
</div>
)
})}
</div>
{showScriptCta && onGenerateScript && (
<button
onClick={onGenerateScript}

View File

@@ -10,9 +10,9 @@ interface WhatWeKnowProps {
}
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: '?' },
confirmed: { icon: Check, color: 'text-success', label: 'Confirmed', tooltip: 'Confirmed — click to cycle, right-click to reverse' },
ruled_out: { icon: X, color: 'text-danger', label: 'Ruled out', tooltip: 'Ruled out — click to cycle, right-click to reverse' },
pending: { icon: HelpCircle, color: 'text-muted-foreground', label: 'Pending', tooltip: 'Pending — click to cycle, right-click to reverse' },
} as const
const STATUS_CYCLE: EvidenceItem['status'][] = ['confirmed', 'ruled_out', 'pending']
@@ -30,10 +30,11 @@ export function WhatWeKnow({ items, onAdd, onEdit }: WhatWeKnowProps) {
setShowAddInput(false)
}
const handleStatusToggle = (idx: number) => {
const handleStatusCycle = (idx: number, reverse = false) => {
const item = items[idx]
const currentIdx = STATUS_CYCLE.indexOf(item.status)
const nextStatus = STATUS_CYCLE[(currentIdx + 1) % STATUS_CYCLE.length]
const offset = reverse ? STATUS_CYCLE.length - 1 : 1
const nextStatus = STATUS_CYCLE[(currentIdx + offset) % STATUS_CYCLE.length]
onEdit(idx, item.text, nextStatus)
}
@@ -65,9 +66,13 @@ export function WhatWeKnow({ items, onAdd, onEdit }: WhatWeKnowProps) {
return (
<div key={idx} className="flex items-start gap-2 text-sm group">
<button
onClick={() => handleStatusToggle(idx)}
onClick={() => handleStatusCycle(idx)}
onContextMenu={(e) => {
e.preventDefault()
handleStatusCycle(idx, true)
}}
className={cn('mt-0.5 flex-shrink-0 transition-colors hover:opacity-70', config.color)}
title="Click to cycle status"
title={config.tooltip}
>
<Icon size={13} />
</button>

View File

@@ -84,7 +84,15 @@ export default function AssistantChatPage() {
const saved = localStorage.getItem('rf-assistant-work-zone-height')
return saved ? parseFloat(saved) : 55
})
const [activeStepIndex] = useState(0)
const [activeStepIndex, setActiveStepIndex] = useState(0)
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set())
const [showOnboarding, setShowOnboarding] = useState(() =>
!localStorage.getItem('rf-cockpit-onboarded')
)
const dismissOnboarding = () => {
setShowOnboarding(false)
localStorage.setItem('rf-cockpit-onboarded', '1')
}
const splitContainerRef = useRef<HTMLDivElement>(null)
const toggleSidebarCollapse = () => {
const next = !sidebarCollapsed
@@ -336,6 +344,7 @@ export default function AssistantChatPage() {
.map((u) => u.result!.id)
setInput('')
setPendingUploads([])
if (showOnboarding) dismissOnboarding()
setMessages(prev => [...prev, { role: 'user', content: userMessage }])
setLoading(true)
@@ -480,7 +489,24 @@ export default function AssistantChatPage() {
}
}, [activeChatId, triageMeta.evidence_items])
const handleStepComplete = useCallback((index: number) => {
setCompletedSteps(prev => {
const next = new Set(prev)
next.add(index)
return next
})
// Auto-advance to the next incomplete step
const nextIncomplete = activeActions.findIndex((_, i) => i > index && !completedSteps.has(i))
if (nextIncomplete !== -1) {
setActiveStepIndex(nextIncomplete)
} else if (index + 1 < activeActions.length) {
setActiveStepIndex(index + 1)
}
}, [activeActions, completedSteps])
const handleStepSelect = useCallback((index: number) => {
setActiveStepIndex(index)
}, [])
// Merge triage_update from AI response into local state
const mergeTriageUpdate = useCallback((update: TriageUpdate) => {
@@ -674,11 +700,43 @@ export default function AssistantChatPage() {
sessionId={activeChatId}
onFieldSave={handleTriageFieldSave}
onResolve={() => setShowConclude(true)}
onOverflow={() => {}}
onClose={() => setShowConclude(true)}
/>
{/* Resizable work zone + conversation log split */}
<div ref={splitContainerRef} className="flex-1 flex flex-col min-h-0">
<div ref={splitContainerRef} className="flex-1 flex flex-col min-h-0 relative">
{/* First-run onboarding overlay */}
{showOnboarding && messages.length === 0 && (
<div className="absolute inset-0 z-30 pointer-events-none">
{/* Steps zone label */}
<div className="absolute top-3 left-3 pointer-events-auto">
<div className="bg-elevated border border-accent/30 rounded-lg px-3 py-2 shadow-lg max-w-[200px]">
<p className="text-xs font-medium text-accent mb-0.5">Steps Panel</p>
<p className="text-[11px] text-muted-foreground leading-snug">Troubleshooting steps appear here as FlowPilot identifies them. Click to mark done.</p>
</div>
</div>
{/* FlowPilot Asks zone label */}
<div className="absolute top-3 right-3 pointer-events-auto">
<div className="bg-elevated border border-warning/30 rounded-lg px-3 py-2 shadow-lg max-w-[200px]">
<p className="text-xs font-medium text-warning mb-0.5">AI Questions & Evidence</p>
<p className="text-[11px] text-muted-foreground leading-snug">FlowPilot asks clarifying questions here. Evidence you confirm or rule out is tracked below.</p>
</div>
</div>
{/* Conversation log label */}
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 pointer-events-auto">
<div className="bg-elevated border border-default rounded-lg px-3 py-2 shadow-lg max-w-[280px] text-center">
<p className="text-xs font-medium text-muted-foreground mb-0.5">Conversation Log</p>
<p className="text-[11px] text-muted-foreground leading-snug">Full chat history lives here. Drag the handle above to resize.</p>
<button
onClick={dismissOnboarding}
className="mt-2 text-[11px] text-accent hover:text-accent/80 transition-colors pointer-events-auto"
>
Got it, dismiss
</button>
</div>
</div>
</div>
)}
{/* Work zone */}
<div className="flex min-h-0 overflow-hidden" style={{ height: `${workZonePct}%` }}>
{/* Left: Steps panel */}
@@ -686,6 +744,9 @@ export default function AssistantChatPage() {
<StepsPanel
actions={activeActions}
activeIndex={activeStepIndex}
completedSteps={completedSteps}
onStepComplete={handleStepComplete}
onStepSelect={handleStepSelect}
/>
</div>
{/* Right: FlowPilot Asks + What We Know */}
@@ -709,13 +770,14 @@ export default function AssistantChatPage() {
{/* Drag handle */}
<div
onMouseDown={handleDragStart}
className="h-2 flex items-center justify-center cursor-row-resize hover:bg-elevated/50 transition-colors flex-shrink-0 border-y border-default/50"
className="h-4 flex items-center justify-center cursor-row-resize hover:bg-elevated transition-colors flex-shrink-0 border-y border-default/50 group"
title="Drag to resize"
>
<GripHorizontal size={14} className="text-muted" />
<GripHorizontal size={16} className="text-muted group-hover:text-muted-foreground transition-colors" />
</div>
{/* Conversation log */}
<div className="flex-1 min-h-0 overflow-y-auto" style={{ background: '#13151c' }}>
<div className="flex-1 min-h-[180px] overflow-y-auto bg-sidebar">
<div className="px-4 pt-2 pb-1">
<span className="text-[10px] uppercase tracking-wider text-muted font-semibold">Conversation Log</span>
</div>
@@ -727,26 +789,34 @@ export default function AssistantChatPage() {
</p>
</div>
)}
<div className="px-4 pb-2 space-y-1">
<div className="px-4 pb-2 space-y-1.5">
{messages.map((msg, i) => (
<div key={i} className="flex gap-2 text-xs leading-relaxed">
<div
key={i}
className={cn(
'flex gap-3 text-sm leading-relaxed rounded-md px-3 py-2 border-l-2',
msg.role === 'user'
? 'border-l-muted-foreground/30 bg-white/[0.02]'
: 'border-l-accent/40 bg-accent/[0.03]'
)}
>
<span className={cn(
'font-mono flex-shrink-0 mt-px',
msg.role === 'user' ? 'text-muted' : 'text-accent/50'
'text-xs font-medium flex-shrink-0 mt-0.5 min-w-[58px]',
msg.role === 'user' ? 'text-muted-foreground' : 'text-accent/70'
)}>
{msg.role === 'user' ? 'you:' : 'fp:'}
{msg.role === 'user' ? 'You' : 'FlowPilot'}
</span>
<span className={cn(
msg.role === 'user' ? 'text-muted-foreground/70' : 'text-muted-foreground'
msg.role === 'user' ? 'text-muted-foreground/80' : 'text-primary/80'
)}>
{msg.content.length > 300 ? msg.content.slice(0, 300) + '…' : msg.content}
{msg.content}
</span>
</div>
))}
{loading && (
<div className="flex gap-2 text-xs">
<span className="font-mono text-accent/50">fp:</span>
<Loader2 size={12} className="animate-spin text-muted-foreground" />
<div className="flex gap-3 text-sm px-3 py-2 border-l-2 border-l-accent/40 bg-accent/[0.03] rounded-md">
<span className="text-xs font-medium text-accent/70 min-w-[58px]">FlowPilot</span>
<Loader2 size={14} className="animate-spin text-muted-foreground" />
</div>
)}
<div ref={messagesEndRef} />
@@ -786,7 +856,7 @@ export default function AssistantChatPage() {
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={loading ? 'FlowPilot is working...' : 'Describe finding, paste log output, or ask FlowPilot...'}
placeholder={loading ? 'FlowPilot is working...' : 'Describe the issue or ask FlowPilot...'}
disabled={loading}
rows={1}
className="w-full resize-none bg-transparent px-4 pt-3 pb-1 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed"