diff --git a/.gitignore b/.gitignore index 7f8a1ce4..96e50c37 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# Tests +do-wishlist.sh +do-housekeeping.sh + # Dependencies node_modules/ diff --git a/CLAUDE.md b/CLAUDE.md index 05594277..8ad05e2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,10 +65,13 @@ Maestro uses Electron's main/renderer architecture with strict context isolation - `preload.ts` - Secure IPC bridge via contextBridge (no direct Node.js exposure to renderer) **Renderer Process (`src/renderer/`)** - React frontend with no direct Node.js access -- `App.tsx` - Main UI component (being refactored - currently 2,988 lines) +- `App.tsx` - Main UI coordinator (~1,650 lines, continuously being refactored) - `main.tsx` - Renderer entry point - `components/` - React components (modals, panels, UI elements) - - `SessionList.tsx` - Left sidebar component (extracted from App.tsx) + - `SessionList.tsx` - Left sidebar with sessions and groups + - `MainPanel.tsx` - Center workspace (terminal, log viewer, input) + - `RightPanel.tsx` - Right sidebar (files, history, scratchpad) + - `LogViewer.tsx` - System logs viewer with filtering and search - `SettingsModal.tsx`, `NewInstanceModal.tsx`, `Scratchpad.tsx`, `FilePreview.tsx` - Other UI components - `hooks/` - Custom React hooks for reusable state logic - `useSettings.ts` - Settings management and persistence @@ -339,10 +342,25 @@ The main application is structured in three columns: ### Key Components +#### MainPanel (`src/renderer/components/MainPanel.tsx`) +- Center workspace container that handles three states: + - LogViewer when system logs are open + - Empty state when no session is active + - Normal session view (top bar, terminal output, input area, file preview) +- Encapsulates all main panel UI logic outside of App.tsx + +#### LogViewer (`src/renderer/components/LogViewer.tsx`) +- System logs viewer accessible via Cmd+K → "View System Logs" +- Color-coded log levels (Debug, Info, Warn, Error) +- Searchable with `/` key, filterable by log level +- Export logs to file, clear all logs +- Keyboard navigation (arrows to scroll, Cmd+arrows to jump) + #### SettingsModal (`src/renderer/components/SettingsModal.tsx`) - Tabbed interface: General, LLM, Shortcuts, Themes, Network - All settings changes should use wrapper functions for persistence - Includes LLM test functionality to verify API connectivity +- Log level selector with color-coded buttons (defaults to "info") #### Scratchpad (`src/renderer/components/Scratchpad.tsx`) - Edit/Preview mode toggle (Command-E to switch) @@ -438,6 +456,45 @@ Sessions persist scroll positions, expanded states, and UI state per-session. - Interface definitions for all data structures - Type exports via `preload.ts` for renderer types +### Component Extraction Pattern + +**Principle**: Keep App.tsx minimal by extracting UI sections into dedicated components. + +When adding new features that would add significant complexity to App.tsx: + +1. **Create a new component** in `src/renderer/components/` that encapsulates the entire UI section +2. **Pass only necessary props** - state, setters, refs, and callback functions +3. **Handle all conditional logic** within the component (e.g., empty states, different views) +4. **Keep App.tsx as a coordinator** - it should orchestrate state and wire components together, not contain UI logic + +**Example - MainPanel component:** +```typescript +// App.tsx - Minimal integration + + +// MainPanel.tsx - Contains all UI logic +export function MainPanel(props: MainPanelProps) { + // Handles: log viewer, empty state, normal session view + if (logViewerOpen) return ; + if (!activeSession) return ; + return ; +} +``` + +**Benefits:** +- App.tsx stays manageable and readable +- Components are self-contained and testable +- Changes to UI sections don't bloat App.tsx +- Easier code review and maintenance + ### Commit Message Format Use conventional commits: @@ -878,6 +935,9 @@ Currently no test suite implemented. When adding tests, use the `test` script in ## Recent Features Added +- **System Log Viewer** - Cmd+K → "View System Logs" for internal logging with color-coded levels, search, and export +- **Structured Logging** - Configurable log levels (Debug, Info, Warn, Error) with persistence and UI controls +- **Component Extraction** - MainPanel component to keep App.tsx minimal and maintainable - **Output Search/Filter** - Press `/` in output window to filter logs - **Scratchpad Command-E** - Toggle between Edit and Preview modes - **File Preview Focus** - Arrow keys scroll, Escape returns to file tree diff --git a/do-housekeeping.sh b/do-housekeeping.sh deleted file mode 100755 index 7e4d132d..00000000 --- a/do-housekeeping.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/sh - -TASKS_BEFORE=$(grep "\- \[ \]" ./tmp/HOUSEKEEPING.md | wc -l) - -if [ "$TASKS_BEFORE" -eq 0 ]; then - echo "no tasks remaining in document, exiting..." - exit 0 -fi - -PROMPT=$(cat ./tmp/housekeeping.prompt) -RESPONSE=$(claude --dangerously-skip-permissions -p "$PROMPT") -echo "$RESPONSE" -TASKS_AFTER=$(grep "\- \[ \]" ./tmp/HOUSEKEEPING.md | wc -l) -echo "Tasks before $TASKS_BEFORE and after $TASKS_AFTER" diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 7fcff59c..a63ce151 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -23,6 +23,7 @@ import { RenameSessionModal } from './components/RenameSessionModal'; import { RenameGroupModal } from './components/RenameGroupModal'; import { ConfirmModal } from './components/ConfirmModal'; import { ErrorBoundary } from './components/ErrorBoundary'; +import { MainPanel } from './components/MainPanel'; // Import custom hooks import { useSettings, useSessionManager, useFileExplorer } from './hooks'; @@ -109,6 +110,7 @@ export default function MaestroConsole() { const [settingsTab, setSettingsTab] = useState<'general' | 'shortcuts' | 'theme' | 'network'>('general'); const [lightboxImage, setLightboxImage] = useState(null); const [aboutModalOpen, setAboutModalOpen] = useState(false); + const [logViewerOpen, setLogViewerOpen] = useState(false); const [createGroupModalOpen, setCreateGroupModalOpen] = useState(false); const [newGroupName, setNewGroupName] = useState(''); const [newGroupEmoji, setNewGroupEmoji] = useState('📂'); @@ -164,6 +166,9 @@ export default function MaestroConsole() { const [fontSize, setFontSizeState] = useState(14); // Base font size in px const [customFonts, setCustomFonts] = useState([]); + // Logging Config + const [logLevel, setLogLevelState] = useState('info'); + // Wrapper functions that persist to electron-store const setLlmProviderPersist = (value: LLMProvider) => { setLlmProvider(value); @@ -205,6 +210,11 @@ export default function MaestroConsole() { window.maestro.settings.set('fontSize', value); }; + const setLogLevel = async (value: string) => { + setLogLevelState(value); + await window.maestro.logger.setLogLevel(value); + }; + // Load settings from electron-store on mount useEffect(() => { const loadSettings = async () => { @@ -222,6 +232,7 @@ export default function MaestroConsole() { const savedRightPanelWidth = await window.maestro.settings.get('rightPanelWidth'); const savedMarkdownRawMode = await window.maestro.settings.get('markdownRawMode'); const savedShortcuts = await window.maestro.settings.get('shortcuts'); + const savedLogLevel = await window.maestro.logger.getLogLevel(); if (savedEnterToSend !== undefined) setEnterToSendState(savedEnterToSend); if (savedLlmProvider !== undefined) setLlmProvider(savedLlmProvider); @@ -236,6 +247,7 @@ export default function MaestroConsole() { if (savedLeftSidebarWidth !== undefined) setLeftSidebarWidthState(savedLeftSidebarWidth); if (savedRightPanelWidth !== undefined) setRightPanelWidthState(savedRightPanelWidth); if (savedMarkdownRawMode !== undefined) setMarkdownRawModeState(savedMarkdownRawMode); + if (savedLogLevel !== undefined) setLogLevelState(savedLogLevel); // Merge saved shortcuts with defaults (in case new shortcuts were added) if (savedShortcuts !== undefined) { @@ -1383,6 +1395,7 @@ export default function MaestroConsole() { setSettingsTab={setSettingsTab} setShortcutsHelpOpen={setShortcutsHelpOpen} setAboutModalOpen={setAboutModalOpen} + setLogViewerOpen={setLogViewerOpen} setActiveRightTab={setActiveRightTab} /> )} @@ -1509,145 +1522,49 @@ export default function MaestroConsole() { {/* --- CENTER WORKSPACE --- */} - {!activeSession ? ( - <> -
- -

