From dddf781dfd692baeb14cb506f5f6f8f7bb19595b Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Wed, 4 Mar 2026 11:39:26 -0800 Subject: [PATCH 1/5] feat: gate Maestro Symphony behind Encore Features toggle, add multi-URL registry support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `symphony: boolean` (default true) to EncoreFeatureFlags - Gate Symphony modal, menu item, keyboard shortcut (⇧⌘Y), and command palette entry - Add `symphonyRegistryUrls` setting for user-configured additional registry URLs - Replace single `fetchRegistry()` with `fetchRegistries()` that fetches default + custom URLs in parallel - Merge repositories by slug (default registry wins on conflicts), isolated per-URL error handling - Add Symphony toggle + Registry Sources UI in Settings > Encore tab - Update tests for new symphony flag across all encore feature assertions --- .../components/SettingsModal.test.tsx | 144 +++- src/main/index.ts | 1 + src/main/ipc/handlers/index.ts | 1 + src/main/ipc/handlers/symphony.ts | 77 +- src/renderer/App.tsx | 10 +- src/renderer/components/AppModals.tsx | 4 +- src/renderer/components/QuickActionsModal.tsx | 24 +- src/renderer/components/SessionList.tsx | 98 +-- src/renderer/components/SettingsModal.tsx | 783 +++++++++++------- .../hooks/keyboard/useMainKeyboardHandler.ts | 4 +- src/renderer/hooks/settings/useSettings.ts | 4 + src/renderer/stores/settingsStore.ts | 14 + src/renderer/types/index.ts | 2 + 13 files changed, 771 insertions(+), 395 deletions(-) diff --git a/src/__tests__/renderer/components/SettingsModal.test.tsx b/src/__tests__/renderer/components/SettingsModal.test.tsx index b3a460cbb..88dcde030 100644 --- a/src/__tests__/renderer/components/SettingsModal.test.tsx +++ b/src/__tests__/renderer/components/SettingsModal.test.tsx @@ -111,6 +111,9 @@ vi.mock('../../../renderer/hooks/settings/useSettings', () => ({ setWakatimeEnabled: vi.fn(), wakatimeApiKey: '', setWakatimeApiKey: vi.fn(), + // Symphony registry URLs + symphonyRegistryUrls: [], + setSymphonyRegistryUrls: vi.fn(), ...mockUseSettingsOverrides, }), })); @@ -258,7 +261,7 @@ const createDefaultProps = (overrides = {}) => ({ setCrashReportingEnabled: vi.fn(), customAICommands: [], setCustomAICommands: vi.fn(), - encoreFeatures: { directorNotes: false }, + encoreFeatures: { directorNotes: false, usageStats: true, symphony: true }, setEncoreFeatures: mockSetEncoreFeatures, ...overrides, }); @@ -2237,13 +2240,15 @@ describe('SettingsModal', () => { expect(mockSetEncoreFeatures).toHaveBeenCalledWith({ directorNotes: true, + usageStats: true, + symphony: true, }); }); it('should call setEncoreFeatures with false when toggling DN off', async () => { mockSetEncoreFeatures.mockClear(); - render(); + render(); await act(async () => { await vi.advanceTimersByTimeAsync(50); @@ -2261,11 +2266,144 @@ describe('SettingsModal', () => { expect(mockSetEncoreFeatures).toHaveBeenCalledWith({ directorNotes: false, + usageStats: true, + symphony: true, }); }); + it('should show Usage & Stats feature toggle defaulting to on', async () => { + render(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + fireEvent.click(screen.getByTitle('Encore Features')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + expect(screen.getByText('Usage & Stats')).toBeInTheDocument(); + // Settings should be visible when enabled (default on) + expect(screen.getByText('Enable stats collection')).toBeInTheDocument(); + }); + + it('should call setEncoreFeatures when Usage & Stats toggle is clicked off', async () => { + mockSetEncoreFeatures.mockClear(); + + render(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + fireEvent.click(screen.getByTitle('Encore Features')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + const usSection = screen.getByText('Usage & Stats').closest('button'); + expect(usSection).toBeInTheDocument(); + fireEvent.click(usSection!); + + expect(mockSetEncoreFeatures).toHaveBeenCalledWith({ + directorNotes: false, + usageStats: false, + symphony: true, + }); + }); + + it('should show Maestro Symphony feature toggle defaulting to on', async () => { + render(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + fireEvent.click(screen.getByTitle('Encore Features')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + expect(screen.getByText('Maestro Symphony')).toBeInTheDocument(); + // Settings should be visible when enabled (default on) + expect(screen.getByText('Registry Sources')).toBeInTheDocument(); + }); + + it('should call setEncoreFeatures when Symphony toggle is clicked off', async () => { + mockSetEncoreFeatures.mockClear(); + + render(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + fireEvent.click(screen.getByTitle('Encore Features')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + const symphonySection = screen.getByText('Maestro Symphony').closest('button'); + expect(symphonySection).toBeInTheDocument(); + fireEvent.click(symphonySection!); + + expect(mockSetEncoreFeatures).toHaveBeenCalledWith({ + directorNotes: false, + usageStats: true, + symphony: false, + }); + }); + + it('should call setEncoreFeatures when Symphony toggle is clicked on', async () => { + mockSetEncoreFeatures.mockClear(); + + render(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + fireEvent.click(screen.getByTitle('Encore Features')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + const symphonySection = screen.getByText('Maestro Symphony').closest('button'); + expect(symphonySection).toBeInTheDocument(); + fireEvent.click(symphonySection!); + + expect(mockSetEncoreFeatures).toHaveBeenCalledWith({ + directorNotes: false, + usageStats: true, + symphony: true, + }); + }); + + it('should hide Symphony registry settings when symphony is disabled', async () => { + render(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + fireEvent.click(screen.getByTitle('Encore Features')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + expect(screen.getByText('Maestro Symphony')).toBeInTheDocument(); + expect(screen.queryByText('Registry Sources')).not.toBeInTheDocument(); + }); + describe('with Director\'s Notes enabled', () => { - const dnEnabledProps = { encoreFeatures: { directorNotes: true } }; + const dnEnabledProps = { encoreFeatures: { directorNotes: true, usageStats: true, symphony: true } }; it('should render provider dropdown with detected available agents', async () => { render(); diff --git a/src/main/index.ts b/src/main/index.ts index 2794e1dc5..b4394e7f2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -648,6 +648,7 @@ function setupIpcHandlers() { app, getMainWindow: () => mainWindow, sessionsStore, + settingsStore: store, }); // Register tab naming handlers for automatic tab naming diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts index ba41c326b..dac6b3ae2 100644 --- a/src/main/ipc/handlers/index.ts +++ b/src/main/ipc/handlers/index.ts @@ -265,6 +265,7 @@ export function registerAllHandlers(deps: HandlerDependencies): void { app: deps.app, getMainWindow: deps.getMainWindow, sessionsStore: deps.sessionsStore, + settingsStore: deps.settingsStore, }); // Register agent error handlers (error state management) registerAgentErrorHandlers(); diff --git a/src/main/ipc/handlers/symphony.ts b/src/main/ipc/handlers/symphony.ts index 115a0ad3f..f9e755ec0 100644 --- a/src/main/ipc/handlers/symphony.ts +++ b/src/main/ipc/handlers/symphony.ts @@ -198,6 +198,7 @@ export interface SymphonyHandlerDependencies { app: App; getMainWindow: () => BrowserWindow | null; sessionsStore: Store; + settingsStore: Store; } // ============================================================================ @@ -379,39 +380,67 @@ function parseDocumentPaths(body: string): DocumentReference[] { // ============================================================================ /** - * Fetch the symphony registry from GitHub. + * Fetch a single symphony registry from a URL. + * Returns null on failure instead of throwing (isolated error handling per URL). */ -async function fetchRegistry(): Promise { - logger.info('Fetching Symphony registry', LOG_CONTEXT); - +async function fetchSingleRegistry(url: string): Promise { try { - const response = await fetch(SYMPHONY_REGISTRY_URL); - + const response = await fetch(url); if (!response.ok) { - throw new SymphonyError( - `Failed to fetch registry: ${response.status} ${response.statusText}`, - 'network' - ); + logger.warn(`Failed to fetch registry from ${url}: ${response.status}`, LOG_CONTEXT); + return null; } - const data = (await response.json()) as SymphonyRegistry; - if (!data.repositories || !Array.isArray(data.repositories)) { - throw new SymphonyError('Invalid registry structure', 'parse'); + logger.warn(`Invalid registry structure from ${url}`, LOG_CONTEXT); + return null; } - - logger.info(`Fetched registry with ${data.repositories.length} repos`, LOG_CONTEXT); + logger.info(`Fetched ${data.repositories.length} repos from ${url}`, LOG_CONTEXT); return data; } catch (error) { - if (error instanceof SymphonyError) throw error; - throw new SymphonyError( - `Network error: ${error instanceof Error ? error.message : String(error)}`, - 'network', - error - ); + logger.warn(`Network error fetching registry from ${url}: ${error instanceof Error ? error.message : String(error)}`, LOG_CONTEXT); + return null; } } +/** + * Fetch and merge symphony registries from all configured URLs. + * Default URL always fetched first (wins on slug conflicts). + * Custom URL failures are isolated — other registries still load. + */ +async function fetchRegistries(customUrls: string[]): Promise { + logger.info(`Fetching Symphony registries (1 default + ${customUrls.length} custom)`, LOG_CONTEXT); + + const allUrls = [SYMPHONY_REGISTRY_URL, ...customUrls]; + const results = await Promise.allSettled(allUrls.map(fetchSingleRegistry)); + + const seenSlugs = new Set(); + const mergedRepos: SymphonyRegistry['repositories'] = []; + + for (const result of results) { + if (result.status === 'fulfilled' && result.value) { + for (const repo of result.value.repositories) { + if (!seenSlugs.has(repo.slug)) { + seenSlugs.add(repo.slug); + mergedRepos.push(repo); + } + } + } + } + + if (mergedRepos.length === 0) { + throw new SymphonyError('Failed to fetch registry from all configured URLs', 'network'); + } + + logger.info(`Merged registry: ${mergedRepos.length} repos from ${allUrls.length} sources`, LOG_CONTEXT); + + return { + schemaVersion: '1.0', + lastUpdated: new Date().toISOString(), + repositories: mergedRepos, + }; +} + /** * Fetch GitHub star counts for multiple repositories. * Uses concurrent requests with a concurrency limit to stay within rate limits. @@ -957,6 +986,7 @@ export function registerSymphonyHandlers({ app, getMainWindow, sessionsStore, + settingsStore, }: SymphonyHandlerDependencies): void { // ───────────────────────────────────────────────────────────────────────── // Registry Operations @@ -1045,9 +1075,10 @@ export function registerSymphonyHandlers({ }; } - // Fetch fresh data + // Fetch fresh data from all configured registries try { - const registry = await fetchRegistry(); + const customUrls = (settingsStore.get('symphonyRegistryUrls') as string[] | undefined) ?? []; + const registry = await fetchRegistries(customUrls); const enriched = await enrichWithStars(registry, cache, !!forceRefresh); // Update cache (enriched registry includes stars on repo objects, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index fa25cfb13..7b5417a8b 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -11494,7 +11494,7 @@ You are taking over this conversation. Based on the context above, provide a bri setLogViewerOpen, setProcessMonitorOpen, setUsageDashboardOpen, - setSymphonyModalOpen, + setSymphonyModalOpen: encoreFeatures.symphony ? setSymphonyModalOpen : undefined, setDirectorNotesOpen: encoreFeatures.directorNotes ? setDirectorNotesOpen : undefined, setGroups, setSessions, @@ -11777,7 +11777,7 @@ You are taking over this conversation. Based on the context above, provide a bri onCloseProcessMonitor={handleCloseProcessMonitor} onNavigateToSession={handleProcessMonitorNavigateToSession} onNavigateToGroupChat={handleProcessMonitorNavigateToGroupChat} - usageDashboardOpen={usageDashboardOpen} + usageDashboardOpen={encoreFeatures.usageStats && usageDashboardOpen} onCloseUsageDashboard={() => setUsageDashboardOpen(false)} defaultStatsTimeRange={defaultStatsTimeRange} colorBlindMode={colorBlindMode} @@ -11866,7 +11866,7 @@ You are taking over this conversation. Based on the context above, provide a bri setAboutModalOpen={setAboutModalOpen} setLogViewerOpen={setLogViewerOpen} setProcessMonitorOpen={setProcessMonitorOpen} - setUsageDashboardOpen={setUsageDashboardOpen} + setUsageDashboardOpen={encoreFeatures.usageStats ? setUsageDashboardOpen : undefined} setActiveRightTab={setActiveRightTab} setAgentSessionsOpen={setAgentSessionsOpen} setActiveAgentSessionId={setActiveAgentSessionId} @@ -11945,7 +11945,7 @@ You are taking over this conversation. Based on the context above, provide a bri getDocumentTaskCount={getDocumentTaskCount} onAutoRunRefresh={handleAutoRunRefresh} onOpenMarketplace={handleOpenMarketplace} - onOpenSymphony={() => setSymphonyModalOpen(true)} + onOpenSymphony={encoreFeatures.symphony ? () => setSymphonyModalOpen(true) : undefined} onOpenDirectorNotes={encoreFeatures.directorNotes ? () => setDirectorNotesOpen(true) : undefined} tabSwitcherOpen={tabSwitcherOpen} onCloseTabSwitcher={handleCloseTabSwitcher} @@ -12118,7 +12118,7 @@ You are taking over this conversation. Based on the context above, provide a bri )} {/* --- SYMPHONY MODAL (lazy-loaded) --- */} - {symphonyModalOpen && ( + {encoreFeatures.symphony && symphonyModalOpen && ( void; setLogViewerOpen: (open: boolean) => void; setProcessMonitorOpen: (open: boolean) => void; - setUsageDashboardOpen: (open: boolean) => void; + setUsageDashboardOpen?: (open: boolean) => void; setActiveRightTab: (tab: RightPanelTab) => void; setAgentSessionsOpen: (open: boolean) => void; setActiveAgentSessionId: (id: string | null) => void; @@ -1894,7 +1894,7 @@ export interface AppModalsProps { setAboutModalOpen: (open: boolean) => void; setLogViewerOpen: (open: boolean) => void; setProcessMonitorOpen: (open: boolean) => void; - setUsageDashboardOpen: (open: boolean) => void; + setUsageDashboardOpen?: (open: boolean) => void; setActiveRightTab: (tab: RightPanelTab) => void; setAgentSessionsOpen: (open: boolean) => void; setActiveAgentSessionId: (id: string | null) => void; diff --git a/src/renderer/components/QuickActionsModal.tsx b/src/renderer/components/QuickActionsModal.tsx index a22bb8e52..c4226fb22 100644 --- a/src/renderer/components/QuickActionsModal.tsx +++ b/src/renderer/components/QuickActionsModal.tsx @@ -50,7 +50,7 @@ interface QuickActionsModalProps { setAboutModalOpen: (open: boolean) => void; setLogViewerOpen: (open: boolean) => void; setProcessMonitorOpen: (open: boolean) => void; - setUsageDashboardOpen: (open: boolean) => void; + setUsageDashboardOpen?: (open: boolean) => void; setAgentSessionsOpen: (open: boolean) => void; setActiveAgentSessionId: (id: string | null) => void; setGitDiffPreview: (diff: string | null) => void; @@ -680,15 +680,19 @@ export function QuickActionsModal(props: QuickActionsModalProps) { setQuickActionOpen(false); }, }, - { - id: 'usageDashboard', - label: 'Usage Dashboard', - shortcut: shortcuts.usageDashboard, - action: () => { - setUsageDashboardOpen(true); - setQuickActionOpen(false); - }, - }, + ...(setUsageDashboardOpen + ? [ + { + id: 'usageDashboard', + label: 'Usage Dashboard', + shortcut: shortcuts.usageDashboard, + action: () => { + setUsageDashboardOpen(true); + setQuickActionOpen(false); + }, + }, + ] + : []), ...(activeSession && hasActiveSessionCapability?.('supportsSessionStorage') ? [ { diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx index e3f322375..4ff3578ad 100644 --- a/src/renderer/components/SessionList.tsx +++ b/src/renderer/components/SessionList.tsx @@ -439,8 +439,8 @@ interface HamburgerMenuContentProps { setSettingsTab: (tab: SettingsTab) => void; setLogViewerOpen: (open: boolean) => void; setProcessMonitorOpen: (open: boolean) => void; - setUsageDashboardOpen: (open: boolean) => void; - setSymphonyModalOpen: (open: boolean) => void; + setUsageDashboardOpen?: (open: boolean) => void; + setSymphonyModalOpen?: (open: boolean) => void; setDirectorNotesOpen?: (open: boolean) => void; setUpdateCheckModalOpen: (open: boolean) => void; setAboutModalOpen: (open: boolean) => void; @@ -655,52 +655,56 @@ function HamburgerMenuContent({ {formatShortcutKeys(shortcuts.processMonitor.keys)} - - + )} + {setSymphonyModalOpen && ( + + +
+
+ Maestro Symphony +
+
+ Contribute to open source +
+
+ + {shortcuts.openSymphony ? formatShortcutKeys(shortcuts.openSymphony.keys) : '⇧⌘Y'} + + + )} {setDirectorNotesOpen && ( - - - {/* Default Time Range */} -
- - -

- Time range shown when opening the Usage Dashboard. -

-
- - {/* Divider */} -
- - {/* Database Size Display */} -
- - Database size - - - {statsDbSize !== null - ? (statsDbSize / 1024 / 1024).toFixed(2) + ' MB' - : 'Loading...'} - {statsEarliestDate && ( - - {' '} - (since {statsEarliestDate}) - - )} - -
- - {/* Clear Old Data Dropdown */} -
- -
- - -
-

- Remove old query events, Auto Run sessions, and tasks from the stats database. -

-
- - {/* Clear Result Feedback */} - {statsClearResult && ( -
- {statsClearResult.success ? ( - <> - - - Cleared{' '} - {statsClearResult.deletedQueryEvents + - statsClearResult.deletedAutoRunSessions + - statsClearResult.deletedAutoRunTasks}{' '} - records ({statsClearResult.deletedQueryEvents} queries,{' '} - {statsClearResult.deletedAutoRunSessions} sessions,{' '} - {statsClearResult.deletedAutoRunTasks} tasks) - - - ) : ( - <> - - {statsClearResult.error || 'Failed to clear stats data'} - - )} -
- )} - - {/* Divider */} -
- - {/* WakaTime Integration */} -
-
-

- - Enable WakaTime tracking -

-

- Track coding activity in Maestro sessions via WakaTime. -

-
- -
- - {/* CLI not found warning */} - {wakatimeEnabled && wakatimeCliStatus && !wakatimeCliStatus.available && ( -

- WakaTime CLI is being installed automatically... -

- )} - - {/* API Key Input (only shown when enabled) */} - {wakatimeEnabled && ( -
- -
- - setWakatimeApiKey(e.target.value)} - onBlur={() => { - if (wakatimeApiKey) { - setWakatimeKeyValidating(true); - setWakatimeKeyValid(null); - window.maestro.wakatime - .validateApiKey(wakatimeApiKey) - .then((result) => setWakatimeKeyValid(result.valid)) - .catch(() => setWakatimeKeyValid(false)) - .finally(() => setWakatimeKeyValidating(false)); - } - }} - className="bg-transparent flex-1 text-sm outline-none" - style={{ color: theme.colors.textMain }} - placeholder="waka_..." - /> - {wakatimeKeyValidating && ( - ... - )} - {!wakatimeKeyValidating && wakatimeKeyValid === true && ( - - )} - {!wakatimeKeyValidating && wakatimeKeyValid === false && wakatimeApiKey && ( - - )} - {wakatimeApiKey && ( - - )} -
-

- Get your API key from wakatime.com/settings/api-key. Keys are stored locally - in ~/.maestro/settings.json. -

-
- )} -
-
- {/* Settings Storage Location */}
+ + {/* Usage & Stats Feature Section */} +
+ {/* Feature Toggle Header */} + + + {/* Usage & Stats Settings (shown when enabled) */} + {encoreFeatures.usageStats && ( +
+ {/* Enable/Disable Stats Collection */} +
+
+

+ Enable stats collection +

+

+ Track queries and Auto Run sessions for the dashboard. +

+
+ +
+ + {/* Default Time Range */} +
+ + +

+ Time range shown when opening the Usage Dashboard. +

+
+ + {/* Divider */} +
+ + {/* Database Size Display */} +
+ + Database size + + + {statsDbSize !== null + ? (statsDbSize / 1024 / 1024).toFixed(2) + ' MB' + : 'Loading...'} + {statsEarliestDate && ( + + {' '} + (since {statsEarliestDate}) + + )} + +
+ + {/* Clear Old Data Dropdown */} +
+ +
+ + +
+

+ Remove old query events, Auto Run sessions, and tasks from the stats database. +

+
+ + {/* Clear Result Feedback */} + {statsClearResult && ( +
+ {statsClearResult.success ? ( + <> + + + Cleared{' '} + {statsClearResult.deletedQueryEvents + + statsClearResult.deletedAutoRunSessions + + statsClearResult.deletedAutoRunTasks}{' '} + records ({statsClearResult.deletedQueryEvents} queries,{' '} + {statsClearResult.deletedAutoRunSessions} sessions,{' '} + {statsClearResult.deletedAutoRunTasks} tasks) + + + ) : ( + <> + + {statsClearResult.error || 'Failed to clear stats data'} + + )} +
+ )} + + {/* Divider */} +
+ + {/* WakaTime Integration */} +
+
+

+ + Enable WakaTime tracking +

+

+ Track coding activity in Maestro sessions via WakaTime. +

+
+ +
+ + {/* CLI not found warning */} + {wakatimeEnabled && wakatimeCliStatus && !wakatimeCliStatus.available && ( +

+ WakaTime CLI is being installed automatically... +

+ )} + + {/* API Key Input (only shown when enabled) */} + {wakatimeEnabled && ( +
+ +
+ + setWakatimeApiKey(e.target.value)} + onBlur={() => { + if (wakatimeApiKey) { + setWakatimeKeyValidating(true); + setWakatimeKeyValid(null); + window.maestro.wakatime + .validateApiKey(wakatimeApiKey) + .then((result) => setWakatimeKeyValid(result.valid)) + .catch(() => setWakatimeKeyValid(false)) + .finally(() => setWakatimeKeyValidating(false)); + } + }} + className="bg-transparent flex-1 text-sm outline-none" + style={{ color: theme.colors.textMain }} + placeholder="waka_..." + /> + {wakatimeKeyValidating && ( + ... + )} + {!wakatimeKeyValidating && wakatimeKeyValid === true && ( + + )} + {!wakatimeKeyValidating && wakatimeKeyValid === false && wakatimeApiKey && ( + + )} + {wakatimeApiKey && ( + + )} +
+

+ Get your API key from wakatime.com/settings/api-key. Keys are stored locally + in ~/.maestro/settings.json. +

+
+ )} +
+ )} +
+ + {/* Maestro Symphony Feature Section */} +
+ {/* Feature Toggle Header */} + + + {/* Registry URL Management (shown when enabled) */} + {encoreFeatures.symphony && ( +
+
+ +

+ Repositories are loaded from all configured registry URLs. The default registry cannot be removed. +

+ + {/* Default URL (immutable) */} +
+ + + {SYMPHONY_REGISTRY_URL} + + + default + +
+ + {/* Custom URLs list */} + {symphonyRegistryUrls.map((url) => ( +
+ {url} + +
+ ))} + + {/* Add new URL input */} +
+
+ { setNewRegistryUrl(e.target.value); setRegistryUrlError(null); }} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddRegistryUrl(); } }} + placeholder="https://example.com/registry.json" + className="w-full px-3 py-2 rounded text-sm font-mono outline-none" + style={{ + backgroundColor: theme.colors.bgActivity, + borderColor: registryUrlError ? theme.colors.error : theme.colors.border, + border: '1px solid', + color: theme.colors.textMain, + }} + /> + {registryUrlError && ( +

+ {registryUrlError} +

+ )} +
+ +
+
+
+ )} +
)}
diff --git a/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts b/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts index df5ec4ef6..e2e0e2cc1 100644 --- a/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts +++ b/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts @@ -396,11 +396,11 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn { e.preventDefault(); ctx.setProcessMonitorOpen(true); trackShortcut('processMonitor'); - } else if (ctx.isShortcut(e, 'usageDashboard')) { + } else if (ctx.isShortcut(e, 'usageDashboard') && ctx.encoreFeatures?.usageStats) { e.preventDefault(); ctx.setUsageDashboardOpen(true); trackShortcut('usageDashboard'); - } else if (ctx.isShortcut(e, 'openSymphony')) { + } else if (ctx.isShortcut(e, 'openSymphony') && ctx.encoreFeatures?.symphony) { e.preventDefault(); ctx.setSymphonyModalOpen(true); trackShortcut('openSymphony'); diff --git a/src/renderer/hooks/settings/useSettings.ts b/src/renderer/hooks/settings/useSettings.ts index c735919e6..35fb424d3 100644 --- a/src/renderer/hooks/settings/useSettings.ts +++ b/src/renderer/hooks/settings/useSettings.ts @@ -275,6 +275,10 @@ export interface UseSettingsReturn { encoreFeatures: EncoreFeatureFlags; setEncoreFeatures: (value: EncoreFeatureFlags) => void; + // Symphony registry URLs (additional user-configured registries) + symphonyRegistryUrls: string[]; + setSymphonyRegistryUrls: (value: string[]) => void; + // Director's Notes settings directorNotesSettings: DirectorNotesSettings; setDirectorNotesSettings: (value: DirectorNotesSettings) => void; diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts index 3b9f0f0d3..4a662eb64 100644 --- a/src/renderer/stores/settingsStore.ts +++ b/src/renderer/stores/settingsStore.ts @@ -118,6 +118,8 @@ export const DEFAULT_ONBOARDING_STATS: OnboardingStats = { export const DEFAULT_ENCORE_FEATURES: EncoreFeatureFlags = { directorNotes: false, + usageStats: true, + symphony: true, }; export const DEFAULT_DIRECTOR_NOTES_SETTINGS: DirectorNotesSettings = { @@ -242,6 +244,7 @@ export interface SettingsStoreState { fileTabAutoRefreshEnabled: boolean; suppressWindowsWarning: boolean; encoreFeatures: EncoreFeatureFlags; + symphonyRegistryUrls: string[]; directorNotesSettings: DirectorNotesSettings; wakatimeApiKey: string; wakatimeEnabled: boolean; @@ -305,6 +308,7 @@ export interface SettingsStoreActions { setFileTabAutoRefreshEnabled: (value: boolean) => void; setSuppressWindowsWarning: (value: boolean) => void; setEncoreFeatures: (value: EncoreFeatureFlags) => void; + setSymphonyRegistryUrls: (value: string[]) => void; setDirectorNotesSettings: (value: DirectorNotesSettings) => void; setWakatimeApiKey: (value: string) => void; setWakatimeEnabled: (value: boolean) => void; @@ -444,6 +448,7 @@ export const useSettingsStore = create()((set, get) => ({ fileTabAutoRefreshEnabled: false, suppressWindowsWarning: false, encoreFeatures: DEFAULT_ENCORE_FEATURES, + symphonyRegistryUrls: [], directorNotesSettings: DEFAULT_DIRECTOR_NOTES_SETTINGS, wakatimeApiKey: '', wakatimeEnabled: false, @@ -740,6 +745,11 @@ export const useSettingsStore = create()((set, get) => ({ window.maestro.settings.set('encoreFeatures', value); }, + setSymphonyRegistryUrls: (value) => { + set({ symphonyRegistryUrls: value }); + window.maestro.settings.set('symphonyRegistryUrls', value); + }, + setDirectorNotesSettings: (value) => { set({ directorNotesSettings: value }); window.maestro.settings.set('directorNotesSettings', value); @@ -1612,6 +1622,10 @@ export async function loadAllSettings(): Promise { }; } + // Symphony registry URLs (additional user-configured registries) + if (allSettings['symphonyRegistryUrls'] !== undefined) + patch.symphonyRegistryUrls = allSettings['symphonyRegistryUrls'] as string[]; + // Director's Notes settings (merge with defaults to preserve new fields) if (allSettings['directorNotesSettings'] !== undefined) { patch.directorNotesSettings = { diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index a195552b0..30d9031d0 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -905,6 +905,8 @@ export interface LeaderboardSubmitResponse { // Each key is a feature ID, value indicates whether it's enabled export interface EncoreFeatureFlags { directorNotes: boolean; + usageStats: boolean; + symphony: boolean; } // Director's Notes settings for synopsis generation From 222e20113daf93360fa88143552fc8fbc17110d2 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Wed, 4 Mar 2026 15:23:10 -0800 Subject: [PATCH 2/5] fix: address CodeRabbit PR review findings for encore-features - Redact registry URLs before logging to prevent credential leakage - Skip registry cache when custom source URLs are configured (stale cache fix) - Runtime-validate symphonyRegistryUrls from settings store - Reset modal-open flags when Encore Feature toggles are disabled - Normalize registry URLs before duplicate/default checks - Add aria-label to icon-only registry URL remove button - Expose setSymphonyRegistryUrls in getSettingsActions() - Validate persisted symphonyRegistryUrls with Array.isArray guard --- src/main/ipc/handlers/symphony.ts | 37 ++++++++++++++++--- src/renderer/App.tsx | 9 +++++ .../components/Settings/tabs/EncoreTab.tsx | 28 +++++++++++--- src/renderer/stores/settingsStore.ts | 8 +++- 4 files changed, 69 insertions(+), 13 deletions(-) diff --git a/src/main/ipc/handlers/symphony.ts b/src/main/ipc/handlers/symphony.ts index 524b8a6af..e2e2652d7 100644 --- a/src/main/ipc/handlers/symphony.ts +++ b/src/main/ipc/handlers/symphony.ts @@ -385,22 +385,39 @@ function parseDocumentPaths(body: string): DocumentReference[] { * Fetch a single symphony registry from a URL. * Returns null on failure instead of throwing (isolated error handling per URL). */ +/** + * Redact a URL for safe logging — strips credentials, query params, and fragments. + */ +function redactUrlForLog(rawUrl: string): string { + try { + const parsed = new URL(rawUrl); + parsed.username = ''; + parsed.password = ''; + parsed.search = ''; + parsed.hash = ''; + return parsed.toString(); + } catch { + return '[invalid-url]'; + } +} + async function fetchSingleRegistry(url: string): Promise { + const safeUrl = redactUrlForLog(url); try { const response = await fetch(url); if (!response.ok) { - logger.warn(`Failed to fetch registry from ${url}: ${response.status}`, LOG_CONTEXT); + logger.warn(`Failed to fetch registry from ${safeUrl}: ${response.status}`, LOG_CONTEXT); return null; } const data = (await response.json()) as SymphonyRegistry; if (!data.repositories || !Array.isArray(data.repositories)) { - logger.warn(`Invalid registry structure from ${url}`, LOG_CONTEXT); + logger.warn(`Invalid registry structure from ${safeUrl}`, LOG_CONTEXT); return null; } - logger.info(`Fetched ${data.repositories.length} repos from ${url}`, LOG_CONTEXT); + logger.info(`Fetched ${data.repositories.length} repos from ${safeUrl}`, LOG_CONTEXT); return data; } catch (error) { - logger.warn(`Network error fetching registry from ${url}: ${error instanceof Error ? error.message : String(error)}`, LOG_CONTEXT); + logger.warn(`Network error fetching registry from ${safeUrl}: ${error instanceof Error ? error.message : String(error)}`, LOG_CONTEXT); return null; } } @@ -1118,9 +1135,20 @@ export function registerSymphonyHandlers({ async (forceRefresh?: boolean): Promise> => { const cache = await readCache(app); + // Runtime-validate custom URLs from settings + const rawCustomUrls = settingsStore.get('symphonyRegistryUrls'); + const customUrls = Array.isArray(rawCustomUrls) + ? rawCustomUrls.filter((u): u is string => typeof u === 'string' && u.trim().length > 0) + : []; + + // Skip cache when custom sources are configured — cache doesn't track + // which source URLs produced it, so URL changes would serve stale data. + const hasCustomSources = customUrls.length > 0; + // Check cache validity if ( !forceRefresh && + !hasCustomSources && cache?.registry && isCacheValid(cache.registry.fetchedAt, REGISTRY_CACHE_TTL_MS) ) { @@ -1134,7 +1162,6 @@ export function registerSymphonyHandlers({ // Fetch fresh data from all configured registries try { - const customUrls = (settingsStore.get('symphonyRegistryUrls') as string[] | undefined) ?? []; const registry = await fetchRegistries(customUrls); const enriched = await enrichWithStars(registry, cache, !!forceRefresh); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0d585303a..1e36dca0a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -415,6 +415,15 @@ function MaestroConsoleInner() { encoreFeatures, } = settings; + // Reset modal-open flags when their Encore Feature toggle is disabled + useEffect(() => { + if (!encoreFeatures.symphony) setSymphonyModalOpen(false); + }, [encoreFeatures.symphony, setSymphonyModalOpen]); + + useEffect(() => { + if (!encoreFeatures.usageStats) setUsageDashboardOpen(false); + }, [encoreFeatures.usageStats, setUsageDashboardOpen]); + // --- KEYBOARD SHORTCUT HELPERS --- const { isShortcut, isTabShortcut } = useKeyboardShortcutHelpers({ shortcuts, diff --git a/src/renderer/components/Settings/tabs/EncoreTab.tsx b/src/renderer/components/Settings/tabs/EncoreTab.tsx index efb055a2b..8e3a924c7 100644 --- a/src/renderer/components/Settings/tabs/EncoreTab.tsx +++ b/src/renderer/components/Settings/tabs/EncoreTab.tsx @@ -47,31 +47,46 @@ export function EncoreTab({ theme, isOpen }: EncoreTabProps) { const [newRegistryUrl, setNewRegistryUrl] = useState(''); const [registryUrlError, setRegistryUrlError] = useState(null); + const canonicalizeUrl = (raw: string): string => { + const u = new URL(raw.trim()); + u.hash = ''; + return u.href; + }; + const handleAddRegistryUrl = () => { const trimmed = newRegistryUrl.trim(); if (!trimmed) { setRegistryUrlError('URL cannot be empty'); return; } + let canonical: string; try { const parsed = new URL(trimmed); if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { setRegistryUrlError('URL must use HTTP or HTTPS'); return; } + canonical = canonicalizeUrl(trimmed); } catch { setRegistryUrlError('Invalid URL format'); return; } - if (trimmed === SYMPHONY_REGISTRY_URL) { - setRegistryUrlError('This is the default registry URL'); - return; - } - if (symphonyRegistryUrls.includes(trimmed)) { + try { + if (canonical === canonicalizeUrl(SYMPHONY_REGISTRY_URL)) { + setRegistryUrlError('This is the default registry URL'); + return; + } + } catch { /* default URL should always parse */ } + const existing = new Set( + symphonyRegistryUrls.map((u) => { + try { return canonicalizeUrl(u); } catch { return u.trim(); } + }) + ); + if (existing.has(canonical)) { setRegistryUrlError('URL already added'); return; } - setSymphonyRegistryUrls([...symphonyRegistryUrls, trimmed]); + setSymphonyRegistryUrls([...symphonyRegistryUrls, canonical]); setNewRegistryUrl(''); setRegistryUrlError(null); }; @@ -629,6 +644,7 @@ export function EncoreTab({ theme, isOpen }: EncoreTabProps) { className="p-0.5 rounded hover:bg-white/10 transition-colors flex-shrink-0" style={{ color: theme.colors.error }} title="Remove registry URL" + aria-label={`Remove registry URL ${url}`} > diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts index fc2744d0c..13b7e52ed 100644 --- a/src/renderer/stores/settingsStore.ts +++ b/src/renderer/stores/settingsStore.ts @@ -1707,8 +1707,11 @@ export async function loadAllSettings(): Promise { } // Symphony registry URLs (additional user-configured registries) - if (allSettings['symphonyRegistryUrls'] !== undefined) - patch.symphonyRegistryUrls = allSettings['symphonyRegistryUrls'] as string[]; + if (Array.isArray(allSettings['symphonyRegistryUrls'])) { + patch.symphonyRegistryUrls = (allSettings['symphonyRegistryUrls'] as unknown[]) + .filter((v): v is string => typeof v === 'string' && v.trim().length > 0) + .map((v) => v.trim()); + } // Director's Notes settings (merge with defaults to preserve new fields) if (allSettings['directorNotesSettings'] !== undefined) { @@ -1841,6 +1844,7 @@ export function getSettingsActions() { setSuppressWindowsWarning: state.setSuppressWindowsWarning, setAutoScrollAiMode: state.setAutoScrollAiMode, setEncoreFeatures: state.setEncoreFeatures, + setSymphonyRegistryUrls: state.setSymphonyRegistryUrls, setDirectorNotesSettings: state.setDirectorNotesSettings, setWakatimeApiKey: state.setWakatimeApiKey, setWakatimeEnabled: state.setWakatimeEnabled, From 8910615b28d92e7e710e388be9201083fb1411f5 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 5 Mar 2026 22:11:33 -0600 Subject: [PATCH 3/5] refactor: move Usage & Stats settings from General tab to Encore Features tab Stats collection, time range, database management, and WakaTime settings now live under their respective Encore Feature toggle instead of General. --- .../Settings/tabs/EncoreTab.test.tsx | 24 + .../Settings/tabs/GeneralTab.test.tsx | 295 +----------- .../components/SettingsModal.test.tsx | 137 ------ .../components/Settings/tabs/EncoreTab.tsx | 432 ++++++++++++++++- .../components/Settings/tabs/GeneralTab.tsx | 451 +----------------- 5 files changed, 453 insertions(+), 886 deletions(-) diff --git a/src/__tests__/renderer/components/Settings/tabs/EncoreTab.test.tsx b/src/__tests__/renderer/components/Settings/tabs/EncoreTab.test.tsx index 6378957b4..7bd5a17fa 100644 --- a/src/__tests__/renderer/components/Settings/tabs/EncoreTab.test.tsx +++ b/src/__tests__/renderer/components/Settings/tabs/EncoreTab.test.tsx @@ -87,6 +87,11 @@ vi.mock('../../../../../renderer/components/Wizard/screens/AgentSelectionScreen' // Shared mock fns for useSettings setters const mockSetEncoreFeatures = vi.fn(); const mockSetDirectorNotesSettings = vi.fn(); +const mockSetStatsCollectionEnabled = vi.fn(); +const mockSetDefaultStatsTimeRange = vi.fn(); +const mockSetWakatimeEnabled = vi.fn(); +const mockSetWakatimeApiKey = vi.fn(); +const mockSetWakatimeDetailedTracking = vi.fn(); // Override mechanism for per-test customization let mockUseSettingsOverrides: Record = {}; @@ -100,6 +105,21 @@ vi.mock('../../../../../renderer/hooks/settings/useSettings', () => ({ defaultLookbackDays: 7, }, setDirectorNotesSettings: mockSetDirectorNotesSettings, + // Stats + statsCollectionEnabled: true, + setStatsCollectionEnabled: mockSetStatsCollectionEnabled, + defaultStatsTimeRange: 'week', + setDefaultStatsTimeRange: mockSetDefaultStatsTimeRange, + // WakaTime + wakatimeEnabled: false, + setWakatimeEnabled: mockSetWakatimeEnabled, + wakatimeApiKey: '', + setWakatimeApiKey: mockSetWakatimeApiKey, + wakatimeDetailedTracking: false, + setWakatimeDetailedTracking: mockSetWakatimeDetailedTracking, + // Symphony + symphonyRegistryUrls: [], + setSymphonyRegistryUrls: vi.fn(), ...mockUseSettingsOverrides, }), })); @@ -170,6 +190,10 @@ describe('EncoreTab', () => { vi.mocked(window.maestro.agents.getConfig).mockResolvedValue({}); vi.mocked(window.maestro.agents.setConfig).mockResolvedValue(undefined); vi.mocked(window.maestro.agents.getModels).mockResolvedValue([]); + vi.mocked(window.maestro.stats.getDatabaseSize).mockResolvedValue(1024 * 1024); + vi.mocked(window.maestro.stats.getEarliestTimestamp).mockResolvedValue(null); + vi.mocked(window.maestro.wakatime.checkCli).mockResolvedValue({ available: false }); + vi.mocked(window.maestro.wakatime.validateApiKey).mockResolvedValue({ valid: false }); }); afterEach(() => { diff --git a/src/__tests__/renderer/components/Settings/tabs/GeneralTab.test.tsx b/src/__tests__/renderer/components/Settings/tabs/GeneralTab.test.tsx index ed7288383..264388d46 100644 --- a/src/__tests__/renderer/components/Settings/tabs/GeneralTab.test.tsx +++ b/src/__tests__/renderer/components/Settings/tabs/GeneralTab.test.tsx @@ -17,8 +17,6 @@ * - Rendering options (GPU acceleration, confetti) * - Update settings (check on startup, beta updates) * - Crash reporting toggle - * - Stats collection toggle and time range selector - * - WakaTime integration (toggle, API key, detailed tracking, CLI check) * - Storage location display */ @@ -53,11 +51,6 @@ const mockSetDisableConfetti = vi.fn(); const mockSetCheckForUpdatesOnStartup = vi.fn(); const mockSetEnableBetaUpdates = vi.fn(); const mockSetCrashReportingEnabled = vi.fn(); -const mockSetStatsCollectionEnabled = vi.fn(); -const mockSetDefaultStatsTimeRange = vi.fn(); -const mockSetWakatimeEnabled = vi.fn(); -const mockSetWakatimeApiKey = vi.fn(); -const mockSetWakatimeDetailedTracking = vi.fn(); // Allow per-test overrides of settings let mockUseSettingsOverrides: Record = {}; @@ -109,18 +102,6 @@ vi.mock('../../../../../renderer/hooks/settings/useSettings', () => ({ setEnableBetaUpdates: mockSetEnableBetaUpdates, crashReportingEnabled: true, setCrashReportingEnabled: mockSetCrashReportingEnabled, - // Stats - statsCollectionEnabled: true, - setStatsCollectionEnabled: mockSetStatsCollectionEnabled, - defaultStatsTimeRange: 'week', - setDefaultStatsTimeRange: mockSetDefaultStatsTimeRange, - // WakaTime - wakatimeEnabled: false, - setWakatimeEnabled: mockSetWakatimeEnabled, - wakatimeApiKey: '', - setWakatimeApiKey: mockSetWakatimeApiKey, - wakatimeDetailedTracking: false, - setWakatimeDetailedTracking: mockSetWakatimeDetailedTracking, ...mockUseSettingsOverrides, }), })); @@ -158,10 +139,6 @@ describe('GeneralTab', () => { // Reset window.maestro mocks vi.mocked(window.maestro.shells.detect).mockResolvedValue(mockShells); - vi.mocked(window.maestro.wakatime.checkCli).mockResolvedValue({ available: false }); - vi.mocked(window.maestro.wakatime.validateApiKey).mockResolvedValue({ valid: false }); - vi.mocked(window.maestro.stats.getDatabaseSize).mockResolvedValue(1024 * 1024); - vi.mocked(window.maestro.stats.getEarliestTimestamp).mockResolvedValue(null); vi.mocked(window.maestro.sync.getDefaultPath).mockResolvedValue('/default/path'); vi.mocked(window.maestro.sync.getSettings).mockResolvedValue({ customSyncPath: undefined, @@ -200,7 +177,6 @@ describe('GeneralTab', () => { expect(screen.getByText('Updates')).toBeInTheDocument(); expect(screen.getByText('Pre-release Channel')).toBeInTheDocument(); expect(screen.getByText('Privacy')).toBeInTheDocument(); - expect(screen.getByText('Usage & Stats')).toBeInTheDocument(); expect(screen.getByText('Storage Location')).toBeInTheDocument(); }); @@ -212,9 +188,8 @@ describe('GeneralTab', () => { }); // The component still renders its JSX, but effects that fetch data won't fire - // Verify the sync/stats load effects didn't run + // Verify the sync load effects didn't run expect(window.maestro.sync.getDefaultPath).not.toHaveBeenCalled(); - expect(window.maestro.stats.getDatabaseSize).not.toHaveBeenCalled(); }); }); @@ -1319,274 +1294,6 @@ describe('GeneralTab', () => { }); }); - // ========================================================================= - // 18. WakaTime - // ========================================================================= - describe('WakaTime', () => { - it('should render WakaTime enable toggle', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - expect(screen.getByText('Enable WakaTime tracking')).toBeInTheDocument(); - }); - - it('should call setWakatimeEnabled when toggle is clicked', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - // The WakaTime toggle is a standalone switch (not wrapped in SettingCheckbox). - // Find it via aria-checked attribute on the WakaTime section's switch. - // Since wakatimeEnabled is false by default, find the switch with aria-checked=false - // that is adjacent to the WakaTime label. - const titleElement = screen.getByText('Enable WakaTime tracking'); - // Walk up from

->

->
- const outerContainer = titleElement.parentElement?.parentElement; - const toggleSwitch = outerContainer?.querySelector('button[role="switch"]'); - - fireEvent.click(toggleSwitch!); - expect(mockSetWakatimeEnabled).toHaveBeenCalledWith(true); - }); - - it('should show API key input when WakaTime is enabled', async () => { - mockUseSettingsOverrides = { wakatimeEnabled: true }; - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - expect(screen.getByPlaceholderText('waka_...')).toBeInTheDocument(); - }); - - it('should not show API key input when WakaTime is disabled', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - expect(screen.queryByPlaceholderText('waka_...')).not.toBeInTheDocument(); - }); - - it('should call setWakatimeApiKey when API key input changes', async () => { - mockUseSettingsOverrides = { wakatimeEnabled: true }; - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - const apiKeyInput = screen.getByPlaceholderText('waka_...'); - fireEvent.change(apiKeyInput, { target: { value: 'waka_test123' } }); - - expect(mockSetWakatimeApiKey).toHaveBeenCalledWith('waka_test123'); - }); - - it('should show detailed tracking toggle when WakaTime is enabled', async () => { - mockUseSettingsOverrides = { wakatimeEnabled: true }; - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - expect(screen.getByText('Detailed file tracking')).toBeInTheDocument(); - }); - - it('should call setWakatimeDetailedTracking when toggle is clicked', async () => { - mockUseSettingsOverrides = { wakatimeEnabled: true }; - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - const titleElement = screen.getByText('Detailed file tracking'); - const parentDiv = titleElement.closest('.flex'); - const toggleSwitch = parentDiv?.querySelector('button[role="switch"]'); - - fireEvent.click(toggleSwitch!); - expect(mockSetWakatimeDetailedTracking).toHaveBeenCalledWith(true); - }); - - it('should check WakaTime CLI when enabled and modal is open', async () => { - mockUseSettingsOverrides = { wakatimeEnabled: true }; - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - expect(window.maestro.wakatime.checkCli).toHaveBeenCalled(); - }); - - it('should not check WakaTime CLI when disabled', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - expect(window.maestro.wakatime.checkCli).not.toHaveBeenCalled(); - }); - - it('should show CLI installing message when CLI is not available', async () => { - mockUseSettingsOverrides = { wakatimeEnabled: true }; - vi.mocked(window.maestro.wakatime.checkCli).mockResolvedValue({ available: false }); - - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - expect( - screen.getByText('WakaTime CLI is being installed automatically...') - ).toBeInTheDocument(); - }); - - it('should not show CLI installing message when CLI is available', async () => { - mockUseSettingsOverrides = { wakatimeEnabled: true }; - vi.mocked(window.maestro.wakatime.checkCli).mockResolvedValue({ - available: true, - version: '1.0.0', - }); - - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - expect( - screen.queryByText('WakaTime CLI is being installed automatically...') - ).not.toBeInTheDocument(); - }); - - it('should validate API key on blur', async () => { - mockUseSettingsOverrides = { - wakatimeEnabled: true, - wakatimeApiKey: 'waka_test123', - }; - vi.mocked(window.maestro.wakatime.validateApiKey).mockResolvedValue({ valid: true }); - - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - const apiKeyInput = screen.getByPlaceholderText('waka_...'); - fireEvent.blur(apiKeyInput); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - expect(window.maestro.wakatime.validateApiKey).toHaveBeenCalledWith('waka_test123'); - }); - }); - - // ========================================================================= - // 19. Stats Collection - // ========================================================================= - describe('Stats Collection', () => { - it('should render stats collection toggle', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - expect(screen.getByText('Enable stats collection')).toBeInTheDocument(); - }); - - it('should call setStatsCollectionEnabled when toggle is clicked', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - const titleElement = screen.getByText('Enable stats collection'); - const parentDiv = titleElement.closest('.flex'); - const toggleSwitch = parentDiv?.querySelector('button[role="switch"]'); - - fireEvent.click(toggleSwitch!); - expect(mockSetStatsCollectionEnabled).toHaveBeenCalledWith(false); - }); - - it('should render time range select', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - expect(screen.getByText('Default dashboard time range')).toBeInTheDocument(); - const select = screen.getByDisplayValue('Last 7 days') as HTMLSelectElement; - expect(select).toBeInTheDocument(); - }); - - it('should call setDefaultStatsTimeRange when time range is changed', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - const select = screen.getByDisplayValue('Last 7 days'); - fireEvent.change(select, { target: { value: 'month' } }); - - expect(mockSetDefaultStatsTimeRange).toHaveBeenCalledWith('month'); - }); - - it('should show all time range options', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - expect(screen.getByText('Last 24 hours')).toBeInTheDocument(); - expect(screen.getByText('Last 7 days')).toBeInTheDocument(); - expect(screen.getByText('Last 30 days')).toBeInTheDocument(); - expect(screen.getByText('Last 365 days')).toBeInTheDocument(); - expect(screen.getByText('All time')).toBeInTheDocument(); - }); - - it('should display database size', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - expect(screen.getByText('Database size')).toBeInTheDocument(); - // 1024*1024 bytes = 1.00 MB - expect(screen.getByText(/1\.00 MB/)).toBeInTheDocument(); - }); - - it('should display Loading... when stats size is not yet loaded', async () => { - vi.mocked(window.maestro.stats.getDatabaseSize).mockImplementation( - () => new Promise(() => {}) // never resolves - ); - - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - expect(screen.getByText('Loading...')).toBeInTheDocument(); - }); - }); // ========================================================================= // 20. Shell Detection Failure diff --git a/src/__tests__/renderer/components/SettingsModal.test.tsx b/src/__tests__/renderer/components/SettingsModal.test.tsx index a5d2c1bbc..d2a11fa4a 100644 --- a/src/__tests__/renderer/components/SettingsModal.test.tsx +++ b/src/__tests__/renderer/components/SettingsModal.test.tsx @@ -2076,143 +2076,6 @@ describe('SettingsModal', () => { }); }); - describe('WakaTime CLI status', () => { - it('should not check CLI when WakaTime is disabled', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - expect(window.maestro.wakatime.checkCli).not.toHaveBeenCalled(); - }); - - it('should check CLI when WakaTime is enabled', async () => { - mockUseSettingsOverrides = { wakatimeEnabled: true }; - vi.mocked(window.maestro.wakatime.checkCli).mockResolvedValue({ - available: true, - version: '1.0.0', - }); - - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - expect(window.maestro.wakatime.checkCli).toHaveBeenCalled(); - }); - - it('should show auto-install message when CLI is not available', async () => { - mockUseSettingsOverrides = { wakatimeEnabled: true }; - vi.mocked(window.maestro.wakatime.checkCli).mockResolvedValue({ available: false }); - - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - expect( - screen.getByText('WakaTime CLI is being installed automatically...') - ).toBeInTheDocument(); - }); - - it('should retry CLI check after 3 seconds when first check returns unavailable', async () => { - mockUseSettingsOverrides = { wakatimeEnabled: true }; - vi.mocked(window.maestro.wakatime.checkCli) - .mockResolvedValueOnce({ available: false }) - .mockResolvedValueOnce({ available: true, version: '1.0.0' }); - - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - // First check should have been called - expect(window.maestro.wakatime.checkCli).toHaveBeenCalledTimes(1); - - // Advance to trigger retry - await act(async () => { - await vi.advanceTimersByTimeAsync(3000); - }); - - // Second check should have been called - expect(window.maestro.wakatime.checkCli).toHaveBeenCalledTimes(2); - }); - - it('should not retry CLI check when first check returns available', async () => { - mockUseSettingsOverrides = { wakatimeEnabled: true }; - vi.mocked(window.maestro.wakatime.checkCli).mockResolvedValue({ - available: true, - version: '1.0.0', - }); - - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - expect(window.maestro.wakatime.checkCli).toHaveBeenCalledTimes(1); - - // Advance past retry timeout - await act(async () => { - await vi.advanceTimersByTimeAsync(3000); - }); - - // Should still be 1 — no retry needed - expect(window.maestro.wakatime.checkCli).toHaveBeenCalledTimes(1); - }); - - it('should not show auto-install message when CLI is available', async () => { - mockUseSettingsOverrides = { wakatimeEnabled: true }; - vi.mocked(window.maestro.wakatime.checkCli).mockResolvedValue({ - available: true, - version: '1.0.0', - }); - - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - expect( - screen.queryByText('WakaTime CLI is being installed automatically...') - ).not.toBeInTheDocument(); - }); - - it('should retry on error and update status after retry succeeds', async () => { - mockUseSettingsOverrides = { wakatimeEnabled: true }; - vi.mocked(window.maestro.wakatime.checkCli) - .mockRejectedValueOnce(new Error('Network error')) - .mockResolvedValueOnce({ available: true, version: '1.0.0' }); - - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - // Should show the auto-install message after error - expect( - screen.getByText('WakaTime CLI is being installed automatically...') - ).toBeInTheDocument(); - - // Advance to trigger retry - await act(async () => { - await vi.advanceTimersByTimeAsync(3000); - }); - - // After retry succeeds, message should disappear - expect( - screen.queryByText('WakaTime CLI is being installed automatically...') - ).not.toBeInTheDocument(); - }); - }); - describe('Shell selection with mouseEnter and focus', () => { it('should load shells on mouseEnter', async () => { render(); diff --git a/src/renderer/components/Settings/tabs/EncoreTab.tsx b/src/renderer/components/Settings/tabs/EncoreTab.tsx index 8e3a924c7..159cea0f7 100644 --- a/src/renderer/components/Settings/tabs/EncoreTab.tsx +++ b/src/renderer/components/Settings/tabs/EncoreTab.tsx @@ -2,11 +2,12 @@ * EncoreTab - Encore Features settings tab for SettingsModal * * Contains: Feature flags for optional/experimental Maestro capabilities, - * Director's Notes configuration (provider selection, agent config, lookback period). + * Director's Notes configuration (provider selection, agent config, lookback period), + * Usage & Stats configuration (stats collection, time ranges, WakaTime integration). */ -import { useState, useEffect, useRef } from 'react'; -import { Clapperboard, ChevronDown, Settings, Check, Database, Music, Lock, Plus, X } from 'lucide-react'; +import { useState, useEffect, useRef, useCallback } from 'react'; +import { Clapperboard, ChevronDown, Settings, Check, Database, Music, Lock, Plus, X, Timer, Key, Trash2 } from 'lucide-react'; import { useSettings } from '../../../hooks'; import { SYMPHONY_REGISTRY_URL } from '../../../../shared/symphony-constants'; import type { Theme, AgentConfig, ToolType } from '../../../types'; @@ -26,6 +27,18 @@ export function EncoreTab({ theme, isOpen }: EncoreTabProps) { setDirectorNotesSettings, symphonyRegistryUrls, setSymphonyRegistryUrls, + // Stats + statsCollectionEnabled, + setStatsCollectionEnabled, + defaultStatsTimeRange, + setDefaultStatsTimeRange, + // WakaTime + wakatimeEnabled, + setWakatimeEnabled, + wakatimeApiKey, + setWakatimeApiKey, + wakatimeDetailedTracking, + setWakatimeDetailedTracking, } = useSettings(); // Director's Notes agent configuration state @@ -43,10 +56,117 @@ export function EncoreTab({ theme, isOpen }: EncoreTabProps) { const [dnRefreshingAgent, setDnRefreshingAgent] = useState(false); const dnAgentConfigRef = useRef>({}); + // Stats data management state + const [statsDbSize, setStatsDbSize] = useState(null); + const [statsEarliestDate, setStatsEarliestDate] = useState(null); + const [statsClearing, setStatsClearing] = useState(false); + const [statsClearResult, setStatsClearResult] = useState<{ + success: boolean; + deletedQueryEvents: number; + deletedAutoRunSessions: number; + deletedAutoRunTasks: number; + error?: string; + } | null>(null); + + // WakaTime CLI check and API key validation state + const [wakatimeCliStatus, setWakatimeCliStatus] = useState<{ + available: boolean; + version?: string; + } | null>(null); + const [wakatimeKeyValid, setWakatimeKeyValid] = useState(null); + const [wakatimeKeyValidating, setWakatimeKeyValidating] = useState(false); + const handleWakatimeApiKeyChange = useCallback( + (value: string) => { + setWakatimeApiKey(value); + setWakatimeKeyValid(null); + }, + [setWakatimeApiKey] + ); + // Symphony registry URL management const [newRegistryUrl, setNewRegistryUrl] = useState(''); const [registryUrlError, setRegistryUrlError] = useState(null); + // Check WakaTime CLI availability when section renders or toggle is enabled + useEffect(() => { + if (!isOpen || !wakatimeEnabled) return; + let cancelled = false; + let retryTimer: ReturnType | null = null; + + window.maestro.wakatime + .checkCli() + .then((status) => { + if (cancelled) return; + setWakatimeCliStatus(status); + if (!status.available) { + retryTimer = setTimeout(() => { + if (!cancelled) { + window.maestro.wakatime + .checkCli() + .then((retryStatus) => { + if (!cancelled) setWakatimeCliStatus(retryStatus); + }) + .catch(() => { + if (!cancelled) setWakatimeCliStatus({ available: false }); + }); + } + }, 3000); + } + }) + .catch(() => { + if (cancelled) return; + setWakatimeCliStatus({ available: false }); + retryTimer = setTimeout(() => { + if (!cancelled) { + window.maestro.wakatime + .checkCli() + .then((retryStatus) => { + if (!cancelled) setWakatimeCliStatus(retryStatus); + }) + .catch(() => { + if (!cancelled) setWakatimeCliStatus({ available: false }); + }); + } + }, 3000); + }); + + return () => { + cancelled = true; + if (retryTimer) clearTimeout(retryTimer); + }; + }, [isOpen, wakatimeEnabled]); + + // Load stats database size and earliest timestamp when tab opens + useEffect(() => { + if (!isOpen) return; + + window.maestro.stats + .getDatabaseSize() + .then((size) => { + setStatsDbSize(size); + }) + .catch((err) => { + console.error('Failed to load stats database size:', err); + }); + + window.maestro.stats + .getEarliestTimestamp() + .then((timestamp) => { + if (timestamp) { + const date = new Date(timestamp); + const formatted = date.toISOString().split('T')[0]; + setStatsEarliestDate(formatted); + } else { + setStatsEarliestDate(null); + } + }) + .catch((err) => { + console.error('Failed to load earliest stats timestamp:', err); + }); + + setStatsClearResult(null); + }, [isOpen]); + const canonicalizeUrl = (raw: string): string => { const u = new URL(raw.trim()); u.hash = ''; @@ -563,10 +683,308 @@ export function EncoreTab({ theme, isOpen }: EncoreTabProps) { {encoreFeatures.usageStats && ( -
-

