feat: add status update generation to assistant chat

Wire StatusUpdateModal into AssistantChatPage with "Update" button in
the chat toolbar. Enhance ConcludeSessionModal pause/escalate outcomes
to offer ticket notes, client update, or email draft generation instead
of static messages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-03-29 06:03:34 +00:00
parent 0d89597fc0
commit 2c8aca3951
2 changed files with 165 additions and 41 deletions

View File

@@ -11,6 +11,9 @@ import {
ClipboardList,
Sparkles,
AlertTriangle,
FileText,
User,
Mail,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
@@ -76,6 +79,7 @@ export function ConcludeSessionModal({
const [error, setError] = useState<string | null>(null)
const [streaming, setStreaming] = useState(false)
const [streamError, setStreamError] = useState<string | null>(null)
const [generatingUpdate, setGeneratingUpdate] = useState(false)
const summaryRef = useRef('')
// Reset state when modal opens
@@ -90,6 +94,7 @@ export function ConcludeSessionModal({
setError(null)
setStreaming(false)
setStreamError(null)
setGeneratingUpdate(false)
summaryRef.current = ''
}
}, [isOpen])
@@ -142,10 +147,9 @@ export function ConcludeSessionModal({
})
},
)
} 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.')
// For paused/escalated: don't set summary yet — show status update options
setSummary('')
}
} catch {
setError('Failed to conclude session. Please try again.')
@@ -176,6 +180,25 @@ export function ConcludeSessionModal({
onClose()
}
const handleGenerateStatusUpdate = async (audience: 'ticket_notes' | 'client_update' | 'email_draft') => {
if (!sessionId) return
setGeneratingUpdate(true)
try {
const context = outcome === 'escalated' ? 'escalation' : 'status'
const result = await aiSessionsApi.generateStatusUpdate(sessionId, {
audience,
length: 'detailed',
context,
})
setSummary(result.content)
setCopied(false)
} catch {
setSummary('Failed to generate status update. You can copy the conversation from the chat.')
} finally {
setGeneratingUpdate(false)
}
}
if (!isOpen) return null
const selectedOutcome = OUTCOMES.find(o => o.value === outcome)
@@ -354,42 +377,115 @@ export function ConcludeSessionModal({
</div>
)}
{/* Generated ticket notes */}
<div
className="rounded-xl border p-5 bg-card"
style={{ borderColor: 'var(--color-border-default)' }}
>
<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">
<Sparkles size={10} className="text-primary" />
Ticket Notes
</span>
{streaming && (
<Loader2 size={14} className="animate-spin text-primary" />
)}
</div>
{/* Resolved: streamed ticket notes */}
{outcome === 'resolved' && (
<div
className="rounded-xl border p-5 bg-card"
style={{ borderColor: 'var(--color-border-default)' }}
>
<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">
<Sparkles size={10} className="text-primary" />
Ticket Notes
</span>
{streaming && (
<Loader2 size={14} className="animate-spin text-primary" />
)}
</div>
{/* Streaming content or skeleton */}
{summary ? (
{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>
)}
{/* Paused/Escalated: status update options */}
{(outcome === 'paused' || outcome === 'escalated') && !summary && !generatingUpdate && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
{outcome === 'paused'
? 'Session paused. Generate a status update to share progress.'
: 'Session escalated. Generate an update to document the handoff.'}
</p>
<div className="space-y-2">
<button
onClick={() => handleGenerateStatusUpdate('ticket_notes')}
className="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left transition-colors hover:bg-[var(--color-bg-elevated)]"
style={{ border: '1px solid var(--color-border-default)' }}
>
<FileText size={18} className="text-muted-foreground shrink-0" />
<div>
<p className="text-sm font-medium text-foreground">Ticket Notes</p>
<p className="text-xs text-muted-foreground">Technical, for your PSA</p>
</div>
</button>
<button
onClick={() => handleGenerateStatusUpdate('client_update')}
className="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left transition-colors hover:bg-[var(--color-bg-elevated)]"
style={{ border: '1px solid var(--color-border-default)' }}
>
<User size={18} className="text-muted-foreground shrink-0" />
<div>
<p className="text-sm font-medium text-foreground">Client Update</p>
<p className="text-xs text-muted-foreground">Professional, non-technical</p>
</div>
</button>
<button
onClick={() => handleGenerateStatusUpdate('email_draft')}
className="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left transition-colors hover:bg-[var(--color-bg-elevated)]"
style={{ border: '1px solid var(--color-border-default)' }}
>
<Mail size={18} className="text-muted-foreground shrink-0" />
<div>
<p className="text-sm font-medium text-foreground">Email Draft</p>
<p className="text-xs text-muted-foreground">Full email with subject line</p>
</div>
</button>
</div>
</div>
)}
{/* Paused/Escalated: generating spinner */}
{(outcome === 'paused' || outcome === 'escalated') && generatingUpdate && (
<div className="flex flex-col items-center justify-center py-8 gap-3">
<Loader2 size={24} className="animate-spin text-orange-400" />
<p className="text-sm text-muted-foreground">Generating status update...</p>
</div>
)}
{/* Paused/Escalated: generated result */}
{(outcome === 'paused' || outcome === 'escalated') && summary && !generatingUpdate && (
<div
className="rounded-xl border p-5 bg-card"
style={{ borderColor: 'var(--color-border-default)' }}
>
<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">
<Sparkles size={10} className="text-primary" />
Status Update
</span>
</div>
<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>
@@ -451,9 +547,17 @@ export function ConcludeSessionModal({
Resume in New Chat
</button>
)}
{(outcome === 'paused' || outcome === 'escalated') && summary && !generatingUpdate && (
<button
onClick={() => { setSummary(''); setCopied(false) }}
className="flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground bg-input border border-border hover:border-border-hover transition-all"
>
Switch Format
</button>
)}
</div>
<div className="flex items-center gap-2">
{summary && !streaming && (
{summary && !streaming && !generatingUpdate && (
<button
onClick={handleCopy}
className={cn(

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { Sparkles, Send, Loader2, Flag, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks } from 'lucide-react'
import { Sparkles, Send, Loader2, Flag, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText } from 'lucide-react'
import { cn } from '@/lib/utils'
import { uploadsApi } from '@/api/uploads'
import type { PendingUpload } from '@/types/upload'
@@ -14,6 +14,7 @@ import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/Cha
import { ChatMessage } from '@/components/assistant/ChatMessage'
import { TaskLane } from '@/components/assistant/TaskLane'
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat'
import type { SuggestedFlow } from '@/types/copilot'
@@ -39,6 +40,7 @@ export default function AssistantChatPage() {
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [showConclude, setShowConclude] = useState(false)
const [showStatusUpdate, setShowStatusUpdate] = useState(false)
const branching = useBranching()
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
const [showLogs, setShowLogs] = useState(false)
@@ -746,10 +748,16 @@ export default function AssistantChatPage() {
</button>
)}
{messages.length >= 2 && (
<button type="button" onClick={() => setShowConclude(true)} disabled={loading} className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-muted-foreground hover:text-amber-400 hover:bg-amber-400/10 transition-colors disabled:opacity-40" title="Conclude session">
<Flag size={14} />
<span className="hidden sm:inline">Conclude</span>
</button>
<>
<button type="button" onClick={() => setShowStatusUpdate(true)} disabled={loading} className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-muted-foreground hover:text-orange-400 hover:bg-orange-500/10 transition-colors disabled:opacity-40" title="Share status update">
<FileText size={14} />
<span className="hidden sm:inline">Update</span>
</button>
<button type="button" onClick={() => setShowConclude(true)} disabled={loading} className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-muted-foreground hover:text-amber-400 hover:bg-amber-400/10 transition-colors disabled:opacity-40" title="Conclude session">
<Flag size={14} />
<span className="hidden sm:inline">Conclude</span>
</button>
</>
)}
{!showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0) && (
<button
@@ -825,6 +833,18 @@ export default function AssistantChatPage() {
chatTitle={chats.find(c => c.id === activeChatId)?.title ?? 'Chat'}
sessionId={activeChatId}
/>
{/* Status Update Modal */}
{activeChatId && (
<StatusUpdateModal
open={showStatusUpdate}
onClose={() => setShowStatusUpdate(false)}
onGenerate={(audience, length, context) =>
aiSessionsApi.generateStatusUpdate(activeChatId, { audience, length, context })
}
context="status"
/>
)}
</div>
</>
)