No agents. Create one to get started.

-
-
- - ) : ( - <> - -
setActiveFocus('main')} - > - {/* Top Bar */} -
-
-
- {(activeSession.inputMode === 'terminal' ? (activeSession.shellCwd || activeSession.cwd) : activeSession.cwd).split('/').pop() || '/'} / - - {activeSession.isGitRepo ? 'GIT' : 'LOCAL'} - -
- -
- - {activeSession.tunnelActive && ( -
-
Public Endpoint
-
- {activeSession.tunnelUrl} -
-
Local Address
-
- http://192.168.1.42:{activeSession.port} -
-
- )} -
-
-
-
- Context Window -
-
-
-
- - - {!rightPanelOpen && ( - - )} -
-
- - {/* Logs Area */} - - - {/* Input Area */} - - - {/* File Preview Overlay */} - {previewFile && ( - { - setPreviewFile(null); - setTimeout(() => { - if (fileTreeContainerRef.current) { - fileTreeContainerRef.current.focus(); - } - }, 0); - }} - theme={theme} - markdownRawMode={markdownRawMode} - setMarkdownRawMode={setMarkdownRawMode} - shortcuts={shortcuts} - /> - )} -
- + {/* --- RIGHT PANEL --- */} @@ -1682,8 +1599,6 @@ export default function MaestroConsole() { updateScratchPadState={updateScratchPadState} /> - - )} {/* Old settings modal removed - using new SettingsModal component below */} @@ -1722,6 +1637,8 @@ export default function MaestroConsole() { setFontFamily={setFontFamily} fontSize={fontSize} setFontSize={setFontSize} + logLevel={logLevel} + setLogLevel={setLogLevel} initialTab={settingsTab} />
diff --git a/src/renderer/components/LogViewer.tsx b/src/renderer/components/LogViewer.tsx new file mode 100644 index 00000000..fcfb9df9 --- /dev/null +++ b/src/renderer/components/LogViewer.tsx @@ -0,0 +1,341 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Search, X, Trash2, Download } from 'lucide-react'; +import type { Theme } from '../types'; + +interface SystemLogEntry { + timestamp: number; + level: 'debug' | 'info' | 'warn' | 'error'; + message: string; + context?: string; + data?: unknown; +} + +interface LogViewerProps { + theme: Theme; + onClose: () => void; +} + +export function LogViewer({ theme, onClose }: LogViewerProps) { + const [logs, setLogs] = useState([]); + const [filteredLogs, setFilteredLogs] = useState([]); + const [searchOpen, setSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedLevel, setSelectedLevel] = useState<'debug' | 'info' | 'warn' | 'error' | 'all'>('all'); + const logsEndRef = useRef(null); + const searchInputRef = useRef(null); + const containerRef = useRef(null); + + // Load logs on mount + useEffect(() => { + loadLogs(); + }, []); + + // Filter logs whenever search query or selected level changes + useEffect(() => { + let filtered = logs; + + // Filter by level + if (selectedLevel !== 'all') { + filtered = filtered.filter(log => log.level === selectedLevel); + } + + // Filter by search query + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + filtered = filtered.filter(log => + log.message.toLowerCase().includes(query) || + log.context?.toLowerCase().includes(query) || + (log.data && JSON.stringify(log.data).toLowerCase().includes(query)) + ); + } + + setFilteredLogs(filtered); + }, [logs, searchQuery, selectedLevel]); + + // Focus search input when opened + useEffect(() => { + if (searchOpen) { + searchInputRef.current?.focus(); + } + }, [searchOpen]); + + const loadLogs = async () => { + try { + const systemLogs = await window.maestro.logger.getLogs(); + setLogs(systemLogs); + } catch (error) { + console.error('Failed to load logs:', error); + } + }; + + const handleClearLogs = async () => { + try { + await window.maestro.logger.clearLogs(); + setLogs([]); + setFilteredLogs([]); + } catch (error) { + console.error('Failed to clear logs:', error); + } + }; + + const handleExportLogs = () => { + const logsText = filteredLogs.map(log => { + const timestamp = new Date(log.timestamp).toISOString(); + const contextStr = log.context ? `[${log.context}]` : ''; + const dataStr = log.data ? `\n${JSON.stringify(log.data, null, 2)}` : ''; + return `[${timestamp}] [${log.level.toUpperCase()}] ${contextStr} ${log.message}${dataStr}`; + }).join('\n\n'); + + const blob = new Blob([logsText], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `maestro-logs-${Date.now()}.txt`; + a.click(); + URL.revokeObjectURL(url); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Open search with / + if (e.key === '/' && !searchOpen && document.activeElement !== searchInputRef.current) { + e.preventDefault(); + setSearchOpen(true); + } + // Close search with Escape + else if (e.key === 'Escape' && searchOpen) { + e.preventDefault(); + setSearchOpen(false); + setSearchQuery(''); + containerRef.current?.focus(); + } + // Scroll with arrow keys + else if (e.key === 'ArrowUp' && !searchOpen) { + e.preventDefault(); + containerRef.current?.scrollBy({ top: -100, behavior: 'smooth' }); + } else if (e.key === 'ArrowDown' && !searchOpen) { + e.preventDefault(); + containerRef.current?.scrollBy({ top: 100, behavior: 'smooth' }); + } + // Jump to top/bottom + else if ((e.metaKey || e.ctrlKey) && e.key === 'ArrowUp' && !searchOpen) { + e.preventDefault(); + containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); + } else if ((e.metaKey || e.ctrlKey) && e.key === 'ArrowDown' && !searchOpen) { + e.preventDefault(); + containerRef.current?.scrollTo({ top: containerRef.current.scrollHeight, behavior: 'smooth' }); + } + }; + + const getLevelColor = (level: string) => { + switch (level) { + case 'debug': + return '#6366f1'; // Indigo + case 'info': + return '#3b82f6'; // Blue + case 'warn': + return '#f59e0b'; // Amber + case 'error': + return '#ef4444'; // Red + default: + return theme.colors.textDim; + } + }; + + const getLevelBgColor = (level: string) => { + switch (level) { + case 'debug': + return 'rgba(99, 102, 241, 0.15)'; + case 'info': + return 'rgba(59, 130, 246, 0.15)'; + case 'warn': + return 'rgba(245, 158, 11, 0.15)'; + case 'error': + return 'rgba(239, 68, 68, 0.15)'; + default: + return 'transparent'; + } + }; + + return ( +
+ {/* Header */} +
+
+

+ Maestro System Logs +

+ + {filteredLogs.length} {filteredLogs.length === 1 ? 'entry' : 'entries'} + +
+
+ + + +
+
+ + {/* Level Filters */} +
+ + Filter: + + {(['all', 'debug', 'info', 'warn', 'error'] as const).map(level => ( + + ))} +
+ + {/* Search Bar */} + {searchOpen && ( +
+ + setSearchQuery(e.target.value)} + onKeyDown={e => { + if (e.key === 'Escape') { + e.preventDefault(); + setSearchOpen(false); + setSearchQuery(''); + containerRef.current?.focus(); + } + }} + /> + +
+ )} + + {/* Logs Container */} +
+ {filteredLogs.length === 0 ? ( +
+ {logs.length === 0 ? 'No logs yet' : 'No logs match your filter'} +
+ ) : ( + filteredLogs.map((log, index) => ( +
+
+ {/* Level Pill */} +
+ {log.level} +
+ + {/* Content */} +
+
+ + {new Date(log.timestamp).toLocaleTimeString()} + + {log.context && ( + + {log.context} + + )} +
+
+ {log.message} +
+ {log.data && ( +
+                      {JSON.stringify(log.data, null, 2)}
+                    
+ )} +
+
+
+ )) + )} +
+
+ + {/* Footer hint */} + {!searchOpen && ( +
+ Press / to search +
+ )} +
+ ); +} diff --git a/src/renderer/components/MainPanel.tsx b/src/renderer/components/MainPanel.tsx new file mode 100644 index 00000000..08364954 --- /dev/null +++ b/src/renderer/components/MainPanel.tsx @@ -0,0 +1,230 @@ +import React from 'react'; +import { Wand2, Radio, ExternalLink, Wifi, Info, Columns } from 'lucide-react'; +import { LogViewer } from './LogViewer'; +import { TerminalOutput } from './TerminalOutput'; +import { InputArea } from './InputArea'; +import { FilePreview } from './FilePreview'; +import { ErrorBoundary } from './ErrorBoundary'; +import type { Session, Theme, Shortcut, FocusArea } from '../types'; + +interface MainPanelProps { + // State + logViewerOpen: boolean; + activeSession: Session | null; + theme: Theme; + activeFocus: FocusArea; + outputSearchOpen: boolean; + outputSearchQuery: string; + inputValue: string; + enterToSend: boolean; + stagedImages: string[]; + commandHistoryOpen: boolean; + commandHistoryFilter: string; + commandHistorySelectedIndex: number; + previewFile: { name: string; content: string; path: string } | null; + markdownRawMode: boolean; + shortcuts: Record; + rightPanelOpen: boolean; + + // Setters + setLogViewerOpen: (open: boolean) => void; + setActiveFocus: (focus: FocusArea) => void; + setOutputSearchOpen: (open: boolean) => void; + setOutputSearchQuery: (query: string) => void; + setInputValue: (value: string) => void; + setEnterToSend: (value: boolean) => void; + setStagedImages: (images: string[]) => void; + setLightboxImage: (image: string | null) => void; + setCommandHistoryOpen: (open: boolean) => void; + setCommandHistoryFilter: (filter: string) => void; + setCommandHistorySelectedIndex: (index: number) => void; + setPreviewFile: (file: { name: string; content: string; path: string } | null) => void; + setMarkdownRawMode: (mode: boolean) => void; + setAboutModalOpen: (open: boolean) => void; + setRightPanelOpen: (open: boolean) => void; + + // Refs + inputRef: React.RefObject; + logsEndRef: React.RefObject; + fileTreeContainerRef: React.RefObject; + + // Functions + toggleTunnel: (sessionId: string) => void; + toggleInputMode: () => void; + processInput: () => void; + handleInputKeyDown: (e: React.KeyboardEvent) => void; + handlePaste: (e: React.ClipboardEvent) => void; + handleDrop: (e: React.DragEvent) => void; + getContextColor: (usage: number, theme: Theme) => string; +} + +export function MainPanel(props: MainPanelProps) { + const { + logViewerOpen, activeSession, theme, activeFocus, outputSearchOpen, outputSearchQuery, + inputValue, enterToSend, stagedImages, commandHistoryOpen, commandHistoryFilter, + commandHistorySelectedIndex, previewFile, markdownRawMode, shortcuts, rightPanelOpen, + setLogViewerOpen, setActiveFocus, setOutputSearchOpen, setOutputSearchQuery, + setInputValue, setEnterToSend, setStagedImages, setLightboxImage, setCommandHistoryOpen, + setCommandHistoryFilter, setCommandHistorySelectedIndex, setPreviewFile, setMarkdownRawMode, + setAboutModalOpen, setRightPanelOpen, inputRef, logsEndRef, fileTreeContainerRef, + toggleTunnel, toggleInputMode, processInput, handleInputKeyDown, handlePaste, handleDrop, + getContextColor + } = props; + + // Show log viewer + if (logViewerOpen) { + return ( +
+ setLogViewerOpen(false)} /> +
+ ); + } + + // Show empty state when no active session + if (!activeSession) { + return ( + <> +
+ +

