- export_generated: session copy, copy-for-ticket, download - ai_feature_used: copilot, assistant chat, session-to-flow, KB accelerator, flow assist - psa_connected: ConnectWise integration creation - session_shared: share link creation - flow_created: troubleshooting editor, procedural editor, session-to-flow All 9 events from the product analytics plan are now fully wired. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
361 lines
13 KiB
TypeScript
361 lines
13 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
import { useLocation, useNavigate } from 'react-router-dom'
|
|
import { Sparkles, Send, Loader2, Flag } from 'lucide-react'
|
|
import { PageMeta } from '@/components/common/PageMeta'
|
|
import { assistantChatApi } from '@/api/assistantChat'
|
|
import { analytics } from '@/lib/analytics'
|
|
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 location = useLocation()
|
|
const navigate = useNavigate()
|
|
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)
|
|
const prefillHandledRef = useRef(false)
|
|
|
|
// Load chat list
|
|
useEffect(() => {
|
|
loadChats()
|
|
}, [])
|
|
|
|
// Handle prefill from command palette handoff
|
|
useEffect(() => {
|
|
const prefill = (location.state as { prefill?: string } | null)?.prefill
|
|
if (!prefill || prefillHandledRef.current) return
|
|
prefillHandledRef.current = true
|
|
|
|
// Clear the location state so back-navigation doesn't retrigger
|
|
navigate(location.pathname, { replace: true, state: {} })
|
|
|
|
const sendPrefill = 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([{ role: 'user', content: prefill }])
|
|
setLoading(true)
|
|
|
|
const response = await assistantChatApi.sendMessage(chat.id, prefill)
|
|
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: prefill.slice(0, 100), updated_at: new Date().toISOString() }
|
|
: c
|
|
)
|
|
)
|
|
} catch {
|
|
toast.error('Failed to start AI conversation')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
sendPrefill()
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
// 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)
|
|
analytics.aiFeatureUsed({ feature: 'assistant_chat' })
|
|
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 (
|
|
<>
|
|
<PageMeta title="AI Assistant" />
|
|
<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-white/[0.04] border border-brand-border 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="max-w-3xl mx-auto">
|
|
<div className="flex items-end gap-3">
|
|
<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-hidden focus:border-primary/30"
|
|
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-brand-dark 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>
|
|
<p className="text-[0.625rem] text-muted-foreground mt-1.5 px-1">Shift + Enter for a new line</p>
|
|
</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-brand-dark 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>
|
|
</>
|
|
)
|
|
}
|