From 718f690bf94df356cc0810fa6a5d09d99a3ef8ac Mon Sep 17 00:00:00 2001 From: Auto Date: Tue, 30 Dec 2025 19:21:02 +0200 Subject: [PATCH] hotkeys --- ui/src/App.tsx | 80 +++++++++++++++++++- ui/src/components/DebugLogViewer.tsx | 3 + ui/src/hooks/useFeatureSound.ts | 109 +++++++++++++++++++++++++++ ui/tsconfig.tsbuildinfo | 2 +- 4 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 ui/src/hooks/useFeatureSound.ts diff --git a/ui/src/App.tsx b/ui/src/App.tsx index a299f19..5365a68 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,6 +1,9 @@ -import { useState } from 'react' +import { useState, useEffect, useCallback } from 'react' import { useProjects, useFeatures } from './hooks/useProjects' import { useProjectWebSocket } from './hooks/useWebSocket' +import { useFeatureSound } from './hooks/useFeatureSound' + +const STORAGE_KEY = 'autonomous-coder-selected-project' import { ProjectSelector } from './components/ProjectSelector' import { KanbanBoard } from './components/KanbanBoard' import { AgentControl } from './components/AgentControl' @@ -13,7 +16,14 @@ import { Plus, Loader2 } from 'lucide-react' import type { Feature } from './lib/types' function App() { - const [selectedProject, setSelectedProject] = useState(null) + // Initialize selected project from localStorage + const [selectedProject, setSelectedProject] = useState(() => { + try { + return localStorage.getItem(STORAGE_KEY) + } catch { + return null + } + }) const [showAddFeature, setShowAddFeature] = useState(false) const [selectedFeature, setSelectedFeature] = useState(null) const [setupComplete, setSetupComplete] = useState(true) // Start optimistic @@ -23,6 +33,66 @@ function App() { const { data: features } = useFeatures(selectedProject) const wsState = useProjectWebSocket(selectedProject) + // Play sounds when features move between columns + useFeatureSound(features) + + // Persist selected project to localStorage + const handleSelectProject = useCallback((project: string | null) => { + setSelectedProject(project) + try { + if (project) { + localStorage.setItem(STORAGE_KEY, project) + } else { + localStorage.removeItem(STORAGE_KEY) + } + } catch { + // localStorage not available + } + }, []) + + // Validate stored project exists (clear if project was deleted) + useEffect(() => { + if (selectedProject && projects && !projects.some(p => p.name === selectedProject)) { + handleSelectProject(null) + } + }, [selectedProject, projects, handleSelectProject]) + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Ignore if user is typing in an input + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return + } + + // D : Toggle debug window + if (e.key === 'd' || e.key === 'D') { + e.preventDefault() + setDebugOpen(prev => !prev) + } + + // N : Add new feature (when project selected) + if ((e.key === 'n' || e.key === 'N') && selectedProject) { + e.preventDefault() + setShowAddFeature(true) + } + + // Escape : Close modals + if (e.key === 'Escape') { + if (showAddFeature) { + setShowAddFeature(false) + } else if (selectedFeature) { + setSelectedFeature(null) + } else if (debugOpen) { + setDebugOpen(false) + } + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [selectedProject, showAddFeature, selectedFeature, debugOpen]) + // Combine WebSocket progress with feature data const progress = wsState.progress.total > 0 ? wsState.progress : { passing: features?.done.length ?? 0, @@ -54,7 +124,7 @@ function App() { @@ -63,9 +133,13 @@ function App() { Debug + + D + {logs.length > 0 && ( {logs.length} diff --git a/ui/src/hooks/useFeatureSound.ts b/ui/src/hooks/useFeatureSound.ts new file mode 100644 index 0000000..fd88df3 --- /dev/null +++ b/ui/src/hooks/useFeatureSound.ts @@ -0,0 +1,109 @@ +/** + * Hook for playing sounds when features move between columns + * Uses Web Audio API to generate pleasant chime sounds + */ + +import { useEffect, useRef } from 'react' +import type { FeatureListResponse } from '../lib/types' + +// Sound frequencies for different transitions (in Hz) +const SOUNDS = { + // Feature started (pending -> in_progress): ascending tone + started: [523.25, 659.25], // C5 -> E5 + // Feature completed (in_progress -> done): pleasant major chord arpeggio + completed: [523.25, 659.25, 783.99], // C5 -> E5 -> G5 +} + +type SoundType = keyof typeof SOUNDS + +function playChime(type: SoundType): void { + try { + const audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)() + const frequencies = SOUNDS[type] + const duration = type === 'completed' ? 0.15 : 0.12 + const totalDuration = frequencies.length * duration + + frequencies.forEach((freq, index) => { + const oscillator = audioContext.createOscillator() + const gainNode = audioContext.createGain() + + oscillator.connect(gainNode) + gainNode.connect(audioContext.destination) + + oscillator.type = 'sine' + oscillator.frequency.setValueAtTime(freq, audioContext.currentTime) + + // Envelope for smooth sound + const startTime = audioContext.currentTime + index * duration + gainNode.gain.setValueAtTime(0, startTime) + gainNode.gain.linearRampToValueAtTime(0.3, startTime + 0.02) + gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration) + + oscillator.start(startTime) + oscillator.stop(startTime + duration) + }) + + // Clean up audio context after sounds finish + setTimeout(() => { + audioContext.close() + }, totalDuration * 1000 + 100) + } catch { + // Audio not supported or blocked, fail silently + } +} + +interface FeatureState { + pendingIds: Set + inProgressIds: Set + doneIds: Set +} + +function getFeatureState(features: FeatureListResponse | undefined): FeatureState { + return { + pendingIds: new Set(features?.pending.map(f => f.id) ?? []), + inProgressIds: new Set(features?.in_progress.map(f => f.id) ?? []), + doneIds: new Set(features?.done.map(f => f.id) ?? []), + } +} + +export function useFeatureSound(features: FeatureListResponse | undefined): void { + const prevStateRef = useRef(null) + const isInitializedRef = useRef(false) + + useEffect(() => { + if (!features) return + + const currentState = getFeatureState(features) + + // Skip sound on initial load + if (!isInitializedRef.current) { + prevStateRef.current = currentState + isInitializedRef.current = true + return + } + + const prevState = prevStateRef.current + if (!prevState) { + prevStateRef.current = currentState + return + } + + // Check for features that moved to in_progress (started) + for (const id of currentState.inProgressIds) { + if (prevState.pendingIds.has(id) && !prevState.inProgressIds.has(id)) { + playChime('started') + break // Only play once even if multiple features moved + } + } + + // Check for features that moved to done (completed) + for (const id of currentState.doneIds) { + if (!prevState.doneIds.has(id) && (prevState.inProgressIds.has(id) || prevState.pendingIds.has(id))) { + playChime('completed') + break // Only play once even if multiple features moved + } + } + + prevStateRef.current = currentState + }, [features]) +} diff --git a/ui/tsconfig.tsbuildinfo b/ui/tsconfig.tsbuildinfo index 4e16d75..23d0ee1 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/chatmessage.tsx","./src/components/debuglogviewer.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.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/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/chatmessage.tsx","./src/components/debuglogviewer.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.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/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