AI Assistant - Conclude Session:
- 3-step modal: select outcome (resolved/escalated/paused), add notes, AI-generated summary
- AI generates structured ticket notes from conversation transcript (PSA-ready format)
- Copy to clipboard for pasting into ticketing systems
- "Resume in New Chat" for paused sessions (pre-loads context into new chat)
- Backend: POST /chats/{id}/conclude endpoint, conclusion_summary/outcome/concluded_at fields
- Migration 048: add conclusion fields to assistant_chats
Survey Completion Flow:
- Email-to-self option after submission (branded HTML email with formatted responses)
- Finish button navigates to /survey/thank-you page
- Thank you page with close-window message and feedback email callout
- Already-submitted state updated with same messaging
- Backend: POST /survey/email-copy public endpoint
Survey Admin Management:
- Read/unread indicators (cyan dot, bold name, auto-mark on expand)
- Unread count stat card
- Per-row context menu: mark read/unread, archive/unarchive, delete
- Bulk actions bar: select all, mark read/unread, archive, delete
- Show Archived toggle to filter archived responses
- Backend: 7 new admin endpoints (read, unread, archive, unarchive, delete, bulk)
- Migration 049: add is_read, archived_at to survey_responses
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
import { Sparkles, Send, Loader2, Flag } from 'lucide-react'
|
|
import { assistantChatApi } from '@/api/assistantChat'
|
|
import { toast } from '@/lib/toast'
|
|
import { ChatSidebar } from '@/components/assistant/ChatSidebar'
|
|
import { ChatMessage } from '@/components/assistant/ChatMessage'
|
|
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
|
|
import type { ChatListItem, AssistantChatMessage as ChatMessageType, ConclusionOutcome } from '@/types/assistant-chat'
|
|
import type { SuggestedFlow } from '@/types/copilot'
|
|
|
|
interface MessageWithMeta extends ChatMessageType {
|
|
suggestedFlows?: SuggestedFlow[]
|
|
}
|
|
|
|
export default function AssistantChatPage() {
|
|
const [chats, setChats] = useState<ChatListItem[]>([])
|
|
const [activeChatId, setActiveChatId] = useState<string | null>(null)
|
|
const [messages, setMessages] = useState<MessageWithMeta[]>([])
|
|
const [input, setInput] = useState('')
|
|
const [loading, setLoading] = useState(false)
|
|
const [showConclude, setShowConclude] = useState(false)
|
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
const inputRef = useRef<HTMLTextAreaElement>(null)
|
|
|
|
// Load chat list
|
|
useEffect(() => {
|
|
loadChats()
|
|
}, [])
|
|
|
|
// Auto-scroll
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
}, [messages])
|
|
|
|
const loadChats = async () => {
|
|
try {
|
|
const list = await assistantChatApi.listChats(1, 100)
|
|
setChats(list)
|
|
} catch {
|
|
// silently handle
|
|
}
|
|
}
|
|
|
|
const selectChat = useCallback(async (chatId: string) => {
|
|
setActiveChatId(chatId)
|
|
try {
|
|
const chat = await assistantChatApi.getChat(chatId)
|
|
setMessages(chat.messages.map(m => ({ ...m })))
|
|
} catch {
|
|
setMessages([])
|
|
}
|
|
}, [])
|
|
|
|
const handleNewChat = async () => {
|
|
try {
|
|
const chat = await assistantChatApi.createChat()
|
|
setChats(prev => [
|
|
{ id: chat.id, title: chat.title, message_count: 0, pinned: false, created_at: chat.created_at, updated_at: chat.updated_at },
|
|
...prev,
|
|
])
|
|
setActiveChatId(chat.id)
|
|
setMessages([])
|
|
} catch {
|
|
toast.error('Failed to create chat')
|
|
}
|
|
}
|
|
|
|
const handleDeleteChat = async (chatId: string) => {
|
|
try {
|
|
await assistantChatApi.deleteChat(chatId)
|
|
setChats(prev => prev.filter(c => c.id !== chatId))
|
|
if (activeChatId === chatId) {
|
|
setActiveChatId(null)
|
|
setMessages([])
|
|
}
|
|
} catch {
|
|
toast.error('Failed to delete chat')
|
|
}
|
|
}
|
|
|
|
const handleTogglePin = async (chatId: string, pinned: boolean) => {
|
|
try {
|
|
await assistantChatApi.updateChat(chatId, { pinned })
|
|
setChats(prev =>
|
|
prev.map(c => c.id === chatId ? { ...c, pinned } : c)
|
|
)
|
|
} catch {
|
|
toast.error('Failed to update chat')
|
|
}
|
|
}
|
|
|
|
const handleSend = async () => {
|
|
if (!input.trim() || !activeChatId || loading) return
|
|
|
|
const userMessage = input.trim()
|
|
setInput('')
|
|
setMessages(prev => [...prev, { role: 'user', content: userMessage }])
|
|
setLoading(true)
|
|
|
|
try {
|
|
const response = await assistantChatApi.sendMessage(activeChatId, userMessage)
|
|
setMessages(prev => [
|
|
...prev,
|
|
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows },
|
|
])
|
|
// Update chat list title if it was the first message
|
|
setChats(prev =>
|
|
prev.map(c =>
|
|
c.id === activeChatId
|
|
? { ...c, message_count: c.message_count + 2, title: c.message_count === 0 ? userMessage.slice(0, 100) : c.title, updated_at: new Date().toISOString() }
|
|
: c
|
|
)
|
|
)
|
|
} catch {
|
|
setMessages(prev => [
|
|
...prev,
|
|
{ role: 'assistant', content: 'Sorry, something went wrong. Please try again.' },
|
|
])
|
|
} finally {
|
|
setLoading(false)
|
|
requestAnimationFrame(() => inputRef.current?.focus())
|
|
}
|
|
}
|
|
|
|
const handleConclude = async (outcome: ConclusionOutcome, notes: string): Promise<string> => {
|
|
if (!activeChatId) throw new Error('No active chat')
|
|
const response = await assistantChatApi.concludeChat(activeChatId, { outcome, notes: notes || undefined })
|
|
// Update chat in sidebar to show concluded status
|
|
setChats(prev =>
|
|
prev.map(c =>
|
|
c.id === activeChatId
|
|
? { ...c, concluded_at: response.concluded_at, conclusion_outcome: outcome }
|
|
: c
|
|
)
|
|
)
|
|
return response.summary
|
|
}
|
|
|
|
const handleResumeNew = async (summary: string) => {
|
|
try {
|
|
const chat = await assistantChatApi.createChat()
|
|
setChats(prev => [
|
|
{ id: chat.id, title: chat.title, message_count: 0, pinned: false, created_at: chat.created_at, updated_at: chat.updated_at },
|
|
...prev,
|
|
])
|
|
setActiveChatId(chat.id)
|
|
setMessages([])
|
|
|
|
// Send the summary as the first message to prime the new chat
|
|
const resumePrompt = `I'm continuing a previous troubleshooting session. Here's where we left off:\n\n${summary}\n\nPlease review this context and help me continue from where we stopped.`
|
|
setInput('')
|
|
setMessages([{ role: 'user', content: resumePrompt }])
|
|
setLoading(true)
|
|
|
|
const response = await assistantChatApi.sendMessage(chat.id, resumePrompt)
|
|
setMessages(prev => [
|
|
...prev,
|
|
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows },
|
|
])
|
|
setChats(prev =>
|
|
prev.map(c =>
|
|
c.id === chat.id
|
|
? { ...c, message_count: 2, title: resumePrompt.slice(0, 100), updated_at: new Date().toISOString() }
|
|
: c
|
|
)
|
|
)
|
|
} catch {
|
|
toast.error('Failed to create resume chat')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault()
|
|
handleSend()
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-[calc(100vh-3.5rem)]">
|
|
{/* Sidebar */}
|
|
<ChatSidebar
|
|
chats={chats}
|
|
activeChatId={activeChatId}
|
|
onSelectChat={selectChat}
|
|
onNewChat={handleNewChat}
|
|
onDeleteChat={handleDeleteChat}
|
|
onTogglePin={handleTogglePin}
|
|
/>
|
|
|
|
{/* Main chat area */}
|
|
<div className="flex-1 flex flex-col min-w-0">
|
|
{activeChatId ? (
|
|
<>
|
|
{/* Messages */}
|
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
|
{messages.length === 0 && !loading && (
|
|
<div className="flex flex-col items-center justify-center h-full text-center">
|
|
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
|
<Sparkles size={28} className="text-primary" />
|
|
</div>
|
|
<h2 className="text-lg font-heading font-semibold text-foreground mb-2">
|
|
AI Assistant
|
|
</h2>
|
|
<p className="text-sm text-muted-foreground max-w-md">
|
|
Ask me anything about IT infrastructure, networking, Active Directory,
|
|
cloud platforms, or troubleshooting. I'll also suggest relevant flows from your team's library.
|
|
</p>
|
|
</div>
|
|
)}
|
|
{messages.map((msg, i) => (
|
|
<ChatMessage
|
|
key={i}
|
|
role={msg.role}
|
|
content={msg.content}
|
|
suggestedFlows={msg.suggestedFlows}
|
|
/>
|
|
))}
|
|
{loading && (
|
|
<div className="flex gap-3">
|
|
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center">
|
|
<Sparkles size={14} className="text-primary" />
|
|
</div>
|
|
<div className="bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] rounded-2xl px-4 py-3">
|
|
<Loader2 size={16} className="animate-spin text-primary" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Input */}
|
|
<div className="px-6 py-4 border-t shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
|
|
<div className="flex items-end gap-3 max-w-3xl mx-auto">
|
|
<textarea
|
|
ref={inputRef}
|
|
value={input}
|
|
onChange={e => setInput(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Ask about IT, networking, troubleshooting..."
|
|
rows={3}
|
|
className="flex-1 resize-none rounded-xl border bg-card text-foreground text-sm placeholder:text-muted-foreground px-4 py-3 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
|
|
style={{ borderColor: 'var(--glass-border)' }}
|
|
disabled={loading}
|
|
/>
|
|
<div className="flex flex-col gap-2">
|
|
<button
|
|
onClick={handleSend}
|
|
disabled={!input.trim() || loading}
|
|
className="bg-gradient-brand text-[#101114] p-3 rounded-xl hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40"
|
|
title="Send message"
|
|
>
|
|
<Send size={18} />
|
|
</button>
|
|
{messages.length >= 2 && (
|
|
<button
|
|
onClick={() => setShowConclude(true)}
|
|
disabled={loading}
|
|
className="p-3 rounded-xl border text-muted-foreground hover:text-amber-400 hover:border-amber-400/30 hover:bg-amber-400/10 transition-all disabled:opacity-40"
|
|
style={{ borderColor: 'var(--glass-border)' }}
|
|
title="Conclude session"
|
|
>
|
|
<Flag size={18} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center h-full text-center">
|
|
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
|
<Sparkles size={32} className="text-primary" />
|
|
</div>
|
|
<h2 className="text-xl font-heading font-semibold text-foreground mb-2">
|
|
AI Assistant
|
|
</h2>
|
|
<p className="text-sm text-muted-foreground max-w-md mb-6">
|
|
Your Senior Systems & Network Engineer. Ask anything about IT infrastructure,
|
|
or start a new chat to get personalized help with your team's flows.
|
|
</p>
|
|
<button
|
|
onClick={handleNewChat}
|
|
className="bg-gradient-brand text-[#101114] font-semibold text-sm rounded-[10px] px-6 py-2.5 hover:opacity-90 active:scale-[0.97] transition-all"
|
|
>
|
|
Start a Conversation
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Conclude Session Modal */}
|
|
<ConcludeSessionModal
|
|
isOpen={showConclude}
|
|
onClose={() => setShowConclude(false)}
|
|
onConclude={handleConclude}
|
|
onResumeNew={handleResumeNew}
|
|
chatTitle={chats.find(c => c.id === activeChatId)?.title ?? 'Chat'}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|