import { useState, useRef, useEffect, useCallback } from 'react'
import { useParams } from 'react-router-dom'
import { apiFetch } from '@core/lib/api'
import { useAppPath } from '@core/hooks/useAppPath'
import { Button } from '@core/components/ui/button'
import { Textarea } from '@core/components/ui/textarea'
import { Badge } from '@core/components/ui/badge'
import { Skeleton } from '@core/components/ui/skeleton'
import {
Bot,
User,
ChevronRight,
Send,
RefreshCw,
Zap,
Wrench,
Code2,
AlertTriangle,
Plus,
FileCode2,
} from 'lucide-react'
// ─── TYPES ────────────────────────────────────────────────────────────────────
interface Message {
id: string
role: 'user' | 'assistant' | 'system'
content: string
sources?: string[]
tool_calls_made?: string[]
loading?: boolean
}
// ─── MARKDOWN RENDERER ────────────────────────────────────────────────────────
// Minimal inline renderer — avoids pulling in a full markdown library.
function renderMarkdown(text: string): React.ReactNode[] {
const parts: React.ReactNode[] = []
let key = 0
// Split into code blocks and text blocks
const segments = text.split(/(```[\s\S]*?```)/g)
for (const seg of segments) {
if (seg.startsWith('```')) {
const lines = seg.slice(3, -3).split('\n')
const lang = lines[0].trim()
const code = lines.slice(lang ? 1 : 0).join('\n')
parts.push(
{lang && {lang}
}
{code}
)
} else {
// Inline: bold, code spans, line breaks
const inline = seg.split(/(`[^`]+`|\*\*[^*]+\*\*|\n)/g).map((chunk, i) => {
if (chunk.startsWith('`') && chunk.endsWith('`')) {
return {chunk.slice(1, -1)}
}
if (chunk.startsWith('**') && chunk.endsWith('**')) {
return {chunk.slice(2, -2)}
}
if (chunk === '\n') return
return chunk
})
parts.push({inline})
}
}
return parts
}
// ─── MESSAGE BUBBLE ───────────────────────────────────────────────────────────
function MessageBubble({ message, projectId, appPath }: {
message: Message
projectId: string
appPath: (p: string) => string
}) {
const isUser = message.role === 'user'
if (message.loading) {
return (
)
}
return (
{isUser
?
:
}
{isUser
?
{message.content}
:
{renderMarkdown(message.content)}
}
{/* Tool calls + sources */}
{!isUser && (message.tool_calls_made?.length || message.sources?.length) ? (
{(message.tool_calls_made || []).map(tc => (
{tc}
))}
{(message.sources || []).map(src => (
{src}
))}
) : null}
)
}
// ─── SUGGESTION CHIPS ─────────────────────────────────────────────────────────
const SUGGESTIONS = [
'What does the authentication middleware do?',
'Which functions write to the embeddings table?',
'Is it safe to refactor the createHandler function?',
'What are the side effects of the runAgent function?',
'Which chunks have no callers (potential dead code)?',
'Show me undocumented chunks in this project',
]
// ─── MAIN PAGE ────────────────────────────────────────────────────────────────
export default function CILAgentPage() {
const { id: projectId } = useParams<{ id: string }>()
const appPath = useAppPath()
const [messages, setMessages] = useState([])
const [input, setInput] = useState('')
const [sending, setSending] = useState(false)
const [projectTitle, setProjectTitle] = useState('')
const bottomRef = useRef(null)
const textareaRef = useRef(null)
// Load project title
useEffect(() => {
if (!projectId) return
apiFetch(`/api/custom_cil-project?action=get&id=${projectId}`)
.then(r => r.json())
.then(data => {
const p = data?.project || data?.data?.project
if (p?.title) setProjectTitle(p.title)
})
.catch(() => {})
}, [projectId])
// Scroll to bottom on new messages
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const sendMessage = useCallback(async (text: string) => {
if (!text.trim() || sending) return
const userMsg: Message = {
id: crypto.randomUUID(),
role: 'user',
content: text.trim(),
}
const loadingMsg: Message = {
id: crypto.randomUUID(),
role: 'assistant',
content: '',
loading: true,
}
setMessages(prev => [...prev, userMsg, loadingMsg])
setInput('')
setSending(true)
try {
const res = await apiFetch('/api/custom_cil-agent?action=ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
question: text.trim(),
project_id: projectId,
}),
})
const data = await res.json()
const r = data?.data || data
const answer = r?.answer || 'Sorry, I could not generate a response.'
setMessages(prev => [
...prev.slice(0, -1), // remove loading
{
id: crypto.randomUUID(),
role: 'assistant',
content: answer,
sources: r?.sources || [],
tool_calls_made: r?.tool_calls_made || [],
},
])
} catch (err) {
setMessages(prev => [
...prev.slice(0, -1),
{
id: crypto.randomUUID(),
role: 'assistant',
content: `Error: ${err instanceof Error ? err.message : String(err)}`,
},
])
} finally {
setSending(false)
setTimeout(() => textareaRef.current?.focus(), 50)
}
}, [projectId, sending])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage(input)
}
}
const clearConversation = () => {
setMessages([])
setInput('')
textareaRef.current?.focus()
}
const isEmpty = messages.length === 0
return (
{/* Header */}
{projectId && (
{projectTitle || projectId.slice(0, 8)}
)}
{!isEmpty && (
)}
{/* Messages */}
{isEmpty && (
CIL Codebase Assistant
Ask me anything about this codebase. I'll search the indexed chunk contracts and call graph to answer accurately.
{SUGGESTIONS.map(s => (
))}
{!projectId && (
No project scope — searching across all indexed chunks
)}
)}
{messages.map(msg => (
))}
{/* Input bar */}
Grounded in CIL chunk contracts · Press Shift+Enter for a new line
)
}