From 750ecd47433b40d2e42b2781536d97ad30fb0249 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sun, 28 Dec 2025 06:27:33 -0600 Subject: [PATCH 01/15] ## CHANGES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bumped project version to 0.12.2 for this release rollout πŸš€ - Added why-did-you-render to spotlight unnecessary React re-renders πŸ” - Initialized dev-only WDYR profiling with hooks and memo tracking πŸ§ͺ - Ensured WDYR loads before React for accurate render diagnostics ⏱️ - Refreshed About modal with cleaner creator + Austin side-by-side layout 🧩 - Inserted visual divider to better separate About modal sections 🧱 - Corrected About modal GitHub link targets between repo and profile πŸ”— - Updated About modal tests to match the swapped GitHub link behavior βœ… - Stripped markdown from History list summaries for cleaner previews 🧹 --- package-lock.json | 18 ++- package.json | 1 + .../renderer/components/AboutModal.test.tsx | 12 +- src/renderer/components/AboutModal.tsx | 123 ++++++++++-------- src/renderer/components/HistoryPanel.tsx | 5 +- src/renderer/main.tsx | 2 + src/renderer/wdyr.ts | 52 ++++++++ 7 files changed, 146 insertions(+), 67 deletions(-) create mode 100644 src/renderer/wdyr.ts diff --git a/package-lock.json b/package-lock.json index cd6d7952..317fb1c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "maestro", - "version": "0.12.1", + "version": "0.12.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "maestro", - "version": "0.12.1", + "version": "0.12.2", "hasInstallScript": true, "license": "AGPL 3.0", "dependencies": { @@ -67,6 +67,7 @@ "@typescript-eslint/parser": "^8.50.1", "@vitejs/plugin-react": "^4.2.1", "@vitest/coverage-v8": "^4.0.15", + "@welldone-software/why-did-you-render": "^8.0.3", "autoprefixer": "^10.4.16", "canvas": "^3.2.0", "concurrently": "^8.2.2", @@ -4495,6 +4496,19 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@welldone-software/why-did-you-render": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@welldone-software/why-did-you-render/-/why-did-you-render-8.0.3.tgz", + "integrity": "sha512-bb5bKPMStYnocyTBVBu4UTegZdBqzV1mPhxc0UIV/S43KFUSRflux9gvzJfu2aM4EWLJ3egTvdjOi+viK+LKGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4" + }, + "peerDependencies": { + "react": "^18" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.11", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", diff --git a/package.json b/package.json index 89468c24..d17c849b 100644 --- a/package.json +++ b/package.json @@ -257,6 +257,7 @@ "@typescript-eslint/parser": "^8.50.1", "@vitejs/plugin-react": "^4.2.1", "@vitest/coverage-v8": "^4.0.15", + "@welldone-software/why-did-you-render": "^8.0.3", "autoprefixer": "^10.4.16", "canvas": "^3.2.0", "concurrently": "^8.2.2", diff --git a/src/__tests__/renderer/components/AboutModal.test.tsx b/src/__tests__/renderer/components/AboutModal.test.tsx index 24c411b2..49355cb7 100644 --- a/src/__tests__/renderer/components/AboutModal.test.tsx +++ b/src/__tests__/renderer/components/AboutModal.test.tsx @@ -362,7 +362,7 @@ describe('AboutModal', () => { }); describe('External links', () => { - it('should open GitHub profile on click', async () => { + it('should open GitHub repo on project GitHub click', async () => { render( { /> ); - // The component renders "GitHub" twice - first one is the author profile link + // The component renders "GitHub" twice - first one is the project repo link (in Action Links section) const githubLinks = screen.getAllByText('GitHub'); fireEvent.click(githubLinks[0]); - expect(window.maestro.shell.openExternal).toHaveBeenCalledWith('https://github.com/pedramamini'); + expect(window.maestro.shell.openExternal).toHaveBeenCalledWith('https://github.com/pedramamini/Maestro'); }); it('should open LinkedIn profile on click', async () => { @@ -396,7 +396,7 @@ describe('AboutModal', () => { expect(window.maestro.shell.openExternal).toHaveBeenCalledWith('https://www.linkedin.com/in/pedramamini/'); }); - it('should open GitHub repo on project GitHub click', async () => { + it('should open GitHub profile on click', async () => { render( { /> ); - // The component renders "GitHub" twice - second one is the project repo link + // The component renders "GitHub" twice - second one is the author profile link (in Creator Section) const githubLinks = screen.getAllByText('GitHub'); fireEvent.click(githubLinks[1]); - expect(window.maestro.shell.openExternal).toHaveBeenCalledWith('https://github.com/pedramamini/Maestro'); + expect(window.maestro.shell.openExternal).toHaveBeenCalledWith('https://github.com/pedramamini'); }); it('should open San Jac Saloon on Texas flag click', async () => { diff --git a/src/renderer/components/AboutModal.tsx b/src/renderer/components/AboutModal.tsx index 0ee70896..c8c9308f 100644 --- a/src/renderer/components/AboutModal.tsx +++ b/src/renderer/components/AboutModal.tsx @@ -185,37 +185,6 @@ export function AboutModal({ theme, sessions, autoRunStats, usageStats, onClose, - {/* Author Section */} -
- Pedram Amini -
-
Pedram Amini
-
Founder, Hacker, Investor, Advisor
-
- - Β· - -
-
-
- {/* Achievements Section */} - {/* Made in Austin */} -
- Made in Austin, TX - {/* Texas Flag - Lone Star Flag */} - + Β· + +
+ + + + {/* Vertical divider */} +
+ + {/* Right side - Made in Austin */} +
+ Made in Austin, TX + {/* Texas Flag - Lone Star Flag */} + + + {/* Blue vertical stripe */} + + {/* White horizontal stripe */} + + {/* Red horizontal stripe */} + + {/* White five-pointed star */} + + + +
diff --git a/src/renderer/components/HistoryPanel.tsx b/src/renderer/components/HistoryPanel.tsx index 6429e85b..43b80c18 100644 --- a/src/renderer/components/HistoryPanel.tsx +++ b/src/renderer/components/HistoryPanel.tsx @@ -5,6 +5,7 @@ import { HistoryDetailModal } from './HistoryDetailModal'; import { HistoryHelpModal } from './HistoryHelpModal'; import { useThrottledCallback, useListNavigation } from '../hooks'; import { formatElapsedTime } from '../utils/formatters'; +import { stripMarkdown } from '../utils/textProcessing'; // Double checkmark SVG component for validated entries const DoubleCheck = ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( @@ -1014,7 +1015,7 @@ export const HistoryPanel = React.memo(forwardRef - {/* Summary - 3 lines max */} + {/* Summary - 3 lines max, strip markdown for list view */}

- {entry.summary || 'No summary available'} + {entry.summary ? stripMarkdown(entry.summary) : 'No summary available'}

{/* Footer Row - Time, Cost, and Achievement Action */} diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx index 15f22e47..bc56035e 100644 --- a/src/renderer/main.tsx +++ b/src/renderer/main.tsx @@ -1,3 +1,5 @@ +// IMPORTANT: wdyr must be imported BEFORE React +import './wdyr'; import React from 'react'; import ReactDOM from 'react-dom/client'; import MaestroConsole from './App'; diff --git a/src/renderer/wdyr.ts b/src/renderer/wdyr.ts new file mode 100644 index 00000000..94f93988 --- /dev/null +++ b/src/renderer/wdyr.ts @@ -0,0 +1,52 @@ +/** + * why-did-you-render setup for development performance profiling + * + * This file MUST be imported before React in main.tsx. + * It only runs in development mode - no impact on production builds. + * + * To track a specific component, add this to the component file: + * MyComponent.whyDidYouRender = true; + * + * Or track all pure components by setting trackAllPureComponents: true below. + * + * Output appears in the browser DevTools console showing: + * - Which components re-rendered + * - What props/state changes triggered the re-render + * - Whether the re-render was necessary + */ +import React from 'react'; + +if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const whyDidYouRender = require('@welldone-software/why-did-you-render'); + + whyDidYouRender(React, { + // Track all pure components (React.memo, PureComponent) + // Set to true to see ALL unnecessary re-renders + trackAllPureComponents: true, + + // Track React hooks like useMemo, useCallback + trackHooks: true, + + // Log to console (can also use custom notifier) + logOnDifferentValues: true, + + // Collapse logs by default (expand to see details) + collapseGroups: true, + + // Include component stack traces + include: [ + // Add specific components to always track, e.g.: + // /^RightPanel/, + // /^AutoRun/, + // /^FilePreview/, + ], + + // Exclude noisy components you don't care about + exclude: [ + /^BrowserRouter/, + /^Link/, + /^Route/, + ], + }); +} From 24e23763738d924ef804da1f9ae8ee0258291f99 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sun, 28 Dec 2025 06:45:51 -0600 Subject: [PATCH 02/15] =?UTF-8?q?##=20CHANGES=20-=20Stop=20button=20now=20?= =?UTF-8?q?shows=20whenever=20Auto=20Run=20is=20active=20session-wide=20?= =?UTF-8?q?=F0=9F=9B=91=20-=20Block=20starting=20new=20Auto=20Run=20while?= =?UTF-8?q?=20another=20run=20is=20already=20active=20=F0=9F=94=92=20-=20R?= =?UTF-8?q?un=20button=20replaced=20by=20Stop=20even=20on=20unlocked=20doc?= =?UTF-8?q?uments=20during=20batch=20=F0=9F=A7=AD=20-=20Added=20`isAutoRun?= =?UTF-8?q?Active`=20state=20to=20drive=20clearer=20Run/Stop=20UI=20logic?= =?UTF-8?q?=20=F0=9F=A7=A0=20-=20Expanded=20integration=20tests=20to=20loc?= =?UTF-8?q?k=20in=20single-run-per-session=20behavior=20=F0=9F=A7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AutoRunBatchProcessing.test.tsx | 39 +++++++++++++++++++ src/renderer/components/AutoRun.tsx | 3 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/__tests__/integration/AutoRunBatchProcessing.test.tsx b/src/__tests__/integration/AutoRunBatchProcessing.test.tsx index c1f13116..0975e812 100644 --- a/src/__tests__/integration/AutoRunBatchProcessing.test.tsx +++ b/src/__tests__/integration/AutoRunBatchProcessing.test.tsx @@ -576,6 +576,45 @@ describe('AutoRun + Batch Processing Integration', () => { const runButton = screen.getByTitle(/Cannot run while agent is thinking/i); expect(runButton).toBeDisabled(); }); + + it('shows Stop button even when viewing an unlocked document while Auto Run is active', () => { + // This tests the key behavior: you can only run one Auto Run per session at a time. + // Even if viewing a document NOT in the batch, the Stop button should show. + const batchRunState = createBatchRunState({ + isRunning: true, + lockedDocuments: ['Phase 1'], // Only Phase 1 is locked + }); + // Viewing Phase 2 (not in lockedDocuments), but batch run is active + const props = createDefaultProps({ + batchRunState, + selectedFile: 'Phase 2', + documentList: ['Phase 1', 'Phase 2'], + }); + renderWithProvider(); + + // Should still show Stop button (not Run) because Auto Run is active for session + expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^run$/i })).not.toBeInTheDocument(); + }); + + it('prevents starting another Auto Run while one is already active', () => { + // When Auto Run is active, user should not be able to start another one + const batchRunState = createBatchRunState({ isRunning: true }); + const onOpenBatchRunner = vi.fn(); + const props = createDefaultProps({ batchRunState, onOpenBatchRunner }); + renderWithProvider(); + + // Run button should not be visible at all (replaced by Stop button) + expect(screen.queryByRole('button', { name: /^run$/i })).not.toBeInTheDocument(); + + // Stop button should be visible instead + const stopButton = screen.getByRole('button', { name: /stop/i }); + expect(stopButton).toBeInTheDocument(); + + // Clicking Stop should NOT open batch runner + fireEvent.click(stopButton); + expect(onOpenBatchRunner).not.toHaveBeenCalled(); + }); }); describe('Image Upload Disabled During Batch Run', () => { diff --git a/src/renderer/components/AutoRun.tsx b/src/renderer/components/AutoRun.tsx index 4982df33..038a3bd6 100644 --- a/src/renderer/components/AutoRun.tsx +++ b/src/renderer/components/AutoRun.tsx @@ -359,6 +359,7 @@ const AutoRunInner = forwardRef(function AutoRunInn batchRunState?.lockedDocuments?.includes(selectedFile) ) || false; const isAgentBusy = sessionState === 'busy' || sessionState === 'connecting'; + const isAutoRunActive = batchRunState?.isRunning || false; const isStopping = batchRunState?.isStopping || false; // Error state (Phase 5.10) const isErrorPaused = batchRunState?.errorPaused || false; @@ -1325,7 +1326,7 @@ const AutoRunInner = forwardRef(function AutoRunInn className="hidden" /> {/* Run / Stop button */} - {isLocked ? ( + {isAutoRunActive ? ( + {/* Publish as Gist button - only show if gh CLI is available and not in edit mode */} + {ghCliAvailable && !markdownEditMode && onPublishGist && !isImage && ( + + )} +
+ + +
+ + } + > +
+

