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>