Phase 3 implementation: - AI session analysis service that generates flow proposals from resolved sessions - APScheduler job for batch processing pending analyses (max_instances=1) - Knowledge gap detection (weak options, high escalation signals) - Flow proposals CRUD with team admin review workflow (approve/edit/dismiss/reject) - FlowPilot analytics dashboard with confidence tiers, PSA metrics, knowledge gaps - In-session script generator component - Review queue page with filtering and proposal detail panel Bug fixes from review (12 total): - Fix "Edit & Publish" navigating to non-existent /editor/new route - Hide Approve button for enhancement proposals (require Edit & Publish) - Add max_instances=1 to scheduler to prevent TOCTOU race - Fix eventual_success case() double-counting failed retries - Add tree_structure validation before creating tree from proposal - Simplify script generator rendering condition - Add severity style fallback, toFixed on rates, Link instead of <a href> - Add toast.warning on dismiss failure, fix dedup for domain-less sessions - Cast Decimal to int in knowledge gap evidence dicts Also updates CLAUDE.md with lessons 67-71 and Phase 3 project structure. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
260 lines
9.8 KiB
TypeScript
260 lines
9.8 KiB
TypeScript
import { useState } from 'react'
|
|
import { MessageSquare, Zap, CheckCircle2, SkipForward, ChevronDown, ChevronUp } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import type { AISessionStepResponse, StepResponseRequest } from '@/types/ai-session'
|
|
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
|
import { FlowPilotOptions } from './FlowPilotOptions'
|
|
import { InSessionScriptGenerator } from './InSessionScriptGenerator'
|
|
|
|
interface FlowPilotStepCardProps {
|
|
step: AISessionStepResponse
|
|
isCurrentStep: boolean
|
|
isProcessing: boolean
|
|
sessionId?: string
|
|
onRespond: (response: StepResponseRequest) => void
|
|
}
|
|
|
|
const STEP_TYPE_ICONS = {
|
|
question: MessageSquare,
|
|
action: Zap,
|
|
intake_analysis: MessageSquare,
|
|
verification: CheckCircle2,
|
|
info_request: MessageSquare,
|
|
script_generation: Zap,
|
|
note: MessageSquare,
|
|
} as const
|
|
|
|
export function FlowPilotStepCard({ step, isCurrentStep, isProcessing, sessionId, onRespond }: FlowPilotStepCardProps) {
|
|
const [freeText, setFreeText] = useState('')
|
|
const [showFreeText, setShowFreeText] = useState(false)
|
|
const [isCollapsed, setIsCollapsed] = useState(!isCurrentStep)
|
|
|
|
const content = step.content as Record<string, unknown>
|
|
const stepText = (content.text as string) || ''
|
|
const contentType = (content.type as string) || step.step_type
|
|
const isResolutionSuggestion = contentType === 'resolution_suggestion'
|
|
const Icon = STEP_TYPE_ICONS[step.step_type as keyof typeof STEP_TYPE_ICONS] ?? MessageSquare
|
|
|
|
const handleOptionSelect = (value: string) => {
|
|
onRespond({ selected_option: value })
|
|
}
|
|
|
|
const handleFreeTextSubmit = () => {
|
|
if (!freeText.trim()) return
|
|
onRespond({ free_text_input: freeText.trim() })
|
|
setFreeText('')
|
|
setShowFreeText(false)
|
|
}
|
|
|
|
const handleSkip = () => {
|
|
onRespond({ was_skipped: true })
|
|
}
|
|
|
|
const handleActionComplete = (success: boolean) => {
|
|
onRespond({
|
|
action_result: { success, details: '' },
|
|
})
|
|
}
|
|
|
|
const handleResolutionResponse = (accepted: boolean) => {
|
|
if (accepted) {
|
|
// Parent will handle opening the resolve modal
|
|
onRespond({ selected_option: 'resolution_accepted' })
|
|
} else {
|
|
onRespond({ free_text_input: 'No, keep investigating. The issue is not resolved.' })
|
|
}
|
|
}
|
|
|
|
// Completed step (collapsed view)
|
|
if (!isCurrentStep && isCollapsed) {
|
|
return (
|
|
<button
|
|
onClick={() => setIsCollapsed(false)}
|
|
className="w-full text-left glass-card-static p-4 opacity-70 hover:opacity-90 transition-opacity"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<Icon size={16} className="shrink-0 text-muted-foreground" />
|
|
<p className="text-sm text-foreground truncate flex-1">{stepText}</p>
|
|
<ChevronDown size={14} className="shrink-0 text-muted-foreground" />
|
|
</div>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
// Expanded completed step
|
|
if (!isCurrentStep && !isCollapsed) {
|
|
return (
|
|
<div className="glass-card-static p-4 opacity-80">
|
|
<button
|
|
onClick={() => setIsCollapsed(true)}
|
|
className="mb-2 flex w-full items-center justify-between text-left"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Icon size={16} className="text-muted-foreground" />
|
|
<span className="font-label text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
|
Step {step.step_order + 1}
|
|
</span>
|
|
</div>
|
|
<ChevronUp size={14} className="text-muted-foreground" />
|
|
</button>
|
|
<MarkdownContent content={stepText} className="text-sm text-foreground" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Current active step
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'glass-card-static p-5',
|
|
isResolutionSuggestion && 'border-emerald-500/30'
|
|
)}
|
|
>
|
|
{/* Context message */}
|
|
{step.context_message && (
|
|
<div className="mb-3 rounded-lg bg-primary/5 px-3 py-2 border border-primary/10">
|
|
<p className="text-xs text-muted-foreground">{step.context_message}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Step content */}
|
|
<div className="flex items-start gap-3 mb-4">
|
|
<span className={cn(
|
|
'mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg',
|
|
isResolutionSuggestion ? 'bg-emerald-500/10 text-emerald-400' : 'bg-primary/10 text-primary'
|
|
)}>
|
|
<Icon size={14} />
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<MarkdownContent content={stepText} className="text-sm" />
|
|
{isResolutionSuggestion && typeof content.resolution_summary === 'string' && (
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
{content.resolution_summary}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Interactive area */}
|
|
{isCurrentStep && !isProcessing && (
|
|
<div className="space-y-3">
|
|
{/* Resolution suggestion buttons */}
|
|
{isResolutionSuggestion && (
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => handleResolutionResponse(true)}
|
|
className="flex-1 rounded-lg bg-emerald-500/10 border border-emerald-500/20 px-4 py-2.5 text-sm font-medium text-emerald-400 hover:bg-emerald-500/20 transition-colors"
|
|
>
|
|
Yes, this is resolved
|
|
</button>
|
|
<button
|
|
onClick={() => handleResolutionResponse(false)}
|
|
className="flex-1 rounded-lg bg-card/50 border border-border px-4 py-2.5 text-sm font-medium text-foreground hover:bg-card transition-colors"
|
|
>
|
|
No, keep investigating
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Options for question steps */}
|
|
{!isResolutionSuggestion && step.options.length > 0 && (
|
|
<FlowPilotOptions
|
|
options={step.options}
|
|
onSelect={handleOptionSelect}
|
|
disabled={isProcessing}
|
|
/>
|
|
)}
|
|
|
|
{/* In-session script generator */}
|
|
{!isResolutionSuggestion && (content.action_type as string) === 'script_generation' && sessionId && (
|
|
<InSessionScriptGenerator
|
|
templateId={(content.template_id as string) || ''}
|
|
preFilledParams={(content.pre_filled_params as Record<string, string>) || {}}
|
|
instructions={(content.instructions as string) || stepText}
|
|
sessionId={sessionId}
|
|
onRespond={onRespond}
|
|
/>
|
|
)}
|
|
|
|
{/* Action step buttons */}
|
|
{!isResolutionSuggestion && step.step_type === 'action' && (content.action_type as string) !== 'script_generation' && (
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => handleActionComplete(true)}
|
|
className="flex-1 rounded-lg bg-primary/10 border border-primary/20 px-4 py-2.5 text-sm font-medium text-primary hover:bg-primary/20 transition-colors"
|
|
>
|
|
I've completed this action
|
|
</button>
|
|
<button
|
|
onClick={() => handleActionComplete(false)}
|
|
className="flex-1 rounded-lg bg-card/50 border border-border px-4 py-2.5 text-sm font-medium text-foreground hover:bg-card transition-colors"
|
|
>
|
|
This didn't work
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Free text escape hatch */}
|
|
{!isResolutionSuggestion && step.allow_free_text && (
|
|
<>
|
|
{!showFreeText ? (
|
|
<button
|
|
onClick={() => setShowFreeText(true)}
|
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
None of these — let me describe
|
|
</button>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<textarea
|
|
value={freeText}
|
|
onChange={(e) => setFreeText(e.target.value)}
|
|
placeholder="Describe what you're seeing..."
|
|
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none"
|
|
rows={3}
|
|
autoFocus
|
|
/>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={handleFreeTextSubmit}
|
|
disabled={!freeText.trim()}
|
|
className="rounded-lg bg-gradient-brand px-4 py-1.5 text-sm font-semibold text-[#101114] hover:opacity-90 disabled:opacity-50 transition-opacity"
|
|
>
|
|
Submit
|
|
</button>
|
|
<button
|
|
onClick={() => { setShowFreeText(false); setFreeText('') }}
|
|
className="rounded-lg px-4 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Skip option */}
|
|
{!isResolutionSuggestion && step.allow_skip && (
|
|
<button
|
|
onClick={handleSkip}
|
|
className="flex items-center gap-1.5 text-xs text-[#5a6170] hover:text-muted-foreground transition-colors"
|
|
>
|
|
<SkipForward size={12} />
|
|
I can't check this right now
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Processing indicator */}
|
|
{isProcessing && (
|
|
<div className="flex items-center gap-2 pt-2">
|
|
<div className="h-1.5 w-1.5 rounded-full bg-primary animate-pulse" />
|
|
<span className="text-xs text-muted-foreground">FlowPilot is thinking...</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|