+ {(settings?.testing_mode || 'full') === 'smart' ? 'UI features only' : 'All features'} +
+ element
+ if (isInlineCode) {
+ return (
+
+ {children}
+
+ )
+ }
+
+ // Block code with syntax highlighting - subtle, not card-like
+ return (
+
+ {/* Language label and copy button - positioned inside */}
+
+
+ {language || 'text'}
+
+
+
+
+
+ {codeString}
+
+
+ )
+}
+
+/**
+ * MarkdownViewer renders markdown content with GFM support
+ * and syntax-highlighted code blocks.
+ */
+export function MarkdownViewer({ content, className }: MarkdownViewerProps) {
+ return (
+
+ >,
+ }}
+ >
+ {content}
+
+
+ )
+}
diff --git a/ui/src/components/research/ReanalyzeCodebaseModal.tsx b/ui/src/components/research/ReanalyzeCodebaseModal.tsx
new file mode 100644
index 00000000..eb1ed88e
--- /dev/null
+++ b/ui/src/components/research/ReanalyzeCodebaseModal.tsx
@@ -0,0 +1,180 @@
+/**
+ * Reanalyze Codebase Modal Component
+ *
+ * A confirmation dialog for re-analyzing an existing project's codebase.
+ * This is used when a project is already registered and the user wants
+ * to update the research documentation after external changes.
+ */
+
+import { useState } from 'react'
+import { Loader2, Microscope, RefreshCw, AlertCircle, FileText } from 'lucide-react'
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+
+interface ReanalyzeCodebaseModalProps {
+ isOpen: boolean
+ projectName: string
+ projectPath?: string
+ onClose: () => void
+ onStartAnalysis: () => void
+}
+
+export function ReanalyzeCodebaseModal({
+ isOpen,
+ projectName,
+ projectPath,
+ onClose,
+ onStartAnalysis,
+}: ReanalyzeCodebaseModalProps) {
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ // Start the research analysis
+ const handleStartAnalysis = async () => {
+ setIsLoading(true)
+ setError(null)
+
+ try {
+ // Call the API to start research analysis on existing project
+ const response = await fetch(
+ `/api/projects/${encodeURIComponent(projectName)}/agent/start-research`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({}),
+ }
+ )
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }))
+ throw new Error(errorData.detail || `HTTP ${response.status}`)
+ }
+
+ // Success - call the callback to navigate to progress view
+ onStartAnalysis()
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to start analysis')
+ setIsLoading(false)
+ }
+ }
+
+ // Handle modal close
+ const handleClose = () => {
+ if (!isLoading) {
+ setError(null)
+ onClose()
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/ui/src/components/research/ResearchProgressView.tsx b/ui/src/components/research/ResearchProgressView.tsx
new file mode 100644
index 00000000..390fa656
--- /dev/null
+++ b/ui/src/components/research/ResearchProgressView.tsx
@@ -0,0 +1,505 @@
+/**
+ * ResearchProgressView Component
+ *
+ * Displays real-time progress while the Research Agent analyzes a codebase.
+ * Shows phase indicators, progress bar, statistics, and terminal-style logs.
+ */
+
+import { useState, useEffect, useRef, useCallback } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { Microscope, FileSearch, Brain, FileText, CheckCircle, Square, ChevronDown, ChevronUp, ArrowRight } from 'lucide-react'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import { ScrollArea } from '@/components/ui/scroll-area'
+import { useProjectWebSocket } from '@/hooks/useWebSocket'
+import type { ResearchPhase, ResearchLogEntry } from '@/lib/types'
+
+// Fetch research status from API
+async function fetchResearchStatus(projectName: string) {
+ const response = await fetch(`/api/projects/${encodeURIComponent(projectName)}/agent/research/status`)
+ if (!response.ok) {
+ throw new Error('Failed to fetch research status')
+ }
+ return response.json()
+}
+
+interface ResearchProgressViewProps {
+ projectName: string
+}
+
+// Phase configuration with labels, descriptions, and progress ranges
+const PHASE_CONFIG: Record = {
+ idle: {
+ label: 'Starting...',
+ description: 'Preparing to analyze codebase',
+ icon: ,
+ progressMin: 0,
+ progressMax: 5,
+ },
+ scanning: {
+ label: 'Scanning files...',
+ description: 'Discovering project structure and files',
+ icon: ,
+ progressMin: 5,
+ progressMax: 25,
+ },
+ analyzing: {
+ label: 'Analyzing code patterns...',
+ description: 'Understanding architecture and patterns',
+ icon: ,
+ progressMin: 25,
+ progressMax: 75,
+ },
+ documenting: {
+ label: 'Generating documentation...',
+ description: 'Writing research findings',
+ icon: ,
+ progressMin: 75,
+ progressMax: 95,
+ },
+ complete: {
+ label: 'Analysis complete!',
+ description: 'Research documentation is ready',
+ icon: ,
+ progressMin: 100,
+ progressMax: 100,
+ },
+}
+
+// Research Agent Mascot SVG component
+function ResearchAgentMascot({ phase, size = 48 }: { phase: ResearchPhase; size?: number }) {
+ // Determine animation class based on phase
+ const animationClass = phase === 'idle' ? 'animate-pulse' :
+ phase === 'scanning' ? 'animate-working' :
+ phase === 'analyzing' ? 'animate-thinking' :
+ phase === 'documenting' ? 'animate-working' :
+ phase === 'complete' ? 'animate-celebrate' : ''
+
+ // Colors for the research agent
+ const COLORS = {
+ primary: '#10B981', // Emerald-500
+ secondary: '#34D399', // Emerald-400
+ accent: '#D1FAE5', // Emerald-100
+ lens: '#60A5FA', // Blue-400
+ }
+
+ return (
+
+
+
+ )
+}
+
+// Calculate progress percentage based on phase AND actual metrics
+function calculateProgress(
+ phase: ResearchPhase,
+ filesScanned: number = 0,
+ findingsCount: number = 0
+): number {
+ // Base progress from phase
+ const config = PHASE_CONFIG[phase]
+
+ switch (phase) {
+ case 'idle':
+ return 5
+
+ case 'scanning': {
+ // 5-25%: Progress based on files scanned (estimate ~50 files typical)
+ const scanProgress = Math.min(filesScanned / 50, 1)
+ return 5 + scanProgress * 20
+ }
+
+ case 'analyzing': {
+ // 25-75%: Progress based on findings (estimate ~25 findings typical)
+ const analyzeProgress = Math.min(findingsCount / 25, 1)
+ return 25 + analyzeProgress * 50
+ }
+
+ case 'documenting':
+ // 75-95%: Linear progress during documentation
+ return 85
+
+ case 'complete':
+ return 100
+
+ default:
+ return (config.progressMin + config.progressMax) / 2
+ }
+}
+
+// Format timestamp for log display
+function formatLogTime(timestamp: string): string {
+ try {
+ const date = new Date(timestamp)
+ return date.toLocaleTimeString('en-US', {
+ hour12: false,
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ })
+ } catch {
+ return '--:--:--'
+ }
+}
+
+export function ResearchProgressView({ projectName }: ResearchProgressViewProps) {
+ const navigate = useNavigate()
+ const queryClient = useQueryClient()
+ const { researchState: wsResearchState, isConnected, clearResearchState } = useProjectWebSocket(projectName)
+ const [isLogsExpanded, setIsLogsExpanded] = useState(true)
+ const [isStopping, setIsStopping] = useState(false)
+ const logsEndRef = useRef(null)
+ const maxProgressRef = useRef(0) // Track highest progress to prevent going backwards
+
+ // Reset maxProgress, clear stale WebSocket state, and invalidate query cache on mount
+ useEffect(() => {
+ maxProgressRef.current = 0
+ clearResearchState()
+ // Invalidate query cache to ensure fresh data
+ queryClient.invalidateQueries({ queryKey: ['researchStatus', projectName] })
+ }, [projectName, clearResearchState, queryClient])
+
+ // Poll research status from API to get current state (especially if we missed WebSocket updates)
+ const { data: apiStatus } = useQuery({
+ queryKey: ['researchStatus', projectName],
+ queryFn: () => fetchResearchStatus(projectName),
+ refetchInterval: 1000, // Poll every 1 second for responsive updates
+ staleTime: 0, // Always consider data stale
+ gcTime: 0, // Don't cache results
+ enabled: !!projectName,
+ })
+
+ // Merge WebSocket state with API status
+ // API is authoritative for ALL metrics (phase, filesScanned, findingsCount)
+ // WebSocket only provides real-time activity logs
+ const researchState = (() => {
+ // API is the source of truth for metrics
+ const apiState = apiStatus ? {
+ phase: (apiStatus.phase ?? 'idle') as ResearchPhase,
+ filesScanned: apiStatus.files_scanned ?? 0,
+ findingsCount: apiStatus.findings_count ?? 0,
+ finalized: apiStatus.finalized ?? false,
+ currentTool: wsResearchState?.currentTool ?? null,
+ filesWritten: [] as string[],
+ logs: wsResearchState?.logs ?? [],
+ } : null
+
+ // If no API data yet, use WebSocket state but with 0 counts (they're unreliable)
+ if (!apiState && wsResearchState) {
+ return {
+ ...wsResearchState,
+ filesScanned: 0, // WebSocket can't track this reliably
+ findingsCount: 0, // WebSocket can't track this reliably
+ }
+ }
+
+ return apiState
+ })()
+
+ // Scroll to bottom of logs when new entries arrive
+ useEffect(() => {
+ if (isLogsExpanded && logsEndRef.current) {
+ logsEndRef.current.scrollIntoView({ behavior: 'smooth' })
+ }
+ }, [researchState?.logs, isLogsExpanded])
+
+ // Stop analysis handler
+ const handleStopAnalysis = useCallback(async () => {
+ setIsStopping(true)
+ try {
+ const response = await fetch(`/api/projects/${encodeURIComponent(projectName)}/agent/research/stop`, {
+ method: 'POST',
+ })
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ message: 'Unknown error' }))
+ console.error('Failed to stop research agent:', error)
+ } else {
+ // Navigate back to project after stopping
+ navigate(`/?project=${encodeURIComponent(projectName)}`)
+ }
+ } catch (error) {
+ console.error('Error stopping research agent:', error)
+ } finally {
+ setIsStopping(false)
+ }
+ }, [projectName, navigate])
+
+ // Navigate to results handler
+ const handleViewResults = useCallback(() => {
+ navigate(`/research/${encodeURIComponent(projectName)}/results`)
+ }, [navigate, projectName])
+
+ // Derive current phase - default to idle if no research state
+ const currentPhase: ResearchPhase = researchState?.phase ?? 'idle'
+ const phaseConfig = PHASE_CONFIG[currentPhase]
+
+ // Calculate progress based on actual metrics
+ const filesScanned = researchState?.filesScanned ?? 0
+ const findingsCount = researchState?.findingsCount ?? 0
+ const rawProgress = calculateProgress(currentPhase, filesScanned, findingsCount)
+
+ // Reset progress when a new research starts or no research exists
+ // Detected by: phase is null/idle, or scanning with 0 findings
+ if (!currentPhase || currentPhase === 'idle' || (currentPhase === 'scanning' && findingsCount === 0)) {
+ maxProgressRef.current = 0
+ }
+
+ // Progress should never go backwards (except on reset)
+ if (rawProgress > maxProgressRef.current) {
+ maxProgressRef.current = rawProgress
+ }
+ const progress = maxProgressRef.current
+
+ const isComplete = currentPhase === 'complete'
+ const logs = researchState?.logs ?? []
+
+ return (
+
+ {/* Main Progress Card */}
+
+
+
+
+
+
+ {phaseConfig.icon}
+ {phaseConfig.label}
+ {!isConnected && (
+
+ Reconnecting...
+
+ )}
+
+
+ {phaseConfig.description}
+
+
+
+ {/* Action Buttons */}
+
+ {isComplete ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {/* Progress Bar */}
+
+
+ Progress
+ {Math.round(progress)}%
+
+
+
+ {!isComplete && (
+
+ )}
+
+
+
+
+ {/* Phase Indicators */}
+
+ {(['scanning', 'analyzing', 'documenting', 'complete'] as ResearchPhase[]).map((phase, idx) => {
+ const config = PHASE_CONFIG[phase]
+ const isActive = currentPhase === phase
+ const isPast = PHASE_CONFIG[currentPhase].progressMin >= config.progressMin
+
+ return (
+ 0 ? 'border-l border-border' : ''
+ }`}
+ >
+
+ {config.icon}
+
+
+ {config.label.replace('...', '')}
+
+
+ )
+ })}
+
+
+ {/* Stats Row */}
+
+
+
+
+ Files Scanned
+
+
+ {researchState?.filesScanned ?? 0}
+
+
+
+
+
+ Findings
+
+
+ {researchState?.findingsCount ?? 0}
+
+
+
+
+
+
+ {/* Logs Panel */}
+
+
+
+ {isLogsExpanded && (
+
+
+
+ {logs.length === 0 ? (
+
+ Waiting for activity...
+
+ ) : (
+ logs.map((log: ResearchLogEntry, idx: number) => (
+
+
+ {formatLogTime(log.timestamp)}
+
+
+ {log.message}
+
+
+ ))
+ )}
+
+
+
+
+ )}
+
+
+ )
+}
diff --git a/ui/src/components/research/ResearchResultsView.tsx b/ui/src/components/research/ResearchResultsView.tsx
new file mode 100644
index 00000000..b41e21ef
--- /dev/null
+++ b/ui/src/components/research/ResearchResultsView.tsx
@@ -0,0 +1,350 @@
+/**
+ * ResearchResultsView Component
+ *
+ * Displays the generated documentation after a successful codebase analysis.
+ * Shows tabbed document viewer with markdown rendering and action buttons.
+ */
+
+import { useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import {
+ ArrowLeft,
+ FileText,
+ Layers,
+ Code2,
+ BookOpen,
+ Plug,
+ AlertCircle,
+ ArrowRight,
+ FolderTree,
+ Copy,
+ Check,
+} from 'lucide-react'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { ScrollArea } from '@/components/ui/scroll-area'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { MarkdownViewer } from './MarkdownViewer'
+import { BranchSelectionModal } from './BranchSelectionModal'
+import { cn } from '@/lib/utils'
+import type { ResearchDocsResponse } from '@/lib/types'
+
+// ============================================================================
+// Types
+// ============================================================================
+
+interface ResearchResultsViewProps {
+ projectName: string
+ onConvertToSpec: () => void
+ onBack?: () => void
+}
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+/**
+ * Document tab configuration with icons and labels
+ */
+const DOC_TABS = [
+ { filename: 'STACK.md', label: 'Stack', shortLabel: 'STACK', icon: Layers },
+ { filename: 'ARCHITECTURE.md', label: 'Architecture', shortLabel: 'ARCH', icon: FolderTree },
+ { filename: 'STRUCTURE.md', label: 'Structure', shortLabel: 'STRUCT', icon: Code2 },
+ { filename: 'CONVENTIONS.md', label: 'Conventions', shortLabel: 'CONV', icon: BookOpen },
+ { filename: 'INTEGRATIONS.md', label: 'Integrations', shortLabel: 'INTEG', icon: Plug },
+] as const
+
+// ============================================================================
+// API
+// ============================================================================
+
+async function fetchResearchDocs(projectName: string): Promise {
+ const response = await fetch(`/api/projects/${encodeURIComponent(projectName)}/research-docs`)
+ if (!response.ok) {
+ if (response.status === 404) {
+ throw new Error('Research documentation not found. Run analysis first.')
+ }
+ throw new Error(`Failed to fetch research docs: ${response.statusText}`)
+ }
+ return response.json()
+}
+
+// ============================================================================
+// Helper Components
+// ============================================================================
+
+/**
+ * Loading skeleton for the document viewer
+ */
+function LoadingSkeleton() {
+ return (
+
+ {/* Tabs skeleton */}
+
+
+ {/* Content skeleton */}
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+/**
+ * Empty state when no documents are found
+ */
+function EmptyState({ onBack }: { onBack?: () => void }) {
+ return (
+
+
+
+ No Documentation Found
+
+ The codebase analysis has not generated any documentation yet.
+ Run the analysis first to generate the documentation.
+
+ {onBack && (
+
+ )}
+
+
+ )
+}
+
+// ============================================================================
+// Main Component
+// ============================================================================
+
+/**
+ * ResearchResultsView displays the results of codebase analysis
+ * with tabbed navigation for different documentation sections.
+ */
+export function ResearchResultsView({
+ projectName,
+ onConvertToSpec,
+ onBack,
+}: ResearchResultsViewProps) {
+ const [activeTab, setActiveTab] = useState(DOC_TABS[0].filename)
+ const [copiedDoc, setCopiedDoc] = useState(null)
+ const [showBranchModal, setShowBranchModal] = useState(false)
+
+ // Handle the convert button click - show branch selection first
+ const handleConvertClick = () => {
+ setShowBranchModal(true)
+ }
+
+ // Handle branch selection completion
+ const handleBranchSelected = () => {
+ setShowBranchModal(false)
+ // Proceed with conversion after branch is selected
+ onConvertToSpec()
+ }
+
+ // Fetch research documents
+ const {
+ data,
+ isLoading,
+ isError,
+ error,
+ refetch,
+ } = useQuery({
+ queryKey: ['research-docs', projectName],
+ queryFn: () => fetchResearchDocs(projectName),
+ staleTime: 60000, // Cache for 1 minute
+ retry: 2,
+ })
+
+ // Copy document content to clipboard
+ const handleCopy = async (filename: string, content: string) => {
+ try {
+ await navigator.clipboard.writeText(content)
+ setCopiedDoc(filename)
+ setTimeout(() => setCopiedDoc(null), 2000)
+ } catch (err) {
+ console.error('Failed to copy:', err)
+ }
+ }
+
+ // Format generation timestamp
+ const generatedAt = data?.generated_at
+ ? new Date(data.generated_at * 1000)
+ : null
+
+ return (
+
+ {/* Header */}
+
+
+ {onBack && (
+
+ )}
+
+ Codebase Analysis
+
+ Documentation for
+ {projectName}
+ {generatedAt && (
+
+ ({generatedAt.toLocaleDateString()} at {generatedAt.toLocaleTimeString()})
+
+ )}
+
+
+
+
+
+
+
+ {/* Loading State */}
+ {isLoading && }
+
+ {/* Error State */}
+ {isError && (
+
+
+
+ {error instanceof Error ? error.message : 'Failed to load research documents'}
+
+
+
+ )}
+
+ {/* Empty State */}
+ {data && data.docs.length === 0 && }
+
+ {/* Main Content */}
+ {data && data.docs.length > 0 && (
+ <>
+ {/* Tabbed Document Viewer */}
+
+
+ Generated Documentation
+
+
+
+
+ {DOC_TABS.map((tab) => {
+ const docExists = data.docs.some((d) => d.filename === tab.filename)
+ const Icon = tab.icon
+ return (
+
+
+ {tab.label}
+ {tab.shortLabel}
+
+ )
+ })}
+
+
+ {DOC_TABS.map((tab) => {
+ const doc = data.docs.find((d) => d.filename === tab.filename)
+ return (
+
+ {/* Content container with subtle background */}
+
+ {/* Copy button */}
+ {doc && (
+
+
+
+ )}
+
+
+ {doc?.content ? (
+
+ ) : (
+
+
+ No content available for this document.
+
+ )}
+
+
+
+ )
+ })}
+
+
+
+
+ {/* Bottom CTA */}
+
+
+
+ Ready to start coding?
+
+ Convert this analysis into an AutoForge specification to begin autonomous development.
+
+
+
+
+
+ >
+ )}
+
+ {/* Back to Projects Link */}
+ {onBack && (
+
+
+
+ )}
+
+ {/* Branch Selection Modal */}
+ setShowBranchModal(false)}
+ projectName={projectName}
+ onBranchSelected={handleBranchSelected}
+ />
+
+ )
+}
diff --git a/ui/src/components/research/index.ts b/ui/src/components/research/index.ts
new file mode 100644
index 00000000..c735d496
--- /dev/null
+++ b/ui/src/components/research/index.ts
@@ -0,0 +1,13 @@
+/**
+ * Research Components
+ *
+ * Components for the Research Agent UI integration.
+ * These components handle codebase analysis and research documentation.
+ */
+
+export { AnalyzeCodebaseModal } from './AnalyzeCodebaseModal'
+export { BranchSelectionModal } from './BranchSelectionModal'
+export { ReanalyzeCodebaseModal } from './ReanalyzeCodebaseModal'
+export { ResearchProgressView } from './ResearchProgressView'
+export { ResearchResultsView } from './ResearchResultsView'
+export { MarkdownViewer } from './MarkdownViewer'
diff --git a/ui/src/components/ui/dialog.tsx b/ui/src/components/ui/dialog.tsx
index f96cccef..e1d81ce8 100644
--- a/ui/src/components/ui/dialog.tsx
+++ b/ui/src/components/ui/dialog.tsx
@@ -37,7 +37,7 @@ function DialogOverlay({
) {
+ return (
+
+ )
+}
+
+function RadioGroupItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+ )
+}
+
+export { RadioGroup, RadioGroupItem }
diff --git a/ui/src/components/ui/scroll-area.tsx b/ui/src/components/ui/scroll-area.tsx
new file mode 100644
index 00000000..0f873dcb
--- /dev/null
+++ b/ui/src/components/ui/scroll-area.tsx
@@ -0,0 +1,58 @@
+"use client"
+
+import * as React from "react"
+import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function ScrollArea({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function ScrollBar({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export { ScrollArea, ScrollBar }
diff --git a/ui/src/components/ui/tabs.tsx b/ui/src/components/ui/tabs.tsx
new file mode 100644
index 00000000..7bf18aa7
--- /dev/null
+++ b/ui/src/components/ui/tabs.tsx
@@ -0,0 +1,89 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { Tabs as TabsPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Tabs({
+ className,
+ orientation = "horizontal",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+const tabsListVariants = cva(
+ "rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
+ {
+ variants: {
+ variant: {
+ default: "bg-muted",
+ line: "gap-1 bg-transparent",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function TabsList({
+ className,
+ variant = "default",
+ ...props
+}: React.ComponentProps &
+ VariantProps) {
+ return (
+
+ )
+}
+
+function TabsTrigger({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
diff --git a/ui/src/hooks/useProjects.ts b/ui/src/hooks/useProjects.ts
index e4154544..dcaac55c 100644
--- a/ui/src/hooks/useProjects.ts
+++ b/ui/src/hooks/useProjects.ts
@@ -62,6 +62,15 @@ export function useResetProject(projectName: string) {
})
}
+export function useHasFeatures(projectName: string | null) {
+ return useQuery({
+ queryKey: ['has-features', projectName],
+ queryFn: () => api.checkHasFeatures(projectName!),
+ enabled: !!projectName,
+ staleTime: 0, // Always fetch fresh data
+ })
+}
+
export function useUpdateProjectSettings(projectName: string) {
const queryClient = useQueryClient()
@@ -266,6 +275,7 @@ const DEFAULT_SETTINGS: Settings = {
glm_mode: false,
ollama_mode: false,
testing_agent_ratio: 1,
+ testing_mode: 'full',
playwright_headless: true,
batch_size: 3,
api_provider: 'claude',
diff --git a/ui/src/hooks/useSpecChat.ts b/ui/src/hooks/useSpecChat.ts
index 3bd09bb2..50641e45 100644
--- a/ui/src/hooks/useSpecChat.ts
+++ b/ui/src/hooks/useSpecChat.ts
@@ -10,6 +10,7 @@ type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
interface UseSpecChatOptions {
projectName: string
+ fromResearch?: boolean // True when coming from research results (existing codebase)
onComplete?: (specPath: string) => void
onError?: (error: string) => void
}
@@ -33,6 +34,7 @@ function generateId(): string {
export function useSpecChat({
projectName,
+ fromResearch,
// onComplete intentionally not used - user clicks "Continue to Project" button instead
onError,
}: UseSpecChatOptions): UseSpecChatReturn {
@@ -358,14 +360,14 @@ export function useSpecChat({
const checkAndSend = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
setIsLoading(true)
- wsRef.current.send(JSON.stringify({ type: 'start' }))
+ wsRef.current.send(JSON.stringify({ type: 'start', from_research: fromResearch ?? false }))
} else if (wsRef.current?.readyState === WebSocket.CONNECTING) {
setTimeout(checkAndSend, 100)
}
}
setTimeout(checkAndSend, 100)
- }, [connect])
+ }, [connect, fromResearch])
const sendMessage = useCallback((content: string, attachments?: ImageAttachment[]) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
diff --git a/ui/src/hooks/useWebSocket.ts b/ui/src/hooks/useWebSocket.ts
index b9c0a3fe..74c94f53 100644
--- a/ui/src/hooks/useWebSocket.ts
+++ b/ui/src/hooks/useWebSocket.ts
@@ -12,6 +12,8 @@ import type {
AgentLogEntry,
OrchestratorStatus,
OrchestratorEvent,
+ ResearchPhase,
+ ResearchLogEntry,
} from '../lib/types'
// Activity item for the feed
@@ -29,6 +31,17 @@ interface CelebrationTrigger {
featureId: number
}
+// Research agent state
+interface ResearchState {
+ phase: ResearchPhase
+ filesScanned: number
+ findingsCount: number
+ finalized: boolean
+ currentTool: string | null
+ filesWritten: string[]
+ logs: ResearchLogEntry[]
+}
+
interface WebSocketState {
progress: {
passing: number
@@ -52,11 +65,14 @@ interface WebSocketState {
celebration: CelebrationTrigger | null
// Orchestrator state for Mission Control
orchestratorStatus: OrchestratorStatus | null
+ // Research agent state
+ researchState: ResearchState | null
}
const MAX_LOGS = 100 // Keep last 100 log lines
const MAX_ACTIVITY = 20 // Keep last 20 activity items
const MAX_AGENT_LOGS = 500 // Keep last 500 log lines per agent
+const MAX_RESEARCH_LOGS = 100 // Keep last 100 research log entries
export function useProjectWebSocket(projectName: string | null) {
const [state, setState] = useState({
@@ -73,11 +89,13 @@ export function useProjectWebSocket(projectName: string | null) {
celebrationQueue: [],
celebration: null,
orchestratorStatus: null,
+ researchState: null,
})
const wsRef = useRef(null)
const reconnectTimeoutRef = useRef(null)
const reconnectAttempts = useRef(0)
+ const lastPongTime = useRef(Date.now())
const connect = useCallback(() => {
if (!projectName) return
@@ -94,6 +112,7 @@ export function useProjectWebSocket(projectName: string | null) {
ws.onopen = () => {
setState(prev => ({ ...prev, isConnected: true }))
reconnectAttempts.current = 0
+ lastPongTime.current = Date.now() // Reset pong time on new connection
}
ws.onmessage = (event) => {
@@ -327,7 +346,33 @@ export function useProjectWebSocket(projectName: string | null) {
break
case 'pong':
- // Heartbeat response
+ // Heartbeat response - update last successful pong time
+ lastPongTime.current = Date.now()
+ break
+
+ case 'research_update':
+ setState(prev => {
+ const newLogEntry: ResearchLogEntry = {
+ message: message.message,
+ timestamp: message.timestamp,
+ eventType: message.eventType,
+ }
+
+ const existingLogs = prev.researchState?.logs ?? []
+
+ return {
+ ...prev,
+ researchState: {
+ phase: message.phase,
+ filesScanned: message.filesScanned,
+ findingsCount: message.findingsCount,
+ finalized: message.finalized,
+ currentTool: message.currentTool,
+ filesWritten: message.filesWritten,
+ logs: [...existingLogs.slice(-MAX_RESEARCH_LOGS + 1), newLogEntry],
+ },
+ }
+ })
break
}
} catch {
@@ -363,7 +408,20 @@ export function useProjectWebSocket(projectName: string | null) {
// Send ping to keep connection alive
const sendPing = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
- wsRef.current.send(JSON.stringify({ type: 'ping' }))
+ try {
+ // Check if connection is stale (no pong for > 90 seconds)
+ const timeSinceLastPong = Date.now() - lastPongTime.current
+ if (timeSinceLastPong > 90000) {
+ console.warn('WebSocket connection stale, forcing reconnect')
+ wsRef.current.close()
+ return
+ }
+
+ wsRef.current.send(JSON.stringify({ type: 'ping' }))
+ } catch (e) {
+ console.error('Failed to send ping, closing connection', e)
+ wsRef.current?.close()
+ }
}
}, [])
@@ -398,6 +456,7 @@ export function useProjectWebSocket(projectName: string | null) {
celebrationQueue: [],
celebration: null,
orchestratorStatus: null,
+ researchState: null,
})
if (!projectName) {
@@ -411,11 +470,28 @@ export function useProjectWebSocket(projectName: string | null) {
connect()
- // Ping every 30 seconds
+ // Ping every 30 seconds (increased frequency to handle background tab throttling)
const pingInterval = setInterval(sendPing, 30000)
+ // Handle visibility change - reconnect when tab becomes visible
+ // Browsers heavily throttle timers in background tabs, causing ping failures
+ const handleVisibilityChange = () => {
+ if (document.visibilityState === 'visible') {
+ // Tab became visible - send ping immediately
+ sendPing()
+
+ // If not connected, try to reconnect
+ if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
+ connect()
+ }
+ }
+ }
+
+ document.addEventListener('visibilitychange', handleVisibilityChange)
+
return () => {
clearInterval(pingInterval)
+ document.removeEventListener('visibilitychange', handleVisibilityChange)
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
@@ -471,6 +547,11 @@ export function useProjectWebSocket(projectName: string | null) {
})
}, [])
+ // Clear research state
+ const clearResearchState = useCallback(() => {
+ setState(prev => ({ ...prev, researchState: null }))
+ }, [])
+
return {
...state,
clearLogs,
@@ -478,5 +559,6 @@ export function useProjectWebSocket(projectName: string | null) {
clearCelebration,
getAgentLogs,
clearAgentLogs,
+ clearResearchState,
}
}
diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts
index 10b577b4..89b87b93 100644
--- a/ui/src/lib/api.ts
+++ b/ui/src/lib/api.ts
@@ -33,6 +33,9 @@ import type {
ScheduleUpdate,
ScheduleListResponse,
NextRunResponse,
+ BranchListResponse,
+ CheckoutResponse,
+ CreateBranchResponse,
} from './types'
const API_BASE = '/api'
@@ -129,6 +132,17 @@ export async function resetProject(
})
}
+export interface HasFeaturesResponse {
+ has_features: boolean
+ feature_count: number
+ passing_count: number
+ in_progress_count: number
+}
+
+export async function checkHasFeatures(name: string): Promise {
+ return fetchJSON(`/projects/${encodeURIComponent(name)}/has-features`)
+}
+
// ============================================================================
// Features API
// ============================================================================
@@ -531,3 +545,83 @@ export async function deleteSchedule(
export async function getNextScheduledRun(projectName: string): Promise {
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/schedules/next`)
}
+
+// ============================================================================
+// Git API
+// ============================================================================
+
+export async function listBranches(projectName: string): Promise {
+ return fetchJSON(`/projects/${encodeURIComponent(projectName)}/git/branches`)
+}
+
+export async function checkoutBranch(
+ projectName: string,
+ branch: string
+): Promise {
+ return fetchJSON(`/projects/${encodeURIComponent(projectName)}/git/checkout`, {
+ method: 'POST',
+ body: JSON.stringify({ branch }),
+ })
+}
+
+export async function createBranch(
+ projectName: string,
+ branchName: string,
+ fromBranch?: string
+): Promise {
+ return fetchJSON(`/projects/${encodeURIComponent(projectName)}/git/create-branch`, {
+ method: 'POST',
+ body: JSON.stringify({
+ branch_name: branchName,
+ from_branch: fromBranch,
+ }),
+ })
+}
+
+// ============================================================================
+// Research Agent API
+// ============================================================================
+
+export interface ResearchStatusResponse {
+ status: 'stopped' | 'running' | 'paused' | 'crashed'
+ pid: number | null
+ started_at: string | null
+ model: string | null
+ phase: string | null
+ files_scanned: number
+ findings_count: number
+ finalized: boolean
+ finalized_at: string | null
+}
+
+export interface ResearchActionResponse {
+ success: boolean
+ status: string
+ message: string
+}
+
+export async function getResearchStatus(projectName: string): Promise {
+ return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/research/status`)
+}
+
+export async function startResearchAgent(
+ projectName: string,
+ options: {
+ model?: string
+ projectDir?: string
+ } = {}
+): Promise {
+ return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/start-research`, {
+ method: 'POST',
+ body: JSON.stringify({
+ model: options.model,
+ project_dir: options.projectDir,
+ }),
+ })
+}
+
+export async function stopResearchAgent(projectName: string): Promise {
+ return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/research/stop`, {
+ method: 'POST',
+ })
+}
diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts
index ba8eab94..74e65fee 100644
--- a/ui/src/lib/types.ts
+++ b/ui/src/lib/types.ts
@@ -240,7 +240,7 @@ export interface OrchestratorStatus {
}
// WebSocket message types
-export type WSMessageType = 'progress' | 'feature_update' | 'log' | 'agent_status' | 'pong' | 'dev_log' | 'dev_server_status' | 'agent_update' | 'orchestrator_update'
+export type WSMessageType = 'progress' | 'feature_update' | 'log' | 'agent_status' | 'pong' | 'dev_log' | 'dev_server_status' | 'agent_update' | 'orchestrator_update' | 'research_update'
export interface WSProgressMessage {
type: 'progress'
@@ -315,6 +315,19 @@ export interface WSOrchestratorUpdateMessage {
featureName?: string
}
+export interface WSResearchUpdateMessage {
+ type: 'research_update'
+ eventType: string
+ phase: ResearchPhase
+ message: string
+ timestamp: string
+ filesScanned: number
+ findingsCount: number
+ finalized: boolean
+ currentTool: string | null
+ filesWritten: string[]
+}
+
export type WSMessage =
| WSProgressMessage
| WSFeatureUpdateMessage
@@ -325,6 +338,7 @@ export type WSMessage =
| WSDevLogMessage
| WSDevServerStatusMessage
| WSOrchestratorUpdateMessage
+ | WSResearchUpdateMessage
// ============================================================================
// Spec Chat Types
@@ -551,6 +565,7 @@ export interface Settings {
glm_mode: boolean
ollama_mode: boolean
testing_agent_ratio: number // Regression testing agents (0-3)
+ testing_mode: string // "full", "smart", "minimal", "off"
playwright_headless: boolean
batch_size: number // Features per coding agent batch (1-3)
api_provider: string
@@ -563,6 +578,7 @@ export interface SettingsUpdate {
yolo_mode?: boolean
model?: string
testing_agent_ratio?: number
+ testing_mode?: string
playwright_headless?: boolean
batch_size?: number
api_provider?: string
@@ -624,3 +640,80 @@ export interface NextRunResponse {
is_currently_running: boolean
active_schedule_count: number
}
+
+// ============================================================================
+// Research Agent Types
+// ============================================================================
+
+export type ResearchPhase = 'idle' | 'scanning' | 'analyzing' | 'documenting' | 'complete'
+
+export interface ResearchUpdate {
+ type: 'research_update'
+ eventType: string
+ phase: ResearchPhase
+ message: string
+ timestamp: string
+ filesScanned: number
+ findingsCount: number
+ finalized: boolean
+ currentTool: string | null
+ filesWritten: string[]
+}
+
+export interface ResearchLogEntry {
+ message: string
+ timestamp: string
+ eventType: string
+}
+
+export interface ResearchDoc {
+ filename: string
+ content: string
+}
+
+export interface ResearchDocsResponse {
+ success: boolean
+ docs: ResearchDoc[]
+ generated_at: number
+}
+
+export interface ResearchProject {
+ name: string
+ dir: string
+ status: 'analyzing' | 'complete' | 'error'
+ phase: ResearchPhase
+ filesScanned: number
+ findingsCount: number
+ completedAt?: string
+}
+
+// ============================================================================
+// Git Types
+// ============================================================================
+
+export interface GitBranch {
+ name: string
+ is_current: boolean
+ is_protected: boolean
+}
+
+export interface BranchListResponse {
+ is_git_repo: boolean
+ current_branch: string
+ branches: GitBranch[]
+ protected_branches: string[]
+ has_uncommitted_changes?: boolean
+}
+
+export interface CheckoutResponse {
+ success: boolean
+ previous_branch: string
+ current_branch: string
+ error?: string
+}
+
+export interface CreateBranchResponse {
+ success: boolean
+ branch: string
+ error?: string
+}
diff --git a/ui/src/main.tsx b/ui/src/main.tsx
index e8d98884..b6f8ea30 100644
--- a/ui/src/main.tsx
+++ b/ui/src/main.tsx
@@ -1,5 +1,6 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
+import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './styles/globals.css'
@@ -15,8 +16,10 @@ const queryClient = new QueryClient({
createRoot(document.getElementById('root')!).render(
-
-
-
+
+
+
+
+
,
)