- Configure stats collection, time ranges, and WakaTime in the General tab. -

+
+ {/* Enable/Disable Stats Collection */} +
+
+

+ Enable stats collection +

+

+ Track queries and Auto Run sessions for the dashboard. +

+
+ +
+ + {/* Default Time Range */} +
+
Default dashboard time range
+ +

+ Time range shown when opening the Usage Dashboard. +

+
+ + {/* Divider */} +
+ + {/* Database Size Display */} +
+ + Database size + + + {statsDbSize !== null ? (statsDbSize / 1024 / 1024).toFixed(2) + ' MB' : 'Loading...'} + {statsEarliestDate && ( + (since {statsEarliestDate}) + )} + +
+ + {/* Clear Old Data Dropdown */} +
+
Clear stats older than...
+
+ + +
+

+ Remove old query events, Auto Run sessions, and tasks from the stats database. +

+
+ + {/* Clear Result Feedback */} + {statsClearResult && ( +
+ {statsClearResult.success ? ( + <> + + + Cleared{' '} + {statsClearResult.deletedQueryEvents + + statsClearResult.deletedAutoRunSessions + + statsClearResult.deletedAutoRunTasks}{' '} + records ({statsClearResult.deletedQueryEvents} queries,{' '} + {statsClearResult.deletedAutoRunSessions} sessions,{' '} + {statsClearResult.deletedAutoRunTasks} tasks) + + + ) : ( + <> + + {statsClearResult.error || 'Failed to clear stats data'} + + )} +
+ )} + + {/* Divider */} +
+ + {/* WakaTime Integration */} +
+
+