No agents. Create one to get started.

+
+
+ + ); + } + + // Show normal session view + return ( + <> + +
setActiveFocus('main')} + > + {/* Top Bar */} +
+
+
+ {(activeSession.inputMode === 'terminal' ? (activeSession.shellCwd || activeSession.cwd) : activeSession.cwd).split('/').pop() || '/'} / + + {activeSession.isGitRepo ? 'GIT' : 'LOCAL'} + +
+ +
+ + {activeSession.tunnelActive && ( +
+
Public Endpoint
+
+ {activeSession.tunnelUrl} +
+
Local Address
+
+ http://192.168.1.42:{activeSession.port} +
+
+ )} +
+
+
+
+ Context Window +
+
+
+
+ + + {!rightPanelOpen && ( + + )} +
+
+ + {/* Logs Area */} + + + {/* Input Area */} + + + {/* File Preview Overlay */} + {previewFile && ( + { + setPreviewFile(null); + setTimeout(() => { + if (fileTreeContainerRef.current) { + fileTreeContainerRef.current.focus(); + } + }, 0); + }} + theme={theme} + markdownRawMode={markdownRawMode} + setMarkdownRawMode={setMarkdownRawMode} + shortcuts={shortcuts} + /> + )} +
+ + + ); +} diff --git a/src/renderer/components/QuickActionsModal.tsx b/src/renderer/components/QuickActionsModal.tsx index 6af8afaa..d675d300 100644 --- a/src/renderer/components/QuickActionsModal.tsx +++ b/src/renderer/components/QuickActionsModal.tsx @@ -39,6 +39,7 @@ interface QuickActionsModalProps { setSettingsTab: (tab: string) => void; setShortcutsHelpOpen: (open: boolean) => void; setAboutModalOpen: (open: boolean) => void; + setLogViewerOpen: (open: boolean) => void; } export function QuickActionsModal(props: QuickActionsModalProps) { @@ -49,7 +50,7 @@ export function QuickActionsModal(props: QuickActionsModalProps) { setCreateGroupModalOpen, setNewGroupName, setMoveSessionToNewGroup, setLeftSidebarOpen, setRightPanelOpen, setActiveRightTab, toggleInputMode, deleteSession, addNewSession, setSettingsModalOpen, setSettingsTab, - setShortcutsHelpOpen, setAboutModalOpen + setShortcutsHelpOpen, setAboutModalOpen, setLogViewerOpen } = props; const [search, setSearch] = useState(''); @@ -152,6 +153,7 @@ export function QuickActionsModal(props: QuickActionsModalProps) { { id: 'settings', label: 'Settings', action: () => { setSettingsModalOpen(true); setQuickActionOpen(false); } }, { id: 'theme', label: 'Change Theme', action: () => { setSettingsModalOpen(true); setSettingsTab('theme'); setQuickActionOpen(false); } }, { id: 'shortcuts', label: 'View Shortcuts', shortcut: shortcuts.help, action: () => { setShortcutsHelpOpen(true); setQuickActionOpen(false); } }, + { id: 'logs', label: 'View System Logs', action: () => { setLogViewerOpen(true); setQuickActionOpen(false); } }, { id: 'devtools', label: 'Toggle JavaScript Console', action: () => { window.maestro.devtools.toggle(); setQuickActionOpen(false); } }, { id: 'about', label: 'About Maestro', action: () => { setAboutModalOpen(true); setQuickActionOpen(false); } }, { id: 'goToFiles', label: 'Go to Files Tab', action: () => { setRightPanelOpen(true); setActiveRightTab('files'); setQuickActionOpen(false); } },