diff --git a/docs/keyboard-shortcuts.md b/docs/keyboard-shortcuts.md index d39881a3..31f1c2de 100644 --- a/docs/keyboard-shortcuts.md +++ b/docs/keyboard-shortcuts.md @@ -26,6 +26,7 @@ The command palette is your gateway to nearly every action in Maestro. Press `Cm | Switch AI/Command Terminal | `Cmd+J` | `Ctrl+J` | | Show Shortcuts Help | `Cmd+/` | `Ctrl+/` | | Open Settings | `Cmd+,` | `Ctrl+,` | +| Open Agent Settings | `Opt+Cmd+,` | `Alt+Ctrl+,` | | View All Agent Sessions | `Cmd+Shift+L` | `Ctrl+Shift+L` | | Jump to Bottom | `Cmd+Shift+J` | `Ctrl+Shift+J` | | Cycle Focus Areas | `Tab` | `Tab` | diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 7f3055f9..289481cc 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -37,6 +37,7 @@ import { useDebouncedPersistence, // Session management useActivityTracker, + useHandsOnTimeTracker, useNavigationHistory, useSessionNavigation, useSortedSessions, @@ -276,7 +277,7 @@ function MaestroConsoleInner() { shortcuts, setShortcuts, tabShortcuts, setTabShortcuts, customAICommands, setCustomAICommands, - globalStats: _globalStats, updateGlobalStats, + globalStats, updateGlobalStats, autoRunStats, recordAutoRunComplete, updateAutoRunProgress, acknowledgeBadge, getUnacknowledgedBadgeLevel, usageStats, updateUsageStats, tourCompleted: _tourCompleted, setTourCompleted, @@ -3952,9 +3953,13 @@ function MaestroConsoleInner() { onHistoryCommand: handleHistoryCommand, }); - // Initialize activity tracker for time tracking + // Initialize activity tracker for per-session time tracking useActivityTracker(activeSessionId, setSessions); + // Initialize global hands-on time tracker (persists to settings) + // Tracks total time user spends actively using Maestro (5-minute idle timeout) + useHandsOnTimeTracker(updateGlobalStats); + // Track elapsed time for active auto-runs and update achievement stats every minute // This allows badges to be unlocked during an auto-run, not just when it completes const autoRunProgressRef = useRef<{ lastUpdateTime: number }>({ lastUpdateTime: 0 }); @@ -8148,6 +8153,7 @@ function MaestroConsoleInner() { onCloseAboutModal={handleCloseAboutModal} autoRunStats={autoRunStats} usageStats={usageStats} + handsOnTimeMs={globalStats.totalActiveTimeMs} onOpenLeaderboardRegistration={handleOpenLeaderboardRegistrationFromAbout} isLeaderboardRegistered={isLeaderboardRegistered} updateCheckModalOpen={updateCheckModalOpen} diff --git a/src/renderer/components/AboutModal.tsx b/src/renderer/components/AboutModal.tsx index c8c9308f..ad5fe0cd 100644 --- a/src/renderer/components/AboutModal.tsx +++ b/src/renderer/components/AboutModal.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState, useCallback } from 'react'; import { X, Wand2, ExternalLink, FileCode, BarChart3, Loader2, Trophy, Globe, Check, BookOpen } from 'lucide-react'; -import type { Theme, Session, AutoRunStats, MaestroUsageStats, LeaderboardRegistration } from '../types'; +import type { Theme, AutoRunStats, MaestroUsageStats, LeaderboardRegistration } from '../types'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; import pedramAvatar from '../assets/pedram-avatar.png'; import { AchievementCard } from './AchievementCard'; @@ -32,16 +32,17 @@ interface GlobalAgentStats { interface AboutModalProps { theme: Theme; - sessions: Session[]; autoRunStats: AutoRunStats; usageStats?: MaestroUsageStats | null; + /** Global hands-on time in milliseconds (from settings, persists across sessions) */ + handsOnTimeMs: number; onClose: () => void; onOpenLeaderboardRegistration?: () => void; isLeaderboardRegistered?: boolean; leaderboardRegistration?: LeaderboardRegistration | null; } -export function AboutModal({ theme, sessions, autoRunStats, usageStats, onClose, onOpenLeaderboardRegistration, isLeaderboardRegistered, leaderboardRegistration }: AboutModalProps) { +export function AboutModal({ theme, autoRunStats, usageStats, handsOnTimeMs, onClose, onOpenLeaderboardRegistration, isLeaderboardRegistered, leaderboardRegistration }: AboutModalProps) { const [globalStats, setGlobalStats] = useState(null); const [loading, setLoading] = useState(true); const [isStatsComplete, setIsStatsComplete] = useState(false); @@ -86,9 +87,6 @@ export function AboutModal({ theme, sessions, autoRunStats, usageStats, onClose, }; }, []); - // Calculate active time from current sessions - const totalActiveTimeMs = sessions.reduce((sum, s) => sum + (s.activeTimeMs || 0), 0); - // formatTokensCompact and formatSize imported from ../utils/formatters // Format duration from milliseconds @@ -191,7 +189,7 @@ export function AboutModal({ theme, sessions, autoRunStats, usageStats, onClose, autoRunStats={autoRunStats} globalStats={globalStats} usageStats={usageStats} - handsOnTimeMs={totalActiveTimeMs} + handsOnTimeMs={handsOnTimeMs} leaderboardRegistration={leaderboardRegistration} onEscapeWithBadgeOpen={(handler) => { badgeEscapeHandlerRef.current = handler; }} /> @@ -249,12 +247,12 @@ export function AboutModal({ theme, sessions, autoRunStats, usageStats, onClose, )} {/* Active Time & Total Cost - show cost only if we have cost data */} - {(totalActiveTimeMs > 0 || globalStats.hasCostData) && ( + {(handsOnTimeMs > 0 || globalStats.hasCostData) && (
- {totalActiveTimeMs > 0 && ( - Hands-on Time: {formatDuration(totalActiveTimeMs)} + {handsOnTimeMs > 0 && ( + Hands-on Time: {formatDuration(handsOnTimeMs)} )} - {!totalActiveTimeMs && globalStats.hasCostData && ( + {!handsOnTimeMs && globalStats.hasCostData && ( Total Cost )} {globalStats.hasCostData && ( diff --git a/src/renderer/components/AppModals.tsx b/src/renderer/components/AppModals.tsx index 9d45ea58..5f5ff254 100644 --- a/src/renderer/components/AppModals.tsx +++ b/src/renderer/components/AppModals.tsx @@ -118,9 +118,10 @@ export interface AppInfoModalsProps { // About Modal aboutModalOpen: boolean; onCloseAboutModal: () => void; - sessions: Session[]; autoRunStats: AutoRunStats; usageStats?: MaestroUsageStats | null; + /** Global hands-on time in milliseconds (from settings) */ + handsOnTimeMs: number; onOpenLeaderboardRegistration: () => void; isLeaderboardRegistered: boolean; leaderboardRegistration?: LeaderboardRegistration | null; @@ -132,6 +133,7 @@ export interface AppInfoModalsProps { // Process Monitor processMonitorOpen: boolean; onCloseProcessMonitor: () => void; + sessions: Session[]; // Used by ProcessMonitor groups: Group[]; groupChats: GroupChat[]; onNavigateToSession: (sessionId: string, tabId?: string) => void; @@ -162,9 +164,9 @@ export function AppInfoModals({ // About Modal aboutModalOpen, onCloseAboutModal, - sessions, autoRunStats, usageStats, + handsOnTimeMs, onOpenLeaderboardRegistration, isLeaderboardRegistered, leaderboardRegistration, @@ -174,6 +176,7 @@ export function AppInfoModals({ // Process Monitor processMonitorOpen, onCloseProcessMonitor, + sessions, groups, groupChats, onNavigateToSession, @@ -197,9 +200,9 @@ export function AppInfoModals({ {aboutModalOpen && ( void; autoRunStats: AutoRunStats; usageStats?: MaestroUsageStats | null; + /** Global hands-on time in milliseconds (from settings) */ + handsOnTimeMs: number; onOpenLeaderboardRegistration: () => void; isLeaderboardRegistered: boolean; // leaderboardRegistration is provided via AppAgentModals props below @@ -1870,6 +1875,7 @@ export function AppModals(props: AppModalsProps) { onCloseAboutModal, autoRunStats, usageStats, + handsOnTimeMs, onOpenLeaderboardRegistration, isLeaderboardRegistered, // leaderboardRegistration is destructured below in Agent modals section @@ -2112,9 +2118,9 @@ export function AppModals(props: AppModalsProps) { keyboardMasteryStats={keyboardMasteryStats} aboutModalOpen={aboutModalOpen} onCloseAboutModal={onCloseAboutModal} - sessions={sessions} autoRunStats={autoRunStats} usageStats={usageStats} + handsOnTimeMs={handsOnTimeMs} onOpenLeaderboardRegistration={onOpenLeaderboardRegistration} isLeaderboardRegistered={isLeaderboardRegistered} leaderboardRegistration={leaderboardRegistration} @@ -2122,6 +2128,7 @@ export function AppModals(props: AppModalsProps) { onCloseUpdateCheckModal={onCloseUpdateCheckModal} processMonitorOpen={processMonitorOpen} onCloseProcessMonitor={onCloseProcessMonitor} + sessions={sessions} groups={groups} groupChats={groupChats} onNavigateToSession={onNavigateToSession} diff --git a/src/renderer/constants/shortcuts.ts b/src/renderer/constants/shortcuts.ts index c28ad57d..afc977b5 100644 --- a/src/renderer/constants/shortcuts.ts +++ b/src/renderer/constants/shortcuts.ts @@ -15,7 +15,7 @@ export const DEFAULT_SHORTCUTS: Record = { quickAction: { id: 'quickAction', label: 'Quick Actions', keys: ['Meta', 'k'] }, help: { id: 'help', label: 'Show Shortcuts', keys: ['Meta', '/'] }, settings: { id: 'settings', label: 'Open Settings', keys: ['Meta', ','] }, - agentSettings: { id: 'agentSettings', label: 'Open Agent Settings', keys: ['Shift', ','] }, + agentSettings: { id: 'agentSettings', label: 'Open Agent Settings', keys: ['Alt', 'Meta', ','] }, goToFiles: { id: 'goToFiles', label: 'Go to Files Tab', keys: ['Meta', 'Shift', 'f'] }, goToHistory: { id: 'goToHistory', label: 'Go to History Tab', keys: ['Meta', 'Shift', 'h'] }, goToAutoRun: { id: 'goToAutoRun', label: 'Go to Auto Run Tab', keys: ['Meta', 'Shift', '1'] }, diff --git a/src/renderer/hooks/session/index.ts b/src/renderer/hooks/session/index.ts index 0489dacf..9cf90ee2 100644 --- a/src/renderer/hooks/session/index.ts +++ b/src/renderer/hooks/session/index.ts @@ -29,6 +29,9 @@ export type { export { useBatchedSessionUpdates, DEFAULT_BATCH_FLUSH_INTERVAL } from './useBatchedSessionUpdates'; export type { UseBatchedSessionUpdatesReturn, BatchedUpdater } from './useBatchedSessionUpdates'; -// Activity time tracking +// Activity time tracking (per-session) export { useActivityTracker } from './useActivityTracker'; export type { UseActivityTrackerReturn } from './useActivityTracker'; + +// Global hands-on time tracking (persists to settings) +export { useHandsOnTimeTracker } from './useHandsOnTimeTracker'; diff --git a/src/renderer/hooks/session/useHandsOnTimeTracker.ts b/src/renderer/hooks/session/useHandsOnTimeTracker.ts new file mode 100644 index 00000000..e5c0f4a1 --- /dev/null +++ b/src/renderer/hooks/session/useHandsOnTimeTracker.ts @@ -0,0 +1,152 @@ +import { useEffect, useRef, useCallback } from 'react'; + +const ACTIVITY_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes of inactivity = idle +const TICK_INTERVAL_MS = 1000; // Update every second +const PERSIST_INTERVAL_MS = 30000; // Persist to settings every 30 seconds + +/** + * Hook to track global user hands-on time in Maestro. + * + * Time is tracked when the user is "active" - meaning they've interacted + * with the app (keyboard, mouse, wheel, touch) within the last 5 minutes. + * + * The accumulated time is persisted to settings every 30 seconds and on + * visibility change/app quit, ensuring no time is lost. + * + * This is a global tracker - it doesn't care which session is active, + * just that the user is actively using Maestro. + */ +export function useHandsOnTimeTracker( + updateGlobalStats: (delta: { totalActiveTimeMs: number }) => void +): void { + const lastActivityRef = useRef(Date.now()); + const isActiveRef = useRef(false); + const accumulatedTimeRef = useRef(0); + const lastPersistRef = useRef(Date.now()); + const intervalRef = useRef | null>(null); + const updateGlobalStatsRef = useRef(updateGlobalStats); + + // Keep ref in sync + updateGlobalStatsRef.current = updateGlobalStats; + + // Persist accumulated time to settings + const persistAccumulatedTime = useCallback(() => { + if (accumulatedTimeRef.current > 0) { + const timeToAdd = accumulatedTimeRef.current; + accumulatedTimeRef.current = 0; + lastPersistRef.current = Date.now(); + updateGlobalStatsRef.current({ totalActiveTimeMs: timeToAdd }); + } + }, []); + + const startInterval = useCallback(() => { + if (!intervalRef.current && !document.hidden) { + intervalRef.current = setInterval(() => { + const now = Date.now(); + const timeSinceLastActivity = now - lastActivityRef.current; + + // Check if still active (activity within the last 5 minutes) + if (timeSinceLastActivity < ACTIVITY_TIMEOUT_MS && isActiveRef.current) { + // Accumulate time + accumulatedTimeRef.current += TICK_INTERVAL_MS; + + // Persist every 30 seconds + const timeSinceLastPersist = now - lastPersistRef.current; + if (timeSinceLastPersist >= PERSIST_INTERVAL_MS) { + persistAccumulatedTime(); + } + } else { + // User is idle - persist any accumulated time and stop tracking + persistAccumulatedTime(); + isActiveRef.current = false; + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + } + }, TICK_INTERVAL_MS); + } + }, [persistAccumulatedTime]); + + const stopInterval = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + // Handle visibility changes - persist and pause when hidden + useEffect(() => { + const handleVisibilityChange = () => { + if (document.hidden) { + // Persist accumulated time when user switches away + persistAccumulatedTime(); + stopInterval(); + } else if (isActiveRef.current) { + // Restart if user was active + startInterval(); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [startInterval, stopInterval, persistAccumulatedTime]); + + // Listen to global activity events + useEffect(() => { + const handleActivity = () => { + lastActivityRef.current = Date.now(); + const wasInactive = !isActiveRef.current; + isActiveRef.current = true; + + // Restart interval if it was stopped due to inactivity + if (wasInactive) { + startInterval(); + } + }; + + // Listen for various user interactions + window.addEventListener('keydown', handleActivity); + window.addEventListener('mousedown', handleActivity); + window.addEventListener('wheel', handleActivity); + window.addEventListener('touchstart', handleActivity); + window.addEventListener('click', handleActivity); + + return () => { + window.removeEventListener('keydown', handleActivity); + window.removeEventListener('mousedown', handleActivity); + window.removeEventListener('wheel', handleActivity); + window.removeEventListener('touchstart', handleActivity); + window.removeEventListener('click', handleActivity); + }; + }, [startInterval]); + + // Persist on unmount + useEffect(() => { + return () => { + stopInterval(); + persistAccumulatedTime(); + }; + }, [stopInterval, persistAccumulatedTime]); + + // Persist on beforeunload (app closing) + useEffect(() => { + const handleBeforeUnload = () => { + // Synchronous - can't use async here + if (accumulatedTimeRef.current > 0) { + const timeToAdd = accumulatedTimeRef.current; + accumulatedTimeRef.current = 0; + updateGlobalStatsRef.current({ totalActiveTimeMs: timeToAdd }); + } + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + }, []); +}