+ + Enable WakaTime tracking +

+

+ Track coding activity in Maestro sessions via WakaTime. +

+
+ +
+ + {/* CLI not found warning */} + {wakatimeEnabled && wakatimeCliStatus && !wakatimeCliStatus.available && ( +

+ WakaTime CLI is being installed automatically... +

+ )} + + {/* Detailed file tracking toggle (only shown when enabled) */} + {wakatimeEnabled && ( +
+
+

+ Detailed file tracking +

+

+ Track per-file write activity. Sends file paths (not content) to WakaTime. +

+
+ +
+ )} + + {/* API Key Input (only shown when enabled) */} + {wakatimeEnabled && ( +
+
API Key
+
+ + handleWakatimeApiKeyChange(e.target.value)} + onBlur={() => { + if (wakatimeApiKey) { + setWakatimeKeyValidating(true); + setWakatimeKeyValid(null); + window.maestro.wakatime + .validateApiKey(wakatimeApiKey) + .then((result) => setWakatimeKeyValid(result.valid)) + .catch(() => setWakatimeKeyValid(false)) + .finally(() => setWakatimeKeyValidating(false)); + } + }} + className="bg-transparent flex-1 text-sm outline-none" + style={{ color: theme.colors.textMain }} + placeholder="waka_..." + /> + {wakatimeKeyValidating && ...} + {!wakatimeKeyValidating && wakatimeKeyValid === true && ( + + )} + {!wakatimeKeyValidating && wakatimeKeyValid === false && wakatimeApiKey && ( + + )} + {wakatimeApiKey && ( + + )} +
+

+ Get your API key from wakatime.com/settings/api-key. Keys are stored locally in + ~/.maestro/settings.json. +

+
+ )}
)}
diff --git a/src/renderer/components/Settings/tabs/GeneralTab.tsx b/src/renderer/components/Settings/tabs/GeneralTab.tsx index 178ebe9af..9a77d5f2c 100644 --- a/src/renderer/components/Settings/tabs/GeneralTab.tsx +++ b/src/renderer/components/Settings/tabs/GeneralTab.tsx @@ -3,13 +3,12 @@ * * Contains: About Me, Shell, Log Level, GitHub CLI, Input Behavior, * History, Thinking Mode, Tab Naming, Auto-scroll, Power, Rendering, - * Updates, Pre-release, Privacy, Stats & WakaTime, Storage Location. + * Updates, Pre-release, Privacy, Storage Location. */ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect } from 'react'; import { X, - Key, Check, Terminal, History, @@ -22,18 +21,15 @@ import { ChevronDown, Brain, FlaskConical, - Database, Battery, Monitor, PartyPopper, Tag, - Timer, User, ArrowDownToLine, HelpCircle, ExternalLink, Keyboard, - Trash2, } from 'lucide-react'; import { useSettings } from '../../../hooks'; import type { Theme, ShellInfo } from '../../../types'; @@ -96,20 +92,6 @@ export function GeneralTab({ theme, isOpen }: GeneralTabProps) { setEnableBetaUpdates, crashReportingEnabled, setCrashReportingEnabled, - // Encore features - encoreFeatures, - // Stats - statsCollectionEnabled, - setStatsCollectionEnabled, - defaultStatsTimeRange, - setDefaultStatsTimeRange, - // WakaTime - wakatimeEnabled, - setWakatimeEnabled, - wakatimeApiKey, - setWakatimeApiKey, - wakatimeDetailedTracking, - setWakatimeDetailedTracking, } = useSettings(); // Shell state @@ -127,83 +109,8 @@ export function GeneralTab({ theme, isOpen }: GeneralTabProps) { const [syncError, setSyncError] = useState(null); const [syncMigratedCount, setSyncMigratedCount] = useState(null); - // Stats data management state - const [statsDbSize, setStatsDbSize] = useState(null); - const [statsEarliestDate, setStatsEarliestDate] = useState(null); - const [statsClearing, setStatsClearing] = useState(false); - const [statsClearResult, setStatsClearResult] = useState<{ - success: boolean; - deletedQueryEvents: number; - deletedAutoRunSessions: number; - deletedAutoRunTasks: number; - error?: string; - } | null>(null); - // WakaTime CLI check and API key validation state - const [wakatimeCliStatus, setWakatimeCliStatus] = useState<{ - available: boolean; - version?: string; - } | null>(null); - const [wakatimeKeyValid, setWakatimeKeyValid] = useState(null); - const [wakatimeKeyValidating, setWakatimeKeyValidating] = useState(false); - const handleWakatimeApiKeyChange = useCallback( - (value: string) => { - setWakatimeApiKey(value); - setWakatimeKeyValid(null); - }, - [setWakatimeApiKey] - ); - - // Check WakaTime CLI availability when section renders or toggle is enabled - useEffect(() => { - if (!isOpen || !wakatimeEnabled) return; - let cancelled = false; - let retryTimer: ReturnType | null = null; - - window.maestro.wakatime - .checkCli() - .then((status) => { - if (cancelled) return; - setWakatimeCliStatus(status); - if (!status.available) { - retryTimer = setTimeout(() => { - if (!cancelled) { - window.maestro.wakatime - .checkCli() - .then((retryStatus) => { - if (!cancelled) setWakatimeCliStatus(retryStatus); - }) - .catch(() => { - if (!cancelled) setWakatimeCliStatus({ available: false }); - }); - } - }, 3000); - } - }) - .catch(() => { - if (cancelled) return; - setWakatimeCliStatus({ available: false }); - retryTimer = setTimeout(() => { - if (!cancelled) { - window.maestro.wakatime - .checkCli() - .then((retryStatus) => { - if (!cancelled) setWakatimeCliStatus(retryStatus); - }) - .catch(() => { - if (!cancelled) setWakatimeCliStatus({ available: false }); - }); - } - }, 3000); - }); - - return () => { - cancelled = true; - if (retryTimer) clearTimeout(retryTimer); - }; - }, [isOpen, wakatimeEnabled]); - - // Load sync settings and stats data when modal opens + // Load sync settings when modal opens useEffect(() => { if (!isOpen) return; @@ -226,33 +133,6 @@ export function GeneralTab({ theme, isOpen }: GeneralTabProps) { setSyncError('Failed to load storage settings'); }); - // Load stats database size and earliest timestamp - window.maestro.stats - .getDatabaseSize() - .then((size) => { - setStatsDbSize(size); - }) - .catch((err) => { - console.error('Failed to load stats database size:', err); - }); - - window.maestro.stats - .getEarliestTimestamp() - .then((timestamp) => { - if (timestamp) { - const date = new Date(timestamp); - const formatted = date.toISOString().split('T')[0]; // YYYY-MM-DD - setStatsEarliestDate(formatted); - } else { - setStatsEarliestDate(null); - } - }) - .catch((err) => { - console.error('Failed to load earliest stats timestamp:', err); - }); - - // Reset stats clear state - setStatsClearResult(null); }, [isOpen]); const loadShells = async () => { @@ -966,331 +846,6 @@ export function GeneralTab({ theme, isOpen }: GeneralTabProps) { theme={theme} /> - {/* Stats Data Management (gated by Encore Features) */} - {encoreFeatures.usageStats && (<> - {/* Stats Data Management */} -
-
- - Usage & Stats - - Beta - -
-
- {/* Enable/Disable Stats Collection */} -
-
-

- Enable stats collection -

-

- Track queries and Auto Run sessions for the dashboard. -

-
- -
- - {/* Default Time Range */} -
-
Default dashboard time range
- -

- Time range shown when opening the Usage Dashboard. -

-
- - {/* Divider */} -
- - {/* Database Size Display */} -
- - Database size - - - {statsDbSize !== null ? (statsDbSize / 1024 / 1024).toFixed(2) + ' MB' : 'Loading...'} - {statsEarliestDate && ( - (since {statsEarliestDate}) - )} - -
- - {/* Clear Old Data Dropdown */} -
-
Clear stats older than...
-
- - -
-

- Remove old query events, Auto Run sessions, and tasks from the stats database. -

-
- - {/* Clear Result Feedback */} - {statsClearResult && ( -
- {statsClearResult.success ? ( - <> - - - Cleared{' '} - {statsClearResult.deletedQueryEvents + - statsClearResult.deletedAutoRunSessions + - statsClearResult.deletedAutoRunTasks}{' '} - records ({statsClearResult.deletedQueryEvents} queries,{' '} - {statsClearResult.deletedAutoRunSessions} sessions,{' '} - {statsClearResult.deletedAutoRunTasks} tasks) - - - ) : ( - <> - - {statsClearResult.error || 'Failed to clear stats data'} - - )} -
- )} - - {/* Divider */} -
- - {/* WakaTime Integration */} -
-
-

- - Enable WakaTime tracking -

-

- Track coding activity in Maestro sessions via WakaTime. -

-
- -
- - {/* CLI not found warning */} - {wakatimeEnabled && wakatimeCliStatus && !wakatimeCliStatus.available && ( -

- WakaTime CLI is being installed automatically... -

- )} - - {/* Detailed file tracking toggle (only shown when enabled) */} - {wakatimeEnabled && ( -
-
-

- Detailed file tracking -

-

- Track per-file write activity. Sends file paths (not content) to WakaTime. -

-
- -
- )} - - {/* API Key Input (only shown when enabled) */} - {wakatimeEnabled && ( -
-
API Key
-
- - handleWakatimeApiKeyChange(e.target.value)} - onBlur={() => { - if (wakatimeApiKey) { - setWakatimeKeyValidating(true); - setWakatimeKeyValid(null); - window.maestro.wakatime - .validateApiKey(wakatimeApiKey) - .then((result) => setWakatimeKeyValid(result.valid)) - .catch(() => setWakatimeKeyValid(false)) - .finally(() => setWakatimeKeyValidating(false)); - } - }} - className="bg-transparent flex-1 text-sm outline-none" - style={{ color: theme.colors.textMain }} - placeholder="waka_..." - /> - {wakatimeKeyValidating && ...} - {!wakatimeKeyValidating && wakatimeKeyValid === true && ( - - )} - {!wakatimeKeyValidating && wakatimeKeyValid === false && wakatimeApiKey && ( - - )} - {wakatimeApiKey && ( - - )} -
-

- Get your API key from wakatime.com/settings/api-key. Keys are stored locally in - ~/.maestro/settings.json. -

-
- )} -
-
- )} {/* Settings Storage Location */}
From 5092e462117a187c11c6a9575612b251d68cd107 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 5 Mar 2026 22:20:36 -0600 Subject: [PATCH 4/5] docs: move Usage Dashboard and Maestro Symphony under Encore Features section --- docs/docs.json | 4 +--- docs/encore-features.md | 4 ++-- docs/features.md | 4 ++-- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/docs/docs.json b/docs/docs.json index 069f0c48b..9f986fa1c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -52,8 +52,6 @@ "history", "context-management", "document-graph", - "usage-dashboard", - "symphony", "git-worktrees", "group-chat", "remote-access", @@ -74,7 +72,7 @@ { "group": "Encore Features", "icon": "flask", - "pages": ["encore-features", "director-notes"] + "pages": ["encore-features", "director-notes", "usage-dashboard", "symphony"] }, { "group": "Providers & CLI", diff --git a/docs/encore-features.md b/docs/encore-features.md index 9b4928de7..0f6338d7d 100644 --- a/docs/encore-features.md +++ b/docs/encore-features.md @@ -19,8 +19,8 @@ Open **Settings** (`Cmd+,` / `Ctrl+,`) and navigate to the **Encore Features** t | Feature | Shortcut | Description | | ------------------------------------ | ------------------------------ | --------------------------------------------------------------- | | [Director's Notes](./director-notes) | `Cmd+Shift+O` / `Ctrl+Shift+O` | Unified timeline of all agent activity with AI-powered synopses | - -More features will be added here as they ship. +| [Usage Dashboard](./usage-dashboard) | `Opt+Cmd+U` / `Alt+Ctrl+U` | Comprehensive analytics for tracking AI usage patterns | +| [Maestro Symphony](./symphony) | `Cmd+Shift+Y` / `Ctrl+Shift+Y` | Contribute to open source by donating AI tokens | ## For Developers diff --git a/docs/features.md b/docs/features.md index 8c556e17f..baaadf7fa 100644 --- a/docs/features.md +++ b/docs/features.md @@ -9,7 +9,7 @@ icon: sparkles - 🌳 **[Git Worktrees](./git-worktrees)** - Run AI agents in parallel on isolated branches. Create worktree sub-agents from the git branch menu, each operating in their own directory. Work interactively in the main repo while sub-agents process tasks independently — then create PRs with one click. True parallel development without conflicts. - 🤖 **[Auto Run & Playbooks](./autorun-playbooks)** - File-system-based task runner that processes markdown checklists through AI agents. Create Playbooks (collections of Auto Run documents) for repeatable workflows, run in loops, and track progress with full history. Each task gets its own AI session for clean conversation context. - 🏪 **[Playbook Exchange](./playbook-exchange)** - Browse and import community-contributed playbooks directly into your Auto Run folder. Categories, search, and one-click import get you started with proven workflows for security audits, code reviews, documentation, and more. -- 🎵 **[Maestro Symphony](./symphony)** - Contribute to open source by donating AI tokens. Browse registered projects, select GitHub issues, and let Maestro clone, process Auto Run docs, and create PRs automatically. Distributed computing for AI-assisted development. +- 🎵 **[Maestro Symphony](./symphony)** - Contribute to open source by donating AI tokens. Browse registered projects, select GitHub issues, and let Maestro clone, process Auto Run docs, and create PRs automatically. Distributed computing for AI-assisted development. _(Encore Feature — enable in Settings > Encore Features)_ - 💬 **[Group Chat](./group-chat)** - Coordinate multiple AI agents in a single conversation. A moderator AI orchestrates discussions, routing questions to the right agents and synthesizing their responses for cross-project questions and architecture discussions. - 🌐 **[Remote Access](./remote-access)** - Built-in web server with QR code access. Monitor and control all your agents from your phone. Supports local network access and remote tunneling via Cloudflare for access from anywhere. - 🔗 **[SSH Remote Execution](./ssh-remote-execution)** - Run AI agents on remote hosts via SSH. Leverage powerful cloud VMs, access tools not installed locally, or work with projects requiring specific environments — all while controlling everything from your local Maestro instance. @@ -34,7 +34,7 @@ icon: sparkles - 🎨 **[Beautiful Themes](https://github.com/RunMaestro/Maestro/blob/main/THEMES.md)** - 17 built-in themes across dark (Dracula, Monokai, Nord, Tokyo Night, Catppuccin Mocha, Gruvbox Dark), light (GitHub, Solarized, One Light, Gruvbox Light, Catppuccin Latte, Ayu Light), and vibe (Pedurple, Maestro's Choice, Dre Synth, InQuest) categories, plus a fully customizable theme builder. - ⏱️ **[WakaTime Integration](./configuration#wakatime-integration)** - Automatic time tracking via WakaTime with optional per-file write activity tracking across all supported agents. - 💰 **Cost Tracking** - Real-time token usage and cost tracking per session and globally. -- 📊 **[Usage Dashboard](./usage-dashboard)** - Comprehensive analytics for tracking AI usage patterns. View aggregated statistics, compare agent performance, analyze activity heatmaps, and export data to CSV. Access via `Opt+Cmd+U` / `Alt+Ctrl+U`. +- 📊 **[Usage Dashboard](./usage-dashboard)** - Comprehensive analytics for tracking AI usage patterns. View aggregated statistics, compare agent performance, analyze activity heatmaps, and export data to CSV. Access via `Opt+Cmd+U` / `Alt+Ctrl+U`. _(Encore Feature — enable in Settings > Encore Features)_ - 🎬 **[Director's Notes](./director-notes)** - Bird's-eye view of all agent activity in a unified timeline. Aggregate history from every agent, search and filter entries, and generate AI-powered synopses of recent work. Access via `Cmd+Shift+O` / `Ctrl+Shift+O`. _(Encore Feature — enable in Settings > Encore Features)_ - 🏆 **[Achievements](./achievements)** - Level up from Apprentice to Titan of the Baton based on cumulative Auto Run time. 11 conductor-themed ranks to unlock. From debe883ff796789d550213c12ddbba8bf9710403 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 5 Mar 2026 22:44:37 -0600 Subject: [PATCH 5/5] refactor: reorder Encore Features, remove BETA from Usage Dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reorder Encore Features tab: Usage & Stats → Symphony → Director's Notes - Remove BETA badge from Usage Dashboard modal header - Director's Notes retains BETA badge as it's still in beta - Usage & Stats and Symphony are default-on stable features --- package-lock.json | 80 +- .../components/Settings/tabs/EncoreTab.tsx | 1056 +++++++++-------- .../UsageDashboard/UsageDashboardModal.tsx | 6 - 3 files changed, 613 insertions(+), 529 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7482623e1..52ae04685 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "maestro", - "version": "0.15.0", + "version": "0.15.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "maestro", - "version": "0.15.0", + "version": "0.15.1", "hasInstallScript": true, "license": "AGPL 3.0", "dependencies": { @@ -264,6 +264,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -667,6 +668,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -710,6 +712,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2283,6 +2286,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2304,6 +2308,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.2.0.tgz", "integrity": "sha512-qRkLWiUEZNAmYapZ7KGS5C4OmBLcP/H2foXeOEaowYCR0wi89fHejrfYfbuLVCMLp/dWZXKvQusdbUEZjERfwQ==", "license": "Apache-2.0", + "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -2316,6 +2321,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2331,6 +2337,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz", "integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.208.0", "import-in-the-middle": "^2.0.0", @@ -2718,6 +2725,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2734,6 +2742,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", @@ -2751,6 +2760,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.38.0.tgz", "integrity": "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" } @@ -3809,8 +3819,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4348,6 +4357,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4359,6 +4369,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4484,6 +4495,7 @@ "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", @@ -4914,6 +4926,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4995,6 +5008,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5998,6 +6012,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -6480,6 +6495,7 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -7205,6 +7221,7 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -7614,6 +7631,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -8111,6 +8129,7 @@ "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", @@ -8206,8 +8225,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dompurify": { "version": "3.3.0", @@ -8351,7 +8369,6 @@ "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "archiver": "^5.3.1", @@ -8365,7 +8382,6 @@ "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -8385,7 +8401,6 @@ "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -8408,7 +8423,6 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -8425,7 +8439,6 @@ "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", @@ -8442,7 +8455,6 @@ "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -8457,7 +8469,6 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -8473,7 +8484,6 @@ "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -8486,8 +8496,7 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/electron-builder-squirrel-windows/node_modules/string_decoder": { "version": "1.1.1", @@ -8495,7 +8504,6 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -8506,7 +8514,6 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -8517,7 +8524,6 @@ "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -8533,7 +8539,6 @@ "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", @@ -9215,6 +9220,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11134,6 +11140,7 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -11954,6 +11961,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -12423,16 +12431,14 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.difference": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", @@ -12445,8 +12451,7 @@ "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.isequal": { "version": "4.5.0", @@ -12460,8 +12465,7 @@ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -12475,8 +12479,7 @@ "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/log-symbols": { "version": "4.1.0", @@ -12567,7 +12570,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -15065,6 +15067,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -15305,7 +15308,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -15321,7 +15323,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -15666,6 +15667,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -15695,6 +15697,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -15742,6 +15745,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -15928,7 +15932,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -17685,6 +17690,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17995,6 +18001,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18368,6 +18375,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -18873,6 +18881,7 @@ "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.15", "@vitest/mocker": "4.0.15", @@ -19463,6 +19472,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -19476,6 +19486,7 @@ "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -20073,6 +20084,7 @@ "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/renderer/components/Settings/tabs/EncoreTab.tsx b/src/renderer/components/Settings/tabs/EncoreTab.tsx index 159cea0f7..8e1254b8f 100644 --- a/src/renderer/components/Settings/tabs/EncoreTab.tsx +++ b/src/renderer/components/Settings/tabs/EncoreTab.tsx @@ -7,7 +7,20 @@ */ import { useState, useEffect, useRef, useCallback } from 'react'; -import { Clapperboard, ChevronDown, Settings, Check, Database, Music, Lock, Plus, X, Timer, Key, Trash2 } from 'lucide-react'; +import { + Clapperboard, + ChevronDown, + Settings, + Check, + Database, + Music, + Lock, + Plus, + X, + Timer, + Key, + Trash2, +} from 'lucide-react'; import { useSettings } from '../../../hooks'; import { SYMPHONY_REGISTRY_URL } from '../../../../shared/symphony-constants'; import type { Theme, AgentConfig, ToolType } from '../../../types'; @@ -196,10 +209,16 @@ export function EncoreTab({ theme, isOpen }: EncoreTabProps) { setRegistryUrlError('This is the default registry URL'); return; } - } catch { /* default URL should always parse */ } + } catch { + /* default URL should always parse */ + } const existing = new Set( symphonyRegistryUrls.map((u) => { - try { return canonicalizeUrl(u); } catch { return u.trim(); } + try { + return canonicalizeUrl(u); + } catch { + return u.trim(); + } }) ); if (existing.has(canonical)) { @@ -354,58 +373,40 @@ export function EncoreTab({ theme, isOpen }: EncoreTabProps) {

- {/* Director's Notes Feature Section */} + {/* Usage & Stats Feature Section */}
- {/* Feature Toggle Header */} - {/* Director's Notes Settings (shown when enabled) */} - {encoreFeatures.directorNotes && ( + {encoreFeatures.usageStats && (
- {/* Provider Selection */} -
-
- Synopsis Provider + {/* Enable/Disable Stats Collection */} +
+
+

+ Enable stats collection +

+

+ Track queries and Auto Run sessions for the dashboard. +

+ +
- {dnIsDetecting ? ( -
-
- - Detecting agents... - -
- ) : dnAvailableTiles.length === 0 ? ( -
- No agents available. Please install Claude Code, OpenCode, Codex, or Factory - Droid. -
- ) : ( -
-
- - -
+ {/* Default Time Range */} +
+
Default dashboard time range
+ +

+ Time range shown when opening the Usage Dashboard. +

+
- -
- )} + {/* Divider */} +
- {dnIsConfigExpanded && dnSelectedAgentConfig && dnSelectedTile && ( -
+ + Database size + + + {statsDbSize !== null + ? (statsDbSize / 1024 / 1024).toFixed(2) + ' MB' + : 'Loading...'} + {statsEarliestDate && ( + (since {statsEarliestDate}) + )} + +
+ + {/* Clear Old Data Dropdown */} +
+
Clear stats older than...
+
+ +
- )} - -

- The AI agent used to generate synopsis summaries + + {statsClearing ? 'Clearing...' : 'Clear'} + +

+

+ Remove old query events, Auto Run sessions, and tasks from the stats database.

- {/* Default Lookback Period */} -
-
- Default Lookback Period: {directorNotesSettings.defaultLookbackDays} days -
- - setDirectorNotesSettings({ - ...directorNotesSettings, - defaultLookbackDays: parseInt(e.target.value, 10), - }) - } - className="w-full" - /> + {/* Clear Result Feedback */} + {statsClearResult && (
- 1 day - 7 - 14 - 30 - 60 - 90 days + {statsClearResult.success ? ( + <> + + + Cleared{' '} + {statsClearResult.deletedQueryEvents + + statsClearResult.deletedAutoRunSessions + + statsClearResult.deletedAutoRunTasks}{' '} + records ({statsClearResult.deletedQueryEvents} queries,{' '} + {statsClearResult.deletedAutoRunSessions} sessions,{' '} + {statsClearResult.deletedAutoRunTasks} tasks) + + + ) : ( + <> + + {statsClearResult.error || 'Failed to clear stats data'} + + )}
-

- How far back to look when generating notes (can be adjusted per-report) -

-
-
- )} -
+ )} - {/* Usage & Stats Feature Section */} -
- + {/* Divider */} +
- {encoreFeatures.usageStats && ( -
- {/* Enable/Disable Stats Collection */} -
+ {/* WakaTime Integration */} +
-

- Enable stats collection +

+ + Enable WakaTime tracking

- Track queries and Auto Run sessions for the dashboard. + Track coding activity in Maestro sessions via WakaTime.

- {/* Default Time Range */} -
-
Default dashboard time range
- -

- Time range shown when opening the Usage Dashboard. + {/* CLI not found warning */} + {wakatimeEnabled && wakatimeCliStatus && !wakatimeCliStatus.available && ( +

+ WakaTime CLI is being installed automatically...

-
- - {/* Divider */} -
- - {/* Database Size Display */} -
- - Database size - - - {statsDbSize !== null ? (statsDbSize / 1024 / 1024).toFixed(2) + ' MB' : 'Loading...'} - {statsEarliestDate && ( - (since {statsEarliestDate}) - )} - -
- - {/* Clear Old Data Dropdown */} -
-
Clear stats older than...
-
- - -
-

- Remove old query events, Auto Run sessions, and tasks from the stats database. -

-
- - {/* Clear Result Feedback */} - {statsClearResult && ( -
- {statsClearResult.success ? ( - <> - - - Cleared{' '} - {statsClearResult.deletedQueryEvents + - statsClearResult.deletedAutoRunSessions + - statsClearResult.deletedAutoRunTasks}{' '} - records ({statsClearResult.deletedQueryEvents} queries,{' '} - {statsClearResult.deletedAutoRunSessions} sessions,{' '} - {statsClearResult.deletedAutoRunTasks} tasks) - - - ) : ( - <> - - {statsClearResult.error || 'Failed to clear stats data'} - - )} -
- )} - - {/* Divider */} -
- - {/* WakaTime Integration */} -
-
-

- - Enable WakaTime tracking -

-

- Track coding activity in Maestro sessions via WakaTime. -

-
- -
- - {/* CLI not found warning */} - {wakatimeEnabled && wakatimeCliStatus && !wakatimeCliStatus.available && ( -

- WakaTime CLI is being installed automatically... -

- )} + )} {/* Detailed file tracking toggle (only shown when enabled) */} {wakatimeEnabled && ( @@ -999,10 +741,17 @@ export function EncoreTab({ theme, isOpen }: EncoreTabProps) { > {/* Registry URL Management (shown when enabled) */} {encoreFeatures.symphony && ( -
+
-
+ + {/* Director's Notes Feature Section */} +
+ {/* Feature Toggle Header */} + + + {/* Director's Notes Settings (shown when enabled) */} + {encoreFeatures.directorNotes && ( +
+ {/* Provider Selection */} +
+
+ Synopsis Provider +
+ + {dnIsDetecting ? ( +
+
+ + Detecting agents... + +
+ ) : dnAvailableTiles.length === 0 ? ( +
+ No agents available. Please install Claude Code, OpenCode, Codex, or Factory + Droid. +
+ ) : ( +
+
+ + +
+ + +
+ )} + + {dnIsConfigExpanded && dnSelectedAgentConfig && dnSelectedTile && ( +
+
+ + {dnSelectedTile.name} Configuration + + {dnHasCustomization && ( +
+ + + Customized + +
+ )} +
+ { + setDnCustomPath(''); + setDirectorNotesSettings({ + ...directorNotesSettings, + customPath: undefined, + }); + }} + customArgs={dnCustomArgs} + onCustomArgsChange={setDnCustomArgs} + onCustomArgsBlur={persistDnCustomConfig} + onCustomArgsClear={() => { + setDnCustomArgs(''); + setDirectorNotesSettings({ + ...directorNotesSettings, + customArgs: undefined, + }); + }} + customEnvVars={dnCustomEnvVars} + onEnvVarKeyChange={(oldKey, newKey, value) => { + const newVars = { ...dnCustomEnvVars }; + delete newVars[oldKey]; + newVars[newKey] = value; + setDnCustomEnvVars(newVars); + }} + onEnvVarValueChange={(key, value) => { + setDnCustomEnvVars({ ...dnCustomEnvVars, [key]: value }); + }} + onEnvVarRemove={(key) => { + const newVars = { ...dnCustomEnvVars }; + delete newVars[key]; + setDnCustomEnvVars(newVars); + }} + onEnvVarAdd={() => { + let newKey = 'NEW_VAR'; + let counter = 1; + while (dnCustomEnvVars[newKey]) { + newKey = `NEW_VAR_${counter}`; + counter++; + } + setDnCustomEnvVars({ ...dnCustomEnvVars, [newKey]: '' }); + }} + onEnvVarsBlur={persistDnCustomConfig} + agentConfig={dnAgentConfig} + onConfigChange={(key, value) => { + const newConfig = { ...dnAgentConfig, [key]: value }; + setDnAgentConfig(newConfig); + dnAgentConfigRef.current = newConfig; + }} + onConfigBlur={async () => { + if (directorNotesSettings.provider) { + await window.maestro.agents.setConfig( + directorNotesSettings.provider, + dnAgentConfigRef.current + ); + } + }} + availableModels={dnAvailableModels} + loadingModels={dnLoadingModels} + onRefreshModels={handleDnRefreshModels} + onRefreshAgent={handleDnRefreshAgent} + refreshingAgent={dnRefreshingAgent} + compact + showBuiltInEnvVars + /> +
+ )} + +

+ The AI agent used to generate synopsis summaries +

+
+ + {/* Default Lookback Period */} +
+
+ Default Lookback Period: {directorNotesSettings.defaultLookbackDays} days +
+ + setDirectorNotesSettings({ + ...directorNotesSettings, + defaultLookbackDays: parseInt(e.target.value, 10), + }) + } + className="w-full" + /> +
+ 1 day + 7 + 14 + 30 + 60 + 90 days +
+

+ How far back to look when generating notes (can be adjusted per-report) +

+
+
+ )} +
); } diff --git a/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx b/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx index 842aa51e3..cfb98d448 100644 --- a/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx +++ b/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx @@ -533,12 +533,6 @@ export function UsageDashboardModal({

Usage Dashboard

- - Beta - {/* New Data Indicator - appears briefly when real-time data arrives */} {showNewDataIndicator && (