+ Publish {filename} as a GitHub Gist? +

+ +
+

+ Secret:{' '} + Not searchable, only accessible via direct link +

+

+ Public:{' '} + Visible on your public profile and searchable +

+
+ + {error && ( +
+ {error} +
+ )} +
+ + ); +} diff --git a/src/renderer/components/MainPanel.tsx b/src/renderer/components/MainPanel.tsx index 2f86a6b9..199bfb52 100644 --- a/src/renderer/components/MainPanel.tsx +++ b/src/renderer/components/MainPanel.tsx @@ -219,6 +219,10 @@ interface MainPanelProps { // Keyboard mastery tracking onShortcutUsed?: (shortcutId: string) => void; + + // Gist publishing + ghCliAvailable?: boolean; + onPublishGist?: () => void; } // PERFORMANCE: Wrap with React.memo to prevent re-renders when parent (App.tsx) re-renders @@ -1009,6 +1013,8 @@ export const MainPanel = React.memo(forwardRef( onNavigateToIndex={props.onNavigateToIndex} onOpenFuzzySearch={props.onOpenFuzzySearch} onShortcutUsed={props.onShortcutUsed} + ghCliAvailable={props.ghCliAvailable} + onPublishGist={props.onPublishGist} /> ) : ( diff --git a/src/renderer/components/QuickActionsModal.tsx b/src/renderer/components/QuickActionsModal.tsx index 72f6c647..a5c31e50 100644 --- a/src/renderer/components/QuickActionsModal.tsx +++ b/src/renderer/components/QuickActionsModal.tsx @@ -99,6 +99,10 @@ interface QuickActionsModalProps { onCloseOtherTabs?: () => void; onCloseTabsLeft?: () => void; onCloseTabsRight?: () => void; + // Gist publishing + isFilePreviewOpen?: boolean; + ghCliAvailable?: boolean; + onPublishGist?: () => void; } export function QuickActionsModal(props: QuickActionsModalProps) { @@ -118,7 +122,8 @@ export function QuickActionsModal(props: QuickActionsModalProps) { hasActiveSessionCapability, onOpenMergeSession, onOpenSendToAgent, onOpenCreatePR, onSummarizeAndContinue, canSummarizeActiveTab, autoRunSelectedDocument, autoRunCompletedTaskCount, onAutoRunResetTasks, - onCloseAllTabs, onCloseOtherTabs, onCloseTabsLeft, onCloseTabsRight + onCloseAllTabs, onCloseOtherTabs, onCloseTabsLeft, onCloseTabsRight, + isFilePreviewOpen, ghCliAvailable, onPublishGist } = props; const [search, setSearch] = useState(''); @@ -396,6 +401,16 @@ export function QuickActionsModal(props: QuickActionsModalProps) { } }] : []), ...(setFuzzyFileSearchOpen ? [{ id: 'fuzzyFileSearch', label: 'Fuzzy File Search', shortcut: shortcuts.fuzzyFileSearch, action: () => { setFuzzyFileSearchOpen(true); setQuickActionOpen(false); } }] : []), + // Publish document as GitHub Gist - only when file preview is open, gh CLI is available, and not in edit mode + ...(isFilePreviewOpen && ghCliAvailable && onPublishGist && !markdownEditMode ? [{ + id: 'publishGist', + label: 'Publish Document as GitHub Gist', + subtext: 'Share current file as a public or secret gist', + action: () => { + onPublishGist(); + setQuickActionOpen(false); + } + }] : []), // Group Chat commands - only show when at least 2 AI agents exist ...(onNewGroupChat && sessions.filter(s => s.toolType !== 'terminal').length >= 2 ? [{ id: 'newGroupChat', label: 'New Group Chat', action: () => { onNewGroupChat(); setQuickActionOpen(false); } }] : []), ...(activeGroupChatId && onCloseGroupChat ? [{ id: 'closeGroupChat', label: 'Close Group Chat', action: () => { onCloseGroupChat(); setQuickActionOpen(false); } }] : []), diff --git a/src/renderer/constants/modalPriorities.ts b/src/renderer/constants/modalPriorities.ts index 0ba5b187..a5da9a13 100644 --- a/src/renderer/constants/modalPriorities.ts +++ b/src/renderer/constants/modalPriorities.ts @@ -32,6 +32,9 @@ export const MODAL_PRIORITIES = { /** Confirmation dialogs - highest priority, always on top */ CONFIRM: 1000, + /** Gist publish confirmation modal - high priority */ + GIST_PUBLISH: 980, + /** Playbook delete confirmation - high priority, appears on top of BatchRunner */ PLAYBOOK_DELETE_CONFIRM: 950, diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 966e1924..fcb5e9e2 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -283,6 +283,11 @@ interface MaestroAPI { tags: (cwd: string) => Promise<{ tags: string[] }>; commitCount: (cwd: string) => Promise<{ count: number; error: string | null }>; checkGhCli: (ghPath?: string) => Promise<{ installed: boolean; authenticated: boolean }>; + createGist: (filename: string, content: string, description: string, isPublic: boolean, ghPath?: string) => Promise<{ + success: boolean; + gistUrl?: string; + error?: string; + }>; // Git worktree operations for Auto Run parallelization worktreeInfo: (worktreePath: string) => Promise<{ success: boolean; diff --git a/src/renderer/hooks/settings/useSettings.ts b/src/renderer/hooks/settings/useSettings.ts index 8976a6a0..6e632041 100644 --- a/src/renderer/hooks/settings/useSettings.ts +++ b/src/renderer/hooks/settings/useSettings.ts @@ -1056,8 +1056,12 @@ export function useSettings(): UseSettingsReturn { // Load settings from electron-store on mount useEffect(() => { + console.log('[Settings] useEffect triggered, about to call loadSettings'); const loadSettings = async () => { + console.log('[Settings] loadSettings started'); + try { const savedEnterToSendAI = await window.maestro.settings.get('enterToSendAI'); + console.log('[Settings] Got first setting'); const savedEnterToSendTerminal = await window.maestro.settings.get('enterToSendTerminal'); const savedDefaultSaveToHistory = await window.maestro.settings.get('defaultSaveToHistory'); const savedDefaultShowThinking = await window.maestro.settings.get('defaultShowThinking'); @@ -1324,8 +1328,12 @@ export function useSettings(): UseSettingsReturn { setKeyboardMasteryStatsState({ ...DEFAULT_KEYBOARD_MASTERY_STATS, ...(savedKeyboardMasteryStats as Partial) }); } - // Mark settings as loaded - setSettingsLoaded(true); + } catch (error) { + console.error('[Settings] Failed to load settings:', error); + } finally { + // Mark settings as loaded even if there was an error (use defaults) + setSettingsLoaded(true); + } }; loadSettings(); }, []); From c398f6a778c99c5756153481ac5f8d7405bb7901 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sun, 28 Dec 2025 08:30:45 -0600 Subject: [PATCH 04/15] ## CHANGES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added new Git IPC handler for creating gists (26 total)! πŸ“ - Logger now supports dedicated `autorun` level with structured workflow data 🚦 - Autorun logs bypass log-level filtering, always capturing critical run events πŸ” - Unknown logger levels now safely fall back to info, avoiding silent drops πŸ›‘οΈ - Expanded system IPC logger tests for autorun payloads and unknown levels πŸ§ͺ - Enhanced logger tests to validate autorun sequencing, filtering, and console output πŸ“Š - Tab hover overlay now visually connects to tab with seamless connector strip 🧷 - Overlay positioning now uses exact tab bottom and tracks tab width πŸ“ - Simplified overlay container styling while keeping bordered content panel clean 🎨 - Switched `why-did-you-render` to dynamic import for Vite/ESM compatibility ⚑ --- src/__tests__/main/ipc/handlers/git.test.ts | 5 +- .../main/ipc/handlers/system.test.ts | 22 +++++ src/__tests__/main/utils/logger.test.ts | 90 +++++++++++++++++++ src/main/ipc/handlers/system.ts | 4 + src/renderer/components/TabBar.tsx | 34 +++++-- src/renderer/wdyr.ts | 54 +++++------ 6 files changed, 175 insertions(+), 34 deletions(-) diff --git a/src/__tests__/main/ipc/handlers/git.test.ts b/src/__tests__/main/ipc/handlers/git.test.ts index 53b2eed3..36f1dc94 100644 --- a/src/__tests__/main/ipc/handlers/git.test.ts +++ b/src/__tests__/main/ipc/handlers/git.test.ts @@ -101,7 +101,7 @@ describe('Git IPC handlers', () => { }); describe('registration', () => { - it('should register all 25 git handlers', () => { + it('should register all 26 git handlers', () => { const expectedChannels = [ 'git:status', 'git:diff', @@ -128,9 +128,10 @@ describe('Git IPC handlers', () => { 'git:watchWorktreeDirectory', 'git:unwatchWorktreeDirectory', 'git:removeWorktree', + 'git:createGist', ]; - expect(handlers.size).toBe(25); + expect(handlers.size).toBe(26); for (const channel of expectedChannels) { expect(handlers.has(channel)).toBe(true); } diff --git a/src/__tests__/main/ipc/handlers/system.test.ts b/src/__tests__/main/ipc/handlers/system.test.ts index e834004d..b509a110 100644 --- a/src/__tests__/main/ipc/handlers/system.test.ts +++ b/src/__tests__/main/ipc/handlers/system.test.ts @@ -723,6 +723,28 @@ describe('system IPC handlers', () => { expect(logger.autorun).toHaveBeenCalledWith('Autorun message', 'TestContext', undefined); }); + + it('should log autorun message with full Auto Run workflow data', async () => { + const autorunData = { + documents: ['phase-1.md', 'phase-2.md'], + totalTasks: 10, + loopEnabled: true, + maxLoops: 3 + }; + + const handler = handlers.get('logger:log'); + await handler!({} as any, 'autorun', 'Auto Run started', 'MySession', autorunData); + + expect(logger.autorun).toHaveBeenCalledWith('Auto Run started', 'MySession', autorunData); + }); + + it('should handle unknown log level gracefully via default case', async () => { + const handler = handlers.get('logger:log'); + await handler!({} as any, 'unknown-level', 'Unknown level message', 'TestContext'); + + // Unknown levels fall through to default case which logs as info + expect(logger.info).toHaveBeenCalledWith('[unknown-level] Unknown level message', 'TestContext', undefined); + }); }); describe('logger:getLogs', () => { diff --git a/src/__tests__/main/utils/logger.test.ts b/src/__tests__/main/utils/logger.test.ts index 23100d52..540d347b 100644 --- a/src/__tests__/main/utils/logger.test.ts +++ b/src/__tests__/main/utils/logger.test.ts @@ -336,6 +336,70 @@ describe('Logger', () => { expect(consoleInfoSpy.mock.calls[0][0]).toContain('toast console test'); }); }); + + describe('autorun', () => { + it('should log autorun message with correct structure', async () => { + logger.autorun('Auto Run started'); + + const logs = logger.getLogs(); + expect(logs).toHaveLength(1); + expect(logs[0].level).toBe('autorun'); + expect(logs[0].message).toBe('Auto Run started'); + }); + + it('should always log autorun regardless of log level', async () => { + logger.setLogLevel('error'); + logger.autorun('Auto Run started'); + + // Autorun should be logged even though level is error + const logs = logger.getLogs(); + expect(logs).toHaveLength(1); + expect(logs[0].level).toBe('autorun'); + }); + + it('should log autorun with context (session name)', async () => { + logger.autorun('Auto Run started', 'MySession'); + + const logs = logger.getLogs(); + expect(logs[0].context).toBe('MySession'); + }); + + it('should log autorun with data (documents and task info)', async () => { + const autorunData = { + documents: ['phase-1.md', 'phase-2.md'], + totalTasks: 10, + loopEnabled: true, + maxLoops: 3 + }; + logger.autorun('Auto Run started', 'MySession', autorunData); + + const logs = logger.getLogs(); + expect(logs[0].data).toEqual(autorunData); + }); + + it('should output to console.info for autorun level', async () => { + logger.autorun('Auto Run console test'); + + expect(consoleInfoSpy).toHaveBeenCalled(); + expect(consoleInfoSpy.mock.calls[0][0]).toContain('[AUTORUN]'); + expect(consoleInfoSpy.mock.calls[0][0]).toContain('Auto Run console test'); + }); + + it('should log autorun workflow events in sequence', async () => { + // Simulate a typical Auto Run workflow + logger.autorun('Auto Run started', 'TestSession', { documents: ['phase-1.md'], totalTasks: 3 }); + logger.autorun('Processing document: phase-1.md', 'TestSession'); + logger.autorun('Loop 1 completed', 'TestSession', { tasksCompleted: 3 }); + logger.autorun('Auto Run exiting: All tasks completed', 'TestSession'); + + const logs = logger.getLogs(); + expect(logs).toHaveLength(4); + expect(logs.every(l => l.level === 'autorun')).toBe(true); + expect(logs.every(l => l.context === 'TestSession')).toBe(true); + expect(logs[0].message).toBe('Auto Run started'); + expect(logs[3].message).toBe('Auto Run exiting: All tasks completed'); + }); + }); }); describe('Log Retrieval (getLogs)', () => { @@ -594,5 +658,31 @@ describe('Logger', () => { const infoLevel = logger.getLogs({ level: 'info' }); expect(infoLevel).toHaveLength(1); }); + + it('should treat autorun as info priority for filtering in getLogs', async () => { + logger.autorun('autorun message'); + + // Autorun has priority 1 (same as info), so filtering by warn should exclude it + const warnLevel = logger.getLogs({ level: 'warn' }); + expect(warnLevel).toHaveLength(0); + + // But filtering by info should include it + const infoLevel = logger.getLogs({ level: 'info' }); + expect(infoLevel).toHaveLength(1); + }); + + it('should log both toast and autorun alongside regular levels', async () => { + logger.setLogLevel('debug'); + logger.debug('debug'); + logger.info('info'); + logger.toast('toast'); + logger.autorun('autorun'); + logger.warn('warn'); + logger.error('error'); + + const logs = logger.getLogs(); + expect(logs).toHaveLength(6); + expect(logs.map(l => l.level)).toEqual(['debug', 'info', 'toast', 'autorun', 'warn', 'error']); + }); }); }); diff --git a/src/main/ipc/handlers/system.ts b/src/main/ipc/handlers/system.ts index 18e819c3..d6f93cf1 100644 --- a/src/main/ipc/handlers/system.ts +++ b/src/main/ipc/handlers/system.ts @@ -249,6 +249,10 @@ export function registerSystemHandlers(deps: SystemHandlerDependencies): void { case 'autorun': logger.autorun(message, context, data); break; + default: + // Log unknown levels as info to prevent silent failures + logger.info(`[${level}] ${message}`, context, data); + break; } }); diff --git a/src/renderer/components/TabBar.tsx b/src/renderer/components/TabBar.tsx index af7e0aae..9eb500ee 100644 --- a/src/renderer/components/TabBar.tsx +++ b/src/renderer/components/TabBar.tsx @@ -153,7 +153,7 @@ function Tab({ const [isHovered, setIsHovered] = useState(false); const [overlayOpen, setOverlayOpen] = useState(false); const [showCopied, setShowCopied] = useState(false); - const [overlayPosition, setOverlayPosition] = useState<{ top: number; left: number } | null>(null); + const [overlayPosition, setOverlayPosition] = useState<{ top: number; left: number; tabWidth?: number } | null>(null); const hoverTimeoutRef = useRef | null>(null); const tabRef = useRef(null); @@ -172,10 +172,12 @@ function Tab({ // Open overlay after delay hoverTimeoutRef.current = setTimeout(() => { - // Calculate position for fixed overlay + // Calculate position for fixed overlay - connect directly to tab bottom if (tabRef.current) { const rect = tabRef.current.getBoundingClientRect(); - setOverlayPosition({ top: rect.bottom + 4, left: rect.left }); + // Position overlay directly at tab bottom (no gap) for connected appearance + // Store tab width for connector sizing + setOverlayPosition({ top: rect.bottom, left: rect.left, tabWidth: rect.width }); } setOverlayOpen(true); }, 400); @@ -406,11 +408,8 @@ function Tab({ {/* Hover overlay with session info and actions - rendered via portal to escape stacking context */} {overlayOpen && overlayPosition && createPortal(
+ {/* Connector tab that visually bridges the gap between tab and overlay */} +
+ {/* Main overlay content */} +
{/* Header with session name and ID - only show for tabs with sessions */} {tab.agentSessionId && (
)}
+
, document.body )} diff --git a/src/renderer/wdyr.ts b/src/renderer/wdyr.ts index 94f93988..b59c99c1 100644 --- a/src/renderer/wdyr.ts +++ b/src/renderer/wdyr.ts @@ -17,36 +17,40 @@ import React from 'react'; if (process.env.NODE_ENV === 'development') { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const whyDidYouRender = require('@welldone-software/why-did-you-render'); + // Use dynamic import for ESM compatibility with Vite + import('@welldone-software/why-did-you-render').then((whyDidYouRenderModule) => { + const whyDidYouRender = whyDidYouRenderModule.default; - whyDidYouRender(React, { - // Track all pure components (React.memo, PureComponent) - // Set to true to see ALL unnecessary re-renders - trackAllPureComponents: true, + whyDidYouRender(React, { + // Track all pure components (React.memo, PureComponent) + // Set to true to see ALL unnecessary re-renders + trackAllPureComponents: true, - // Track React hooks like useMemo, useCallback - trackHooks: true, + // Track React hooks like useMemo, useCallback + trackHooks: true, - // Log to console (can also use custom notifier) - logOnDifferentValues: true, + // Log to console (can also use custom notifier) + logOnDifferentValues: true, - // Collapse logs by default (expand to see details) - collapseGroups: true, + // Collapse logs by default (expand to see details) + collapseGroups: true, - // Include component stack traces - include: [ - // Add specific components to always track, e.g.: - // /^RightPanel/, - // /^AutoRun/, - // /^FilePreview/, - ], + // Include component stack traces + include: [ + // Add specific components to always track, e.g.: + // /^RightPanel/, + // /^AutoRun/, + // /^FilePreview/, + ], - // Exclude noisy components you don't care about - exclude: [ - /^BrowserRouter/, - /^Link/, - /^Route/, - ], + // Exclude noisy components you don't care about + exclude: [ + /^BrowserRouter/, + /^Link/, + /^Route/, + ], + }); + }).catch((err) => { + console.warn('[wdyr] Failed to load why-did-you-render:', err); }); } From a1b8b44724ddd899ee35c6891b212bb128fa6a37 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sun, 28 Dec 2025 08:35:10 -0600 Subject: [PATCH 05/15] feat: add beta opt-in setting for pre-release updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow users to opt into receiving beta, release candidate, and alpha updates via a new toggle in Settings β†’ General. When enabled, the update checker includes pre-release versions alongside stable releases. - Add enableBetaUpdates setting with persistence - Extend update-checker to filter/include prereleases based on flag - Configure electron-updater allowPrerelease via new IPC handler - Add FlaskConical icon toggle in Settings modal - Document pre-release channel in configuration docs --- docs/configuration.md | 28 ++++++++++++++++++++ src/main/auto-updater.ts | 11 ++++++++ src/main/ipc/handlers/system.ts | 10 +++++-- src/main/preload.ts | 4 ++- src/main/update-checker.ts | 23 ++++++++++++---- src/renderer/App.tsx | 14 ++++++++-- src/renderer/components/SettingsModal.tsx | 15 ++++++++++- src/renderer/components/UpdateCheckModal.tsx | 8 ++++-- src/renderer/global.d.ts | 3 ++- src/renderer/hooks/settings/useSettings.ts | 14 ++++++++++ 10 files changed, 116 insertions(+), 14 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 3363d274..ddb54f3d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -32,6 +32,34 @@ When an update is available, you'll see: - **Download** button to get the latest release from GitHub - Option to enable/disable automatic update checks +### Pre-release Channel (Beta Opt-in) + +By default, Maestro only notifies you about stable releases. If you want to try new features before they're officially released, you can opt into the pre-release channel. + +**To enable beta updates:** +1. Open **Settings** (`Cmd+,` / `Ctrl+,`) β†’ **General** tab +2. Toggle **Include beta and release candidate updates** on + +**What changes:** +- Update checks will include pre-release versions (e.g., `v0.11.1-rc`, `v0.12.0-beta`) +- You'll receive notifications for beta, release candidate (rc), and alpha releases +- The Update dialog will show all available pre-release versions + +**Pre-release version types:** +| Suffix | Description | +|--------|-------------| +| `-alpha` | Early development, may be unstable | +| `-beta` | Feature-complete but still testing | +| `-rc` | Release candidate, nearly ready for stable | +| `-dev` | Development builds | +| `-canary` | Cutting-edge nightly builds | + +**Reverting to stable:** Toggle the setting off and download the latest stable release from GitHub. Pre-releases won't auto-downgrade to stable versions. + + +Pre-release versions may contain experimental features and bugs. Use at your own risk. If you encounter issues, you can always download the latest stable release from [GitHub Releases](https://github.com/pedramamini/maestro/releases). + + ## Notifications & Sound Configure audio and visual notifications in **Settings** (`Cmd+,` / `Ctrl+,`) β†’ **Notifications** tab. diff --git a/src/main/auto-updater.ts b/src/main/auto-updater.ts index da774a4c..d9de08f9 100644 --- a/src/main/auto-updater.ts +++ b/src/main/auto-updater.ts @@ -10,6 +10,8 @@ import { logger } from './utils/logger'; // Don't auto-download - we want user to initiate autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = true; +// Default to stable releases only (will be updated from settings) +autoUpdater.allowPrerelease = false; export interface UpdateStatus { status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'; @@ -144,3 +146,12 @@ export async function checkForUpdatesManual(): Promise { return null; } } + +/** + * Configure whether to include prerelease/beta versions in updates + * This should be called when the user setting changes + */ +export function setAllowPrerelease(allow: boolean): void { + autoUpdater.allowPrerelease = allow; + logger.info(`Auto-updater prerelease mode: ${allow ? 'enabled' : 'disabled'}`, 'AutoUpdater'); +} diff --git a/src/main/ipc/handlers/system.ts b/src/main/ipc/handlers/system.ts index 18e819c3..2bdabae6 100644 --- a/src/main/ipc/handlers/system.ts +++ b/src/main/ipc/handlers/system.ts @@ -24,6 +24,7 @@ import { detectShells } from '../../utils/shellDetector'; import { isCloudflaredInstalled } from '../../utils/cliDetection'; import { tunnelManager as tunnelManagerInstance } from '../../tunnel-manager'; import { checkForUpdates } from '../../update-checker'; +import { setAllowPrerelease } from '../../auto-updater'; import { WebServer } from '../../web-server'; // Type for tunnel manager instance @@ -221,9 +222,14 @@ export function registerSystemHandlers(deps: SystemHandlerDependencies): void { // ============ Update Check Handler ============ - ipcMain.handle('updates:check', async () => { + ipcMain.handle('updates:check', async (_event, includePrerelease: boolean = false) => { const currentVersion = app.getVersion(); - return checkForUpdates(currentVersion); + return checkForUpdates(currentVersion, includePrerelease); + }); + + // Set whether to allow prerelease updates (for electron-updater) + ipcMain.handle('updates:setAllowPrerelease', async (_event, allow: boolean) => { + setAllowPrerelease(allow); }); // ============ Logger Handlers ============ diff --git a/src/main/preload.ts b/src/main/preload.ts index 7fb00a24..506824df 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -585,7 +585,7 @@ contextBridge.exposeInMainWorld('maestro', { // Updates API updates: { - check: () => ipcRenderer.invoke('updates:check') as Promise<{ + check: (includePrerelease?: boolean) => ipcRenderer.invoke('updates:check', includePrerelease) as Promise<{ currentVersion: string; latestVersion: string; updateAvailable: boolean; @@ -620,6 +620,8 @@ contextBridge.exposeInMainWorld('maestro', { ipcRenderer.on('updates:status', handler); return () => ipcRenderer.removeListener('updates:status', handler); }, + // Set whether to allow prerelease updates (for electron-updater) + setAllowPrerelease: (allow: boolean) => ipcRenderer.invoke('updates:setAllowPrerelease', allow) as Promise, }, // Logger API diff --git a/src/main/update-checker.ts b/src/main/update-checker.ts index 39cbdd5c..62d98588 100644 --- a/src/main/update-checker.ts +++ b/src/main/update-checker.ts @@ -107,8 +107,9 @@ function compareVersions(a: string, b: string): number { /** * Fetch all releases from GitHub API + * @param includePrerelease - If true, include beta/rc/alpha releases. Default: false (stable only) */ -async function fetchReleases(): Promise { +async function fetchReleases(includePrerelease: boolean = false): Promise { const response = await fetch(RELEASES_URL, { headers: { 'Accept': 'application/vnd.github.v3+json', @@ -122,10 +123,20 @@ async function fetchReleases(): Promise { const releases = (await response.json()) as Release[]; - // Filter out drafts, prereleases, and tags with prerelease suffixes (-rc, -beta, -alpha) + // Filter out drafts (always excluded) + // Filter out prereleases and prerelease suffixes (-rc, -beta, -alpha) unless includePrerelease is true const prereleasePattern = /-(rc|beta|alpha|dev|canary)/i; return releases - .filter(r => !r.draft && !r.prerelease && !prereleasePattern.test(r.tag_name)) + .filter(r => { + // Always filter out drafts + if (r.draft) return false; + + // If including prereleases, allow all non-draft releases + if (includePrerelease) return true; + + // Otherwise, filter out prereleases and releases with prerelease suffixes + return !r.prerelease && !prereleasePattern.test(r.tag_name); + }) .sort((a, b) => compareVersions(b.tag_name, a.tag_name)); } @@ -153,12 +164,14 @@ function getNewerReleases(currentVersion: string, releases: Release[]): Release[ /** * Check for updates + * @param currentVersion - The current app version + * @param includePrerelease - If true, include beta/rc/alpha releases. Default: false (stable only) */ -export async function checkForUpdates(currentVersion: string): Promise { +export async function checkForUpdates(currentVersion: string, includePrerelease: boolean = false): Promise { const releasesUrl = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases`; try { - const allReleases = await fetchReleases(); + const allReleases = await fetchReleases(includePrerelease); if (allReleases.length === 0) { return { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d4cfbfd1..c01bb91f 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -271,6 +271,7 @@ function MaestroConsoleInner() { audioFeedbackCommand, setAudioFeedbackCommand, toastDuration, setToastDuration, checkForUpdatesOnStartup, setCheckForUpdatesOnStartup, + enableBetaUpdates, setEnableBetaUpdates, crashReportingEnabled, setCrashReportingEnabled, shortcuts, setShortcuts, tabShortcuts, setTabShortcuts, @@ -1080,13 +1081,20 @@ function MaestroConsoleInner() { }, [sessionsLoaded]); // Only run once when sessions are loaded + // Sync beta updates setting to electron-updater when it changes + useEffect(() => { + if (settingsLoaded) { + window.maestro.updates.setAllowPrerelease(enableBetaUpdates); + } + }, [settingsLoaded, enableBetaUpdates]); + // Check for updates on startup if enabled useEffect(() => { if (settingsLoaded && checkForUpdatesOnStartup) { // Delay to let the app fully initialize const timer = setTimeout(async () => { try { - const result = await window.maestro.updates.check(); + const result = await window.maestro.updates.check(enableBetaUpdates); if (result.updateAvailable && !result.error) { setUpdateCheckModalOpen(true); } @@ -1096,7 +1104,7 @@ function MaestroConsoleInner() { }, 2000); return () => clearTimeout(timer); } - }, [settingsLoaded, checkForUpdatesOnStartup]); + }, [settingsLoaded, checkForUpdatesOnStartup, enableBetaUpdates]); // Load spec-kit commands on startup useEffect(() => { @@ -9375,6 +9383,8 @@ function MaestroConsoleInner() { setToastDuration={setToastDuration} checkForUpdatesOnStartup={checkForUpdatesOnStartup} setCheckForUpdatesOnStartup={setCheckForUpdatesOnStartup} + enableBetaUpdates={enableBetaUpdates} + setEnableBetaUpdates={setEnableBetaUpdates} crashReportingEnabled={crashReportingEnabled} setCrashReportingEnabled={setCrashReportingEnabled} customAICommands={customAICommands} diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx index 4e55d3d4..72ac5365 100644 --- a/src/renderer/components/SettingsModal.tsx +++ b/src/renderer/components/SettingsModal.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, memo } from 'react'; -import { X, Key, Moon, Sun, Keyboard, Check, Terminal, Bell, Cpu, Settings, Palette, Sparkles, History, Download, Bug, Cloud, FolderSync, RotateCcw, Folder, ChevronDown, Plus, Trash2, Brain, AlertTriangle } from 'lucide-react'; +import { X, Key, Moon, Sun, Keyboard, Check, Terminal, Bell, Cpu, Settings, Palette, Sparkles, History, Download, Bug, Cloud, FolderSync, RotateCcw, Folder, ChevronDown, Plus, Trash2, Brain, AlertTriangle, FlaskConical } from 'lucide-react'; import { useSettings } from '../hooks'; import type { Theme, ThemeColors, ThemeId, Shortcut, ShellInfo, CustomAICommand, LLMProvider } from '../types'; import { CustomThemeBuilder } from './CustomThemeBuilder'; @@ -212,6 +212,8 @@ interface SettingsModalProps { setToastDuration: (value: number) => void; checkForUpdatesOnStartup: boolean; setCheckForUpdatesOnStartup: (value: boolean) => void; + enableBetaUpdates: boolean; + setEnableBetaUpdates: (value: boolean) => void; crashReportingEnabled: boolean; setCrashReportingEnabled: (value: boolean) => void; customAICommands: CustomAICommand[]; @@ -1138,6 +1140,17 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro theme={theme} /> + {/* Beta Updates */} + + {/* Crash Reporting */} (null); const [expandedReleases, setExpandedReleases] = useState>(new Set()); + // Get beta updates setting + const { enableBetaUpdates } = useSettings(); + // Auto-updater state const [downloadStatus, setDownloadStatus] = useState({ status: 'idle' }); const [downloadError, setDownloadError] = useState(null); @@ -48,7 +52,7 @@ export function UpdateCheckModal({ theme, onClose }: UpdateCheckModalProps) { // Check for updates on mount useEffect(() => { checkForUpdates(); - }, []); + }, [enableBetaUpdates]); // Subscribe to update status changes useEffect(() => { @@ -65,7 +69,7 @@ export function UpdateCheckModal({ theme, onClose }: UpdateCheckModalProps) { setLoading(true); setDownloadError(null); try { - const updateResult = await window.maestro.updates.check(); + const updateResult = await window.maestro.updates.check(enableBetaUpdates); setResult(updateResult); // Auto-expand if only 1 version behind, otherwise keep all collapsed if (updateResult.updateAvailable && updateResult.releases.length === 1) { diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 966e1924..0ca5c759 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -840,7 +840,7 @@ interface MaestroAPI { }; // Updates API updates: { - check: () => Promise<{ + check: (includePrerelease?: boolean) => Promise<{ currentVersion: string; latestVersion: string; updateAvailable: boolean; @@ -870,6 +870,7 @@ interface MaestroAPI { progress?: { percent: number; bytesPerSecond: number; total: number; transferred: number }; error?: string; }) => void) => () => void; + setAllowPrerelease: (allow: boolean) => Promise; }; // Debug Package API debug: { diff --git a/src/renderer/hooks/settings/useSettings.ts b/src/renderer/hooks/settings/useSettings.ts index 8976a6a0..4bf443aa 100644 --- a/src/renderer/hooks/settings/useSettings.ts +++ b/src/renderer/hooks/settings/useSettings.ts @@ -190,6 +190,8 @@ export interface UseSettingsReturn { // Update settings checkForUpdatesOnStartup: boolean; setCheckForUpdatesOnStartup: (value: boolean) => void; + enableBetaUpdates: boolean; + setEnableBetaUpdates: (value: boolean) => void; // Crash reporting settings crashReportingEnabled: boolean; @@ -331,6 +333,7 @@ export function useSettings(): UseSettingsReturn { // Update Config const [checkForUpdatesOnStartup, setCheckForUpdatesOnStartupState] = useState(true); // Default: on + const [enableBetaUpdates, setEnableBetaUpdatesState] = useState(false); // Default: off (stable only) // Crash Reporting Config const [crashReportingEnabled, setCrashReportingEnabledState] = useState(true); // Default: on (opt-out) @@ -546,6 +549,11 @@ export function useSettings(): UseSettingsReturn { window.maestro.settings.set('checkForUpdatesOnStartup', value); }, []); + const setEnableBetaUpdates = useCallback((value: boolean) => { + setEnableBetaUpdatesState(value); + window.maestro.settings.set('enableBetaUpdates', value); + }, []); + const setCrashReportingEnabled = useCallback((value: boolean) => { setCrashReportingEnabledState(value); window.maestro.settings.set('crashReportingEnabled', value); @@ -1090,6 +1098,7 @@ export function useSettings(): UseSettingsReturn { const savedAudioFeedbackCommand = await window.maestro.settings.get('audioFeedbackCommand'); const savedToastDuration = await window.maestro.settings.get('toastDuration'); const savedCheckForUpdatesOnStartup = await window.maestro.settings.get('checkForUpdatesOnStartup'); + const savedEnableBetaUpdates = await window.maestro.settings.get('enableBetaUpdates'); const savedCrashReportingEnabled = await window.maestro.settings.get('crashReportingEnabled'); const savedLogViewerSelectedLevels = await window.maestro.settings.get('logViewerSelectedLevels'); const savedCustomAICommands = await window.maestro.settings.get('customAICommands'); @@ -1138,6 +1147,7 @@ export function useSettings(): UseSettingsReturn { if (savedAudioFeedbackCommand !== undefined) setAudioFeedbackCommandState(savedAudioFeedbackCommand as string); if (savedToastDuration !== undefined) setToastDurationState(savedToastDuration as number); if (savedCheckForUpdatesOnStartup !== undefined) setCheckForUpdatesOnStartupState(savedCheckForUpdatesOnStartup as boolean); + if (savedEnableBetaUpdates !== undefined) setEnableBetaUpdatesState(savedEnableBetaUpdates as boolean); if (savedCrashReportingEnabled !== undefined) setCrashReportingEnabledState(savedCrashReportingEnabled as boolean); if (savedLogViewerSelectedLevels !== undefined) setLogViewerSelectedLevelsState(savedLogViewerSelectedLevels as string[]); @@ -1401,6 +1411,8 @@ export function useSettings(): UseSettingsReturn { setToastDuration, checkForUpdatesOnStartup, setCheckForUpdatesOnStartup, + enableBetaUpdates, + setEnableBetaUpdates, crashReportingEnabled, setCrashReportingEnabled, logViewerSelectedLevels, @@ -1487,6 +1499,7 @@ export function useSettings(): UseSettingsReturn { audioFeedbackCommand, toastDuration, checkForUpdatesOnStartup, + enableBetaUpdates, crashReportingEnabled, logViewerSelectedLevels, shortcuts, @@ -1530,6 +1543,7 @@ export function useSettings(): UseSettingsReturn { setAudioFeedbackCommand, setToastDuration, setCheckForUpdatesOnStartup, + setEnableBetaUpdates, setCrashReportingEnabled, setLogViewerSelectedLevels, setShortcuts, From d65b8d2c6b8e8086ea9653e8514b81f58e545bc7 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sun, 28 Dec 2025 08:51:42 -0600 Subject: [PATCH 06/15] ## CHANGES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added `dev:prod-data` to develop against real production sessions safely πŸ§ͺ - Dev mode now defaults to an isolated `maestro-dev` data directory πŸ—‚οΈ - App can explicitly opt into production userData via `USE_PROD_DATA=1` πŸ”€ - Contributor docs now clearly map dev commands to their data directories πŸ“š - Reduced dev/production database lock conflicts when running side-by-side πŸ”’ - Tab hover overlay redesigned to look like a clean β€œopen folder” panel πŸ—ƒοΈ - Removed tab title tooltip to streamline the tab interaction feel βœ‚οΈ - Toast logging now captures whether audio/TTS notifications were enabled πŸŽ™οΈ - Toast logs include the exact audio command used for notifications 🧾 - TTS playback now reuses captured audio state for consistent behavior πŸ”Š --- CLAUDE.md | 19 ++++++++++--------- CONTRIBUTING.md | 18 +++++++++++++++++- package.json | 2 ++ src/main/index.ts | 5 ++++- src/renderer/components/TabBar.tsx | 20 ++++++-------------- src/renderer/contexts/ToastContext.tsx | 19 ++++++++++++++----- 6 files changed, 53 insertions(+), 30 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e3f2b91d..861bfb4f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,15 +27,16 @@ Maestro is an Electron desktop app for managing multiple AI coding assistants (C ## Quick Commands ```bash -npm run dev # Development with hot reload -npm run dev:web # Web interface development -npm run build # Full production build -npm run clean # Clean build artifacts -npm run lint # TypeScript type checking (all configs) -npm run lint:eslint # ESLint code quality checks -npm run package # Package for all platforms -npm run test # Run test suite -npm run test:watch # Run tests in watch mode +npm run dev # Development with hot reload (isolated data, can run alongside production) +npm run dev:prod-data # Development using production data (close production app first) +npm run dev:web # Web interface development +npm run build # Full production build +npm run clean # Clean build artifacts +npm run lint # TypeScript type checking (all configs) +npm run lint:eslint # ESLint code quality checks +npm run package # Package for all platforms +npm run test # Run test suite +npm run test:watch # Run tests in watch mode ``` ## Architecture at a Glance diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 29eddfc8..192baa03 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,7 +97,8 @@ maestro/ ## Development Scripts ```bash -npm run dev # Start dev server with hot reload +npm run dev # Start dev server with hot reload (isolated data directory) +npm run dev:prod-data # Start dev server using production data (requires closing production app) npm run dev:demo # Start in demo mode (fresh settings, isolated data) npm run dev:web # Start web interface dev server npm run build # Full production build (main + renderer + web + CLI) @@ -114,6 +115,21 @@ npm run package:win # Package for Windows npm run package:linux # Package for Linux ``` +### Development Data Directories + +By default, `npm run dev` uses an isolated data directory (`~/Library/Application Support/maestro-dev/`) separate from production. This allows you to run both dev and production instances simultaneouslyβ€”useful when using the production Maestro to work on the dev instance. + +| Command | Data Directory | Can Run Alongside Production? | +|---------|---------------|-------------------------------| +| `npm run dev` | `maestro-dev/` | βœ… Yes | +| `npm run dev:prod-data` | `maestro/` (production) | ❌ No - close production first | +| `npm run dev:demo` | `/tmp/maestro-demo/` | βœ… Yes | + +**When to use each:** +- **`npm run dev`** β€” Default for most development. Start fresh or use dev-specific test data. +- **`npm run dev:prod-data`** β€” Test with your real sessions and settings. Must close production app first to avoid database lock conflicts. +- **`npm run dev:demo`** β€” Screenshots, demos, or testing with completely fresh state. + ### Demo Mode Use demo mode to run Maestro with a fresh, isolated data directory - useful for demos, testing, or screenshots without affecting your real settings: diff --git a/package.json b/package.json index d17c849b..fdbe42b5 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,10 @@ }, "scripts": { "dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\"", + "dev:prod-data": "USE_PROD_DATA=1 concurrently \"npm run dev:main:prod-data\" \"npm run dev:renderer\"", "dev:demo": "MAESTRO_DEMO_DIR=/tmp/maestro-demo npm run dev", "dev:main": "npm run build:prompts && tsc -p tsconfig.main.json && NODE_ENV=development electron .", + "dev:main:prod-data": "npm run build:prompts && tsc -p tsconfig.main.json && NODE_ENV=development USE_PROD_DATA=1 electron .", "dev:renderer": "vite", "dev:web": "vite --config vite.config.web.mts", "build": "npm run build:prompts && npm run build:main && npm run build:renderer && npm run build:web && npm run build:cli", diff --git a/src/main/index.ts b/src/main/index.ts index a66f80d2..224d0c04 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -101,10 +101,13 @@ if (DEMO_MODE) { // Development mode: use a separate data directory to allow running alongside production // This prevents database lock conflicts (e.g., Service Worker storage) -if (isDevelopment && !DEMO_MODE) { +// Set USE_PROD_DATA=1 to use the production data directory instead (requires closing production app) +if (isDevelopment && !DEMO_MODE && !process.env.USE_PROD_DATA) { const devDataPath = path.join(app.getPath('userData'), '..', 'maestro-dev'); app.setPath('userData', devDataPath); console.log(`[DEV MODE] Using data directory: ${devDataPath}`); +} else if (isDevelopment && process.env.USE_PROD_DATA) { + console.log(`[DEV MODE] Using production data directory: ${app.getPath('userData')}`); } // Type definitions diff --git a/src/renderer/components/TabBar.tsx b/src/renderer/components/TabBar.tsx index 9eb500ee..8c386d3f 100644 --- a/src/renderer/components/TabBar.tsx +++ b/src/renderer/components/TabBar.tsx @@ -333,7 +333,6 @@ function Tab({ onDragOver={onDragOver} onDragEnd={onDragEnd} onDrop={onDrop} - title={tab.name || tab.agentSessionId || 'New tab'} > {/* Busy indicator - pulsing dot for tabs in write mode */} {tab.state === 'busy' && ( @@ -429,23 +428,16 @@ function Tab({ setIsHovered(false); }} > - {/* Connector tab that visually bridges the gap between tab and overlay */} + {/* Main overlay content - connects directly to tab like an open folder */}
- {/* Main overlay content */} -
@@ -642,7 +634,7 @@ function Tab({ )}
-
+
, document.body )} diff --git a/src/renderer/contexts/ToastContext.tsx b/src/renderer/contexts/ToastContext.tsx index 87809e54..800bbd50 100644 --- a/src/renderer/contexts/ToastContext.tsx +++ b/src/renderer/contexts/ToastContext.tsx @@ -87,7 +87,10 @@ export function ToastProvider({ children, defaultDuration: initialDuration = 20 setToasts(prev => [...prev, newToast]); } - // Log toast to system logs + // Capture audio feedback state for logging + const { enabled: audioEnabled, command: audioCommand } = audioFeedbackRef.current; + + // Log toast to system logs (include audio notification info) window.maestro.logger.toast(toast.title, { type: toast.type, message: toast.message, @@ -95,13 +98,19 @@ export function ToastProvider({ children, defaultDuration: initialDuration = 20 project: toast.project, taskDuration: toast.taskDuration, agentSessionId: toast.agentSessionId, - tabName: toast.tabName + tabName: toast.tabName, + // Audio/TTS notification info + audioNotification: audioEnabled && audioCommand ? { + enabled: true, + command: audioCommand + } : { + enabled: false + } }); // Speak toast via TTS if audio feedback is enabled and command is configured - const { enabled, command } = audioFeedbackRef.current; - if (enabled && command) { - window.maestro.notification.speak(toast.message, command).catch(err => { + if (audioEnabled && audioCommand) { + window.maestro.notification.speak(toast.message, audioCommand).catch(err => { console.error('[ToastContext] Failed to speak toast:', err); }); } From 5182334ea1fdaa319c909c746dfbc5c743ee7f2f Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sun, 28 Dec 2025 09:02:35 -0600 Subject: [PATCH 07/15] test: add coverage for beta opt-in prerelease feature - Add tests for includePrerelease parameter in update-checker - Add tests for updates:setAllowPrerelease IPC handler - Update existing update check tests to verify default behavior --- .../main/ipc/handlers/system.test.ts | 42 +++++++- src/__tests__/main/update-checker.test.ts | 95 +++++++++++++++++++ 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/src/__tests__/main/ipc/handlers/system.test.ts b/src/__tests__/main/ipc/handlers/system.test.ts index e834004d..619aa2e1 100644 --- a/src/__tests__/main/ipc/handlers/system.test.ts +++ b/src/__tests__/main/ipc/handlers/system.test.ts @@ -80,6 +80,11 @@ vi.mock('../../../../main/update-checker', () => ({ checkForUpdates: vi.fn(), })); +// Mock auto-updater +vi.mock('../../../../main/auto-updater', () => ({ + setAllowPrerelease: vi.fn(), +})); + // Mock tunnel manager vi.mock('../../../../main/tunnel-manager', () => ({ tunnelManager: { @@ -109,6 +114,7 @@ import { detectShells } from '../../../../main/utils/shellDetector'; import { isCloudflaredInstalled } from '../../../../main/utils/cliDetection'; import { execFileNoThrow } from '../../../../main/utils/execFile'; import { checkForUpdates } from '../../../../main/update-checker'; +import { setAllowPrerelease } from '../../../../main/auto-updater'; import { tunnelManager } from '../../../../main/tunnel-manager'; import * as fsSync from 'fs'; @@ -209,6 +215,7 @@ describe('system IPC handlers', () => { 'devtools:toggle', // Update handlers 'updates:check', + 'updates:setAllowPrerelease', // Logger handlers 'logger:log', 'logger:getLogs', @@ -649,7 +656,7 @@ describe('system IPC handlers', () => { }); describe('updates:check', () => { - it('should check for updates with current version', async () => { + it('should check for updates with current version (stable only by default)', async () => { const mockUpdateInfo = { hasUpdate: true, latestVersion: '2.0.0', @@ -662,7 +669,22 @@ describe('system IPC handlers', () => { const result = await handler!({} as any); expect(mockApp.getVersion).toHaveBeenCalled(); - expect(checkForUpdates).toHaveBeenCalledWith('1.0.0'); + expect(checkForUpdates).toHaveBeenCalledWith('1.0.0', false); + expect(result).toEqual(mockUpdateInfo); + }); + + it('should pass includePrerelease flag to checkForUpdates', async () => { + const mockUpdateInfo = { + hasUpdate: true, + latestVersion: '2.0.0-beta', + currentVersion: '1.0.0', + }; + vi.mocked(checkForUpdates).mockResolvedValue(mockUpdateInfo); + + const handler = handlers.get('updates:check'); + const result = await handler!({} as any, true); + + expect(checkForUpdates).toHaveBeenCalledWith('1.0.0', true); expect(result).toEqual(mockUpdateInfo); }); @@ -681,6 +703,22 @@ describe('system IPC handlers', () => { }); }); + describe('updates:setAllowPrerelease', () => { + it('should enable prerelease updates', async () => { + const handler = handlers.get('updates:setAllowPrerelease'); + await handler!({} as any, true); + + expect(setAllowPrerelease).toHaveBeenCalledWith(true); + }); + + it('should disable prerelease updates', async () => { + const handler = handlers.get('updates:setAllowPrerelease'); + await handler!({} as any, false); + + expect(setAllowPrerelease).toHaveBeenCalledWith(false); + }); + }); + describe('logger:log', () => { it('should log debug message', async () => { const handler = handlers.get('logger:log'); diff --git a/src/__tests__/main/update-checker.test.ts b/src/__tests__/main/update-checker.test.ts index c39b8d31..587847f8 100644 --- a/src/__tests__/main/update-checker.test.ts +++ b/src/__tests__/main/update-checker.test.ts @@ -279,4 +279,99 @@ describe('update-checker', () => { expect(result.updateAvailable).toBe(true); }); }); + + describe('checkForUpdates with includePrerelease', () => { + it('includes prerelease versions when includePrerelease is true', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve([ + createMockRelease({ tag_name: 'v1.3.0-beta', prerelease: true }), + createMockRelease({ tag_name: 'v1.2.0-rc', prerelease: true }), + createMockRelease({ tag_name: 'v1.1.0' }), + createMockRelease({ tag_name: 'v1.0.0' }), + ]), + }); + + const result = await checkForUpdates('1.0.0', true); + + expect(result.updateAvailable).toBe(true); + expect(result.latestVersion).toBe('1.3.0-beta'); + expect(result.releases.length).toBe(3); + expect(result.releases.some(r => r.tag_name.includes('-beta'))).toBe(true); + expect(result.releases.some(r => r.tag_name.includes('-rc'))).toBe(true); + }); + + it('excludes prerelease versions when includePrerelease is false', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve([ + createMockRelease({ tag_name: 'v1.3.0-beta', prerelease: true }), + createMockRelease({ tag_name: 'v1.2.0-rc', prerelease: true }), + createMockRelease({ tag_name: 'v1.1.0' }), + createMockRelease({ tag_name: 'v1.0.0' }), + ]), + }); + + const result = await checkForUpdates('1.0.0', false); + + expect(result.latestVersion).toBe('1.1.0'); + expect(result.releases.length).toBe(1); + expect(result.releases.some(r => r.tag_name.includes('-beta'))).toBe(false); + expect(result.releases.some(r => r.tag_name.includes('-rc'))).toBe(false); + }); + + it('defaults to excluding prereleases when parameter not provided', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve([ + createMockRelease({ tag_name: 'v1.2.0-alpha' }), + createMockRelease({ tag_name: 'v1.1.0' }), + ]), + }); + + const result = await checkForUpdates('1.0.0'); + + expect(result.latestVersion).toBe('1.1.0'); + expect(result.releases.some(r => r.tag_name.includes('-alpha'))).toBe(false); + }); + + it('includes all prerelease suffix types when enabled', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve([ + createMockRelease({ tag_name: 'v1.6.0-canary' }), + createMockRelease({ tag_name: 'v1.5.0-dev' }), + createMockRelease({ tag_name: 'v1.4.0-alpha' }), + createMockRelease({ tag_name: 'v1.3.0-beta' }), + createMockRelease({ tag_name: 'v1.2.0-rc' }), + createMockRelease({ tag_name: 'v1.1.0' }), + ]), + }); + + const result = await checkForUpdates('1.0.0', true); + + expect(result.releases.length).toBe(6); + expect(result.releases.some(r => r.tag_name.includes('-canary'))).toBe(true); + expect(result.releases.some(r => r.tag_name.includes('-dev'))).toBe(true); + expect(result.releases.some(r => r.tag_name.includes('-alpha'))).toBe(true); + expect(result.releases.some(r => r.tag_name.includes('-beta'))).toBe(true); + expect(result.releases.some(r => r.tag_name.includes('-rc'))).toBe(true); + }); + + it('always filters out draft releases even with includePrerelease', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve([ + createMockRelease({ tag_name: 'v1.3.0', draft: true }), + createMockRelease({ tag_name: 'v1.2.0-beta' }), + createMockRelease({ tag_name: 'v1.1.0' }), + ]), + }); + + const result = await checkForUpdates('1.0.0', true); + + expect(result.latestVersion).toBe('1.2.0-beta'); + expect(result.releases.every(r => !r.draft)).toBe(true); + }); + }); }); From 07f5f2c4ec9b327b6bd02beb29e06b27933f0a4d Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sun, 28 Dec 2025 09:31:01 -0600 Subject: [PATCH 08/15] ## CHANGES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed tab title tooltips for a cleaner, distraction-free TabBar UX 🧼 - Added demo-mode and dev-mode isolated data directories to avoid conflicts πŸ—‚οΈ - Defaulted sync storage path to configured userData when unset πŸ”„ - Ensured userData path is set before Store initialization for correctness 🧭 - Improved TTS IPC reliability with utf8 writes, callbacks, and stdin checks πŸ—£οΈ - Added richer TTS debug logging in main process for easier troubleshooting πŸ” - Logged ToastContext TTS triggers and explicit skip reasons for clarity πŸ“£ - Extended toast test coverage to include audioNotification disabled state βœ… - Fixed FilePreview search match index reset to prevent unnecessary jumps 🎯 --- .../renderer/components/TabBar.test.tsx | 49 +-------------- .../renderer/contexts/ToastContext.test.tsx | 1 + src/main/index.ts | 62 ++++++++++++------- src/renderer/components/FilePreview.tsx | 4 +- src/renderer/contexts/ToastContext.tsx | 3 + 5 files changed, 48 insertions(+), 71 deletions(-) diff --git a/src/__tests__/renderer/components/TabBar.test.tsx b/src/__tests__/renderer/components/TabBar.test.tsx index 6ecce586..497a12e9 100644 --- a/src/__tests__/renderer/components/TabBar.test.tsx +++ b/src/__tests__/renderer/components/TabBar.test.tsx @@ -1512,7 +1512,8 @@ describe('TabBar', () => { expect(inactiveTab.style.backgroundColor).not.toBe('rgba(255, 255, 255, 0.08)'); }); - it('sets tab title attribute', () => { + it('does not set title attribute on tabs (removed for cleaner UX)', () => { + // Tab title tooltips were intentionally removed to streamline the tab interaction feel const tabs = [createTab({ id: 'tab-1', name: 'My Tab', @@ -1531,51 +1532,7 @@ describe('TabBar', () => { ); const tab = screen.getByText('My Tab').closest('[data-tab-id]')!; - expect(tab).toHaveAttribute('title', 'My Tab'); - }); - - it('uses agentSessionId for title when no name', () => { - const tabs = [createTab({ - id: 'tab-1', - name: '', - agentSessionId: 'session-123-abc' - })]; - - render( - - ); - - const tab = screen.getByText('SESSION').closest('[data-tab-id]')!; - expect(tab).toHaveAttribute('title', 'session-123-abc'); - }); - - it('uses "New tab" for title when no name or agentSessionId', () => { - const tabs = [createTab({ - id: 'tab-1', - name: '', - agentSessionId: undefined - })]; - - render( - - ); - - const tab = screen.getByText('New Session').closest('[data-tab-id]')!; - expect(tab).toHaveAttribute('title', 'New tab'); + expect(tab).not.toHaveAttribute('title'); }); }); diff --git a/src/__tests__/renderer/contexts/ToastContext.test.tsx b/src/__tests__/renderer/contexts/ToastContext.test.tsx index d7987377..6dbdb81c 100644 --- a/src/__tests__/renderer/contexts/ToastContext.test.tsx +++ b/src/__tests__/renderer/contexts/ToastContext.test.tsx @@ -233,6 +233,7 @@ describe('ToastContext', () => { taskDuration: 5000, agentSessionId: 'test-session-id', tabName: 'TestTab', + audioNotification: { enabled: false }, }); }); diff --git a/src/main/index.ts b/src/main/index.ts index 224d0c04..50fe18c8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -32,6 +32,31 @@ interface BootstrapSettings { iCloudSyncEnabled?: boolean; // Legacy - kept for backwards compatibility during migration } +// ============================================================================ +// Data Directory Configuration (MUST happen before any Store initialization) +// ============================================================================ +const isDevelopment = process.env.NODE_ENV === 'development'; + +// Demo mode: use a separate data directory for fresh demos +if (DEMO_MODE) { + app.setPath('userData', DEMO_DATA_PATH); + console.log(`[DEMO MODE] Using data directory: ${DEMO_DATA_PATH}`); +} + +// Development mode: use a separate data directory to allow running alongside production +// This prevents database lock conflicts (e.g., Service Worker storage) +// Set USE_PROD_DATA=1 to use the production data directory instead (requires closing production app) +if (isDevelopment && !DEMO_MODE && !process.env.USE_PROD_DATA) { + const devDataPath = path.join(app.getPath('userData'), '..', 'maestro-dev'); + app.setPath('userData', devDataPath); + console.log(`[DEV MODE] Using data directory: ${devDataPath}`); +} else if (isDevelopment && process.env.USE_PROD_DATA) { + console.log(`[DEV MODE] Using production data directory: ${app.getPath('userData')}`); +} + +// ============================================================================ +// Store Initialization (after userData path is configured) +// ============================================================================ const bootstrapStore = new Store({ name: 'maestro-bootstrap', defaults: {}, @@ -62,12 +87,11 @@ function getSyncPath(): string | undefined { } // Get the sync path once at startup -const syncPath = getSyncPath(); +// If no custom sync path, use the (potentially modified) userData path +const syncPath = getSyncPath() || app.getPath('userData'); -// Initialize Sentry for crash reporting (before app.ready) +// Initialize Sentry for crash reporting // Only enable in production - skip during development to avoid noise from hot-reload artifacts -const isDevelopment = process.env.NODE_ENV === 'development'; - // Check if crash reporting is enabled (default: true for opt-out behavior) const crashReportingStore = new Store<{ crashReportingEnabled: boolean }>({ name: 'maestro-settings', @@ -93,23 +117,6 @@ if (crashReportingEnabled && !isDevelopment) { }); } -// Demo mode: use a separate data directory for fresh demos -if (DEMO_MODE) { - app.setPath('userData', DEMO_DATA_PATH); - console.log(`[DEMO MODE] Using data directory: ${DEMO_DATA_PATH}`); -} - -// Development mode: use a separate data directory to allow running alongside production -// This prevents database lock conflicts (e.g., Service Worker storage) -// Set USE_PROD_DATA=1 to use the production data directory instead (requires closing production app) -if (isDevelopment && !DEMO_MODE && !process.env.USE_PROD_DATA) { - const devDataPath = path.join(app.getPath('userData'), '..', 'maestro-dev'); - app.setPath('userData', devDataPath); - console.log(`[DEV MODE] Using data directory: ${devDataPath}`); -} else if (isDevelopment && process.env.USE_PROD_DATA) { - console.log(`[DEV MODE] Using production data directory: ${app.getPath('userData')}`); -} - // Type definitions interface MaestroSettings { activeThemeId: string; @@ -1405,8 +1412,17 @@ function setupIpcHandlers() { logger.error('TTS stdin error', 'TTS', { error: String(err), code: errorCode }); } }); - child.stdin.write(text); - child.stdin.end(); + console.log('[TTS Main] Writing to stdin:', text); + child.stdin.write(text, 'utf8', (err) => { + if (err) { + console.error('[TTS Main] stdin write error:', err); + } else { + console.log('[TTS Main] stdin write completed, ending stream'); + } + child.stdin!.end(); + }); + } else { + console.error('[TTS Main] No stdin available on child process'); } child.on('error', (err) => { diff --git a/src/renderer/components/FilePreview.tsx b/src/renderer/components/FilePreview.tsx index 18952628..2ae03802 100644 --- a/src/renderer/components/FilePreview.tsx +++ b/src/renderer/components/FilePreview.tsx @@ -756,9 +756,9 @@ export function FilePreview({ file, onClose, theme, markdownEditMode, setMarkdow } } - // Update match count + // Update match count - only reset index if it's truly out of bounds and not already 0 setTotalMatches(allRanges.length); - if (allRanges.length > 0 && currentMatchIndex >= allRanges.length) { + if (allRanges.length > 0 && currentMatchIndex >= allRanges.length && currentMatchIndex !== 0) { setCurrentMatchIndex(0); } diff --git a/src/renderer/contexts/ToastContext.tsx b/src/renderer/contexts/ToastContext.tsx index 800bbd50..450f466e 100644 --- a/src/renderer/contexts/ToastContext.tsx +++ b/src/renderer/contexts/ToastContext.tsx @@ -110,9 +110,12 @@ export function ToastProvider({ children, defaultDuration: initialDuration = 20 // Speak toast via TTS if audio feedback is enabled and command is configured if (audioEnabled && audioCommand) { + console.log('[ToastContext] Triggering TTS with message:', toast.message, 'command:', audioCommand); window.maestro.notification.speak(toast.message, audioCommand).catch(err => { console.error('[ToastContext] Failed to speak toast:', err); }); + } else { + console.log('[ToastContext] TTS skipped - enabled:', audioEnabled, 'command:', audioCommand); } // Show OS notification if enabled From 6a8372c7934a1c71ffbf1c1959e53c8a4e7ea112 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sun, 28 Dec 2025 09:48:23 -0600 Subject: [PATCH 09/15] =?UTF-8?q?##=20CHANGES=20-=20Made=20bootstrap=20sto?= =?UTF-8?q?re=20use=20explicit=20userData=20path=20for=20reliability=20?= =?UTF-8?q?=F0=9F=A7=AD=20-=20Aligned=20crash-reporting=20settings=20stora?= =?UTF-8?q?ge=20path=20with=20main=20sync=20settings=20=F0=9F=9B=A1?= =?UTF-8?q?=EF=B8=8F=20-=20Refined=20LIVE/OFFLINE=20badge=20display=20thre?= =?UTF-8?q?sholds=20based=20on=20AutoRun=20badge=20level=20=F0=9F=8F=B7?= =?UTF-8?q?=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 2 ++ src/renderer/components/SessionList.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/index.ts b/src/main/index.ts index 50fe18c8..7740a932 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -59,6 +59,7 @@ if (isDevelopment && !DEMO_MODE && !process.env.USE_PROD_DATA) { // ============================================================================ const bootstrapStore = new Store({ name: 'maestro-bootstrap', + cwd: app.getPath('userData'), // Explicit path after dev mode adjustment defaults: {}, }); @@ -95,6 +96,7 @@ const syncPath = getSyncPath() || app.getPath('userData'); // Check if crash reporting is enabled (default: true for opt-out behavior) const crashReportingStore = new Store<{ crashReportingEnabled: boolean }>({ name: 'maestro-settings', + cwd: syncPath, // Use same path as main settings store }); const crashReportingEnabled = crashReportingStore.get('crashReportingEnabled', true); diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx index 8e57fed3..ff889469 100644 --- a/src/renderer/components/SessionList.tsx +++ b/src/renderer/components/SessionList.tsx @@ -1356,7 +1356,7 @@ function SessionListInner(props: SessionListProps) { title={isLiveMode ? "Web interface active - Click to show URL" : "Click to enable web interface"} > - {leftSidebarWidthState >= (isLiveMode ? 280 : 310) && (isLiveMode ? 'LIVE' : 'OFFLINE')} + {leftSidebarWidthState >= (autoRunStats && autoRunStats.currentBadgeLevel > 0 ? 295 : 256) && (isLiveMode ? 'LIVE' : 'OFFLINE')} {/* LIVE Overlay with URL and QR Code - Single QR with pill selector */} From f7b7f361dfbf0b8ca97ae1817dc242afcd8a39db Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sun, 28 Dec 2025 10:03:27 -0600 Subject: [PATCH 10/15] version bump --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fdbe42b5..6c9e0c51 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "maestro", - "version": "0.12.2", + "version": "0.12.3", "description": "Maestro hones fractured attention into focused intent.", "main": "dist/main/index.js", "author": { From 2b13fd87d9a7f330f7923e8ad5ff714cc53f4d22 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sun, 28 Dec 2025 10:22:13 -0600 Subject: [PATCH 11/15] ## CHANGES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Release workflow now deduplicates build artifacts, preventing upload race conditions 🚦 - GitHub releases upload from one consolidated `artifacts/release` directory for reliability πŸ“¦ - Docs navigation adds a β€œlife-ring” icon to Troubleshooting for quicker discovery πŸ›Ÿ - New keyboard shortcut (Shift+,) opens Agent Settings for the active session ⌨️ - Main keyboard handler wires Agent Settings shortcut into edit-agent modal flow 🧩 --- .github/workflows/release.yml | 25 +++++++++++++++---- docs/docs.json | 3 ++- src/renderer/App.tsx | 5 +++- src/renderer/constants/shortcuts.ts | 1 + .../hooks/keyboard/useMainKeyboardHandler.ts | 8 ++++++ 5 files changed, 35 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 709e8d4a..b7befec1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -382,6 +382,25 @@ jobs: echo "Available artifacts:" find artifacts -type f 2>/dev/null || echo "No artifacts found" + # Deduplicate artifacts to prevent race conditions when uploading + # Files with the same name from different platform builds are consolidated + - name: Deduplicate artifacts + run: | + mkdir -p artifacts/release + + # Copy all artifacts to a single directory, letting later copies overwrite + # This handles cases where both Linux builds produce the same file + for dir in artifacts/maestro-macos artifacts/maestro-windows artifacts/maestro-linux-x64 artifacts/maestro-linux-arm64; do + if [ -d "$dir" ]; then + echo "Processing $dir..." + cp -v "$dir"/* artifacts/release/ 2>/dev/null || true + fi + done + + echo "" + echo "Final deduplicated artifacts:" + ls -la artifacts/release/ 2>/dev/null || echo "No artifacts" + - name: Check for any artifacts id: check_artifacts run: | @@ -398,11 +417,7 @@ jobs: if: steps.check_artifacts.outputs.has_artifacts == 'true' uses: softprops/action-gh-release@v2 with: - files: | - artifacts/maestro-macos/* - artifacts/maestro-windows/* - artifacts/maestro-linux-x64/* - artifacts/maestro-linux-arm64/* + files: artifacts/release/* fail_on_unmatched_files: false draft: false prerelease: false diff --git a/docs/docs.json b/docs/docs.json index 7f22714f..09937cd7 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -79,7 +79,8 @@ "achievements", "keyboard-shortcuts", "troubleshooting" - ] + ], + "icon": "life-ring" } ] } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 5a58037a..7f3055f9 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -7858,7 +7858,10 @@ function MaestroConsoleInner() { summarizeAndContinue: handleSummarizeAndContinue, // Keyboard mastery gamification - recordShortcutUsage, onKeyboardMasteryLevelUp + recordShortcutUsage, onKeyboardMasteryLevelUp, + + // Edit agent modal + setEditAgentSession, setEditAgentModalOpen }; diff --git a/src/renderer/constants/shortcuts.ts b/src/renderer/constants/shortcuts.ts index 29aec79f..c28ad57d 100644 --- a/src/renderer/constants/shortcuts.ts +++ b/src/renderer/constants/shortcuts.ts @@ -15,6 +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', ','] }, 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/keyboard/useMainKeyboardHandler.ts b/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts index 83125c0d..787bf388 100644 --- a/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts +++ b/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts @@ -216,6 +216,14 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn { } else if (ctx.isShortcut(e, 'help')) { ctx.setShortcutsHelpOpen(true); trackShortcut('help'); } else if (ctx.isShortcut(e, 'settings')) { ctx.setSettingsModalOpen(true); ctx.setSettingsTab('general'); trackShortcut('settings'); } + else if (ctx.isShortcut(e, 'agentSettings')) { + // Open agent settings for the current session + if (ctx.activeSession) { + ctx.setEditAgentSession(ctx.activeSession); + ctx.setEditAgentModalOpen(true); + trackShortcut('agentSettings'); + } + } else if (ctx.isShortcut(e, 'goToFiles')) { e.preventDefault(); ctx.setRightPanelOpen(true); From f7006581f9cfcc84795fa4458f98233941b9ebb3 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sun, 28 Dec 2025 10:50:26 -0600 Subject: [PATCH 12/15] ## CHANGES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added autoRunStats badge-aware sidebar thresholds for LIVE/OFFLINE label visibility 🧩 - Expanded SessionList tests to cover badge-present vs badge-absent behavior πŸ§ͺ - Verified labels hide below 295px when badge level is active 🏷️ - Ensured LIVE/OFFLINE text still renders at 256px without badge 🎯 - Kept radio icon visible even when status text collapses in narrow sidebar πŸ“» --- .../renderer/components/SessionList.test.tsx | 70 +++++++++++++++---- 1 file changed, 58 insertions(+), 12 deletions(-) diff --git a/src/__tests__/renderer/components/SessionList.test.tsx b/src/__tests__/renderer/components/SessionList.test.tsx index b5c46db4..a2beff82 100644 --- a/src/__tests__/renderer/components/SessionList.test.tsx +++ b/src/__tests__/renderer/components/SessionList.test.tsx @@ -413,18 +413,41 @@ describe('SessionList', () => { expect(toggleGlobalLive).toHaveBeenCalled(); }); - it('hides OFFLINE text when sidebar width is narrow (< 280px)', () => { + it('hides OFFLINE text when sidebar width is narrow (< 256px) with autoRunStats badge', () => { + // When autoRunStats.currentBadgeLevel > 0, threshold is 295px + // When no autoRunStats, threshold is 256px + const autoRunStats: AutoRunStats = { + totalDocuments: 1, + currentDocument: 1, + completedTasks: 0, + totalTasks: 5, + currentBadgeLevel: 1, // This raises threshold to 295px + }; const props = createDefaultProps({ leftSidebarOpen: true, - leftSidebarWidthState: 256, // Minimum sidebar width + leftSidebarWidthState: 256, // Below 295px threshold when badge is active + isLiveMode: false, + autoRunStats, + }); + render(); + + // Text should be hidden when below threshold with active badge + expect(screen.queryByText('OFFLINE')).not.toBeInTheDocument(); + // But the Radio icon should still be present + expect(screen.getByTestId('icon-radio')).toBeInTheDocument(); + }); + + it('shows OFFLINE text when sidebar width equals minimum threshold (256px) without autoRunStats', () => { + // Without autoRunStats, threshold is 256px so text shows at exactly 256px + const props = createDefaultProps({ + leftSidebarOpen: true, + leftSidebarWidthState: 256, isLiveMode: false, }); render(); - // Text should be hidden, only icon visible - expect(screen.queryByText('OFFLINE')).not.toBeInTheDocument(); - // But the Radio icon should still be present - expect(screen.getByTestId('icon-radio')).toBeInTheDocument(); + // Text should be visible at minimum threshold when no badge + expect(screen.getByText('OFFLINE')).toBeInTheDocument(); }); it('shows OFFLINE text when sidebar width is wide (>= 310px)', () => { @@ -439,19 +462,42 @@ describe('SessionList', () => { expect(screen.getByText('OFFLINE')).toBeInTheDocument(); }); - it('hides LIVE text when sidebar width is narrow (< 280px)', () => { + it('hides LIVE text when sidebar width is narrow with autoRunStats badge', () => { + // When autoRunStats.currentBadgeLevel > 0, threshold is 295px + const autoRunStats: AutoRunStats = { + totalDocuments: 1, + currentDocument: 1, + completedTasks: 0, + totalTasks: 5, + currentBadgeLevel: 1, // This raises threshold to 295px + }; const props = createDefaultProps({ leftSidebarOpen: true, - leftSidebarWidthState: 256, // Minimum sidebar width + leftSidebarWidthState: 256, // Below 295px threshold when badge is active + isLiveMode: true, + webInterfaceUrl: 'http://localhost:3000', + autoRunStats, + }); + render(); + + // Text should be hidden when below threshold with active badge + expect(screen.queryByText('LIVE')).not.toBeInTheDocument(); + // But the Radio icon should still be present + expect(screen.getByTestId('icon-radio')).toBeInTheDocument(); + }); + + it('shows LIVE text when sidebar width equals minimum threshold (256px) without autoRunStats', () => { + // Without autoRunStats, threshold is 256px so text shows at exactly 256px + const props = createDefaultProps({ + leftSidebarOpen: true, + leftSidebarWidthState: 256, isLiveMode: true, webInterfaceUrl: 'http://localhost:3000', }); render(); - // Text should be hidden, only icon visible - expect(screen.queryByText('LIVE')).not.toBeInTheDocument(); - // But the Radio icon should still be present - expect(screen.getByTestId('icon-radio')).toBeInTheDocument(); + // Text should be visible at minimum threshold when no badge + expect(screen.getByText('LIVE')).toBeInTheDocument(); }); it('shows LIVE text when sidebar width is wide (>= 280px)', () => { From aeb9abebfcf0f2b8c938e3ea5c43d9fcf07be1a9 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sun, 28 Dec 2025 10:53:19 -0600 Subject: [PATCH 13/15] ## CHANGES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Strengthened prompt validation to allow questions and engaging imperatives 🧠 - Expanded tests to verify all initial prompts meet punctuation expectations βœ… - Added clearer failure messaging for malformed prompts during test runs 🧾 --- .../Wizard/services/wizardPrompts.test.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/__tests__/renderer/components/Wizard/services/wizardPrompts.test.ts b/src/__tests__/renderer/components/Wizard/services/wizardPrompts.test.ts index 84edaf18..0be2a556 100644 --- a/src/__tests__/renderer/components/Wizard/services/wizardPrompts.test.ts +++ b/src/__tests__/renderer/components/Wizard/services/wizardPrompts.test.ts @@ -783,10 +783,20 @@ describe('wizardPrompts', () => { } }); - it('should be a question', () => { - const question = getInitialQuestion(); + it('should be a question or engaging prompt', () => { + // Check all questions contain either a question mark or end with proper punctuation + // Some phrases are imperatives (e.g., "Tell me what you're passionate about building.") + // which are valid conversational prompts even without a question mark + const allQuestions = getAllInitialQuestions(); - expect(question).toContain('?'); + for (const question of allQuestions) { + const hasQuestionMark = question.includes('?'); + const endsWithPunctuation = /[.!]$/.test(question); + expect( + hasQuestionMark || endsWithPunctuation, + `Question "${question}" should contain '?' or end with '.' or '!'` + ).toBe(true); + } }); }); From d003dc34081fd55f6830c7c8e9ba0fe7c0e3f754 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sun, 28 Dec 2025 12:24:07 -0600 Subject: [PATCH 14/15] OAuth enabled but no valid token found. Starting authentication... Found expired OAuth token, attempting refresh... Token refresh successful ## CHANGES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added global hands-on time tracking with 5-minute idle timeout ⏱️ - Persist hands-on time to settings every 30 seconds automatically πŸ’Ύ - Flushes tracked time safely on tab hide and app exit πŸ”’ - About modal now shows lifetime hands-on time across sessions πŸ“Š - Reworked About modal props to stop depending on sessions directly 🧩 - Wired new tracker into main app initialization flow 🧠 - Exposed `useHandsOnTimeTracker` hook via session hooks index πŸ”Œ - Updated Agent Settings shortcut to Opt+Cmd+, / Alt+Ctrl+, ⌨️ - Refreshed keyboard shortcuts docs with Agent Settings entry πŸ“š - Cleaned modal wiring so Process Monitor still receives sessions πŸ› οΈ --- docs/keyboard-shortcuts.md | 1 + src/renderer/App.tsx | 10 +- src/renderer/components/AboutModal.tsx | 20 ++- src/renderer/components/AppModals.tsx | 15 +- src/renderer/constants/shortcuts.ts | 2 +- src/renderer/hooks/session/index.ts | 5 +- .../hooks/session/useHandsOnTimeTracker.ts | 152 ++++++++++++++++++ 7 files changed, 186 insertions(+), 19 deletions(-) create mode 100644 src/renderer/hooks/session/useHandsOnTimeTracker.ts 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); + }; + }, []); +} From 4a72d384e888c3426ec2db580fb4074a0bd88b76 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sun, 28 Dec 2025 12:39:31 -0600 Subject: [PATCH 15/15] feat: add beta opt-in checkbox to Check for Updates modal Adds a stylized toggle to enable/disable pre-release updates directly from the update check modal, keeping it in sync with the Settings modal. --- src/renderer/components/UpdateCheckModal.tsx | 58 +++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/UpdateCheckModal.tsx b/src/renderer/components/UpdateCheckModal.tsx index a412df5d..c8ed537b 100644 --- a/src/renderer/components/UpdateCheckModal.tsx +++ b/src/renderer/components/UpdateCheckModal.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { X, Download, ExternalLink, Loader2, CheckCircle2, AlertCircle, RefreshCw, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react'; +import { X, Download, ExternalLink, Loader2, CheckCircle2, AlertCircle, RefreshCw, ChevronDown, ChevronRight, RotateCcw, FlaskConical } from 'lucide-react'; import type { Theme } from '../types'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; import ReactMarkdown from 'react-markdown'; @@ -43,7 +43,7 @@ export function UpdateCheckModal({ theme, onClose }: UpdateCheckModalProps) { const [expandedReleases, setExpandedReleases] = useState>(new Set()); // Get beta updates setting - const { enableBetaUpdates } = useSettings(); + const { enableBetaUpdates, setEnableBetaUpdates } = useSettings(); // Auto-updater state const [downloadStatus, setDownloadStatus] = useState({ status: 'idle' }); @@ -442,6 +442,60 @@ export function UpdateCheckModal({ theme, onClose }: UpdateCheckModalProps) {
)} + + {/* Beta Opt-in Toggle */} +
+ +
);