Files
resolutionflow/frontend/src/pages/AssistantChatPage.tsx
chihlasm c44edc5088 feat: wire remaining PostHog events across all key user actions (#111)
- 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>
2026-03-16 18:49:01 -04:00

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>
</>
)
}