- {/* Status Indicator */}
-
-
- {/* YOLO Mode Indicator - shown when running in YOLO mode */}
- {(status === 'running' || status === 'paused') && yoloMode && (
-
-
-
- YOLO
-
-
+
+ {isStopped ? (
+
+ ) : (
+
)}
-
- {/* Control Buttons */}
-
- {status === 'stopped' || status === 'crashed' ? (
- <>
- {/* YOLO Toggle - only shown when stopped */}
-
-
- >
- ) : status === 'running' ? (
- <>
-
-
- >
- ) : status === 'paused' ? (
- <>
-
-
- >
- ) : null}
-
-
- )
-}
-
-function StatusIndicator({ status }: { status: AgentStatus }) {
- const statusConfig = {
- stopped: {
- color: 'var(--color-neo-text-secondary)',
- label: 'Stopped',
- pulse: false,
- },
- running: {
- color: 'var(--color-neo-done)',
- label: 'Running',
- pulse: true,
- },
- paused: {
- color: 'var(--color-neo-pending)',
- label: 'Paused',
- pulse: false,
- },
- crashed: {
- color: 'var(--color-neo-danger)',
- label: 'Crashed',
- pulse: true,
- },
- }
-
- const config = statusConfig[status]
-
- return (
-
-
-
- {config.label}
-
)
}
diff --git a/ui/src/components/AgentThought.tsx b/ui/src/components/AgentThought.tsx
index 8cc8508..65a50a1 100644
--- a/ui/src/components/AgentThought.tsx
+++ b/ui/src/components/AgentThought.tsx
@@ -24,7 +24,7 @@ function isAgentThought(line: string): boolean {
if (/^Output:/.test(trimmed)) return false
// Skip JSON and very short lines
- if (/^[\[\{]/.test(trimmed)) return false
+ if (/^[[{]/.test(trimmed)) return false
if (trimmed.length < 15) return false
// Skip lines that are just paths or technical output
diff --git a/ui/src/components/SettingsModal.tsx b/ui/src/components/SettingsModal.tsx
new file mode 100644
index 0000000..11608a7
--- /dev/null
+++ b/ui/src/components/SettingsModal.tsx
@@ -0,0 +1,213 @@
+import { useEffect, useRef } from 'react'
+import { X, Loader2, AlertCircle } from 'lucide-react'
+import { useSettings, useUpdateSettings, useAvailableModels } from '../hooks/useProjects'
+
+interface SettingsModalProps {
+ onClose: () => void
+}
+
+export function SettingsModal({ onClose }: SettingsModalProps) {
+ const { data: settings, isLoading, isError, refetch } = useSettings()
+ const { data: modelsData } = useAvailableModels()
+ const updateSettings = useUpdateSettings()
+ const modalRef = useRef
(null)
+ const closeButtonRef = useRef(null)
+
+ // Focus trap - keep focus within modal
+ useEffect(() => {
+ const modal = modalRef.current
+ if (!modal) return
+
+ // Focus the close button when modal opens
+ closeButtonRef.current?.focus()
+
+ const focusableElements = modal.querySelectorAll(
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
+ )
+ const firstElement = focusableElements[0]
+ const lastElement = focusableElements[focusableElements.length - 1]
+
+ const handleTabKey = (e: KeyboardEvent) => {
+ if (e.key !== 'Tab') return
+
+ if (e.shiftKey) {
+ if (document.activeElement === firstElement) {
+ e.preventDefault()
+ lastElement?.focus()
+ }
+ } else {
+ if (document.activeElement === lastElement) {
+ e.preventDefault()
+ firstElement?.focus()
+ }
+ }
+ }
+
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ onClose()
+ }
+ }
+
+ document.addEventListener('keydown', handleTabKey)
+ document.addEventListener('keydown', handleEscape)
+
+ return () => {
+ document.removeEventListener('keydown', handleTabKey)
+ document.removeEventListener('keydown', handleEscape)
+ }
+ }, [onClose])
+
+ const handleYoloToggle = () => {
+ if (settings && !updateSettings.isPending) {
+ updateSettings.mutate({ yolo_mode: !settings.yolo_mode })
+ }
+ }
+
+ const handleModelChange = (modelId: string) => {
+ if (!updateSettings.isPending) {
+ updateSettings.mutate({ model: modelId })
+ }
+ }
+
+ const models = modelsData?.models ?? []
+ const isSaving = updateSettings.isPending
+
+ return (
+
+
e.stopPropagation()}
+ role="dialog"
+ aria-labelledby="settings-title"
+ aria-modal="true"
+ >
+ {/* Header */}
+
+
+ Settings
+ {isSaving && (
+
+ )}
+
+
+
+
+ {/* Loading State */}
+ {isLoading && (
+
+
+ Loading settings...
+
+ )}
+
+ {/* Error State */}
+ {isError && (
+
+
+
+
Failed to load settings
+
+
+
+ )}
+
+ {/* Settings Content */}
+ {settings && !isLoading && (
+
+ {/* YOLO Mode Toggle */}
+
+
+
+
+
+ Skip testing for rapid prototyping
+
+
+
+
+
+
+ {/* Model Selection - Radio Group */}
+
+
+
+ {models.map((model) => (
+
+ ))}
+
+
+
+ {/* Update Error */}
+ {updateSettings.isError && (
+
+ Failed to save settings. Please try again.
+
+ )}
+
+ )}
+
+
+ )
+}
diff --git a/ui/src/hooks/useProjects.ts b/ui/src/hooks/useProjects.ts
index 0cb61fa..6a1098f 100644
--- a/ui/src/hooks/useProjects.ts
+++ b/ui/src/hooks/useProjects.ts
@@ -4,7 +4,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import * as api from '../lib/api'
-import type { FeatureCreate } from '../lib/types'
+import type { FeatureCreate, ModelsResponse, Settings, SettingsUpdate } from '../lib/types'
// ============================================================================
// Projects
@@ -200,3 +200,74 @@ export function useValidatePath() {
mutationFn: (path: string) => api.validatePath(path),
})
}
+
+// ============================================================================
+// Settings
+// ============================================================================
+
+// Default models response for placeholder (until API responds)
+const DEFAULT_MODELS: ModelsResponse = {
+ models: [
+ { id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
+ { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' },
+ ],
+ default: 'claude-opus-4-5-20251101',
+}
+
+const DEFAULT_SETTINGS: Settings = {
+ yolo_mode: false,
+ model: 'claude-opus-4-5-20251101',
+}
+
+export function useAvailableModels() {
+ return useQuery({
+ queryKey: ['available-models'],
+ queryFn: api.getAvailableModels,
+ staleTime: 300000, // Cache for 5 minutes - models don't change often
+ retry: 1,
+ placeholderData: DEFAULT_MODELS,
+ })
+}
+
+export function useSettings() {
+ return useQuery({
+ queryKey: ['settings'],
+ queryFn: api.getSettings,
+ staleTime: 60000, // Cache for 1 minute
+ retry: 1,
+ placeholderData: DEFAULT_SETTINGS,
+ })
+}
+
+export function useUpdateSettings() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (settings: SettingsUpdate) => api.updateSettings(settings),
+ onMutate: async (newSettings) => {
+ // Cancel outgoing refetches
+ await queryClient.cancelQueries({ queryKey: ['settings'] })
+
+ // Snapshot previous value
+ const previous = queryClient.getQueryData(['settings'])
+
+ // Optimistically update
+ queryClient.setQueryData(['settings'], (old) => ({
+ ...DEFAULT_SETTINGS,
+ ...old,
+ ...newSettings,
+ }))
+
+ return { previous }
+ },
+ onError: (_err, _newSettings, context) => {
+ // Rollback on error
+ if (context?.previous) {
+ queryClient.setQueryData(['settings'], context.previous)
+ }
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: ['settings'] })
+ },
+ })
+}
diff --git a/ui/src/hooks/useSpecChat.ts b/ui/src/hooks/useSpecChat.ts
index 7d9fd4b..b2bac62 100644
--- a/ui/src/hooks/useSpecChat.ts
+++ b/ui/src/hooks/useSpecChat.ts
@@ -33,7 +33,7 @@ function generateId(): string {
export function useSpecChat({
projectName,
- onComplete,
+ // onComplete intentionally not used - user clicks "Continue to Project" button instead
onError,
}: UseSpecChatOptions): UseSpecChatReturn {
const [messages, setMessages] = useState([])
@@ -346,7 +346,7 @@ export function useSpecChat({
console.error('Failed to parse WebSocket message:', e)
}
}
- }, [projectName, onComplete, onError])
+ }, [projectName, onError])
const start = useCallback(() => {
connect()
diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts
index 83cf1e5..dea0979 100644
--- a/ui/src/lib/api.ts
+++ b/ui/src/lib/api.ts
@@ -18,6 +18,9 @@ import type {
PathValidationResponse,
AssistantConversation,
AssistantConversationDetail,
+ Settings,
+ SettingsUpdate,
+ ModelsResponse,
} from './types'
const API_BASE = '/api'
@@ -279,3 +282,22 @@ export async function deleteAssistantConversation(
{ method: 'DELETE' }
)
}
+
+// ============================================================================
+// Settings API
+// ============================================================================
+
+export async function getAvailableModels(): Promise {
+ return fetchJSON('/settings/models')
+}
+
+export async function getSettings(): Promise {
+ return fetchJSON('/settings')
+}
+
+export async function updateSettings(settings: SettingsUpdate): Promise {
+ return fetchJSON('/settings', {
+ method: 'PATCH',
+ body: JSON.stringify(settings),
+ })
+}
diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts
index 29931a0..c4d7812 100644
--- a/ui/src/lib/types.ts
+++ b/ui/src/lib/types.ts
@@ -90,6 +90,7 @@ export interface AgentStatusResponse {
pid: number | null
started_at: string | null
yolo_mode: boolean
+ model: string | null // Model being used by running agent
}
export interface AgentActionResponse {
@@ -328,4 +329,25 @@ export interface FeatureBulkCreate {
export interface FeatureBulkCreateResponse {
created: number
features: Feature[]
+// Settings Types
+// ============================================================================
+
+export interface ModelInfo {
+ id: string
+ name: string
+}
+
+export interface ModelsResponse {
+ models: ModelInfo[]
+ default: string
+}
+
+export interface Settings {
+ yolo_mode: boolean
+ model: string
+}
+
+export interface SettingsUpdate {
+ yolo_mode?: boolean
+ model?: string
}
diff --git a/ui/src/styles/globals.css b/ui/src/styles/globals.css
index a1047a2..274cb2f 100644
--- a/ui/src/styles/globals.css
+++ b/ui/src/styles/globals.css
@@ -163,6 +163,31 @@
transform: none;
}
+ /* YOLO Mode Button - Animated fire effect for when YOLO mode is enabled */
+ .neo-btn-yolo {
+ background: linear-gradient(
+ 0deg,
+ #8b0000 0%,
+ #d64500 30%,
+ #ff6a00 60%,
+ #ffa500 100%
+ );
+ background-size: 100% 200%;
+ color: #ffffff;
+ animation: fireGlow 0.8s ease-in-out infinite, fireGradient 1.5s ease-in-out infinite;
+ }
+
+ .neo-btn-yolo:hover {
+ background: linear-gradient(
+ 0deg,
+ #a00000 0%,
+ #e65c00 30%,
+ #ff7800 60%,
+ #ffb700 100%
+ );
+ background-size: 100% 200%;
+ }
+
/* Inputs */
.neo-input {
width: 100%;
@@ -354,6 +379,42 @@
}
}
+@keyframes fireGlow {
+ 0%, 100% {
+ box-shadow:
+ 4px 4px 0 var(--color-neo-border),
+ 0 0 10px rgba(255, 100, 0, 0.5),
+ 0 0 20px rgba(255, 60, 0, 0.3);
+ }
+ 25% {
+ box-shadow:
+ 4px 4px 0 var(--color-neo-border),
+ 0 0 15px rgba(255, 80, 0, 0.6),
+ 0 0 30px rgba(255, 40, 0, 0.4);
+ }
+ 50% {
+ box-shadow:
+ 4px 4px 0 var(--color-neo-border),
+ 0 0 12px rgba(255, 120, 0, 0.7),
+ 0 0 25px rgba(255, 50, 0, 0.5);
+ }
+ 75% {
+ box-shadow:
+ 4px 4px 0 var(--color-neo-border),
+ 0 0 18px rgba(255, 70, 0, 0.55),
+ 0 0 35px rgba(255, 30, 0, 0.35);
+ }
+}
+
+@keyframes fireGradient {
+ 0%, 100% {
+ background-position: 0% 100%;
+ }
+ 50% {
+ background-position: 100% 0%;
+ }
+}
+
/* ============================================================================
Utilities Layer
============================================================================ */
diff --git a/ui/tsconfig.tsbuildinfo b/ui/tsconfig.tsbuildinfo
index 8bf9c84..fd98d1f 100644
--- a/ui/tsconfig.tsbuildinfo
+++ b/ui/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/agentthought.tsx","./src/components/assistantchat.tsx","./src/components/assistantfab.tsx","./src/components/assistantpanel.tsx","./src/components/chatmessage.tsx","./src/components/debuglogviewer.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/folderbrowser.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/typingindicator.tsx","./src/hooks/useassistantchat.ts","./src/hooks/usecelebration.ts","./src/hooks/usefeaturesound.ts","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"}
\ No newline at end of file
+{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/agentthought.tsx","./src/components/assistantchat.tsx","./src/components/assistantfab.tsx","./src/components/assistantpanel.tsx","./src/components/chatmessage.tsx","./src/components/debuglogviewer.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/folderbrowser.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/settingsmodal.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/typingindicator.tsx","./src/hooks/useassistantchat.ts","./src/hooks/usecelebration.ts","./src/hooks/usefeaturesound.ts","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"}
\ No newline at end of file