feat: two-phase resolve with streaming ticket notes generation
ConcludeSessionModal now resolves instantly (Phase 1) then streams ticket notes via SSE (Phase 2), with skeleton loading and fallback. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -10,9 +10,11 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
AlertTriangle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||||
|
import { aiSessionsApi } from '@/api/aiSessions'
|
||||||
|
|
||||||
type ConclusionOutcome = 'resolved' | 'escalated' | 'paused'
|
type ConclusionOutcome = 'resolved' | 'escalated' | 'paused'
|
||||||
|
|
||||||
@@ -22,6 +24,7 @@ interface ConcludeSessionModalProps {
|
|||||||
onConclude: (outcome: ConclusionOutcome, notes: string) => Promise<string>
|
onConclude: (outcome: ConclusionOutcome, notes: string) => Promise<string>
|
||||||
onResumeNew: (summary: string) => void
|
onResumeNew: (summary: string) => void
|
||||||
chatTitle: string
|
chatTitle: string
|
||||||
|
sessionId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const OUTCOMES: { value: ConclusionOutcome; label: string; description: string; icon: typeof CheckCircle2; color: string; bg: string; border: string }[] = [
|
const OUTCOMES: { value: ConclusionOutcome; label: string; description: string; icon: typeof CheckCircle2; color: string; bg: string; border: string }[] = [
|
||||||
@@ -62,6 +65,7 @@ export function ConcludeSessionModal({
|
|||||||
onConclude,
|
onConclude,
|
||||||
onResumeNew,
|
onResumeNew,
|
||||||
chatTitle,
|
chatTitle,
|
||||||
|
sessionId,
|
||||||
}: ConcludeSessionModalProps) {
|
}: ConcludeSessionModalProps) {
|
||||||
const [step, setStep] = useState<ModalStep>('select-outcome')
|
const [step, setStep] = useState<ModalStep>('select-outcome')
|
||||||
const [outcome, setOutcome] = useState<ConclusionOutcome | null>(null)
|
const [outcome, setOutcome] = useState<ConclusionOutcome | null>(null)
|
||||||
@@ -70,6 +74,9 @@ export function ConcludeSessionModal({
|
|||||||
const [generating, setGenerating] = useState(false)
|
const [generating, setGenerating] = useState(false)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [streaming, setStreaming] = useState(false)
|
||||||
|
const [streamError, setStreamError] = useState<string | null>(null)
|
||||||
|
const summaryRef = useRef('')
|
||||||
|
|
||||||
// Reset state when modal opens
|
// Reset state when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -81,6 +88,9 @@ export function ConcludeSessionModal({
|
|||||||
setGenerating(false)
|
setGenerating(false)
|
||||||
setCopied(false)
|
setCopied(false)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
setStreaming(false)
|
||||||
|
setStreamError(null)
|
||||||
|
summaryRef.current = ''
|
||||||
}
|
}
|
||||||
}, [isOpen])
|
}, [isOpen])
|
||||||
|
|
||||||
@@ -95,12 +105,50 @@ export function ConcludeSessionModal({
|
|||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await onConclude(outcome, notes)
|
// Phase 1: Resolve/escalate/pause the session (fast)
|
||||||
setSummary(result)
|
await onConclude(outcome, notes)
|
||||||
|
|
||||||
|
// Phase 2: Transition to summary step immediately
|
||||||
setStep('summary')
|
setStep('summary')
|
||||||
|
setGenerating(false)
|
||||||
|
|
||||||
|
// For resolved sessions, stream ticket notes
|
||||||
|
if (outcome === 'resolved' && sessionId) {
|
||||||
|
setStreaming(true)
|
||||||
|
setStreamError(null)
|
||||||
|
summaryRef.current = ''
|
||||||
|
|
||||||
|
aiSessionsApi.streamDocumentation(
|
||||||
|
sessionId,
|
||||||
|
(chunk) => {
|
||||||
|
summaryRef.current += chunk
|
||||||
|
setSummary(summaryRef.current)
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
setStreaming(false)
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
setStreaming(false)
|
||||||
|
setStreamError(err)
|
||||||
|
// Try non-streaming fallback
|
||||||
|
aiSessionsApi.getDocumentation(sessionId).then((doc) => {
|
||||||
|
const fallback = `## Problem Summary\n${doc.problem_summary}\n\n## Steps Taken\n${doc.diagnostic_steps.map(s => `- ${s.description}`).join('\n')}\n\n## Resolution\n${doc.resolution_summary || 'See conversation'}\n\n## Next Steps\nNone`
|
||||||
|
setSummary(fallback)
|
||||||
|
setStreamError(null)
|
||||||
|
}).catch(() => {
|
||||||
|
if (!summaryRef.current) {
|
||||||
|
setSummary('Documentation generation failed. You can copy the conversation from the chat.')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else if (outcome === 'escalated') {
|
||||||
|
setSummary('Session escalated. Ticket notes will be generated when the session is resolved.')
|
||||||
|
} else {
|
||||||
|
setSummary('Session paused. Progress saved — you can resume anytime.')
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to generate summary. Please try again.')
|
setError('Failed to conclude session. Please try again.')
|
||||||
} finally {
|
|
||||||
setGenerating(false)
|
setGenerating(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,7 +354,7 @@ export function ConcludeSessionModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Generated summary */}
|
{/* Generated ticket notes */}
|
||||||
<div
|
<div
|
||||||
className="rounded-xl border p-5 bg-card"
|
className="rounded-xl border p-5 bg-card"
|
||||||
style={{ borderColor: 'var(--color-border-default)' }}
|
style={{ borderColor: 'var(--color-border-default)' }}
|
||||||
@@ -314,12 +362,33 @@ export function ConcludeSessionModal({
|
|||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-widest text-muted-foreground flex items-center gap-1.5">
|
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-widest text-muted-foreground flex items-center gap-1.5">
|
||||||
<Sparkles size={10} className="text-primary" />
|
<Sparkles size={10} className="text-primary" />
|
||||||
Generated Ticket Notes
|
Ticket Notes
|
||||||
</span>
|
</span>
|
||||||
|
{streaming && (
|
||||||
|
<Loader2 size={14} className="animate-spin text-primary" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="prose-sm text-foreground">
|
|
||||||
<MarkdownContent content={summary} className="text-[0.8125rem] leading-relaxed" />
|
{/* Streaming content or skeleton */}
|
||||||
</div>
|
{summary ? (
|
||||||
|
<div className="prose-sm text-foreground">
|
||||||
|
<MarkdownContent content={summary} className="text-[0.8125rem] leading-relaxed" />
|
||||||
|
</div>
|
||||||
|
) : streaming ? (
|
||||||
|
<div className="space-y-3 animate-pulse">
|
||||||
|
<div className="h-4 bg-elevated rounded w-1/3" />
|
||||||
|
<div className="h-3 bg-elevated rounded w-full" />
|
||||||
|
<div className="h-3 bg-elevated rounded w-5/6" />
|
||||||
|
<div className="h-4 bg-elevated rounded w-1/4 mt-4" />
|
||||||
|
<div className="h-3 bg-elevated rounded w-full" />
|
||||||
|
<div className="h-3 bg-elevated rounded w-4/5" />
|
||||||
|
</div>
|
||||||
|
) : streamError ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-amber-400">
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
{streamError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -384,27 +453,29 @@ export function ConcludeSessionModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
{summary && !streaming && (
|
||||||
onClick={handleCopy}
|
<button
|
||||||
className={cn(
|
onClick={handleCopy}
|
||||||
'flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-all',
|
className={cn(
|
||||||
copied
|
'flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-all',
|
||||||
? 'bg-emerald-400/15 text-emerald-400 border border-emerald-400/30'
|
copied
|
||||||
: 'bg-primary text-white hover:brightness-110 active:scale-[0.98]'
|
? 'bg-emerald-400/15 text-emerald-400 border border-emerald-400/30'
|
||||||
)}
|
: 'bg-primary text-white hover:brightness-110 active:scale-[0.98]'
|
||||||
>
|
)}
|
||||||
{copied ? (
|
>
|
||||||
<>
|
{copied ? (
|
||||||
<Check size={15} />
|
<>
|
||||||
Copied!
|
<Check size={15} />
|
||||||
</>
|
Copied!
|
||||||
) : (
|
</>
|
||||||
<>
|
) : (
|
||||||
<Copy size={15} />
|
<>
|
||||||
Copy to Clipboard
|
<Copy size={15} />
|
||||||
</>
|
Copy to Clipboard
|
||||||
)}
|
</>
|
||||||
</button>
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2.5 rounded-lg text-sm text-muted-foreground hover:text-foreground bg-input border border-border hover:border-border-hover transition-all"
|
className="px-4 py-2.5 rounded-lg text-sm text-muted-foreground hover:text-foreground bg-input border border-border hover:border-border-hover transition-all"
|
||||||
|
|||||||
@@ -396,21 +396,19 @@ export default function AssistantChatPage() {
|
|||||||
const handleConclude = async (outcome: ConclusionOutcome, _notes: string): Promise<string> => {
|
const handleConclude = async (outcome: ConclusionOutcome, _notes: string): Promise<string> => {
|
||||||
if (!activeChatId) throw new Error('No active chat')
|
if (!activeChatId) throw new Error('No active chat')
|
||||||
|
|
||||||
// Map conclusion outcomes to ai_sessions actions
|
|
||||||
if (outcome === 'resolved') {
|
if (outcome === 'resolved') {
|
||||||
const result = await aiSessionsApi.resolveSession(activeChatId, {
|
await aiSessionsApi.resolveSession(activeChatId, {
|
||||||
resolution_summary: _notes || 'Resolved via assistant chat',
|
resolution_summary: _notes || 'Resolved via assistant chat',
|
||||||
})
|
})
|
||||||
return result.documentation?.problem_summary || 'Session resolved'
|
return activeChatId
|
||||||
} else if (outcome === 'escalated') {
|
} else if (outcome === 'escalated') {
|
||||||
const result = await aiSessionsApi.escalateSession(activeChatId, {
|
await aiSessionsApi.escalateSession(activeChatId, {
|
||||||
escalation_reason: _notes || 'Escalated from assistant chat',
|
escalation_reason: _notes || 'Escalated from assistant chat',
|
||||||
})
|
})
|
||||||
return result.documentation?.problem_summary || 'Session escalated'
|
return activeChatId
|
||||||
} else {
|
} else {
|
||||||
// paused
|
|
||||||
await aiSessionsApi.pauseSession(activeChatId)
|
await aiSessionsApi.pauseSession(activeChatId)
|
||||||
return 'Session paused'
|
return activeChatId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -830,6 +828,7 @@ export default function AssistantChatPage() {
|
|||||||
onConclude={handleConclude}
|
onConclude={handleConclude}
|
||||||
onResumeNew={handleResumeNew}
|
onResumeNew={handleResumeNew}
|
||||||
chatTitle={chats.find(c => c.id === activeChatId)?.title ?? 'Chat'}
|
chatTitle={chats.find(c => c.id === activeChatId)?.title ?? 'Chat'}
|
||||||
|
sessionId={activeChatId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user