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:
chihlasm
2026-03-28 23:08:25 +00:00
parent bcab8158ab
commit 5e04aad16f
2 changed files with 108 additions and 38 deletions

View File

@@ -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"

View File

@@ -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>
</> </>