import { Suspense, lazy, useCallback, useEffect, useState } from 'react' import { Group, Panel, Separator } from 'react-resizable-panels' import './App.css' import { appendAssistantTextBlock, applyStreamPacketToTurn, buildChatTurns, createTurn, extractArtifactTagPaths, } from './lib/chat' import { createConversation, deleteConversation, getConversationArtifactUrl, getConversationHistory, getHealth, getLatestArtifacts, listConversations, listModels, probeConversationArtifact, readConversationArtifactText, streamChatEvents, } from './lib/api' import { ConversationView } from './components/ConversationView' import { Sidebar } from './components/Sidebar' import type { ArtifactFileInfo, ChatTurn, ComposerImageAttachment, ConversationSummary, LatestArtifactsResponse, ModelInfo, RawChatMessage, RawMessageContentPart, } from './types' const WorkbenchPane = lazy(async () => { const module = await import('./components/WorkbenchPane') return { default: module.WorkbenchPane } }) function sortConversations(items: ConversationSummary[]): ConversationSummary[] { return [...items].sort((left, right) => { const rightTime = new Date( right.context_last_activity ?? right.context_start_time ?? 0, ).getTime() const leftTime = new Date( left.context_last_activity ?? left.context_start_time ?? 0, ).getTime() return rightTime - leftTime }) } function buildErrorTurn(turns: ChatTurn[], message: string): ChatTurn[] { if (!turns.length) { return [appendAssistantTextBlock(createTurn(''), `Error: ${message}`)] } const nextTurns = [...turns] nextTurns[nextTurns.length - 1] = appendAssistantTextBlock( nextTurns[nextTurns.length - 1], `Error: ${message}`, ) return nextTurns } function makeAttachmentId(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID() } return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}` } async function fileToDataUrl(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => { if (typeof reader.result === 'string') { resolve(reader.result) return } reject(new Error('Failed to encode image attachment')) } reader.onerror = () => reject(reader.error ?? new Error('Failed to read image file')) reader.readAsDataURL(file) }) } function dedupeArtifactFiles(items: ArtifactFileInfo[]): ArtifactFileInfo[] { const seen = new Set() return items.filter((item) => { if (!item.path || seen.has(item.path)) { return false } seen.add(item.path) return true }) } function candidateArtifactPaths(path: string): string[] { const trimmedPath = path.trim() if (!trimmedPath) { return [] } const normalizedPath = trimmedPath.replace(/\\/g, '/').replace(/^\.\//, '') const nextPaths = [trimmedPath] if (normalizedPath && normalizedPath !== trimmedPath) { nextPaths.push(normalizedPath) } if (normalizedPath && !normalizedPath.startsWith('workspace/')) { nextPaths.push(`workspace/${normalizedPath}`) } return Array.from(new Set(nextPaths)) } async function resolveCodeArtifact( conversationId: string, taggedPath: string, ): Promise { for (const candidatePath of candidateArtifactPaths(taggedPath)) { const content = await readConversationArtifactText(conversationId, candidatePath) if (content == null) { continue } return { path: candidatePath, content, url: getConversationArtifactUrl(conversationId, candidatePath), content_type: 'text/plain; charset=utf-8', } } return null } async function resolveModelArtifact( conversationId: string, taggedPath: string, ): Promise { for (const candidatePath of candidateArtifactPaths(taggedPath)) { const exists = await probeConversationArtifact(conversationId, candidatePath) if (!exists) { continue } return { path: candidatePath, url: getConversationArtifactUrl(conversationId, candidatePath), } } return null } async function buildArtifactsFromHistory( conversationId: string, messages: RawChatMessage[], ): Promise { const taggedArtifacts = extractArtifactTagPaths(messages) const [codeFiles, modelFiles] = await Promise.all([ Promise.all(taggedArtifacts.codePaths.map((path) => resolveCodeArtifact(conversationId, path))), Promise.all(taggedArtifacts.modelPaths.map((path) => resolveModelArtifact(conversationId, path))), ]) const resolvedCodeFiles = dedupeArtifactFiles(codeFiles.filter((item): item is ArtifactFileInfo => item !== null)) const resolvedModelFiles = dedupeArtifactFiles( modelFiles.filter((item): item is ArtifactFileInfo => item !== null), ) return { conversation_id: conversationId, code_file: resolvedCodeFiles[0] ?? null, code_files: resolvedCodeFiles, model_file: resolvedModelFiles[0] ?? null, model_files: resolvedModelFiles, output_files: resolvedModelFiles.map((item) => item.path), } } function mergeArtifacts( backendArtifacts: LatestArtifactsResponse | null, historyArtifacts: LatestArtifactsResponse | null, ): LatestArtifactsResponse | null { if (!backendArtifacts && !historyArtifacts) { return null } const baseArtifacts = backendArtifacts ?? historyArtifacts if (!baseArtifacts) { return null } const codeFiles = dedupeArtifactFiles([ ...(backendArtifacts?.code_files ?? []), ...(backendArtifacts?.code_file ? [backendArtifacts.code_file] : []), ...(historyArtifacts?.code_files ?? []), ...(historyArtifacts?.code_file ? [historyArtifacts.code_file] : []), ]) const modelFiles = dedupeArtifactFiles([ ...(backendArtifacts?.model_files ?? []), ...(backendArtifacts?.model_file ? [backendArtifacts.model_file] : []), ...(historyArtifacts?.model_files ?? []), ...(historyArtifacts?.model_file ? [historyArtifacts.model_file] : []), ]) const outputFiles = Array.from( new Set([...(backendArtifacts?.output_files ?? []), ...(historyArtifacts?.output_files ?? [])]), ) return { conversation_id: baseArtifacts.conversation_id, code_file: backendArtifacts?.code_file ?? historyArtifacts?.code_file ?? codeFiles[0] ?? null, code_files: codeFiles, model_file: backendArtifacts?.model_file ?? historyArtifacts?.model_file ?? modelFiles[0] ?? null, model_files: modelFiles, output_files: outputFiles, } } function App() { const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const [conversations, setConversations] = useState([]) const [activeConversationId, setActiveConversationId] = useState('') const [turns, setTurns] = useState([]) const [artifacts, setArtifacts] = useState(null) const [models, setModels] = useState([]) const [selectedModel, setSelectedModel] = useState('cadagent') const [input, setInput] = useState('') const [attachments, setAttachments] = useState([]) const [bootstrapping, setBootstrapping] = useState(true) const [artifactLoading, setArtifactLoading] = useState(false) const [streaming, setStreaming] = useState(false) const [errorMessage, setErrorMessage] = useState(null) const clearConversationState = useCallback(() => { setActiveConversationId('') setTurns([]) setArtifacts(null) }, []) const handleAttachImages = useCallback(async (files: File[]) => { if (!files.length) { return } try { const nextAttachments = await Promise.all( Array.from(files).map(async (file) => ({ id: makeAttachmentId(), name: file.name, dataUrl: await fileToDataUrl(file), })), ) setAttachments((current) => [...current, ...nextAttachments]) } catch (error) { setErrorMessage(error instanceof Error ? error.message : 'Failed to load image attachment') } }, []) const handleRemoveImage = useCallback((attachmentId: string) => { setAttachments((current) => current.filter((attachment) => attachment.id !== attachmentId)) }, []) const refreshConversationList = useCallback(async (): Promise => { const nextConversations = sortConversations(await listConversations()) setConversations(nextConversations) return nextConversations }, []) const loadConversationState = useCallback(async (conversationId: string) => { setArtifactLoading(true) const [historyResult, artifactsResult] = await Promise.allSettled([ getConversationHistory(conversationId), getLatestArtifacts(conversationId), ]) const historyArtifacts = historyResult.status === 'fulfilled' ? await buildArtifactsFromHistory(conversationId, historyResult.value) : null if (historyResult.status === 'fulfilled') { setTurns(buildChatTurns(historyResult.value)) } else { setTurns([]) } if (artifactsResult.status === 'fulfilled') { console.info('[artifacts] loaded latest artifacts', { conversationId, historyMessageCount: historyResult.status === 'fulfilled' ? historyResult.value.length : undefined, codeFile: artifactsResult.value.code_file?.path ?? null, codeFiles: (artifactsResult.value.code_files ?? []).map((item) => item.path), modelFile: artifactsResult.value.model_file?.path ?? null, modelFiles: (artifactsResult.value.model_files ?? []).map((item) => item.path), outputFiles: artifactsResult.value.output_files ?? [], }) } else { console.warn('[artifacts] failed to load latest artifacts', { conversationId, historyStatus: historyResult.status, reason: artifactsResult.reason instanceof Error ? artifactsResult.reason.message : artifactsResult.reason, }) } if (historyArtifacts) { console.info('[artifacts] history tag fallback', { conversationId, codeFile: historyArtifacts.code_file?.path ?? null, codeFiles: historyArtifacts.code_files.map((item) => item.path), modelFile: historyArtifacts.model_file?.path ?? null, modelFiles: historyArtifacts.model_files.map((item) => item.path), outputFiles: historyArtifacts.output_files, }) } const mergedArtifacts = mergeArtifacts( artifactsResult.status === 'fulfilled' ? artifactsResult.value : null, historyArtifacts, ) console.info('[artifacts] merged artifact state', { conversationId, codeFile: mergedArtifacts?.code_file?.path ?? null, codeFiles: mergedArtifacts?.code_files.map((item) => item.path) ?? [], modelFile: mergedArtifacts?.model_file?.path ?? null, modelFiles: mergedArtifacts?.model_files.map((item) => item.path) ?? [], outputFiles: mergedArtifacts?.output_files ?? [], }) setArtifacts(mergedArtifacts) if (historyResult.status === 'rejected') { console.warn('[history] failed to load conversation history', { conversationId, reason: historyResult.reason instanceof Error ? historyResult.reason.message : historyResult.reason, }) } setArtifactLoading(false) }, []) const ensureConversation = useCallback(async (): Promise => { if (activeConversationId) { return activeConversationId } const created = await createConversation() setActiveConversationId(created.conversation_id) return created.conversation_id }, [activeConversationId]) const bootstrap = useCallback(async () => { setBootstrapping(true) setErrorMessage(null) try { await getHealth() } catch (error) { setErrorMessage(error instanceof Error ? error.message : 'Backend unavailable') } try { const [availableModels, knownConversations] = await Promise.all([ listModels(), refreshConversationList(), ]) setModels(availableModels) if (availableModels.length) { setSelectedModel((currentModel) => availableModels.some((model) => model.id === currentModel) ? currentModel : availableModels[0].id, ) } const nextConversationId = knownConversations[0]?.conversation_id ?? (await createConversation()).conversation_id setActiveConversationId(nextConversationId) await loadConversationState(nextConversationId) await refreshConversationList() } catch (error) { setErrorMessage( error instanceof Error ? error.message : 'Failed to initialize the React UI', ) } finally { setBootstrapping(false) } }, [loadConversationState, refreshConversationList]) useEffect(() => { void bootstrap() }, [bootstrap]) const handleCreateConversation = useCallback(async () => { setErrorMessage(null) try { const created = await createConversation() setActiveConversationId(created.conversation_id) setTurns([]) setArtifacts(null) await refreshConversationList() await loadConversationState(created.conversation_id) } catch (error) { setErrorMessage(error instanceof Error ? error.message : 'Failed to create session') } }, [loadConversationState, refreshConversationList]) const handleSelectConversation = useCallback( async (conversationId: string) => { setActiveConversationId(conversationId) setErrorMessage(null) await loadConversationState(conversationId) }, [loadConversationState], ) const handleDeleteConversation = useCallback( async (conversationId: string) => { if (streaming || bootstrapping) { return } const shortId = conversationId.slice(0, 8) if (!window.confirm(`Delete conversation ${shortId}? This cannot be undone.`)) { return } setErrorMessage(null) try { await deleteConversation(conversationId) const refreshed = await refreshConversationList() if (conversationId !== activeConversationId) { return } const nextConversation = refreshed.find( (conversation) => conversation.conversation_id !== conversationId, ) if (!nextConversation) { clearConversationState() return } setActiveConversationId(nextConversation.conversation_id) await loadConversationState(nextConversation.conversation_id) } catch (error) { setErrorMessage(error instanceof Error ? error.message : 'Failed to delete conversation') } }, [ activeConversationId, bootstrapping, clearConversationState, loadConversationState, refreshConversationList, streaming, ], ) useEffect(() => { const intervalId = window.setInterval(() => { void refreshConversationList() }, 10000) return () => { window.clearInterval(intervalId) } }, [refreshConversationList]) const handleSendMessage = useCallback(async () => { const nextMessage = input.trim() if ((!nextMessage && !attachments.length) || streaming) { return } const pendingAttachments = attachments setStreaming(true) setErrorMessage(null) setInput('') setAttachments([]) const messageContent: string | RawMessageContentPart[] = pendingAttachments.length ? [ ...(nextMessage ? [{ type: 'text', text: nextMessage } as const] : []), ...pendingAttachments.map( (attachment) => ({ type: 'image_url', image_url: { url: attachment.dataUrl }, }) as const, ), ] : nextMessage try { const conversationId = await ensureConversation() setActiveConversationId(conversationId) const optimisticTurn = createTurn( nextMessage, pendingAttachments.map((attachment) => attachment.dataUrl), ) setTurns((currentTurns) => [...currentTurns, optimisticTurn]) const result = await streamChatEvents({ conversationId, model: selectedModel, messageContent, onPacket: (packet) => { if (packet.event === 'done') { return } if (packet.event === 'error') { const packetMessage = typeof packet.data === 'object' && packet.data !== null && 'message' in packet.data && typeof packet.data.message === 'string' ? packet.data.message : 'The stream reported an unknown error.' setErrorMessage(packetMessage) setTurns((currentTurns) => buildErrorTurn(currentTurns, packetMessage)) return } setTurns((currentTurns) => { if (!currentTurns.length) { return currentTurns } const nextTurns = [...currentTurns] nextTurns[nextTurns.length - 1] = applyStreamPacketToTurn( nextTurns[nextTurns.length - 1], packet, ) return nextTurns }) }, }) const finalConversationId = result.conversationId || conversationId setActiveConversationId(finalConversationId) await Promise.all([refreshConversationList(), loadConversationState(finalConversationId)]) } catch (error) { const message = error instanceof Error ? error.message : 'Streaming request failed' setErrorMessage(message) setAttachments(pendingAttachments) setTurns((currentTurns) => buildErrorTurn(currentTurns, message)) } finally { setStreaming(false) } }, [ attachments, ensureConversation, input, loadConversationState, refreshConversationList, selectedModel, streaming, ]) return (
setSidebarCollapsed((value) => !value)} onCreateConversation={() => void handleCreateConversation()} onSelectConversation={(conversationId) => void handleSelectConversation(conversationId)} onDeleteConversation={(conversationId) => void handleDeleteConversation(conversationId)} />
void handleAttachImages(files)} onRemoveImage={handleRemoveImage} onModelChange={setSelectedModel} onSubmit={() => void handleSendMessage()} />

Loading workbench

Preparing the preview and code panels.

} >
) } export default App