mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-30 06:12:06 +00:00
feat: add interactive terminal and dev server management
Add new features for interactive terminal sessions and dev server control: Terminal Component: - New Terminal.tsx component using xterm.js for full terminal emulation - WebSocket-based PTY communication with bidirectional I/O - Cross-platform support (Windows via winpty, Unix via built-in pty) - Auto-reconnection with exponential backoff - Fix duplicate WebSocket connection bug by checking CONNECTING state - Add manual close flag to prevent auto-reconnect race conditions - Add project tracking to avoid duplicate connects on initial activation Dev Server Management: - New DevServerControl.tsx for starting/stopping dev servers - DevServerManager service for subprocess management - WebSocket streaming of dev server output - Project configuration service for reading package.json scripts Backend Infrastructure: - Terminal router with WebSocket endpoint for PTY I/O - DevServer router for server lifecycle management - Terminal session manager with callback-based output streaming - Enhanced WebSocket schemas for terminal and dev server messages UI Integration: - New Terminal and Dev Server tabs in the main application - Updated DebugLogViewer with improved UI and functionality - Extended useWebSocket hook for terminal message handling Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,12 +13,13 @@ import { ProgressDashboard } from './components/ProgressDashboard'
|
||||
import { SetupWizard } from './components/SetupWizard'
|
||||
import { AddFeatureForm } from './components/AddFeatureForm'
|
||||
import { FeatureModal } from './components/FeatureModal'
|
||||
import { DebugLogViewer } from './components/DebugLogViewer'
|
||||
import { DebugLogViewer, type TabType } from './components/DebugLogViewer'
|
||||
import { AgentThought } from './components/AgentThought'
|
||||
import { AssistantFAB } from './components/AssistantFAB'
|
||||
import { AssistantPanel } from './components/AssistantPanel'
|
||||
import { ExpandProjectModal } from './components/ExpandProjectModal'
|
||||
import { SettingsModal } from './components/SettingsModal'
|
||||
import { DevServerControl } from './components/DevServerControl'
|
||||
import { Loader2, Settings } from 'lucide-react'
|
||||
import type { Feature } from './lib/types'
|
||||
|
||||
@@ -37,6 +38,7 @@ function App() {
|
||||
const [setupComplete, setSetupComplete] = useState(true) // Start optimistic
|
||||
const [debugOpen, setDebugOpen] = useState(false)
|
||||
const [debugPanelHeight, setDebugPanelHeight] = useState(288) // Default height
|
||||
const [debugActiveTab, setDebugActiveTab] = useState<TabType>('agent')
|
||||
const [assistantOpen, setAssistantOpen] = useState(false)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [isSpecCreating, setIsSpecCreating] = useState(false)
|
||||
@@ -88,6 +90,22 @@ function App() {
|
||||
setDebugOpen(prev => !prev)
|
||||
}
|
||||
|
||||
// T : Toggle terminal tab in debug panel
|
||||
if (e.key === 't' || e.key === 'T') {
|
||||
e.preventDefault()
|
||||
if (!debugOpen) {
|
||||
// If panel is closed, open it and switch to terminal tab
|
||||
setDebugOpen(true)
|
||||
setDebugActiveTab('terminal')
|
||||
} else if (debugActiveTab === 'terminal') {
|
||||
// If already on terminal tab, close the panel
|
||||
setDebugOpen(false)
|
||||
} else {
|
||||
// If open but on different tab, switch to terminal
|
||||
setDebugActiveTab('terminal')
|
||||
}
|
||||
}
|
||||
|
||||
// N : Add new feature (when project selected)
|
||||
if ((e.key === 'n' || e.key === 'N') && selectedProject) {
|
||||
e.preventDefault()
|
||||
@@ -133,7 +151,7 @@ function App() {
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, assistantOpen, features, showSettings, isSpecCreating])
|
||||
}, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, debugActiveTab, assistantOpen, features, showSettings, isSpecCreating])
|
||||
|
||||
// Combine WebSocket progress with feature data
|
||||
const progress = wsState.progress.total > 0 ? wsState.progress : {
|
||||
@@ -178,6 +196,12 @@ function App() {
|
||||
status={wsState.agentStatus}
|
||||
/>
|
||||
|
||||
<DevServerControl
|
||||
projectName={selectedProject}
|
||||
status={wsState.devServerStatus}
|
||||
url={wsState.devServerUrl}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="neo-btn text-sm py-2 px-3"
|
||||
@@ -285,10 +309,15 @@ function App() {
|
||||
{selectedProject && (
|
||||
<DebugLogViewer
|
||||
logs={wsState.logs}
|
||||
devLogs={wsState.devLogs}
|
||||
isOpen={debugOpen}
|
||||
onToggle={() => setDebugOpen(!debugOpen)}
|
||||
onClear={wsState.clearLogs}
|
||||
onClearDevLogs={wsState.clearDevLogs}
|
||||
onHeightChange={setDebugPanelHeight}
|
||||
projectName={selectedProject}
|
||||
activeTab={debugActiveTab}
|
||||
onTabChange={setDebugActiveTab}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -3,49 +3,85 @@
|
||||
*
|
||||
* Collapsible panel at the bottom of the screen showing real-time
|
||||
* agent output (tool calls, results, steps). Similar to browser DevTools.
|
||||
* Features a resizable height via drag handle.
|
||||
* Features a resizable height via drag handle and tabs for different log sources.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { ChevronUp, ChevronDown, Trash2, Terminal, GripHorizontal } from 'lucide-react'
|
||||
import { ChevronUp, ChevronDown, Trash2, Terminal as TerminalIcon, GripHorizontal, Cpu, Server } from 'lucide-react'
|
||||
import { Terminal } from './Terminal'
|
||||
|
||||
const MIN_HEIGHT = 150
|
||||
const MAX_HEIGHT = 600
|
||||
const DEFAULT_HEIGHT = 288
|
||||
const STORAGE_KEY = 'debug-panel-height'
|
||||
const TAB_STORAGE_KEY = 'debug-panel-tab'
|
||||
|
||||
type TabType = 'agent' | 'devserver' | 'terminal'
|
||||
|
||||
interface DebugLogViewerProps {
|
||||
logs: Array<{ line: string; timestamp: string }>
|
||||
devLogs: Array<{ line: string; timestamp: string }>
|
||||
isOpen: boolean
|
||||
onToggle: () => void
|
||||
onClear: () => void
|
||||
onClearDevLogs: () => void
|
||||
onHeightChange?: (height: number) => void
|
||||
projectName: string
|
||||
activeTab?: TabType
|
||||
onTabChange?: (tab: TabType) => void
|
||||
}
|
||||
|
||||
type LogLevel = 'error' | 'warn' | 'debug' | 'info'
|
||||
|
||||
export function DebugLogViewer({
|
||||
logs,
|
||||
devLogs,
|
||||
isOpen,
|
||||
onToggle,
|
||||
onClear,
|
||||
onClearDevLogs,
|
||||
onHeightChange,
|
||||
projectName,
|
||||
activeTab: controlledActiveTab,
|
||||
onTabChange,
|
||||
}: DebugLogViewerProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const devScrollRef = useRef<HTMLDivElement>(null)
|
||||
const [autoScroll, setAutoScroll] = useState(true)
|
||||
const [devAutoScroll, setDevAutoScroll] = useState(true)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const [panelHeight, setPanelHeight] = useState(() => {
|
||||
// Load saved height from localStorage
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
return saved ? Math.min(Math.max(parseInt(saved, 10), MIN_HEIGHT), MAX_HEIGHT) : DEFAULT_HEIGHT
|
||||
})
|
||||
const [internalActiveTab, setInternalActiveTab] = useState<TabType>(() => {
|
||||
// Load saved tab from localStorage
|
||||
const saved = localStorage.getItem(TAB_STORAGE_KEY)
|
||||
return (saved as TabType) || 'agent'
|
||||
})
|
||||
|
||||
// Auto-scroll to bottom when new logs arrive (if user hasn't scrolled up)
|
||||
// Use controlled tab if provided, otherwise use internal state
|
||||
const activeTab = controlledActiveTab ?? internalActiveTab
|
||||
const setActiveTab = (tab: TabType) => {
|
||||
setInternalActiveTab(tab)
|
||||
localStorage.setItem(TAB_STORAGE_KEY, tab)
|
||||
onTabChange?.(tab)
|
||||
}
|
||||
|
||||
// Auto-scroll to bottom when new agent logs arrive (if user hasn't scrolled up)
|
||||
useEffect(() => {
|
||||
if (autoScroll && scrollRef.current && isOpen) {
|
||||
if (autoScroll && scrollRef.current && isOpen && activeTab === 'agent') {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [logs, autoScroll, isOpen])
|
||||
}, [logs, autoScroll, isOpen, activeTab])
|
||||
|
||||
// Auto-scroll to bottom when new dev logs arrive (if user hasn't scrolled up)
|
||||
useEffect(() => {
|
||||
if (devAutoScroll && devScrollRef.current && isOpen && activeTab === 'devserver') {
|
||||
devScrollRef.current.scrollTop = devScrollRef.current.scrollHeight
|
||||
}
|
||||
}, [devLogs, devAutoScroll, isOpen, activeTab])
|
||||
|
||||
// Notify parent of height changes
|
||||
useEffect(() => {
|
||||
@@ -91,13 +127,44 @@ export function DebugLogViewer({
|
||||
setIsResizing(true)
|
||||
}
|
||||
|
||||
// Detect if user scrolled up
|
||||
// Detect if user scrolled up (agent logs)
|
||||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget
|
||||
const isAtBottom = el.scrollHeight - el.scrollTop <= el.clientHeight + 50
|
||||
setAutoScroll(isAtBottom)
|
||||
}
|
||||
|
||||
// Detect if user scrolled up (dev logs)
|
||||
const handleDevScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget
|
||||
const isAtBottom = el.scrollHeight - el.scrollTop <= el.clientHeight + 50
|
||||
setDevAutoScroll(isAtBottom)
|
||||
}
|
||||
|
||||
// Handle clear button based on active tab
|
||||
const handleClear = () => {
|
||||
if (activeTab === 'agent') {
|
||||
onClear()
|
||||
} else if (activeTab === 'devserver') {
|
||||
onClearDevLogs()
|
||||
}
|
||||
// Terminal has no clear button (it's managed internally)
|
||||
}
|
||||
|
||||
// Get the current log count based on active tab
|
||||
const getCurrentLogCount = () => {
|
||||
if (activeTab === 'agent') return logs.length
|
||||
if (activeTab === 'devserver') return devLogs.length
|
||||
return 0
|
||||
}
|
||||
|
||||
// Check if current tab has auto-scroll paused
|
||||
const isAutoScrollPaused = () => {
|
||||
if (activeTab === 'agent') return !autoScroll
|
||||
if (activeTab === 'devserver') return !devAutoScroll
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse log level from line content
|
||||
const getLogLevel = (line: string): LogLevel => {
|
||||
const lowerLine = line.toLowerCase()
|
||||
@@ -164,35 +231,108 @@ export function DebugLogViewer({
|
||||
|
||||
{/* Header bar */}
|
||||
<div
|
||||
className="flex items-center justify-between h-10 px-4 bg-[#1a1a1a] border-t-3 border-black cursor-pointer"
|
||||
onClick={onToggle}
|
||||
className="flex items-center justify-between h-10 px-4 bg-[#1a1a1a] border-t-3 border-black"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal size={16} className="text-green-400" />
|
||||
<span className="font-mono text-sm text-white font-bold">
|
||||
Debug
|
||||
</span>
|
||||
<span className="px-1.5 py-0.5 text-xs font-mono bg-[#333] text-gray-500 rounded" title="Toggle debug panel">
|
||||
D
|
||||
</span>
|
||||
{logs.length > 0 && (
|
||||
<span className="px-2 py-0.5 text-xs font-mono bg-[#333] text-gray-300 rounded">
|
||||
{logs.length}
|
||||
{/* Collapse/expand toggle */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex items-center gap-2 hover:bg-[#333] px-2 py-1 rounded transition-colors cursor-pointer"
|
||||
>
|
||||
<TerminalIcon size={16} className="text-green-400" />
|
||||
<span className="font-mono text-sm text-white font-bold">
|
||||
Debug
|
||||
</span>
|
||||
<span className="px-1.5 py-0.5 text-xs font-mono bg-[#333] text-gray-500 rounded" title="Toggle debug panel">
|
||||
D
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Tabs - only visible when open */}
|
||||
{isOpen && (
|
||||
<div className="flex items-center gap-1 ml-4">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setActiveTab('agent')
|
||||
}}
|
||||
className={`flex items-center gap-1.5 px-3 py-1 text-xs font-mono rounded transition-colors ${
|
||||
activeTab === 'agent'
|
||||
? 'bg-[#333] text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-[#2a2a2a]'
|
||||
}`}
|
||||
>
|
||||
<Cpu size={12} />
|
||||
Agent
|
||||
{logs.length > 0 && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] bg-[#444] rounded">
|
||||
{logs.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setActiveTab('devserver')
|
||||
}}
|
||||
className={`flex items-center gap-1.5 px-3 py-1 text-xs font-mono rounded transition-colors ${
|
||||
activeTab === 'devserver'
|
||||
? 'bg-[#333] text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-[#2a2a2a]'
|
||||
}`}
|
||||
>
|
||||
<Server size={12} />
|
||||
Dev Server
|
||||
{devLogs.length > 0 && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] bg-[#444] rounded">
|
||||
{devLogs.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setActiveTab('terminal')
|
||||
}}
|
||||
className={`flex items-center gap-1.5 px-3 py-1 text-xs font-mono rounded transition-colors ${
|
||||
activeTab === 'terminal'
|
||||
? 'bg-[#333] text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-[#2a2a2a]'
|
||||
}`}
|
||||
>
|
||||
<TerminalIcon size={12} />
|
||||
Terminal
|
||||
<span className="px-1.5 py-0.5 text-[10px] bg-[#444] text-gray-500 rounded" title="Toggle terminal">
|
||||
T
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!autoScroll && isOpen && (
|
||||
<span className="px-2 py-0.5 text-xs font-mono bg-yellow-600 text-white rounded">
|
||||
Paused
|
||||
</span>
|
||||
|
||||
{/* Log count and status - only for log tabs */}
|
||||
{isOpen && activeTab !== 'terminal' && (
|
||||
<>
|
||||
{getCurrentLogCount() > 0 && (
|
||||
<span className="px-2 py-0.5 text-xs font-mono bg-[#333] text-gray-300 rounded ml-2">
|
||||
{getCurrentLogCount()}
|
||||
</span>
|
||||
)}
|
||||
{isAutoScrollPaused() && (
|
||||
<span className="px-2 py-0.5 text-xs font-mono bg-yellow-600 text-white rounded">
|
||||
Paused
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isOpen && (
|
||||
{/* Clear button - only for log tabs */}
|
||||
{isOpen && activeTab !== 'terminal' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClear()
|
||||
handleClear()
|
||||
}}
|
||||
className="p-1.5 hover:bg-[#333] rounded transition-colors"
|
||||
title="Clear logs"
|
||||
@@ -210,42 +350,95 @@ export function DebugLogViewer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log content area */}
|
||||
{/* Content area */}
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="h-[calc(100%-2.5rem)] overflow-y-auto bg-[#1a1a1a] p-2 font-mono text-sm"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
No logs yet. Start the agent to see output.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{logs.map((log, index) => {
|
||||
const level = getLogLevel(log.line)
|
||||
const colorClass = getLogColor(level)
|
||||
const timestamp = formatTimestamp(log.timestamp)
|
||||
<div className="h-[calc(100%-2.5rem)] bg-[#1a1a1a]">
|
||||
{/* Agent Logs Tab */}
|
||||
{activeTab === 'agent' && (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="h-full overflow-y-auto p-2 font-mono text-sm"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
No logs yet. Start the agent to see output.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{logs.map((log, index) => {
|
||||
const level = getLogLevel(log.line)
|
||||
const colorClass = getLogColor(level)
|
||||
const timestamp = formatTimestamp(log.timestamp)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${log.timestamp}-${index}`}
|
||||
className="flex gap-2 hover:bg-[#2a2a2a] px-1 py-0.5 rounded"
|
||||
>
|
||||
<span className="text-gray-500 select-none shrink-0">
|
||||
{timestamp}
|
||||
</span>
|
||||
<span className={`${colorClass} whitespace-pre-wrap break-all`}>
|
||||
{log.line}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
return (
|
||||
<div
|
||||
key={`${log.timestamp}-${index}`}
|
||||
className="flex gap-2 hover:bg-[#2a2a2a] px-1 py-0.5 rounded"
|
||||
>
|
||||
<span className="text-gray-500 select-none shrink-0">
|
||||
{timestamp}
|
||||
</span>
|
||||
<span className={`${colorClass} whitespace-pre-wrap break-all`}>
|
||||
{log.line}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dev Server Logs Tab */}
|
||||
{activeTab === 'devserver' && (
|
||||
<div
|
||||
ref={devScrollRef}
|
||||
onScroll={handleDevScroll}
|
||||
className="h-full overflow-y-auto p-2 font-mono text-sm"
|
||||
>
|
||||
{devLogs.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
No dev server logs yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{devLogs.map((log, index) => {
|
||||
const level = getLogLevel(log.line)
|
||||
const colorClass = getLogColor(level)
|
||||
const timestamp = formatTimestamp(log.timestamp)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${log.timestamp}-${index}`}
|
||||
className="flex gap-2 hover:bg-[#2a2a2a] px-1 py-0.5 rounded"
|
||||
>
|
||||
<span className="text-gray-500 select-none shrink-0">
|
||||
{timestamp}
|
||||
</span>
|
||||
<span className={`${colorClass} whitespace-pre-wrap break-all`}>
|
||||
{log.line}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Terminal Tab */}
|
||||
{activeTab === 'terminal' && (
|
||||
<Terminal
|
||||
projectName={projectName}
|
||||
isActive={activeTab === 'terminal'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Export the TabType for use in parent components
|
||||
export type { TabType }
|
||||
|
||||
155
ui/src/components/DevServerControl.tsx
Normal file
155
ui/src/components/DevServerControl.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Globe, Square, Loader2, ExternalLink, AlertTriangle } from 'lucide-react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import type { DevServerStatus } from '../lib/types'
|
||||
import { startDevServer, stopDevServer } from '../lib/api'
|
||||
|
||||
// Re-export DevServerStatus from lib/types for consumers that import from here
|
||||
export type { DevServerStatus }
|
||||
|
||||
// ============================================================================
|
||||
// React Query Hooks (Internal)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Internal hook to start the dev server for a project.
|
||||
* Invalidates the dev-server-status query on success.
|
||||
*/
|
||||
function useStartDevServer(projectName: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => startDevServer(projectName),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['dev-server-status', projectName] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal hook to stop the dev server for a project.
|
||||
* Invalidates the dev-server-status query on success.
|
||||
*/
|
||||
function useStopDevServer(projectName: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => stopDevServer(projectName),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['dev-server-status', projectName] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
interface DevServerControlProps {
|
||||
projectName: string
|
||||
status: DevServerStatus
|
||||
url: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* DevServerControl provides start/stop controls for a project's development server.
|
||||
*
|
||||
* Features:
|
||||
* - Toggle button to start/stop the dev server
|
||||
* - Shows loading state during operations
|
||||
* - Displays clickable URL when server is running
|
||||
* - Uses neobrutalism design with cyan accent when running
|
||||
*/
|
||||
export function DevServerControl({ projectName, status, url }: DevServerControlProps) {
|
||||
const startDevServerMutation = useStartDevServer(projectName)
|
||||
const stopDevServerMutation = useStopDevServer(projectName)
|
||||
|
||||
const isLoading = startDevServerMutation.isPending || stopDevServerMutation.isPending
|
||||
|
||||
const handleStart = () => {
|
||||
// Clear any previous errors before starting
|
||||
stopDevServerMutation.reset()
|
||||
startDevServerMutation.mutate()
|
||||
}
|
||||
const handleStop = () => {
|
||||
// Clear any previous errors before stopping
|
||||
startDevServerMutation.reset()
|
||||
stopDevServerMutation.mutate()
|
||||
}
|
||||
|
||||
// Server is stopped when status is 'stopped' or 'crashed' (can restart)
|
||||
const isStopped = status === 'stopped' || status === 'crashed'
|
||||
// Server is in a running state
|
||||
const isRunning = status === 'running'
|
||||
// Server has crashed
|
||||
const isCrashed = status === 'crashed'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{isStopped ? (
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={isLoading}
|
||||
className="neo-btn text-sm py-2 px-3"
|
||||
style={isCrashed ? {
|
||||
backgroundColor: 'var(--color-neo-danger)',
|
||||
color: '#ffffff',
|
||||
} : undefined}
|
||||
title={isCrashed ? "Dev Server Crashed - Click to Restart" : "Start Dev Server"}
|
||||
aria-label={isCrashed ? "Restart Dev Server (crashed)" : "Start Dev Server"}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : isCrashed ? (
|
||||
<AlertTriangle size={18} />
|
||||
) : (
|
||||
<Globe size={18} />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={isLoading}
|
||||
className="neo-btn text-sm py-2 px-3"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-neo-progress)',
|
||||
color: '#ffffff',
|
||||
}}
|
||||
title="Stop Dev Server"
|
||||
aria-label="Stop Dev Server"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<Square size={18} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Show URL as clickable link when server is running */}
|
||||
{isRunning && url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="neo-btn text-sm py-2 px-3 gap-1"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-neo-progress)',
|
||||
color: '#ffffff',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
title={`Open ${url} in new tab`}
|
||||
>
|
||||
<span className="font-mono text-xs">{url}</span>
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Error display */}
|
||||
{(startDevServerMutation.error || stopDevServerMutation.error) && (
|
||||
<span className="text-xs font-mono text-[var(--color-neo-danger)] ml-2">
|
||||
{String((startDevServerMutation.error || stopDevServerMutation.error)?.message || 'Operation failed')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
512
ui/src/components/Terminal.tsx
Normal file
512
ui/src/components/Terminal.tsx
Normal file
@@ -0,0 +1,512 @@
|
||||
/**
|
||||
* Interactive Terminal Component
|
||||
*
|
||||
* Full terminal emulation using xterm.js with WebSocket connection to the backend.
|
||||
* Supports input/output streaming, terminal resizing, and reconnection handling.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from 'react'
|
||||
import { Terminal as XTerm } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
|
||||
interface TerminalProps {
|
||||
projectName: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
// WebSocket message types for terminal I/O
|
||||
interface TerminalInputMessage {
|
||||
type: 'input'
|
||||
data: string // base64 encoded
|
||||
}
|
||||
|
||||
interface TerminalResizeMessage {
|
||||
type: 'resize'
|
||||
cols: number
|
||||
rows: number
|
||||
}
|
||||
|
||||
interface TerminalOutputMessage {
|
||||
type: 'output'
|
||||
data: string // base64 encoded
|
||||
}
|
||||
|
||||
interface TerminalExitMessage {
|
||||
type: 'exit'
|
||||
code: number
|
||||
}
|
||||
|
||||
type TerminalServerMessage = TerminalOutputMessage | TerminalExitMessage
|
||||
|
||||
// Neobrutalism theme colors for xterm
|
||||
const TERMINAL_THEME = {
|
||||
background: '#1a1a1a',
|
||||
foreground: '#ffffff',
|
||||
cursor: '#ff006e', // --color-neo-accent
|
||||
cursorAccent: '#1a1a1a',
|
||||
selectionBackground: 'rgba(255, 0, 110, 0.3)',
|
||||
selectionForeground: '#ffffff',
|
||||
black: '#1a1a1a',
|
||||
red: '#ff5400',
|
||||
green: '#70e000',
|
||||
yellow: '#ffd60a',
|
||||
blue: '#00b4d8',
|
||||
magenta: '#ff006e',
|
||||
cyan: '#00b4d8',
|
||||
white: '#ffffff',
|
||||
brightBlack: '#4a4a4a',
|
||||
brightRed: '#ff7733',
|
||||
brightGreen: '#8fff00',
|
||||
brightYellow: '#ffe44d',
|
||||
brightBlue: '#33c7e6',
|
||||
brightMagenta: '#ff4d94',
|
||||
brightCyan: '#33c7e6',
|
||||
brightWhite: '#ffffff',
|
||||
}
|
||||
|
||||
// Reconnection configuration
|
||||
const RECONNECT_DELAY_BASE = 1000
|
||||
const RECONNECT_DELAY_MAX = 30000
|
||||
|
||||
export function Terminal({ projectName, isActive }: TerminalProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const terminalRef = useRef<XTerm | null>(null)
|
||||
const fitAddonRef = useRef<FitAddon | null>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const reconnectTimeoutRef = useRef<number | null>(null)
|
||||
const reconnectAttempts = useRef(0)
|
||||
const isInitializedRef = useRef(false)
|
||||
const isConnectingRef = useRef(false)
|
||||
const hasExitedRef = useRef(false)
|
||||
// Track intentional disconnection to prevent auto-reconnect race condition
|
||||
const isManualCloseRef = useRef(false)
|
||||
// Store connect function in ref to avoid useEffect dependency issues
|
||||
const connectRef = useRef<(() => void) | null>(null)
|
||||
// Track last project to avoid duplicate connect on initial activation
|
||||
const lastProjectRef = useRef<string | null>(null)
|
||||
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const [hasExited, setHasExited] = useState(false)
|
||||
const [exitCode, setExitCode] = useState<number | null>(null)
|
||||
|
||||
// Keep ref in sync with state for use in callbacks without re-creating them
|
||||
useEffect(() => {
|
||||
hasExitedRef.current = hasExited
|
||||
}, [hasExited])
|
||||
|
||||
/**
|
||||
* Encode string to base64
|
||||
*/
|
||||
const encodeBase64 = useCallback((str: string): string => {
|
||||
// Handle Unicode by encoding to UTF-8 first
|
||||
const encoder = new TextEncoder()
|
||||
const bytes = encoder.encode(str)
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
return btoa(binary)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Decode base64 to string
|
||||
*/
|
||||
const decodeBase64 = useCallback((base64: string): string => {
|
||||
try {
|
||||
const binary = atob(base64)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
const decoder = new TextDecoder()
|
||||
return decoder.decode(bytes)
|
||||
} catch {
|
||||
console.error('Failed to decode base64 data')
|
||||
return ''
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Send a message through the WebSocket
|
||||
*/
|
||||
const sendMessage = useCallback(
|
||||
(message: TerminalInputMessage | TerminalResizeMessage) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(message))
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
/**
|
||||
* Send resize message to server
|
||||
*/
|
||||
const sendResize = useCallback(
|
||||
(cols: number, rows: number) => {
|
||||
const message: TerminalResizeMessage = {
|
||||
type: 'resize',
|
||||
cols,
|
||||
rows,
|
||||
}
|
||||
sendMessage(message)
|
||||
},
|
||||
[sendMessage]
|
||||
)
|
||||
|
||||
/**
|
||||
* Fit terminal to container and notify server of new dimensions
|
||||
*/
|
||||
const fitTerminal = useCallback(() => {
|
||||
if (fitAddonRef.current && terminalRef.current) {
|
||||
try {
|
||||
fitAddonRef.current.fit()
|
||||
const { cols, rows } = terminalRef.current
|
||||
sendResize(cols, rows)
|
||||
} catch {
|
||||
// Container may not be visible yet, ignore
|
||||
}
|
||||
}
|
||||
}, [sendResize])
|
||||
|
||||
/**
|
||||
* Connect to the terminal WebSocket
|
||||
*/
|
||||
const connect = useCallback(() => {
|
||||
if (!projectName || !isActive) return
|
||||
|
||||
// Prevent multiple simultaneous connection attempts
|
||||
if (
|
||||
isConnectingRef.current ||
|
||||
wsRef.current?.readyState === WebSocket.CONNECTING ||
|
||||
wsRef.current?.readyState === WebSocket.OPEN
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
isConnectingRef.current = true
|
||||
|
||||
// Clear any pending reconnection
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
reconnectTimeoutRef.current = null
|
||||
}
|
||||
|
||||
// Build WebSocket URL
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const host = window.location.host
|
||||
const wsUrl = `${protocol}//${host}/api/terminal/ws/${encodeURIComponent(projectName)}`
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
isConnectingRef.current = false
|
||||
setIsConnected(true)
|
||||
setHasExited(false)
|
||||
setExitCode(null)
|
||||
reconnectAttempts.current = 0
|
||||
|
||||
// Send initial size after connection
|
||||
if (terminalRef.current) {
|
||||
const { cols, rows } = terminalRef.current
|
||||
sendResize(cols, rows)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message: TerminalServerMessage = JSON.parse(event.data)
|
||||
|
||||
switch (message.type) {
|
||||
case 'output': {
|
||||
const decoded = decodeBase64(message.data)
|
||||
if (decoded && terminalRef.current) {
|
||||
terminalRef.current.write(decoded)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'exit': {
|
||||
setHasExited(true)
|
||||
setExitCode(message.code)
|
||||
if (terminalRef.current) {
|
||||
terminalRef.current.writeln('')
|
||||
terminalRef.current.writeln(
|
||||
`\x1b[33m[Shell exited with code ${message.code}]\x1b[0m`
|
||||
)
|
||||
terminalRef.current.writeln(
|
||||
'\x1b[90mPress any key to reconnect...\x1b[0m'
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
console.error('Failed to parse terminal WebSocket message')
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
isConnectingRef.current = false
|
||||
setIsConnected(false)
|
||||
wsRef.current = null
|
||||
|
||||
// Only reconnect if still active, not intentionally exited, and not manually closed
|
||||
// Use refs to avoid re-creating this callback when state changes
|
||||
const shouldReconnect = isActive && !hasExitedRef.current && !isManualCloseRef.current
|
||||
// Reset manual close flag after checking (so subsequent disconnects can auto-reconnect)
|
||||
isManualCloseRef.current = false
|
||||
|
||||
if (shouldReconnect) {
|
||||
// Exponential backoff reconnection
|
||||
const delay = Math.min(
|
||||
RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttempts.current),
|
||||
RECONNECT_DELAY_MAX
|
||||
)
|
||||
reconnectAttempts.current++
|
||||
|
||||
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||
connect()
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
// Will trigger onclose, which handles reconnection
|
||||
ws.close()
|
||||
}
|
||||
} catch {
|
||||
isConnectingRef.current = false
|
||||
// Failed to connect, attempt reconnection
|
||||
const delay = Math.min(
|
||||
RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttempts.current),
|
||||
RECONNECT_DELAY_MAX
|
||||
)
|
||||
reconnectAttempts.current++
|
||||
|
||||
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||
connect()
|
||||
}, delay)
|
||||
}
|
||||
}, [projectName, isActive, sendResize, decodeBase64])
|
||||
|
||||
// Keep connect ref up to date
|
||||
useEffect(() => {
|
||||
connectRef.current = connect
|
||||
}, [connect])
|
||||
|
||||
/**
|
||||
* Initialize xterm.js terminal
|
||||
*/
|
||||
const initializeTerminal = useCallback(() => {
|
||||
if (!containerRef.current || isInitializedRef.current) return
|
||||
|
||||
// Create terminal instance
|
||||
const terminal = new XTerm({
|
||||
theme: TERMINAL_THEME,
|
||||
fontFamily: 'JetBrains Mono, Consolas, Monaco, monospace',
|
||||
fontSize: 14,
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
allowProposedApi: true,
|
||||
scrollback: 10000,
|
||||
})
|
||||
|
||||
// Create and load FitAddon
|
||||
const fitAddon = new FitAddon()
|
||||
terminal.loadAddon(fitAddon)
|
||||
|
||||
// Open terminal in container
|
||||
terminal.open(containerRef.current)
|
||||
|
||||
// Store references
|
||||
terminalRef.current = terminal
|
||||
fitAddonRef.current = fitAddon
|
||||
isInitializedRef.current = true
|
||||
|
||||
// Initial fit
|
||||
setTimeout(() => {
|
||||
fitTerminal()
|
||||
}, 0)
|
||||
|
||||
// Handle keyboard input
|
||||
terminal.onData((data) => {
|
||||
// If shell has exited, reconnect on any key
|
||||
// Use ref to avoid re-creating this callback when hasExited changes
|
||||
if (hasExitedRef.current) {
|
||||
setHasExited(false)
|
||||
setExitCode(null)
|
||||
connectRef.current?.()
|
||||
return
|
||||
}
|
||||
|
||||
// Send input to server
|
||||
const message: TerminalInputMessage = {
|
||||
type: 'input',
|
||||
data: encodeBase64(data),
|
||||
}
|
||||
sendMessage(message)
|
||||
})
|
||||
|
||||
// Handle terminal resize
|
||||
terminal.onResize(({ cols, rows }) => {
|
||||
sendResize(cols, rows)
|
||||
})
|
||||
}, [fitTerminal, encodeBase64, sendMessage, sendResize])
|
||||
|
||||
/**
|
||||
* Handle window resize
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
|
||||
const handleResize = () => {
|
||||
fitTerminal()
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [isActive, fitTerminal])
|
||||
|
||||
/**
|
||||
* Initialize terminal and WebSocket when becoming active
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
// Clean up when becoming inactive
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
reconnectTimeoutRef.current = null
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize terminal if not already done
|
||||
if (!isInitializedRef.current) {
|
||||
initializeTerminal()
|
||||
} else {
|
||||
// Re-fit when becoming active again
|
||||
setTimeout(() => {
|
||||
fitTerminal()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
// Connect WebSocket using ref to avoid dependency on connect callback
|
||||
connectRef.current?.()
|
||||
}, [isActive, initializeTerminal, fitTerminal])
|
||||
|
||||
/**
|
||||
* Fit terminal when isActive becomes true
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isActive && terminalRef.current) {
|
||||
// Small delay to ensure container is visible
|
||||
const timeoutId = setTimeout(() => {
|
||||
fitTerminal()
|
||||
terminalRef.current?.focus()
|
||||
}, 100)
|
||||
return () => clearTimeout(timeoutId)
|
||||
}
|
||||
}, [isActive, fitTerminal])
|
||||
|
||||
/**
|
||||
* Cleanup on unmount
|
||||
*/
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
}
|
||||
if (terminalRef.current) {
|
||||
terminalRef.current.dispose()
|
||||
}
|
||||
isInitializedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Reconnect when project changes
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isActive && isInitializedRef.current) {
|
||||
// Only reconnect if project actually changed, not on initial activation
|
||||
// This prevents duplicate connect calls when both isActive and projectName effects run
|
||||
if (lastProjectRef.current === null) {
|
||||
// Initial activation - just track the project, don't reconnect (the isActive effect handles initial connect)
|
||||
lastProjectRef.current = projectName
|
||||
return
|
||||
}
|
||||
|
||||
if (lastProjectRef.current === projectName) {
|
||||
// Project didn't change, skip
|
||||
return
|
||||
}
|
||||
|
||||
// Project changed - update tracking
|
||||
lastProjectRef.current = projectName
|
||||
|
||||
// Clear terminal and reset cursor position
|
||||
if (terminalRef.current) {
|
||||
terminalRef.current.clear()
|
||||
terminalRef.current.write('\x1b[H') // Move cursor to home position
|
||||
}
|
||||
|
||||
// Set manual close flag to prevent auto-reconnect race condition
|
||||
isManualCloseRef.current = true
|
||||
|
||||
// Close existing connection and reset connecting state
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
isConnectingRef.current = false
|
||||
|
||||
// Reset state
|
||||
setHasExited(false)
|
||||
setExitCode(null)
|
||||
reconnectAttempts.current = 0
|
||||
|
||||
// Connect to new project using ref to avoid dependency on connect callback
|
||||
connectRef.current?.()
|
||||
}
|
||||
}, [projectName, isActive])
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full bg-[#1a1a1a]">
|
||||
{/* Connection status indicator */}
|
||||
<div className="absolute top-2 right-2 z-10 flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
isConnected ? 'bg-neo-done' : 'bg-neo-danger'
|
||||
}`}
|
||||
title={isConnected ? 'Connected' : 'Disconnected'}
|
||||
/>
|
||||
{!isConnected && !hasExited && (
|
||||
<span className="text-xs font-mono text-gray-500">Connecting...</span>
|
||||
)}
|
||||
{hasExited && exitCode !== null && (
|
||||
<span className="text-xs font-mono text-yellow-500">
|
||||
Exit: {exitCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Terminal container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full p-2"
|
||||
style={{ minHeight: '100px' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import type { WSMessage, AgentStatus } from '../lib/types'
|
||||
import type { WSMessage, AgentStatus, DevServerStatus } from '../lib/types'
|
||||
|
||||
interface WebSocketState {
|
||||
progress: {
|
||||
@@ -15,6 +15,9 @@ interface WebSocketState {
|
||||
agentStatus: AgentStatus
|
||||
logs: Array<{ line: string; timestamp: string }>
|
||||
isConnected: boolean
|
||||
devServerStatus: DevServerStatus
|
||||
devServerUrl: string | null
|
||||
devLogs: Array<{ line: string; timestamp: string }>
|
||||
}
|
||||
|
||||
const MAX_LOGS = 100 // Keep last 100 log lines
|
||||
@@ -25,6 +28,9 @@ export function useProjectWebSocket(projectName: string | null) {
|
||||
agentStatus: 'stopped',
|
||||
logs: [],
|
||||
isConnected: false,
|
||||
devServerStatus: 'stopped',
|
||||
devServerUrl: null,
|
||||
devLogs: [],
|
||||
})
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
@@ -86,6 +92,24 @@ export function useProjectWebSocket(projectName: string | null) {
|
||||
// Feature updates will trigger a refetch via React Query
|
||||
break
|
||||
|
||||
case 'dev_log':
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
devLogs: [
|
||||
...prev.devLogs.slice(-MAX_LOGS + 1),
|
||||
{ line: message.line, timestamp: message.timestamp },
|
||||
],
|
||||
}))
|
||||
break
|
||||
|
||||
case 'dev_server_status':
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
devServerStatus: message.status,
|
||||
devServerUrl: message.url,
|
||||
}))
|
||||
break
|
||||
|
||||
case 'pong':
|
||||
// Heartbeat response
|
||||
break
|
||||
@@ -131,6 +155,9 @@ export function useProjectWebSocket(projectName: string | null) {
|
||||
agentStatus: 'stopped',
|
||||
logs: [],
|
||||
isConnected: false,
|
||||
devServerStatus: 'stopped',
|
||||
devServerUrl: null,
|
||||
devLogs: [],
|
||||
})
|
||||
|
||||
if (!projectName) {
|
||||
@@ -164,8 +191,14 @@ export function useProjectWebSocket(projectName: string | null) {
|
||||
setState(prev => ({ ...prev, logs: [] }))
|
||||
}, [])
|
||||
|
||||
// Clear dev logs function
|
||||
const clearDevLogs = useCallback(() => {
|
||||
setState(prev => ({ ...prev, devLogs: [] }))
|
||||
}, [])
|
||||
|
||||
return {
|
||||
...state,
|
||||
clearLogs,
|
||||
clearDevLogs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ import type {
|
||||
Settings,
|
||||
SettingsUpdate,
|
||||
ModelsResponse,
|
||||
DevServerStatusResponse,
|
||||
DevServerConfig,
|
||||
} from './types'
|
||||
|
||||
const API_BASE = '/api'
|
||||
@@ -301,3 +303,33 @@ export async function updateSettings(settings: SettingsUpdate): Promise<Settings
|
||||
body: JSON.stringify(settings),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Dev Server API
|
||||
// ============================================================================
|
||||
|
||||
export async function getDevServerStatus(projectName: string): Promise<DevServerStatusResponse> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/devserver/status`)
|
||||
}
|
||||
|
||||
export async function startDevServer(
|
||||
projectName: string,
|
||||
command?: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/devserver/start`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ command }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function stopDevServer(
|
||||
projectName: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/devserver/stop`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export async function getDevServerConfig(projectName: string): Promise<DevServerConfig> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/devserver/config`)
|
||||
}
|
||||
|
||||
@@ -107,8 +107,26 @@ export interface SetupStatus {
|
||||
npm: boolean
|
||||
}
|
||||
|
||||
// Dev Server types
|
||||
export type DevServerStatus = 'stopped' | 'running' | 'crashed'
|
||||
|
||||
export interface DevServerStatusResponse {
|
||||
status: DevServerStatus
|
||||
pid: number | null
|
||||
url: string | null
|
||||
command: string | null
|
||||
started_at: string | null
|
||||
}
|
||||
|
||||
export interface DevServerConfig {
|
||||
detected_type: string | null
|
||||
detected_command: string | null
|
||||
custom_command: string | null
|
||||
effective_command: string | null
|
||||
}
|
||||
|
||||
// WebSocket message types
|
||||
export type WSMessageType = 'progress' | 'feature_update' | 'log' | 'agent_status' | 'pong'
|
||||
export type WSMessageType = 'progress' | 'feature_update' | 'log' | 'agent_status' | 'pong' | 'dev_log' | 'dev_server_status'
|
||||
|
||||
export interface WSProgressMessage {
|
||||
type: 'progress'
|
||||
@@ -139,12 +157,26 @@ export interface WSPongMessage {
|
||||
type: 'pong'
|
||||
}
|
||||
|
||||
export interface WSDevLogMessage {
|
||||
type: 'dev_log'
|
||||
line: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface WSDevServerStatusMessage {
|
||||
type: 'dev_server_status'
|
||||
status: DevServerStatus
|
||||
url: string | null
|
||||
}
|
||||
|
||||
export type WSMessage =
|
||||
| WSProgressMessage
|
||||
| WSFeatureUpdateMessage
|
||||
| WSLogMessage
|
||||
| WSAgentStatusMessage
|
||||
| WSPongMessage
|
||||
| WSDevLogMessage
|
||||
| WSDevServerStatusMessage
|
||||
|
||||
// ============================================================================
|
||||
// Spec Chat Types
|
||||
|
||||
Reference in New Issue
Block a user