feat: unified sessions — merge assistant chat into ai_sessions table

Add session_type ('guided'|'chat') and title columns to ai_sessions,
enabling both FlowPilot guided sessions and assistant chat sessions to
live in a single table. This is the foundation for a unified session
history and consistent UX across both interaction modes.

Backend:
- Migration 066: session_type + title columns
- unified_chat_service: chat sessions on ai_sessions with same AI/RAG
- POST /ai-sessions supports session_type='chat' creation
- POST /ai-sessions/{id}/chat for chat messages
- DELETE /ai-sessions/{id} for session deletion
- session_type filter on GET /ai-sessions

Frontend:
- AssistantChatPage rewired to aiSessionsApi (no more assistantChatApi)
- /assistant/:sessionId route for deep-linking
- Session history: type filter pills (All/Guided/Chat), type icons
- Dashboard: both types shown with correct routing and icons
- Fixed glass-border → border-default in dashboard components

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 17:29:25 +00:00
parent 72678e7f26
commit b414502062
15 changed files with 685 additions and 88 deletions

View File

@@ -1,28 +1,31 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { Sparkles, Send, Loader2, Flag, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus } from 'lucide-react'
import { cn } from '@/lib/utils'
import { uploadsApi } from '@/api/uploads'
import type { PendingUpload } from '@/types/upload'
import { PageMeta } from '@/components/common/PageMeta'
import { assistantChatApi } from '@/api/assistantChat'
import { aiSessionsApi } from '@/api/aiSessions'
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 { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat'
import type { SuggestedFlow } from '@/types/copilot'
interface MessageWithMeta extends ChatMessageType {
interface MessageWithMeta {
role: 'user' | 'assistant'
content: string
suggestedFlows?: SuggestedFlow[]
}
export default function AssistantChatPage() {
const location = useLocation()
const navigate = useNavigate()
const { sessionId: urlSessionId } = useParams<{ sessionId?: string }>()
const [chats, setChats] = useState<ChatListItem[]>([])
const [activeChatId, setActiveChatId] = useState<string | null>(null)
const [activeChatId, setActiveChatId] = useState<string | null>(urlSessionId || null)
const [messages, setMessages] = useState<MessageWithMeta[]>([])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
@@ -38,39 +41,53 @@ export default function AssistantChatPage() {
const dragCounterRef = useRef(0)
const prefillHandledRef = useRef(false)
// Load chat list
// Load chat list from ai_sessions
useEffect(() => {
loadChats()
}, [])
// Handle prefill from command palette handoff
// If URL has a session ID, load it
useEffect(() => {
if (urlSessionId && urlSessionId !== activeChatId) {
selectChat(urlSessionId)
}
}, [urlSessionId]) // eslint-disable-line react-hooks/exhaustive-deps
// Handle prefill from command palette / dashboard 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)
const session = await aiSessionsApi.createChatSession({
intake_type: 'free_text',
intake_content: { text: prefill },
})
const chatItem: ChatListItem = {
id: session.session_id,
title: session.title,
message_count: 0,
pinned: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
setChats(prev => [chatItem, ...prev])
setActiveChatId(session.session_id)
setMessages([{ role: 'user', content: prefill }])
setLoading(true)
const response = await assistantChatApi.sendMessage(chat.id, prefill)
const response = await aiSessionsApi.sendChatMessage(session.session_id, { message: prefill })
setMessages(prev => [
...prev,
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows },
])
setChats(prev =>
prev.map(c =>
c.id === chat.id
c.id === session.session_id
? { ...c, message_count: 2, title: prefill.slice(0, 100), updated_at: new Date().toISOString() }
: c
)
@@ -93,8 +110,15 @@ export default function AssistantChatPage() {
const loadChats = async () => {
try {
const list = await assistantChatApi.listChats(1, 100)
setChats(list)
const sessions = await aiSessionsApi.listSessions({ session_type: 'chat', limit: 100 })
setChats(sessions.map(s => ({
id: s.id,
title: s.title || s.problem_summary || 'New Chat',
message_count: s.step_count,
pinned: false,
created_at: s.created_at,
updated_at: s.created_at,
})))
} catch {
// silently handle
}
@@ -103,8 +127,13 @@ export default function AssistantChatPage() {
const selectChat = useCallback(async (chatId: string) => {
setActiveChatId(chatId)
try {
const chat = await assistantChatApi.getChat(chatId)
setMessages(chat.messages.map(m => ({ ...m })))
const detail = await aiSessionsApi.getSession(chatId)
setMessages(
(detail.conversation_messages || []).map(m => ({
role: m.role as 'user' | 'assistant',
content: m.content,
}))
)
} catch {
setMessages([])
}
@@ -112,12 +141,20 @@ export default function AssistantChatPage() {
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)
const session = await aiSessionsApi.createChatSession({
intake_type: 'free_text',
intake_content: { text: '' },
})
const chatItem: ChatListItem = {
id: session.session_id,
title: session.title,
message_count: 0,
pinned: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
setChats(prev => [chatItem, ...prev])
setActiveChatId(session.session_id)
setMessages([])
} catch {
toast.error('Failed to create chat')
@@ -126,7 +163,7 @@ export default function AssistantChatPage() {
const handleDeleteChat = async (chatId: string) => {
try {
await assistantChatApi.deleteChat(chatId)
await aiSessionsApi.deleteSession(chatId)
setChats(prev => prev.filter(c => c.id !== chatId))
if (activeChatId === chatId) {
setActiveChatId(null)
@@ -137,15 +174,9 @@ export default function AssistantChatPage() {
}
}
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 handleTogglePin = async (_chatId: string, _pinned: boolean) => {
// Pin/unpin not yet supported on unified sessions — no-op for now
toast.info('Pin feature coming soon')
}
const handleSend = async () => {
@@ -157,13 +188,12 @@ export default function AssistantChatPage() {
setLoading(true)
try {
const response = await assistantChatApi.sendMessage(activeChatId, userMessage)
const response = await aiSessionsApi.sendChatMessage(activeChatId, { message: 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
@@ -182,44 +212,55 @@ 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')
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
// Map conclusion outcomes to ai_sessions actions
if (outcome === 'resolved') {
const result = await aiSessionsApi.resolveSession(activeChatId, {
resolution_summary: _notes || 'Resolved via assistant chat',
})
return result.documentation?.problem_summary || 'Session resolved'
} else if (outcome === 'escalated') {
const result = await aiSessionsApi.escalateSession(activeChatId, {
escalation_reason: _notes || 'Escalated from assistant chat',
})
return result.documentation?.problem_summary || 'Session escalated'
} else {
// paused
await aiSessionsApi.pauseSession(activeChatId)
return 'Session paused'
}
}
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('')
const session = await aiSessionsApi.createChatSession({
intake_type: 'free_text',
intake_content: { text: resumePrompt },
})
const chatItem: ChatListItem = {
id: session.session_id,
title: session.title,
message_count: 0,
pinned: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
setChats(prev => [chatItem, ...prev])
setActiveChatId(session.session_id)
setMessages([{ role: 'user', content: resumePrompt }])
setLoading(true)
const response = await assistantChatApi.sendMessage(chat.id, resumePrompt)
const response = await aiSessionsApi.sendChatMessage(session.session_id, { message: resumePrompt })
setMessages(prev => [
...prev,
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows },
])
setChats(prev =>
prev.map(c =>
c.id === chat.id
c.id === session.session_id
? { ...c, message_count: 2, title: resumePrompt.slice(0, 100), updated_at: new Date().toISOString() }
: c
)

View File

@@ -27,6 +27,7 @@ export function SessionHistoryPage() {
const aiSearchTimeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const [aiFilters, setAiFilters] = useState({
q: '',
session_type: '',
problem_domain: '',
confidence_tier: '',
date_from: '',
@@ -176,6 +177,7 @@ export function SessionHistoryPage() {
const data = await aiSessionsApi.listSessions({
limit: 50,
q: aiFilters.q || undefined,
session_type: aiFilters.session_type || undefined,
problem_domain: aiFilters.problem_domain || undefined,
confidence_tier: aiFilters.confidence_tier || undefined,
date_from: aiFilters.date_from || undefined,
@@ -267,7 +269,7 @@ export function SessionHistoryPage() {
return labels[outcome] ?? outcome
}
const hasAiFiltersActive = !!(aiSearchInput || aiFilters.q || aiFilters.problem_domain || aiFilters.confidence_tier || aiFilters.date_from || aiFilters.date_to)
const hasAiFiltersActive = !!(aiSearchInput || aiFilters.q || aiFilters.session_type || aiFilters.problem_domain || aiFilters.confidence_tier || aiFilters.date_from || aiFilters.date_to)
const hasFlowFiltersActive = !!(filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from)
// Determine section visibility
@@ -314,7 +316,7 @@ export function SessionHistoryPage() {
{/* FlowPilot Sessions Section */}
{showAiSection && (
<>
<h2 className="font-sans text-xs text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-3">FlowPilot Sessions</h2>
<h2 className="font-sans text-xs text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-3">AI Sessions</h2>
{/* AI Session Filter Bar */}
<div className="card-flat p-3 mb-4">
@@ -331,6 +333,24 @@ export function SessionHistoryPage() {
/>
</div>
{/* Session type pills */}
<div className="flex gap-1">
{(['', 'guided', 'chat'] as const).map((t) => (
<button
key={t}
onClick={() => setAiFilters((f) => ({ ...f, session_type: t }))}
className={cn(
'rounded-full border px-3 py-1 text-xs font-sans transition-colors',
aiFilters.session_type === t
? 'bg-accent-dim text-foreground border-primary/30'
: 'bg-card text-muted-foreground border-border hover:text-foreground hover:border-[rgba(255,255,255,0.12)]'
)}
>
{t === '' ? 'All' : t === 'guided' ? 'Guided' : 'Chat'}
</button>
))}
</div>
{/* Problem domain dropdown */}
<select
value={aiFilters.problem_domain}
@@ -393,7 +413,7 @@ export function SessionHistoryPage() {
<button
onClick={() => {
setAiSearchInput('')
setAiFilters({ q: '', problem_domain: '', confidence_tier: '', date_from: '', date_to: '' })
setAiFilters({ q: '', session_type: '', problem_domain: '', confidence_tier: '', date_from: '', date_to: '' })
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
@@ -415,7 +435,7 @@ export function SessionHistoryPage() {
<button
onClick={() => {
setAiSearchInput('')
setAiFilters({ q: '', problem_domain: '', confidence_tier: '', date_from: '', date_to: '' })
setAiFilters({ q: '', session_type: '', problem_domain: '', confidence_tier: '', date_from: '', date_to: '' })
}}
className="text-foreground hover:underline text-sm"
>