diff --git a/ui/package-lock.json b/ui/package-lock.json index de5e442..48ccb59 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -96,7 +96,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2826,7 +2825,6 @@ "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2836,7 +2834,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2847,7 +2844,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2903,7 +2899,6 @@ "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", @@ -3214,7 +3209,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3346,7 +3340,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3618,7 +3611,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -3844,7 +3836,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5845,7 +5836,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5961,7 +5951,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5971,7 +5960,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6436,7 +6424,6 @@ "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6690,7 +6677,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index f1c0970..7e01de4 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -302,13 +302,12 @@ function App() { onClick={() => setShowSettings(true)} variant="outline" size="sm" - title="Settings (,)" aria-label="Open Settings" > - Settings + Settings (,) @@ -323,7 +322,7 @@ function App() { - Reset + Reset (R) {/* Ollama Mode Indicator */} diff --git a/ui/src/components/DevServerConfigDialog.tsx b/ui/src/components/DevServerConfigDialog.tsx new file mode 100644 index 0000000..4273d61 --- /dev/null +++ b/ui/src/components/DevServerConfigDialog.tsx @@ -0,0 +1,182 @@ +import { useState, useEffect } from 'react' +import { Loader2, RotateCcw, Terminal } from 'lucide-react' +import { useQueryClient } from '@tanstack/react-query' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useDevServerConfig, useUpdateDevServerConfig } from '@/hooks/useProjects' +import { startDevServer } from '@/lib/api' + +interface DevServerConfigDialogProps { + projectName: string + isOpen: boolean + onClose: () => void + autoStartOnSave?: boolean +} + +export function DevServerConfigDialog({ + projectName, + isOpen, + onClose, + autoStartOnSave = false, +}: DevServerConfigDialogProps) { + const { data: config } = useDevServerConfig(isOpen ? projectName : null) + const updateConfig = useUpdateDevServerConfig(projectName) + const queryClient = useQueryClient() + + const [command, setCommand] = useState('') + const [error, setError] = useState(null) + const [isSaving, setIsSaving] = useState(false) + + // Sync input with config when dialog opens or config loads + useEffect(() => { + if (isOpen && config) { + setCommand(config.custom_command ?? config.effective_command ?? '') + setError(null) + } + }, [isOpen, config]) + + const hasCustomCommand = !!config?.custom_command + + const handleSaveAndStart = async () => { + const trimmed = command.trim() + if (!trimmed) { + setError('Please enter a dev server command.') + return + } + + setIsSaving(true) + setError(null) + + try { + await updateConfig.mutateAsync(trimmed) + + if (autoStartOnSave) { + await startDevServer(projectName) + queryClient.invalidateQueries({ queryKey: ['dev-server-status', projectName] }) + } + + onClose() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save configuration') + } finally { + setIsSaving(false) + } + } + + const handleClear = async () => { + setIsSaving(true) + setError(null) + + try { + await updateConfig.mutateAsync(null) + setCommand(config?.detected_command ?? '') + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to clear configuration') + } finally { + setIsSaving(false) + } + } + + return ( + !open && onClose()}> + + +
+
+ +
+ Dev Server Configuration +
+
+ + +
+ {/* Detection info */} +
+ {config?.detected_type ? ( +

+ Detected project type: {config.detected_type} + {config.detected_command && ( + — {config.detected_command} + )} +

+ ) : ( +

+ No project type detected. Enter a custom command below. +

+ )} +
+ + {/* Command input */} +
+ + { + setCommand(e.target.value) + setError(null) + }} + placeholder="npm run dev" + onKeyDown={(e) => { + if (e.key === 'Enter' && !isSaving) { + handleSaveAndStart() + } + }} + /> +

+ Allowed runners: npm, npx, pnpm, yarn, python, uvicorn, flask, poetry, cargo, go +

+
+ + {/* Clear custom command button */} + {hasCustomCommand && ( + + )} + + {/* Error display */} + {error && ( +

{error}

+ )} +
+
+ + + + + +
+
+ ) +} diff --git a/ui/src/components/DevServerControl.tsx b/ui/src/components/DevServerControl.tsx index 188e875..42b4bad 100644 --- a/ui/src/components/DevServerControl.tsx +++ b/ui/src/components/DevServerControl.tsx @@ -1,8 +1,10 @@ -import { Globe, Square, Loader2, ExternalLink, AlertTriangle } from 'lucide-react' +import { useState } from 'react' +import { Globe, Square, Loader2, ExternalLink, AlertTriangle, Settings2 } from 'lucide-react' import { useMutation, useQueryClient } from '@tanstack/react-query' import type { DevServerStatus } from '../lib/types' import { startDevServer, stopDevServer } from '../lib/api' import { Button } from '@/components/ui/button' +import { DevServerConfigDialog } from './DevServerConfigDialog' // Re-export DevServerStatus from lib/types for consumers that import from here export type { DevServerStatus } @@ -59,17 +61,27 @@ interface DevServerControlProps { * - Shows loading state during operations * - Displays clickable URL when server is running * - Uses neobrutalism design with cyan accent when running + * - Config dialog for setting custom dev commands */ export function DevServerControl({ projectName, status, url }: DevServerControlProps) { const startDevServerMutation = useStartDevServer(projectName) const stopDevServerMutation = useStopDevServer(projectName) + const [showConfigDialog, setShowConfigDialog] = useState(false) + const [autoStartOnSave, setAutoStartOnSave] = useState(false) const isLoading = startDevServerMutation.isPending || stopDevServerMutation.isPending const handleStart = () => { // Clear any previous errors before starting stopDevServerMutation.reset() - startDevServerMutation.mutate() + startDevServerMutation.mutate(undefined, { + onError: (err) => { + if (err.message?.includes('No dev command available')) { + setAutoStartOnSave(true) + setShowConfigDialog(true) + } + }, + }) } const handleStop = () => { // Clear any previous errors before stopping @@ -77,6 +89,19 @@ export function DevServerControl({ projectName, status, url }: DevServerControlP stopDevServerMutation.mutate() } + const handleOpenConfig = () => { + setAutoStartOnSave(false) + setShowConfigDialog(true) + } + + const handleCloseConfig = () => { + setShowConfigDialog(false) + // Clear the start error if config dialog was opened reactively + if (startDevServerMutation.error?.message?.includes('No dev command available')) { + startDevServerMutation.reset() + } + } + // Server is stopped when status is 'stopped' or 'crashed' (can restart) const isStopped = status === 'stopped' || status === 'crashed' // Server is in a running state @@ -84,25 +109,40 @@ export function DevServerControl({ projectName, status, url }: DevServerControlP // Server has crashed const isCrashed = status === 'crashed' + // Hide inline error when config dialog is handling it + const startError = startDevServerMutation.error + const showInlineError = startError && !startError.message?.includes('No dev command available') + return (
{isStopped ? ( - + <> + + + ) : (
) } diff --git a/ui/src/hooks/useProjects.ts b/ui/src/hooks/useProjects.ts index e415454..f69d90f 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, FeatureUpdate, ModelsResponse, ProjectSettingsUpdate, ProvidersResponse, Settings, SettingsUpdate } from '../lib/types' +import type { DevServerConfig, FeatureCreate, FeatureUpdate, ModelsResponse, ProjectSettingsUpdate, ProvidersResponse, Settings, SettingsUpdate } from '../lib/types' // ============================================================================ // Projects @@ -345,3 +345,36 @@ export function useUpdateSettings() { }, }) } + +// ============================================================================ +// Dev Server Config +// ============================================================================ + +// Default config for placeholder (until API responds) +const DEFAULT_DEV_SERVER_CONFIG: DevServerConfig = { + detected_type: null, + detected_command: null, + custom_command: null, + effective_command: null, +} + +export function useDevServerConfig(projectName: string | null) { + return useQuery({ + queryKey: ['dev-server-config', projectName], + queryFn: () => api.getDevServerConfig(projectName!), + enabled: !!projectName, + staleTime: 30_000, + placeholderData: DEFAULT_DEV_SERVER_CONFIG, + }) +} + +export function useUpdateDevServerConfig(projectName: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (customCommand: string | null) => + api.updateDevServerConfig(projectName, customCommand), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['dev-server-config', projectName] }) + }, + }) +} diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 10b577b..23e9973 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -445,6 +445,16 @@ export async function getDevServerConfig(projectName: string): Promise { + return fetchJSON(`/projects/${encodeURIComponent(projectName)}/devserver/config`, { + method: 'PATCH', + body: JSON.stringify({ custom_command: customCommand }), + }) +} + // ============================================================================ // Terminal API // ============================================================================