From 596153085a4d6b1b610fb9ae07065037d4dca5dc Mon Sep 17 00:00:00 2001
From: chihlasm
Date: Fri, 27 Feb 2026 07:20:04 -0500
Subject: [PATCH] =?UTF-8?q?feat:=20add=20AI=20chat=20builder=20frontend=20?=
=?UTF-8?q?=E2=80=94=20types,=20API=20client,=20store,=20components,=20pag?=
=?UTF-8?q?e,=20routing?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- TypeScript types for chat session, messages, and responses
- API client module with all 6 endpoints
- Zustand store with session management, message sending, tree generation, import, resume
- 7 chat components: ChatMessage, ChatInput, ChatPanel, PhaseIndicator, ChatToolbar, EmptyPreview, StaticTreePreview
- AIChatBuilderPage with split-panel layout (60% chat / 40% preview)
- Route at /ai/chat with lazy loading
- "Build with AI" button on TreeLibraryPage
- Session resume via URL search params
Co-Authored-By: Claude Opus 4.6
---
frontend/src/api/aiChat.ts | 44 ++++
frontend/src/api/index.ts | 1 +
frontend/src/components/ai-chat/ChatInput.tsx | 72 +++++++
.../src/components/ai-chat/ChatMessage.tsx | 41 ++++
frontend/src/components/ai-chat/ChatPanel.tsx | 47 +++++
.../src/components/ai-chat/ChatToolbar.tsx | 76 +++++++
.../src/components/ai-chat/EmptyPreview.tsx | 13 ++
.../src/components/ai-chat/PhaseIndicator.tsx | 50 +++++
.../components/ai-chat/StaticTreePreview.tsx | 80 ++++++++
frontend/src/pages/AIChatBuilderPage.tsx | 151 ++++++++++++++
frontend/src/pages/TreeLibraryPage.tsx | 23 ++-
frontend/src/router.tsx | 9 +
frontend/src/store/aiChatStore.ts | 190 ++++++++++++++++++
frontend/src/types/ai-chat.ts | 43 ++++
frontend/src/types/index.ts | 10 +
15 files changed, 844 insertions(+), 6 deletions(-)
create mode 100644 frontend/src/api/aiChat.ts
create mode 100644 frontend/src/components/ai-chat/ChatInput.tsx
create mode 100644 frontend/src/components/ai-chat/ChatMessage.tsx
create mode 100644 frontend/src/components/ai-chat/ChatPanel.tsx
create mode 100644 frontend/src/components/ai-chat/ChatToolbar.tsx
create mode 100644 frontend/src/components/ai-chat/EmptyPreview.tsx
create mode 100644 frontend/src/components/ai-chat/PhaseIndicator.tsx
create mode 100644 frontend/src/components/ai-chat/StaticTreePreview.tsx
create mode 100644 frontend/src/pages/AIChatBuilderPage.tsx
create mode 100644 frontend/src/store/aiChatStore.ts
create mode 100644 frontend/src/types/ai-chat.ts
diff --git a/frontend/src/api/aiChat.ts b/frontend/src/api/aiChat.ts
new file mode 100644
index 00000000..1722b272
--- /dev/null
+++ b/frontend/src/api/aiChat.ts
@@ -0,0 +1,44 @@
+import { apiClient } from './client'
+import type {
+ AIChatStartResponse,
+ AIChatMessageResponse,
+ AIChatSessionResponse,
+ AIChatGenerateResponse,
+ AIChatImportResponse,
+} from '@/types'
+
+export const aiChatApi = {
+ startSession: async (flowType: 'troubleshooting' | 'procedural'): Promise => {
+ const { data } = await apiClient.post('/ai/chat/sessions', { flow_type: flowType })
+ return data
+ },
+
+ sendMessage: async (sessionId: string, content: string): Promise => {
+ const { data } = await apiClient.post(`/ai/chat/sessions/${sessionId}/messages`, { content })
+ return data
+ },
+
+ getSession: async (sessionId: string): Promise => {
+ const { data } = await apiClient.get(`/ai/chat/sessions/${sessionId}`)
+ return data
+ },
+
+ generateTree: async (sessionId: string): Promise => {
+ const { data } = await apiClient.post(`/ai/chat/sessions/${sessionId}/generate`)
+ return data
+ },
+
+ importTree: async (
+ sessionId: string,
+ params?: { name?: string; description?: string; category_id?: string; tags?: string[] }
+ ): Promise => {
+ const { data } = await apiClient.post(`/ai/chat/sessions/${sessionId}/import`, params || {})
+ return data
+ },
+
+ abandonSession: async (sessionId: string): Promise => {
+ await apiClient.delete(`/ai/chat/sessions/${sessionId}`)
+ },
+}
+
+export default aiChatApi
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts
index fba65d24..016efcbe 100644
--- a/frontend/src/api/index.ts
+++ b/frontend/src/api/index.ts
@@ -17,3 +17,4 @@ export { targetListsApi } from './targetLists'
export { maintenanceSchedulesApi, batchLaunchApi } from './maintenanceSchedules'
export { default as feedbackApi } from './feedback'
export { default as aiBuilderApi } from './aiBuilder'
+export { default as aiChatApi } from './aiChat'
diff --git a/frontend/src/components/ai-chat/ChatInput.tsx b/frontend/src/components/ai-chat/ChatInput.tsx
new file mode 100644
index 00000000..c10f4970
--- /dev/null
+++ b/frontend/src/components/ai-chat/ChatInput.tsx
@@ -0,0 +1,72 @@
+import { useState, useRef, useCallback } from 'react'
+import { Send } from 'lucide-react'
+import { cn } from '@/lib/utils'
+
+interface ChatInputProps {
+ onSend: (content: string) => void
+ disabled?: boolean
+ placeholder?: string
+}
+
+export function ChatInput({ onSend, disabled, placeholder = 'Type a message...' }: ChatInputProps) {
+ const [value, setValue] = useState('')
+ const textareaRef = useRef(null)
+
+ const handleSend = useCallback(() => {
+ const trimmed = value.trim()
+ if (!trimmed || disabled) return
+ onSend(trimmed)
+ setValue('')
+ if (textareaRef.current) {
+ textareaRef.current.style.height = 'auto'
+ }
+ }, [value, disabled, onSend])
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ handleSend()
+ }
+ }
+
+ const handleInput = () => {
+ if (textareaRef.current) {
+ textareaRef.current.style.height = 'auto'
+ textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 160) + 'px'
+ }
+ }
+
+ return (
+
+
+ )
+}
diff --git a/frontend/src/components/ai-chat/ChatMessage.tsx b/frontend/src/components/ai-chat/ChatMessage.tsx
new file mode 100644
index 00000000..844d2fc8
--- /dev/null
+++ b/frontend/src/components/ai-chat/ChatMessage.tsx
@@ -0,0 +1,41 @@
+import { Bot, User } from 'lucide-react'
+import { MarkdownContent } from '@/components/ui/MarkdownContent'
+import { cn } from '@/lib/utils'
+import type { ChatMessage as ChatMessageType } from '@/types'
+
+interface ChatMessageProps {
+ message: ChatMessageType
+}
+
+export function ChatMessage({ message }: ChatMessageProps) {
+ const isAI = message.role === 'assistant'
+
+ return (
+
+ {isAI && (
+
+
+
+ )}
+
+ {isAI ? (
+
+ ) : (
+
{message.content}
+ )}
+
+ {!isAI && (
+
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/ai-chat/ChatPanel.tsx b/frontend/src/components/ai-chat/ChatPanel.tsx
new file mode 100644
index 00000000..f3083673
--- /dev/null
+++ b/frontend/src/components/ai-chat/ChatPanel.tsx
@@ -0,0 +1,47 @@
+import { useEffect, useRef } from 'react'
+import { ChatMessage } from './ChatMessage'
+import { ChatInput } from './ChatInput'
+import { Spinner } from '@/components/common/Spinner'
+import type { ChatMessage as ChatMessageType } from '@/types'
+
+interface ChatPanelProps {
+ messages: ChatMessageType[]
+ isResponding: boolean
+ onSendMessage: (content: string) => void
+ disabled?: boolean
+}
+
+export function ChatPanel({ messages, isResponding, onSendMessage, disabled }: ChatPanelProps) {
+ const scrollRef = useRef(null)
+
+ // Auto-scroll to bottom on new messages
+ useEffect(() => {
+ if (scrollRef.current) {
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight
+ }
+ }, [messages, isResponding])
+
+ return (
+
+ {/* Messages */}
+
+ {messages.map((msg, i) => (
+
+ ))}
+ {isResponding && (
+
+
+ Thinking...
+
+ )}
+
+
+ {/* Input */}
+
+
+ )
+}
diff --git a/frontend/src/components/ai-chat/ChatToolbar.tsx b/frontend/src/components/ai-chat/ChatToolbar.tsx
new file mode 100644
index 00000000..d1dae8b7
--- /dev/null
+++ b/frontend/src/components/ai-chat/ChatToolbar.tsx
@@ -0,0 +1,76 @@
+import { Sparkles, Download, RotateCcw, ArrowRight } from 'lucide-react'
+import { PhaseIndicator } from './PhaseIndicator'
+import { cn } from '@/lib/utils'
+import type { InterviewPhase } from '@/types'
+
+interface ChatToolbarProps {
+ currentPhase: InterviewPhase
+ status: 'idle' | 'active' | 'completed' | 'abandoned'
+ isGenerating: boolean
+ hasGeneratedTree: boolean
+ onGenerate: () => void
+ onImport: () => void
+ onReset: () => void
+}
+
+export function ChatToolbar({
+ currentPhase,
+ status,
+ isGenerating,
+ hasGeneratedTree,
+ onGenerate,
+ onImport,
+ onReset,
+}: ChatToolbarProps) {
+ return (
+
+
+
+
+ Build with AI
+
+
+
+
+
+ {status === 'active' && !hasGeneratedTree && (
+
+ )}
+
+ {hasGeneratedTree && (
+
+ )}
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/ai-chat/EmptyPreview.tsx b/frontend/src/components/ai-chat/EmptyPreview.tsx
new file mode 100644
index 00000000..69985bf5
--- /dev/null
+++ b/frontend/src/components/ai-chat/EmptyPreview.tsx
@@ -0,0 +1,13 @@
+import { TreeDeciduous } from 'lucide-react'
+
+export function EmptyPreview() {
+ return (
+
+
+
Flow Preview
+
+ Your flow will appear here as you describe it to the AI
+
+
+ )
+}
diff --git a/frontend/src/components/ai-chat/PhaseIndicator.tsx b/frontend/src/components/ai-chat/PhaseIndicator.tsx
new file mode 100644
index 00000000..41c16fc0
--- /dev/null
+++ b/frontend/src/components/ai-chat/PhaseIndicator.tsx
@@ -0,0 +1,50 @@
+import { cn } from '@/lib/utils'
+import type { InterviewPhase } from '@/types'
+
+const PHASES: { key: InterviewPhase; label: string }[] = [
+ { key: 'scoping', label: 'Scoping' },
+ { key: 'discovery', label: 'Discovery' },
+ { key: 'enrichment', label: 'Enrichment' },
+ { key: 'review', label: 'Review' },
+ { key: 'generation', label: 'Generate' },
+]
+
+interface PhaseIndicatorProps {
+ currentPhase: InterviewPhase
+}
+
+export function PhaseIndicator({ currentPhase }: PhaseIndicatorProps) {
+ const currentIndex = PHASES.findIndex((p) => p.key === currentPhase)
+
+ return (
+
+ {PHASES.map((phase, i) => {
+ const isActive = phase.key === currentPhase
+ const isCompleted = i < currentIndex
+
+ return (
+
+ {i > 0 && (
+
+ )}
+
+ {phase.label}
+
+
+ )
+ })}
+
+ )
+}
diff --git a/frontend/src/components/ai-chat/StaticTreePreview.tsx b/frontend/src/components/ai-chat/StaticTreePreview.tsx
new file mode 100644
index 00000000..57207e01
--- /dev/null
+++ b/frontend/src/components/ai-chat/StaticTreePreview.tsx
@@ -0,0 +1,80 @@
+import { useState, useMemo, useCallback } from 'react'
+import { TreePreviewNode } from '@/components/tree-preview/TreePreviewNode'
+import type { SharedLinksMap } from '@/components/tree-preview/TreePreviewPanel'
+import type { TreeStructure } from '@/types'
+
+interface StaticTreePreviewProps {
+ tree: TreeStructure
+ name?: string
+}
+
+function findNodeInTree(nodeId: string, tree: TreeStructure): TreeStructure | null {
+ if (tree.id === nodeId) return tree
+ if (tree.children) {
+ for (const child of tree.children) {
+ const found = findNodeInTree(nodeId, child)
+ if (found) return found
+ }
+ }
+ return null
+}
+
+function buildSharedLinksMap(node: TreeStructure, map: SharedLinksMap = new Map()): SharedLinksMap {
+ const nodeLabel = node.type === 'decision' ? node.question : node.title
+ if (node.type === 'decision' && node.options) {
+ for (const opt of node.options) {
+ if (opt.next_node_id) {
+ const existing = map.get(opt.next_node_id) || []
+ existing.push({ id: node.id, label: nodeLabel || 'Untitled' })
+ map.set(opt.next_node_id, existing)
+ }
+ }
+ }
+ if (node.type === 'action' && node.next_node_id) {
+ const existing = map.get(node.next_node_id) || []
+ existing.push({ id: node.id, label: nodeLabel || 'Untitled' })
+ map.set(node.next_node_id, existing)
+ }
+ if (node.children) {
+ for (const child of node.children) {
+ buildSharedLinksMap(child, map)
+ }
+ }
+ return map
+}
+
+export function StaticTreePreview({ tree, name }: StaticTreePreviewProps) {
+ const [selectedNodeId, setSelectedNodeId] = useState(null)
+
+ const findNode = useCallback(
+ (nodeId: string) => findNodeInTree(nodeId, tree),
+ [tree]
+ )
+
+ const sharedLinksMap = useMemo(() => buildSharedLinksMap(tree), [tree])
+
+ return (
+
+
+
+ Preview: {name || 'Untitled Flow'}
+
+
+ Click a node to select
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/AIChatBuilderPage.tsx b/frontend/src/pages/AIChatBuilderPage.tsx
new file mode 100644
index 00000000..913cce73
--- /dev/null
+++ b/frontend/src/pages/AIChatBuilderPage.tsx
@@ -0,0 +1,151 @@
+import { useCallback, useEffect } from 'react'
+import { useNavigate, useSearchParams } from 'react-router-dom'
+import { useAIChatStore } from '@/store/aiChatStore'
+import { ChatPanel } from '@/components/ai-chat/ChatPanel'
+import { ChatToolbar } from '@/components/ai-chat/ChatToolbar'
+import { EmptyPreview } from '@/components/ai-chat/EmptyPreview'
+import { StaticTreePreview } from '@/components/ai-chat/StaticTreePreview'
+import { Spinner } from '@/components/common/Spinner'
+import { getTreeEditorPath } from '@/lib/routing'
+import { toast } from '@/lib/toast'
+import type { TreeStructure } from '@/types'
+
+export function AIChatBuilderPage() {
+ const navigate = useNavigate()
+ const [searchParams, setSearchParams] = useSearchParams()
+ const flowType = searchParams.get('type') === 'procedural' ? 'procedural' : 'troubleshooting'
+
+ const {
+ sessionId,
+ status,
+ currentPhase,
+ messages,
+ isResponding,
+ workingTree,
+ treeMetadata,
+ generatedTree,
+ isGenerating,
+ error,
+ startSession,
+ sendMessage,
+ generateTree,
+ importToEditor,
+ abandonSession,
+ resumeSession,
+ } = useAIChatStore()
+
+ // Start or resume session on mount
+ useEffect(() => {
+ const resumeId = searchParams.get('session')
+ if (resumeId && !sessionId) {
+ resumeSession(resumeId)
+ } else if (!sessionId && status === 'idle') {
+ startSession(flowType as 'troubleshooting' | 'procedural')
+ }
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Store sessionId in URL for resume support
+ useEffect(() => {
+ if (sessionId && !searchParams.get('session')) {
+ setSearchParams((prev) => {
+ const next = new URLSearchParams(prev)
+ next.set('session', sessionId)
+ return next
+ }, { replace: true })
+ }
+ }, [sessionId, searchParams, setSearchParams])
+
+ const handleSendMessage = useCallback(
+ (content: string) => {
+ sendMessage(content)
+ },
+ [sendMessage]
+ )
+
+ const handleGenerate = useCallback(() => {
+ generateTree()
+ }, [generateTree])
+
+ const handleImport = useCallback(async () => {
+ try {
+ const treeId = await importToEditor({
+ name: treeMetadata?.name,
+ description: treeMetadata?.description,
+ tags: treeMetadata?.tags,
+ })
+ const path = getTreeEditorPath(treeId, flowType)
+ navigate(path)
+ toast.success('Flow imported to editor')
+ } catch {
+ toast.error('Failed to import flow')
+ }
+ }, [importToEditor, treeMetadata, flowType, navigate])
+
+ const handleReset = useCallback(() => {
+ abandonSession()
+ // Clear session from URL
+ setSearchParams((prev) => {
+ const next = new URLSearchParams(prev)
+ next.delete('session')
+ return next
+ }, { replace: true })
+ startSession(flowType as 'troubleshooting' | 'procedural')
+ }, [abandonSession, startSession, flowType, setSearchParams])
+
+ // Show error toast
+ useEffect(() => {
+ if (error) {
+ toast.error(error)
+ }
+ }, [error])
+
+ if (status === 'idle' && !sessionId) {
+ return (
+
+
+
+ )
+ }
+
+ const previewTree = (generatedTree || workingTree) as TreeStructure | null
+
+ return (
+
+
+
+
+ {/* Left panel: Chat (60%) */}
+
+
+
+
+ {/* Right panel: Tree preview (40%) — hidden below 1024px */}
+
+ {previewTree ? (
+
+ ) : (
+
+ )}
+
+
+
+ )
+}
+
+export default AIChatBuilderPage
diff --git a/frontend/src/pages/TreeLibraryPage.tsx b/frontend/src/pages/TreeLibraryPage.tsx
index 12e08e2f..b7a83f5d 100644
--- a/frontend/src/pages/TreeLibraryPage.tsx
+++ b/frontend/src/pages/TreeLibraryPage.tsx
@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback, useMemo } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
-import { X, RotateCcw, Play } from 'lucide-react'
+import { X, RotateCcw, Play, Sparkles } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { categoriesApi } from '@/api/categories'
import { foldersApi } from '@/api/folders'
@@ -272,11 +272,22 @@ export function TreeLibraryPage() {
{canCreateTrees && (
- setShowAIBuilder(true)}
- label="Create New"
- />
+
+ {aiEnabled && (
+
+ )}
+ setShowAIBuilder(true)}
+ label="Create New"
+ />
+
)}
diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx
index 3d497ea3..da3162a9 100644
--- a/frontend/src/router.tsx
+++ b/frontend/src/router.tsx
@@ -33,6 +33,7 @@ const TeamAnalyticsPage = lazy(() => import('@/pages/TeamAnalyticsPage'))
const MyAnalyticsPage = lazy(() => import('@/pages/MyAnalyticsPage'))
const FeedbackPage = lazy(() => import('@/pages/FeedbackPage'))
const StepLibraryPage = lazy(() => import('@/pages/StepLibraryPage'))
+const AIChatBuilderPage = lazy(() => import('@/pages/AIChatBuilderPage'))
const AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage'))
// Admin pages
const AdminLayout = lazy(() => import('@/components/admin/AdminLayout'))
@@ -253,6 +254,14 @@ export const router = createBrowserRouter([
),
},
+ {
+ path: 'ai/chat',
+ element: (
+ }>
+
+
+ ),
+ },
// Admin routes
{
path: 'admin',
diff --git a/frontend/src/store/aiChatStore.ts b/frontend/src/store/aiChatStore.ts
new file mode 100644
index 00000000..60eaa5fa
--- /dev/null
+++ b/frontend/src/store/aiChatStore.ts
@@ -0,0 +1,190 @@
+import { create } from 'zustand'
+import { aiChatApi } from '@/api/aiChat'
+import type {
+ ChatMessage,
+ InterviewPhase,
+ TreeStructure,
+} from '@/types'
+
+interface TreeMetadata {
+ name?: string
+ description?: string
+ tags?: string[]
+ category_id?: string
+}
+
+interface AIChatState {
+ // Session
+ sessionId: string | null
+ status: 'idle' | 'active' | 'completed' | 'abandoned'
+ currentPhase: InterviewPhase
+ flowType: 'troubleshooting' | 'procedural' | null
+
+ // Conversation
+ messages: ChatMessage[]
+ isResponding: boolean
+
+ // Progressive tree
+ workingTree: TreeStructure | null
+ treeMetadata: TreeMetadata | null
+
+ // Final generation
+ generatedTree: TreeStructure | null
+ isGenerating: boolean
+ importedTreeId: string | null
+
+ // Error
+ error: string | null
+
+ // Actions
+ startSession: (flowType: 'troubleshooting' | 'procedural') => Promise
+ sendMessage: (content: string) => Promise
+ generateTree: () => Promise
+ importToEditor: (params?: { name?: string; description?: string; category_id?: string; tags?: string[] }) => Promise
+ abandonSession: () => Promise
+ resumeSession: (sessionId: string) => Promise
+ reset: () => void
+}
+
+const initialState = {
+ sessionId: null,
+ status: 'idle' as const,
+ currentPhase: 'scoping' as InterviewPhase,
+ flowType: null,
+ messages: [],
+ isResponding: false,
+ workingTree: null,
+ treeMetadata: null,
+ generatedTree: null,
+ isGenerating: false,
+ importedTreeId: null,
+ error: null,
+}
+
+export const useAIChatStore = create((set, get) => ({
+ ...initialState,
+
+ startSession: async (flowType) => {
+ set({ ...initialState, status: 'active', flowType, isResponding: true, error: null })
+
+ try {
+ const response = await aiChatApi.startSession(flowType)
+ set({
+ sessionId: response.session_id,
+ currentPhase: response.current_phase,
+ messages: [{
+ role: 'assistant',
+ content: response.greeting,
+ timestamp: new Date().toISOString(),
+ }],
+ isResponding: false,
+ })
+ } catch (e: unknown) {
+ const message = e instanceof Error ? e.message : 'Failed to start session'
+ set({ error: message, isResponding: false, status: 'idle' })
+ }
+ },
+
+ sendMessage: async (content) => {
+ const { sessionId, messages } = get()
+ if (!sessionId) return
+
+ const userMessage: ChatMessage = {
+ role: 'user',
+ content,
+ timestamp: new Date().toISOString(),
+ }
+
+ set({
+ messages: [...messages, userMessage],
+ isResponding: true,
+ error: null,
+ })
+
+ try {
+ const response = await aiChatApi.sendMessage(sessionId, content)
+ const aiMessage: ChatMessage = {
+ role: 'assistant',
+ content: response.content,
+ timestamp: new Date().toISOString(),
+ }
+
+ set((state) => ({
+ messages: [...state.messages, aiMessage],
+ currentPhase: response.current_phase,
+ workingTree: (response.working_tree as TreeStructure | null) ?? state.workingTree,
+ treeMetadata: (response.tree_metadata as TreeMetadata | null) ?? state.treeMetadata,
+ isResponding: false,
+ }))
+ } catch (e: unknown) {
+ const message = e instanceof Error ? e.message : 'Failed to send message'
+ set({ error: message, isResponding: false })
+ }
+ },
+
+ generateTree: async () => {
+ const { sessionId } = get()
+ if (!sessionId) return
+
+ set({ isGenerating: true, error: null })
+
+ try {
+ const response = await aiChatApi.generateTree(sessionId)
+ set({
+ generatedTree: response.tree_structure as unknown as TreeStructure,
+ workingTree: response.tree_structure as unknown as TreeStructure,
+ treeMetadata: response.tree_metadata as TreeMetadata,
+ status: 'completed',
+ isGenerating: false,
+ })
+ } catch (e: unknown) {
+ const message = e instanceof Error ? e.message : 'Failed to generate tree'
+ set({ error: message, isGenerating: false })
+ }
+ },
+
+ importToEditor: async (params) => {
+ const { sessionId } = get()
+ if (!sessionId) throw new Error('No active session')
+
+ const response = await aiChatApi.importTree(sessionId, params)
+ set({ importedTreeId: response.tree_id })
+ return response.tree_id
+ },
+
+ abandonSession: async () => {
+ const { sessionId } = get()
+ if (!sessionId) return
+
+ try {
+ await aiChatApi.abandonSession(sessionId)
+ } catch {
+ // Best effort — session may have already expired
+ }
+ set({ ...initialState })
+ },
+
+ resumeSession: async (sessionId) => {
+ set({ isResponding: true, error: null })
+
+ try {
+ const session = await aiChatApi.getSession(sessionId)
+ set({
+ sessionId: session.session_id,
+ status: session.status === 'active' ? 'active' : (session.status as 'completed' | 'abandoned'),
+ currentPhase: session.current_phase,
+ flowType: session.flow_type,
+ messages: session.conversation_history as ChatMessage[],
+ workingTree: session.working_tree as TreeStructure | null,
+ treeMetadata: session.tree_metadata as TreeMetadata | null,
+ generatedTree: session.generated_tree as TreeStructure | null,
+ isResponding: false,
+ })
+ } catch (e: unknown) {
+ const message = e instanceof Error ? e.message : 'Failed to resume session'
+ set({ error: message, isResponding: false })
+ }
+ },
+
+ reset: () => set({ ...initialState }),
+}))
diff --git a/frontend/src/types/ai-chat.ts b/frontend/src/types/ai-chat.ts
new file mode 100644
index 00000000..95804ccf
--- /dev/null
+++ b/frontend/src/types/ai-chat.ts
@@ -0,0 +1,43 @@
+export type InterviewPhase = 'scoping' | 'discovery' | 'enrichment' | 'review' | 'generation'
+
+export interface ChatMessage {
+ role: 'user' | 'assistant'
+ content: string
+ timestamp: string
+}
+
+export interface AIChatStartResponse {
+ session_id: string
+ greeting: string
+ current_phase: InterviewPhase
+}
+
+export interface AIChatMessageResponse {
+ content: string
+ current_phase: InterviewPhase
+ working_tree: Record | null
+ tree_metadata: Record | null
+}
+
+export interface AIChatSessionResponse {
+ session_id: string
+ status: 'active' | 'completed' | 'abandoned'
+ current_phase: InterviewPhase
+ flow_type: 'troubleshooting' | 'procedural'
+ conversation_history: ChatMessage[]
+ working_tree: Record | null
+ tree_metadata: Record | null
+ message_count: number
+ generated_tree: Record | null
+}
+
+export interface AIChatGenerateResponse {
+ tree_structure: Record
+ tree_metadata: Record
+ status: string
+}
+
+export interface AIChatImportResponse {
+ tree_id: string
+ tree_type: string
+}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 2c388169..ba822afa 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -52,3 +52,13 @@ export type {
AIFixProposal,
AIFixValidationError,
} from './ai-fix'
+
+export type {
+ InterviewPhase,
+ ChatMessage,
+ AIChatStartResponse,
+ AIChatMessageResponse,
+ AIChatSessionResponse,
+ AIChatGenerateResponse,
+ AIChatImportResponse,
+} from './ai-chat'