From 121621588e1a784887f5e1de171fb340f83ac152 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Fri, 12 Dec 2025 00:31:48 -0600 Subject: [PATCH 1/9] initial leaderboard functionality --- scripts/github-community-analytics.js | 280 ++++++++++++++++++ src/main/index.ts | 193 ++++++++++++ src/main/preload.ts | 68 +++++ src/renderer/App.tsx | 85 +++++- src/renderer/components/AboutModal.tsx | 57 +++- src/renderer/components/FilePreview.tsx | 30 +- .../components/FirstRunCelebration.tsx | 32 +- .../components/StandingOvationOverlay.tsx | 23 ++ src/renderer/constants/modalPriorities.ts | 3 + src/renderer/hooks/useSettings.ts | 38 ++- src/renderer/types/index.ts | 24 ++ 11 files changed, 809 insertions(+), 24 deletions(-) diff --git a/scripts/github-community-analytics.js b/scripts/github-community-analytics.js index dfa03a7d..0b7b0553 100755 --- a/scripts/github-community-analytics.js +++ b/scripts/github-community-analytics.js @@ -278,6 +278,279 @@ ${report.topInfluencers.map(u => `| [@${u.username}](https://github.com/${u.user return md; } +function generateHtmlDashboard(report, stargazers, forkers, userDetails) { + const embeddedData = { + report, + stargazers, + forkers, + userDetails: userDetails || {} + }; + + return ` + + + + + Maestro Community Dashboard + + + + +
+

Maestro Community Dashboard

+

+
+
+
-
Stars
+
-
Forks
+
-
Unique Members
+
-
Community Reach
+
+
+

Star Growth Over Time

+

Fork Growth Over Time

+
+
+

Top Locations

+

Top Companies

+
+
+

Top Influencers

UserCompanyFollowers
+

Recent Activity

UserActionDate
+

Highly Engaged (Starred + Forked)

UserLocationFollowers
+

All Community Members

UserStatusJoined GitHub
+
+ + +`; +} + async function main() { const args = process.argv.slice(2); const fetchDetails = args.includes('--fetch-details'); @@ -351,6 +624,13 @@ async function main() { markdown ); + // Generate self-contained HTML dashboard + const htmlDashboard = generateHtmlDashboard(report, stargazers, forkers, userDetails); + fs.writeFileSync( + path.join(OUTPUT_DIR, 'index.html'), + htmlDashboard + ); + if (jsonOutput) { console.log(JSON.stringify(report, null, 2)); } else { diff --git a/src/main/index.ts b/src/main/index.ts index 18034a00..3cb08134 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4993,6 +4993,199 @@ function setupIpcHandlers() { } } ); + + // ========================================================================== + // Leaderboard API + // ========================================================================== + + // Submit leaderboard entry to runmaestro.ai + ipcMain.handle( + 'leaderboard:submit', + async ( + _event, + data: { + email: string; + displayName: string; + githubUsername?: string; + twitterHandle?: string; + linkedinHandle?: string; + badgeLevel: number; + badgeName: string; + cumulativeTimeMs: number; + totalRuns: number; + longestRunMs?: number; + longestRunDate?: string; + } + ): Promise<{ + success: boolean; + message: string; + requiresConfirmation?: boolean; + confirmationUrl?: string; + error?: string; + }> => { + try { + logger.info('Submitting leaderboard entry', 'Leaderboard', { + displayName: data.displayName, + email: data.email.substring(0, 3) + '***', + badgeLevel: data.badgeLevel, + }); + + const response = await fetch('https://runmaestro.ai/api/m4estr0/submit', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': `Maestro/${app.getVersion()}`, + }, + body: JSON.stringify(data), + }); + + const result = await response.json() as { + success?: boolean; + message?: string; + requiresConfirmation?: boolean; + confirmationUrl?: string; + error?: string; + }; + + if (response.ok) { + logger.info('Leaderboard submission successful', 'Leaderboard', { + requiresConfirmation: result.requiresConfirmation, + }); + return { + success: true, + message: result.message || 'Submission received', + requiresConfirmation: result.requiresConfirmation, + confirmationUrl: result.confirmationUrl, + }; + } else { + logger.warn('Leaderboard submission failed', 'Leaderboard', { + status: response.status, + error: result.error || result.message, + }); + return { + success: false, + message: result.message || 'Submission failed', + error: result.error || `Server error: ${response.status}`, + }; + } + } catch (error) { + logger.error('Error submitting to leaderboard', 'Leaderboard', error); + return { + success: false, + message: 'Failed to connect to leaderboard server', + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + ); + + // Get leaderboard entries + ipcMain.handle( + 'leaderboard:get', + async ( + _event, + options?: { limit?: number } + ): Promise<{ + success: boolean; + entries?: Array<{ + rank: number; + displayName: string; + githubUsername?: string; + avatarUrl?: string; + badgeLevel: number; + badgeName: string; + cumulativeTimeMs: number; + totalRuns: number; + }>; + error?: string; + }> => { + try { + const limit = options?.limit || 50; + const response = await fetch(`https://runmaestro.ai/api/leaderboard?limit=${limit}`, { + headers: { + 'User-Agent': `Maestro/${app.getVersion()}`, + }, + }); + + if (response.ok) { + const data = await response.json() as { entries?: unknown[] }; + return { success: true, entries: data.entries as Array<{ + rank: number; + displayName: string; + githubUsername?: string; + avatarUrl?: string; + badgeLevel: number; + badgeName: string; + cumulativeTimeMs: number; + totalRuns: number; + }> }; + } else { + return { + success: false, + error: `Server error: ${response.status}`, + }; + } + } catch (error) { + logger.error('Error fetching leaderboard', 'Leaderboard', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + ); + + // Get longest runs leaderboard + ipcMain.handle( + 'leaderboard:getLongestRuns', + async ( + _event, + options?: { limit?: number } + ): Promise<{ + success: boolean; + entries?: Array<{ + rank: number; + displayName: string; + githubUsername?: string; + avatarUrl?: string; + longestRunMs: number; + runDate: string; + }>; + error?: string; + }> => { + try { + const limit = options?.limit || 50; + const response = await fetch(`https://runmaestro.ai/api/longest-runs?limit=${limit}`, { + headers: { + 'User-Agent': `Maestro/${app.getVersion()}`, + }, + }); + + if (response.ok) { + const data = await response.json() as { entries?: unknown[] }; + return { success: true, entries: data.entries as Array<{ + rank: number; + displayName: string; + githubUsername?: string; + avatarUrl?: string; + longestRunMs: number; + runDate: string; + }> }; + } else { + return { + success: false, + error: `Server error: ${response.status}`, + }; + } + } catch (error) { + logger.error('Error fetching longest runs leaderboard', 'Leaderboard', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + ); } // Handle process output streaming (set up after initialization) diff --git a/src/main/preload.ts b/src/main/preload.ts index 9cc3fbaf..1791e470 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -641,6 +641,27 @@ contextBridge.exposeInMainWorld('maestro', { import: (sessionId: string, autoRunFolderPath: string) => ipcRenderer.invoke('playbooks:import', sessionId, autoRunFolderPath), }, + + // Leaderboard API (runmaestro.ai integration) + leaderboard: { + submit: (data: { + email: string; + displayName: string; + githubUsername?: string; + twitterHandle?: string; + linkedinHandle?: string; + badgeLevel: number; + badgeName: string; + cumulativeTimeMs: number; + totalRuns: number; + longestRunMs?: number; + longestRunDate?: string; + }) => ipcRenderer.invoke('leaderboard:submit', data), + get: (options?: { limit?: number }) => + ipcRenderer.invoke('leaderboard:get', options), + getLongestRuns: (options?: { limit?: number }) => + ipcRenderer.invoke('leaderboard:getLongestRuns', options), + }, }); // Type definitions for TypeScript @@ -1135,6 +1156,53 @@ export interface MaestroAPI { error?: string; }>; }; + leaderboard: { + submit: (data: { + email: string; + displayName: string; + githubUsername?: string; + twitterHandle?: string; + linkedinHandle?: string; + badgeLevel: number; + badgeName: string; + cumulativeTimeMs: number; + totalRuns: number; + longestRunMs?: number; + longestRunDate?: string; + }) => Promise<{ + success: boolean; + message: string; + requiresConfirmation?: boolean; + confirmationUrl?: string; + error?: string; + }>; + get: (options?: { limit?: number }) => Promise<{ + success: boolean; + entries?: Array<{ + rank: number; + displayName: string; + githubUsername?: string; + avatarUrl?: string; + badgeLevel: number; + badgeName: string; + cumulativeTimeMs: number; + totalRuns: number; + }>; + error?: string; + }>; + getLongestRuns: (options?: { limit?: number }) => Promise<{ + success: boolean; + entries?: Array<{ + rank: number; + displayName: string; + githubUsername?: string; + avatarUrl?: string; + longestRunMs: number; + runDate: string; + }>; + error?: string; + }>; + }; } declare global { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index ce58615e..41fbdf90 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -25,6 +25,7 @@ import { PromptComposerModal } from './components/PromptComposerModal'; import { ExecutionQueueBrowser } from './components/ExecutionQueueBrowser'; import { StandingOvationOverlay } from './components/StandingOvationOverlay'; import { FirstRunCelebration } from './components/FirstRunCelebration'; +import { LeaderboardRegistrationModal } from './components/LeaderboardRegistrationModal'; import { PlaygroundPanel } from './components/PlaygroundPanel'; import { AutoRunSetupModal } from './components/AutoRunSetupModal'; import { DebugWizardModal } from './components/DebugWizardModal'; @@ -181,6 +182,7 @@ export default function MaestroConsole() { firstAutoRunCompleted, setFirstAutoRunCompleted, recordWizardStart, recordWizardComplete, recordWizardAbandon, recordWizardResume, recordTourStart, recordTourComplete, recordTourSkip, + leaderboardRegistration, setLeaderboardRegistration, isLeaderboardRegistered, } = settings; // --- STATE --- @@ -262,6 +264,7 @@ export default function MaestroConsole() { const [lightboxImages, setLightboxImages] = useState([]); // Context images for navigation const [aboutModalOpen, setAboutModalOpen] = useState(false); const [updateCheckModalOpen, setUpdateCheckModalOpen] = useState(false); + const [leaderboardRegistrationOpen, setLeaderboardRegistrationOpen] = useState(false); const [standingOvationData, setStandingOvationData] = useState<{ badge: typeof CONDUCTOR_BADGES[number]; isNewRecord: boolean; @@ -2698,6 +2701,54 @@ export default function MaestroConsole() { }, 500); } } + + // Submit to leaderboard if registered and email confirmed + if (isLeaderboardRegistered && leaderboardRegistration) { + // Calculate updated stats after this run (simulating what recordAutoRunComplete updated) + const updatedCumulativeTimeMs = autoRunStats.cumulativeTimeMs + info.elapsedTimeMs; + const updatedTotalRuns = autoRunStats.totalRuns + 1; + const updatedLongestRunMs = Math.max(autoRunStats.longestRunMs || 0, info.elapsedTimeMs); + const updatedBadge = CONDUCTOR_BADGES.find(b => + b.thresholdMs <= updatedCumulativeTimeMs + ); + const updatedBadgeLevel = updatedBadge?.level || 0; + const updatedBadgeName = updatedBadge?.name || 'No Badge Yet'; + + // Format longest run date + let longestRunDate: string | undefined; + if (isNewRecord) { + longestRunDate = new Date().toISOString().split('T')[0]; + } else if (autoRunStats.longestRunTimestamp > 0) { + longestRunDate = new Date(autoRunStats.longestRunTimestamp).toISOString().split('T')[0]; + } + + // Submit to leaderboard in background + window.maestro.leaderboard.submit({ + email: leaderboardRegistration.email, + displayName: leaderboardRegistration.displayName, + githubUsername: leaderboardRegistration.githubUsername, + twitterHandle: leaderboardRegistration.twitterHandle, + linkedinHandle: leaderboardRegistration.linkedinHandle, + badgeLevel: updatedBadgeLevel, + badgeName: updatedBadgeName, + cumulativeTimeMs: updatedCumulativeTimeMs, + totalRuns: updatedTotalRuns, + longestRunMs: updatedLongestRunMs, + longestRunDate, + }).then(result => { + if (result.success) { + // Update last submission timestamp + setLeaderboardRegistration({ + ...leaderboardRegistration, + lastSubmissionAt: Date.now(), + emailConfirmed: !result.requiresConfirmation, + }); + } + // Silent failure - don't bother the user if submission fails + }).catch(() => { + // Silent failure - leaderboard submission is not critical + }); + } } }, onPRResult: (info) => { @@ -3990,11 +4041,13 @@ export default function MaestroConsole() { } } - // Ungrouped sessions (sorted alphabetically) - const ungroupedSessions = sessions - .filter(s => !s.groupId) - .sort((a, b) => compareNamesIgnoringEmojis(a.name, b.name)); - visualOrder.push(...ungroupedSessions); + // Ungrouped sessions (sorted alphabetically) - only if not collapsed + if (!settings.ungroupedCollapsed) { + const ungroupedSessions = sessions + .filter(s => !s.groupId) + .sort((a, b) => compareNamesIgnoringEmojis(a.name, b.name)); + visualOrder.push(...ungroupedSessions); + } } else { // Sidebar collapsed: cycle through all sessions in their sorted order visualOrder.push(...sortedSessions); @@ -6796,6 +6849,24 @@ export default function MaestroConsole() { sessions={sessions} autoRunStats={autoRunStats} onClose={() => setAboutModalOpen(false)} + onOpenLeaderboardRegistration={() => { + setAboutModalOpen(false); + setLeaderboardRegistrationOpen(true); + }} + isLeaderboardRegistered={isLeaderboardRegistered} + /> + )} + + {/* --- LEADERBOARD REGISTRATION MODAL --- */} + {leaderboardRegistrationOpen && ( + setLeaderboardRegistrationOpen(false)} + onSave={(registration) => { + setLeaderboardRegistration(registration); + }} /> )} @@ -6815,6 +6886,8 @@ export default function MaestroConsole() { completedTasks={firstRunCelebrationData.completedTasks} totalTasks={firstRunCelebrationData.totalTasks} onClose={() => setFirstRunCelebrationData(null)} + onOpenLeaderboardRegistration={() => setLeaderboardRegistrationOpen(true)} + isLeaderboardRegistered={isLeaderboardRegistered} /> )} @@ -6832,6 +6905,8 @@ export default function MaestroConsole() { acknowledgeBadge(standingOvationData.badge.level); setStandingOvationData(null); }} + onOpenLeaderboardRegistration={() => setLeaderboardRegistrationOpen(true)} + isLeaderboardRegistered={isLeaderboardRegistered} /> )} diff --git a/src/renderer/components/AboutModal.tsx b/src/renderer/components/AboutModal.tsx index 9eb78f08..477b2092 100644 --- a/src/renderer/components/AboutModal.tsx +++ b/src/renderer/components/AboutModal.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState, useCallback } from 'react'; -import { X, Wand2, ExternalLink, FileCode, BarChart3, Loader2 } from 'lucide-react'; +import { X, Wand2, ExternalLink, FileCode, BarChart3, Loader2, Trophy } from 'lucide-react'; import type { Theme, Session, AutoRunStats } from '../types'; import { useLayerStack } from '../contexts/LayerStackContext'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; @@ -23,9 +23,11 @@ interface AboutModalProps { sessions: Session[]; autoRunStats: AutoRunStats; onClose: () => void; + onOpenLeaderboardRegistration?: () => void; + isLeaderboardRegistered?: boolean; } -export function AboutModal({ theme, sessions, autoRunStats, onClose }: AboutModalProps) { +export function AboutModal({ theme, sessions, autoRunStats, onClose, onOpenLeaderboardRegistration, isLeaderboardRegistered }: AboutModalProps) { const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack(); const layerIdRef = useRef(); const [globalStats, setGlobalStats] = useState(null); @@ -275,18 +277,45 @@ export function AboutModal({ theme, sessions, autoRunStats, onClose }: AboutModa )} - {/* Project Link */} - + {/* Action Links */} +
+ {/* Project Link */} + + + {/* Leaderboard Registration */} + {onOpenLeaderboardRegistration && ( + + )} +
{/* Made in Austin */}
diff --git a/src/renderer/components/FilePreview.tsx b/src/renderer/components/FilePreview.tsx index e3592616..7e7ad7fc 100644 --- a/src/renderer/components/FilePreview.tsx +++ b/src/renderer/components/FilePreview.tsx @@ -97,6 +97,17 @@ const formatTokenCount = (count: number): string => { return count.toLocaleString(); }; +// Count markdown tasks (checkboxes) +const countMarkdownTasks = (content: string): { open: number; closed: number } => { + // Match markdown checkboxes: - [ ] or - [x] (also * [ ] and * [x]) + const openMatches = content.match(/^[\s]*[-*]\s*\[\s*\]/gm); + const closedMatches = content.match(/^[\s]*[-*]\s*\[[xX]\]/gm); + return { + open: openMatches?.length || 0, + closed: closedMatches?.length || 0 + }; +}; + // Lazy-loaded tokenizer encoder (cl100k_base is used by Claude/GPT-4) let encoderPromise: Promise> | null = null; const getEncoder = () => { @@ -322,6 +333,15 @@ export function FilePreview({ file, onClose, theme, markdownRawMode, setMarkdown const isMarkdown = language === 'markdown'; const isImage = isImageFile(file.name); + // Calculate task counts for markdown files + const taskCounts = useMemo(() => { + if (!isMarkdown || !file?.content) return null; + const counts = countMarkdownTasks(file.content); + // Only return if there are any tasks + if (counts.open === 0 && counts.closed === 0) return null; + return counts; + }, [isMarkdown, file?.content]); + // Extract directory path without filename const directoryPath = file.path.substring(0, file.path.lastIndexOf('/')); @@ -796,7 +816,7 @@ export function FilePreview({ file, onClose, theme, markdownRawMode, setMarkdown
{/* File Stats subbar - hidden on scroll */} - {(fileStats || tokenCount !== null) && showStatsBar && ( + {(fileStats || tokenCount !== null || taskCounts) && showStatsBar && (
)} + {taskCounts && ( +
+ Tasks:{' '} + {taskCounts.closed} + / + {taskCounts.open + taskCounts.closed} +
+ )}
)} diff --git a/src/renderer/components/FirstRunCelebration.tsx b/src/renderer/components/FirstRunCelebration.tsx index 012102fc..b2aab412 100644 --- a/src/renderer/components/FirstRunCelebration.tsx +++ b/src/renderer/components/FirstRunCelebration.tsx @@ -28,6 +28,10 @@ interface FirstRunCelebrationProps { totalTasks: number; /** Callback when modal is dismissed */ onClose: () => void; + /** Callback to open leaderboard registration */ + onOpenLeaderboardRegistration?: () => void; + /** Whether the user is already registered for the leaderboard */ + isLeaderboardRegistered?: boolean; } /** @@ -66,6 +70,8 @@ export function FirstRunCelebration({ completedTasks, totalTasks, onClose, + onOpenLeaderboardRegistration, + isLeaderboardRegistered, }: FirstRunCelebrationProps): JSX.Element { const containerRef = useRef(null); const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack(); @@ -408,7 +414,7 @@ export function FirstRunCelebration({ {/* Button */} -
+
+ )} +

Press Enter or Escape to dismiss diff --git a/src/renderer/components/StandingOvationOverlay.tsx b/src/renderer/components/StandingOvationOverlay.tsx index 251f642f..c2d01d66 100644 --- a/src/renderer/components/StandingOvationOverlay.tsx +++ b/src/renderer/components/StandingOvationOverlay.tsx @@ -16,6 +16,8 @@ interface StandingOvationOverlayProps { recordTimeMs?: number; cumulativeTimeMs: number; onClose: () => void; + onOpenLeaderboardRegistration?: () => void; + isLeaderboardRegistered?: boolean; } /** @@ -30,6 +32,8 @@ export function StandingOvationOverlay({ recordTimeMs, cumulativeTimeMs, onClose, + onOpenLeaderboardRegistration, + isLeaderboardRegistered, }: StandingOvationOverlayProps) { const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack(); const layerIdRef = useRef(); @@ -616,6 +620,25 @@ export function StandingOvationOverlay({

)}
+ + {/* Leaderboard Registration */} + {onOpenLeaderboardRegistration && !isLeaderboardRegistered && ( + + )} diff --git a/src/renderer/constants/modalPriorities.ts b/src/renderer/constants/modalPriorities.ts index 3e7ae9cb..b9c6a660 100644 --- a/src/renderer/constants/modalPriorities.ts +++ b/src/renderer/constants/modalPriorities.ts @@ -86,6 +86,9 @@ export const MODAL_PRIORITIES = { /** Keyboard shortcuts help modal */ SHORTCUTS_HELP: 650, + /** Leaderboard registration modal */ + LEADERBOARD_REGISTRATION: 620, + /** About/info modal */ ABOUT: 600, diff --git a/src/renderer/hooks/useSettings.ts b/src/renderer/hooks/useSettings.ts index ea5a6a13..dbefb1c8 100644 --- a/src/renderer/hooks/useSettings.ts +++ b/src/renderer/hooks/useSettings.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; -import type { LLMProvider, ThemeId, Shortcut, CustomAICommand, GlobalStats, AutoRunStats, OnboardingStats } from '../types'; +import type { LLMProvider, ThemeId, Shortcut, CustomAICommand, GlobalStats, AutoRunStats, OnboardingStats, LeaderboardRegistration } from '../types'; import { DEFAULT_SHORTCUTS } from '../constants/shortcuts'; // Default global stats @@ -208,6 +208,11 @@ export interface UseSettingsReturn { averageConversationExchanges: number; averagePhasesPerWizard: number; }; + + // Leaderboard Registration (persistent) + leaderboardRegistration: LeaderboardRegistration | null; + setLeaderboardRegistration: (value: LeaderboardRegistration | null) => void; + isLeaderboardRegistered: boolean; } export function useSettings(): UseSettingsReturn { @@ -287,6 +292,9 @@ export function useSettings(): UseSettingsReturn { // Onboarding Stats (persistent, local-only analytics) const [onboardingStats, setOnboardingStatsState] = useState(DEFAULT_ONBOARDING_STATS); + // Leaderboard Registration (persistent) + const [leaderboardRegistration, setLeaderboardRegistrationState] = useState(null); + // Wrapper functions that persist to electron-store // PERF: All wrapped in useCallback to prevent re-renders const setLlmProvider = useCallback((value: LLMProvider) => { @@ -611,8 +619,11 @@ export function useSettings(): UseSettingsReturn { // UI collapse state setters const setUngroupedCollapsed = useCallback((value: boolean) => { + console.log('[useSettings] setUngroupedCollapsed called with:', value); setUngroupedCollapsedState(value); - window.maestro.settings.set('ungroupedCollapsed', value); + window.maestro.settings.set('ungroupedCollapsed', value) + .then(() => console.log('[useSettings] ungroupedCollapsed persisted successfully')) + .catch((err: unknown) => console.error('[useSettings] Failed to persist ungroupedCollapsed:', err)); }, []); // Onboarding setters @@ -792,6 +803,17 @@ export function useSettings(): UseSettingsReturn { onboardingStats.averagePhasesPerWizard, ]); + // Leaderboard Registration setter + const setLeaderboardRegistration = useCallback((value: LeaderboardRegistration | null) => { + setLeaderboardRegistrationState(value); + window.maestro.settings.set('leaderboardRegistration', value); + }, []); + + // Computed property for checking if registered + const isLeaderboardRegistered = useMemo(() => { + return leaderboardRegistration !== null && leaderboardRegistration.emailConfirmed; + }, [leaderboardRegistration]); + // Load settings from electron-store on mount useEffect(() => { const loadSettings = async () => { @@ -831,6 +853,7 @@ export function useSettings(): UseSettingsReturn { const savedTourCompleted = await window.maestro.settings.get('tourCompleted'); const savedFirstAutoRunCompleted = await window.maestro.settings.get('firstAutoRunCompleted'); const savedOnboardingStats = await window.maestro.settings.get('onboardingStats'); + const savedLeaderboardRegistration = await window.maestro.settings.get('leaderboardRegistration'); if (savedEnterToSendAI !== undefined) setEnterToSendAIState(savedEnterToSendAI); if (savedEnterToSendTerminal !== undefined) setEnterToSendTerminalState(savedEnterToSendTerminal); @@ -950,6 +973,11 @@ export function useSettings(): UseSettingsReturn { setOnboardingStatsState({ ...DEFAULT_ONBOARDING_STATS, ...savedOnboardingStats }); } + // Load leaderboard registration + if (savedLeaderboardRegistration !== undefined) { + setLeaderboardRegistrationState(savedLeaderboardRegistration as LeaderboardRegistration | null); + } + // Mark settings as loaded setSettingsLoaded(true); }; @@ -1050,6 +1078,9 @@ export function useSettings(): UseSettingsReturn { recordTourComplete, recordTourSkip, getOnboardingAnalytics, + leaderboardRegistration, + setLeaderboardRegistration, + isLeaderboardRegistered, }), [ // State values settingsLoaded, @@ -1137,5 +1168,8 @@ export function useSettings(): UseSettingsReturn { recordTourComplete, recordTourSkip, getOnboardingAnalytics, + leaderboardRegistration, + setLeaderboardRegistration, + isLeaderboardRegistered, ]); } diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index 79b98f6e..66f86d14 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -451,3 +451,27 @@ export interface CustomAICommand { isBuiltIn?: boolean; // If true, cannot be deleted (only edited) } +// Leaderboard registration data for runmaestro.ai integration +export interface LeaderboardRegistration { + // Required fields + email: string; // User's email (will be confirmed) + displayName: string; // Display name on leaderboard + // Optional social handles (without @) + twitterHandle?: string; // X/Twitter handle + githubUsername?: string; // GitHub username + linkedinHandle?: string; // LinkedIn handle + // Registration state + registeredAt: number; // Timestamp when registered + emailConfirmed: boolean; // Whether email has been confirmed + lastSubmissionAt?: number; // Last successful submission timestamp +} + +// Response from leaderboard submission API +export interface LeaderboardSubmitResponse { + success: boolean; + message: string; + requiresConfirmation?: boolean; + confirmationUrl?: string; + error?: string; +} + From d553c41c6b4be6503d62e1de46140e9d00d1e4f1 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Fri, 12 Dec 2025 00:31:55 -0600 Subject: [PATCH 2/9] missing file --- .../LeaderboardRegistrationModal.tsx | 418 ++++++++++++++++++ 1 file changed, 418 insertions(+) create mode 100644 src/renderer/components/LeaderboardRegistrationModal.tsx diff --git a/src/renderer/components/LeaderboardRegistrationModal.tsx b/src/renderer/components/LeaderboardRegistrationModal.tsx new file mode 100644 index 00000000..044b4871 --- /dev/null +++ b/src/renderer/components/LeaderboardRegistrationModal.tsx @@ -0,0 +1,418 @@ +/** + * LeaderboardRegistrationModal.tsx + * + * Modal for registering to the runmaestro.ai leaderboard. + * Users provide display name, email (required), and optional social handles. + * On submission, stats are sent to the API and email confirmation is required. + */ + +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { X, Trophy, Mail, User, Loader2, Check, AlertCircle, ExternalLink } from 'lucide-react'; +import type { Theme, AutoRunStats, LeaderboardRegistration } from '../types'; +import { useLayerStack } from '../contexts/LayerStackContext'; +import { MODAL_PRIORITIES } from '../constants/modalPriorities'; +import { getBadgeForTime, CONDUCTOR_BADGES } from '../constants/conductorBadges'; + +// Social media icons as SVG components +const GithubIcon = ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( + + + +); + +const XTwitterIcon = ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( + + + +); + +const LinkedInIcon = ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( + + + +); + +interface LeaderboardRegistrationModalProps { + theme: Theme; + autoRunStats: AutoRunStats; + existingRegistration: LeaderboardRegistration | null; + onClose: () => void; + onSave: (registration: LeaderboardRegistration) => void; +} + +type SubmitState = 'idle' | 'submitting' | 'success' | 'awaiting_confirmation' | 'error'; + +export function LeaderboardRegistrationModal({ + theme, + autoRunStats, + existingRegistration, + onClose, + onSave, +}: LeaderboardRegistrationModalProps) { + const { registerLayer, unregisterLayer } = useLayerStack(); + const layerIdRef = useRef(); + const containerRef = useRef(null); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + // Form state + const [displayName, setDisplayName] = useState(existingRegistration?.displayName || ''); + const [email, setEmail] = useState(existingRegistration?.email || ''); + const [twitterHandle, setTwitterHandle] = useState(existingRegistration?.twitterHandle || ''); + const [githubUsername, setGithubUsername] = useState(existingRegistration?.githubUsername || ''); + const [linkedinHandle, setLinkedinHandle] = useState(existingRegistration?.linkedinHandle || ''); + + // Submission state + const [submitState, setSubmitState] = useState('idle'); + const [errorMessage, setErrorMessage] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); + + // Get current badge info + const currentBadge = getBadgeForTime(autoRunStats.cumulativeTimeMs); + const badgeLevel = currentBadge?.level || 0; + const badgeName = currentBadge?.name || 'No Badge Yet'; + + // Validate email format + const isValidEmail = (email: string): boolean => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + }; + + // Check if form is valid + const isFormValid = displayName.trim().length > 0 && email.trim().length > 0 && isValidEmail(email); + + // Handle form submission + const handleSubmit = useCallback(async () => { + if (!isFormValid) return; + + setSubmitState('submitting'); + setErrorMessage(''); + + try { + // Format longest run date if available + let longestRunDate: string | undefined; + if (autoRunStats.longestRunTimestamp > 0) { + longestRunDate = new Date(autoRunStats.longestRunTimestamp).toISOString().split('T')[0]; + } + + const result = await window.maestro.leaderboard.submit({ + email: email.trim(), + displayName: displayName.trim(), + githubUsername: githubUsername.trim() || undefined, + twitterHandle: twitterHandle.trim() || undefined, + linkedinHandle: linkedinHandle.trim() || undefined, + badgeLevel, + badgeName, + cumulativeTimeMs: autoRunStats.cumulativeTimeMs, + totalRuns: autoRunStats.totalRuns, + longestRunMs: autoRunStats.longestRunMs || undefined, + longestRunDate, + }); + + if (result.success) { + // Save registration locally + const registration: LeaderboardRegistration = { + email: email.trim(), + displayName: displayName.trim(), + twitterHandle: twitterHandle.trim() || undefined, + githubUsername: githubUsername.trim() || undefined, + linkedinHandle: linkedinHandle.trim() || undefined, + registeredAt: Date.now(), + emailConfirmed: !result.requiresConfirmation, + lastSubmissionAt: Date.now(), + }; + onSave(registration); + + if (result.requiresConfirmation) { + setSubmitState('awaiting_confirmation'); + setSuccessMessage('Please check your email to confirm your registration. Your submission will be queued for approval once confirmed.'); + } else { + setSubmitState('success'); + setSuccessMessage('Your stats have been submitted! Your entry is now queued for manual approval.'); + } + } else { + setSubmitState('error'); + setErrorMessage(result.error || result.message || 'Submission failed'); + } + } catch (error) { + setSubmitState('error'); + setErrorMessage(error instanceof Error ? error.message : 'An unexpected error occurred'); + } + }, [isFormValid, email, displayName, githubUsername, twitterHandle, linkedinHandle, badgeLevel, badgeName, autoRunStats, onSave]); + + // Register layer on mount + useEffect(() => { + const id = registerLayer({ + type: 'modal', + priority: MODAL_PRIORITIES.LEADERBOARD_REGISTRATION, + blocksLowerLayers: true, + capturesFocus: true, + focusTrap: 'strict', + ariaLabel: 'Register for Leaderboard', + onEscape: () => onCloseRef.current(), + }); + layerIdRef.current = id; + + containerRef.current?.focus(); + + return () => { + if (layerIdRef.current) { + unregisterLayer(layerIdRef.current); + } + }; + }, [registerLayer, unregisterLayer]); + + // Handle Enter key for form submission + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey && isFormValid && submitState === 'idle') { + e.preventDefault(); + handleSubmit(); + } + }, [isFormValid, submitState, handleSubmit]); + + return ( +
+
+ {/* Header */} +
+
+ +

+ {existingRegistration ? 'Update Leaderboard Registration' : 'Register for Leaderboard'} +

+
+ +
+ + {/* Content */} +
+ {/* Info text */} +

+ Join the global Maestro leaderboard at{' '} + + . Your cumulative AutoRun time and achievements will be displayed. +

+ + {/* Current stats preview */} +
+
+ + Your Current Stats +
+
+
+ Badge: + {badgeName} +
+
+ Total Runs: + {autoRunStats.totalRuns} +
+
+
+ + {/* Form fields */} +
+ {/* Display Name - Required */} +
+ + setDisplayName(e.target.value)} + placeholder="ConductorPedram" + className="w-full px-3 py-2 text-sm rounded border outline-none focus:ring-1" + style={{ + backgroundColor: theme.colors.bgActivity, + borderColor: theme.colors.border, + color: theme.colors.textMain, + }} + disabled={submitState === 'submitting'} + /> +
+ + {/* Email - Required */} +
+ + setEmail(e.target.value)} + placeholder="conductor@maestro.ai" + className="w-full px-3 py-2 text-sm rounded border outline-none focus:ring-1" + style={{ + backgroundColor: theme.colors.bgActivity, + borderColor: email && !isValidEmail(email) ? theme.colors.error : theme.colors.border, + color: theme.colors.textMain, + }} + disabled={submitState === 'submitting'} + /> + {email && !isValidEmail(email) && ( +

+ Please enter a valid email address +

+ )} +

+ You'll receive a confirmation email to verify your registration +

+
+ + {/* Social handles - Optional */} +
+

+ Optional: Link your social profiles +

+ +
+ {/* GitHub */} +
+ + setGithubUsername(e.target.value.replace(/^@/, ''))} + placeholder="username" + className="flex-1 px-3 py-1.5 text-sm rounded border outline-none focus:ring-1" + style={{ + backgroundColor: theme.colors.bgActivity, + borderColor: theme.colors.border, + color: theme.colors.textMain, + }} + disabled={submitState === 'submitting'} + /> +
+ + {/* X/Twitter */} +
+ + setTwitterHandle(e.target.value.replace(/^@/, ''))} + placeholder="handle" + className="flex-1 px-3 py-1.5 text-sm rounded border outline-none focus:ring-1" + style={{ + backgroundColor: theme.colors.bgActivity, + borderColor: theme.colors.border, + color: theme.colors.textMain, + }} + disabled={submitState === 'submitting'} + /> +
+ + {/* LinkedIn */} +
+ + setLinkedinHandle(e.target.value.replace(/^@/, ''))} + placeholder="username" + className="flex-1 px-3 py-1.5 text-sm rounded border outline-none focus:ring-1" + style={{ + backgroundColor: theme.colors.bgActivity, + borderColor: theme.colors.border, + color: theme.colors.textMain, + }} + disabled={submitState === 'submitting'} + /> +
+
+
+
+ + {/* Status messages */} + {submitState === 'error' && ( +
+ +

{errorMessage}

+
+ )} + + {(submitState === 'success' || submitState === 'awaiting_confirmation') && ( +
+ +

{successMessage}

+
+ )} +
+ + {/* Footer */} +
+ + {submitState !== 'success' && submitState !== 'awaiting_confirmation' && ( + + )} +
+
+
+ ); +} + +export default LeaderboardRegistrationModal; From 5b37edf5467158d680491372dd320ec159e51b89 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Fri, 12 Dec 2025 00:44:29 -0600 Subject: [PATCH 3/9] fix: Update AboutModal tests for dual GitHub links The AboutModal now has two "GitHub" text elements (author profile and project repo link), so tests updated to use getAllByText and select the appropriate link by index. --- .../renderer/components/AboutModal.test.tsx | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/__tests__/renderer/components/AboutModal.test.tsx b/src/__tests__/renderer/components/AboutModal.test.tsx index d1bdebe9..8309bb19 100644 --- a/src/__tests__/renderer/components/AboutModal.test.tsx +++ b/src/__tests__/renderer/components/AboutModal.test.tsx @@ -326,9 +326,10 @@ describe('AboutModal', () => { /> ); - // The component renders "GitHub" as the button text in author section - // Use getByText since there are multiple GitHub buttons - expect(screen.getByText('GitHub')).toBeInTheDocument(); + // The component renders "GitHub" twice - author section and project link + // Use getAllByText since there are multiple GitHub buttons + const githubLinks = screen.getAllByText('GitHub'); + expect(githubLinks.length).toBeGreaterThanOrEqual(1); }); it('should have LinkedIn profile link', () => { @@ -357,9 +358,9 @@ describe('AboutModal', () => { /> ); - // The component renders "GitHub" as the button text - use getByText and find parent button - const githubLink = screen.getByText('GitHub'); - fireEvent.click(githubLink); + // The component renders "GitHub" twice - first one is the author profile link + const githubLinks = screen.getAllByText('GitHub'); + fireEvent.click(githubLinks[0]); expect(window.maestro.shell.openExternal).toHaveBeenCalledWith('https://github.com/pedramamini'); }); @@ -381,7 +382,7 @@ describe('AboutModal', () => { expect(window.maestro.shell.openExternal).toHaveBeenCalledWith('https://www.linkedin.com/in/pedramamini/'); }); - it('should open GitHub repo on View on GitHub click', async () => { + it('should open GitHub repo on project GitHub click', async () => { render( { /> ); - const viewOnGitHub = screen.getByText('View on GitHub'); - fireEvent.click(viewOnGitHub); + // The component renders "GitHub" twice - second one is the project repo link + const githubLinks = screen.getAllByText('GitHub'); + fireEvent.click(githubLinks[1]); expect(window.maestro.shell.openExternal).toHaveBeenCalledWith('https://github.com/pedramamini/Maestro'); }); From ca34d3f4ad16cb8632b32f3118bece250a2e558e Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Fri, 12 Dec 2025 00:47:02 -0600 Subject: [PATCH 4/9] version bump --- package.json | 10 ++--- src/renderer/components/TabSwitcherModal.tsx | 43 ++++++++++++++++++-- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 433c5475..f562f2ab 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { "name": "maestro", - "version": "0.7.4", - "description": "Multi-Instance AI Coding Console - Unified IDE for managing multiple AI coding assistants", + "version": "0.8.0", + "description": "Run AI coding agents autonomously for days.", "main": "dist/main/index.js", "author": { - "name": "Maestro Team", - "email": "maestro@example.com" + "name": "Pedram Amini", + "email": "pedram@runmaestro.ai" }, - "license": "MIT", + "license": "AGPL 3.0", "repository": { "type": "git", "url": "https://github.com/yourusername/maestro.git" diff --git a/src/renderer/components/TabSwitcherModal.tsx b/src/renderer/components/TabSwitcherModal.tsx index 55b468ee..935c1c11 100644 --- a/src/renderer/components/TabSwitcherModal.tsx +++ b/src/renderer/components/TabSwitcherModal.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useMemo } from 'react'; -import { Search } from 'lucide-react'; +import { Search, Star } from 'lucide-react'; import type { AITab, Theme, Shortcut } from '../types'; import { fuzzyMatchWithScore } from '../utils/search'; import { useLayerStack } from '../contexts/LayerStackContext'; @@ -158,7 +158,7 @@ function ContextGauge({ percentage, theme, size = 36 }: { percentage: number; th ); } -type ViewMode = 'open' | 'all-named'; +type ViewMode = 'open' | 'all-named' | 'starred'; /** * Tab Switcher Modal - Quick navigation between AI tabs with fuzzy search. @@ -281,6 +281,32 @@ export function TabSwitcherModal({ return nameA.localeCompare(nameB); }); return sorted.map(tab => ({ type: 'open' as const, tab })); + } else if (viewMode === 'starred') { + // Starred mode - show all starred sessions (open or closed) for the current project + const items: ListItem[] = []; + + // Add starred open tabs + for (const tab of tabs) { + if (tab.starred && tab.claudeSessionId) { + items.push({ type: 'open' as const, tab }); + } + } + + // Add starred closed sessions from the same project (not currently open) + for (const session of namedSessions) { + if (session.starred && session.projectPath === projectRoot && !openTabSessionIds.has(session.claudeSessionId)) { + items.push({ type: 'named' as const, session }); + } + } + + // Sort by display name + items.sort((a, b) => { + const nameA = a.type === 'open' ? getTabDisplayName(a.tab).toLowerCase() : a.session.sessionName.toLowerCase(); + const nameB = b.type === 'open' ? getTabDisplayName(b.tab).toLowerCase() : b.session.sessionName.toLowerCase(); + return nameA.localeCompare(nameB); + }); + + return items; } else { // All Named mode - show sessions with claudeSessionId for the CURRENT PROJECT (including open ones) // For open tabs, use the 'open' type so we get usage stats; for closed ones use 'named' @@ -452,7 +478,18 @@ export function TabSwitcherModal({ color: viewMode === 'all-named' ? theme.colors.accentForeground : theme.colors.textDim }} > - All Named ({tabs.filter(t => t.name && t.claudeSessionId).length + namedSessions.filter(s => s.projectPath === projectRoot && !openTabSessionIds.has(s.claudeSessionId)).length}) + All Named ({tabs.filter(t => t.claudeSessionId).length + namedSessions.filter(s => s.projectPath === projectRoot && !openTabSessionIds.has(s.claudeSessionId)).length}) + + Tab to switch From 6ffefd563c4841a52387c6dd5dbf6941950c6c79 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Fri, 12 Dec 2025 00:52:49 -0600 Subject: [PATCH 5/9] feat: Add Starred filter to Tab Switcher modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a third filter option to the Tab Switcher modal that shows all starred sessions (both open tabs and closed sessions) for the current project. The modal now cycles through three modes with Tab key: Open Tabs โ†’ All Named โ†’ Starred. Changes: - Add 'starred' to ViewMode type - Add filtering logic for starred sessions (open + closed) - Add Starred button pill with star icon and count - Update placeholder, empty state, and footer text for starred mode - Update Tab key cycling to include all three modes - Add 4 new tests for starred mode functionality Claude ID: 472a496d-f767-49dc-8074-a08d20d2b550 Maestro ID: b9bc0d08-5be2-4fdf-93cd-5618a8d53b35 --- .../components/TabSwitcherModal.test.tsx | 147 +++++++++++++++++- src/renderer/components/TabSwitcherModal.tsx | 12 +- 2 files changed, 152 insertions(+), 7 deletions(-) diff --git a/src/__tests__/renderer/components/TabSwitcherModal.test.tsx b/src/__tests__/renderer/components/TabSwitcherModal.test.tsx index 1997b198..d17b3f05 100644 --- a/src/__tests__/renderer/components/TabSwitcherModal.test.tsx +++ b/src/__tests__/renderer/components/TabSwitcherModal.test.tsx @@ -21,6 +21,7 @@ import type { Theme, AITab } from '../../../renderer/types'; // Mock lucide-react vi.mock('lucide-react', () => ({ Search: () => , + Star: () => , })); // Create a test theme @@ -994,6 +995,145 @@ describe('TabSwitcherModal', () => { expect(screen.queryByText('Different Project Session')).not.toBeInTheDocument(); }); }); + + it('switches to Starred mode on pill click', async () => { + const starredTab = createTestTab({ name: 'Starred Tab', starred: true }); + const unstarredTab = createTestTab({ name: 'Unstarred Tab', starred: false }); + + vi.mocked(window.maestro.claude.getAllNamedSessions).mockResolvedValue([ + { + claudeSessionId: 'starred-closed-123', + projectPath: '/test', + sessionName: 'Starred Closed Session', + starred: true, + }, + { + claudeSessionId: 'unstarred-closed-456', + projectPath: '/test', + sessionName: 'Unstarred Closed Session', + starred: false, + }, + ]); + + renderWithLayerStack( + + ); + + await waitFor(() => { + expect(window.maestro.claude.getAllNamedSessions).toHaveBeenCalled(); + }); + + // Click Starred pill (use exact pattern to avoid matching list items) + fireEvent.click(screen.getByRole('button', { name: /Starred \(\d+\)/ })); + + // Should show only starred items + await waitFor(() => { + expect(screen.getByText('Starred Tab')).toBeInTheDocument(); + expect(screen.queryByText('Unstarred Tab')).not.toBeInTheDocument(); + // Closed starred session should also appear + expect(screen.getByText('Starred Closed Session')).toBeInTheDocument(); + expect(screen.queryByText('Unstarred Closed Session')).not.toBeInTheDocument(); + }); + + // Placeholder should indicate starred mode + expect(screen.getByPlaceholderText('Search starred sessions...')).toBeInTheDocument(); + }); + + it('shows "No starred sessions" when there are no starred items', async () => { + vi.mocked(window.maestro.claude.getAllNamedSessions).mockResolvedValue([]); + + renderWithLayerStack( + + ); + + await waitFor(() => { + expect(window.maestro.claude.getAllNamedSessions).toHaveBeenCalled(); + }); + + fireEvent.click(screen.getByRole('button', { name: /Starred \(\d+\)/ })); + + await waitFor(() => { + expect(screen.getByText('No starred sessions')).toBeInTheDocument(); + }); + }); + + it('shows correct count for Starred pill', async () => { + const starredTab1 = createTestTab({ name: 'Starred 1', starred: true }); + const starredTab2 = createTestTab({ name: 'Starred 2', starred: true }); + const unstarredTab = createTestTab({ name: 'Unstarred', starred: false }); + + vi.mocked(window.maestro.claude.getAllNamedSessions).mockResolvedValue([ + { + claudeSessionId: 'starred-closed-abc', + projectPath: '/test', + sessionName: 'Starred Closed', + starred: true, + }, + ]); + + renderWithLayerStack( + + ); + + await waitFor(() => { + expect(window.maestro.claude.getAllNamedSessions).toHaveBeenCalled(); + }); + + // Should show count of 3: 2 open starred + 1 closed starred + expect(screen.getByText(/Starred \(3\)/)).toBeInTheDocument(); + }); + + it('cycles through all three modes with Tab key', async () => { + renderWithLayerStack( + + ); + + const input = screen.getByPlaceholderText('Search open tabs...'); + + // Tab 1: open -> all-named + fireEvent.keyDown(input, { key: 'Tab' }); + expect(screen.getByPlaceholderText('Search named sessions...')).toBeInTheDocument(); + + // Tab 2: all-named -> starred + fireEvent.keyDown(input, { key: 'Tab' }); + expect(screen.getByPlaceholderText('Search starred sessions...')).toBeInTheDocument(); + + // Tab 3: starred -> open + fireEvent.keyDown(input, { key: 'Tab' }); + expect(screen.getByPlaceholderText('Search open tabs...')).toBeInTheDocument(); + }); }); describe('search functionality', () => { @@ -1804,12 +1944,13 @@ describe('TabSwitcherModal', () => { const input = screen.getByPlaceholderText('Search open tabs...'); - // Rapid Tab key presses - for (let i = 0; i < 10; i++) { + // Rapid Tab key presses - cycles through 3 modes: open -> all-named -> starred -> open + // 9 presses = 9 mod 3 = 0, so we end up back at open tabs + for (let i = 0; i < 9; i++) { fireEvent.keyDown(input, { key: 'Tab' }); } - // Should be back to open tabs (even number of switches) + // Should be back to open tabs (multiple of 3 switches) expect(screen.getByPlaceholderText('Search open tabs...')).toBeInTheDocument(); }); diff --git a/src/renderer/components/TabSwitcherModal.tsx b/src/renderer/components/TabSwitcherModal.tsx index 935c1c11..dcdc0ce6 100644 --- a/src/renderer/components/TabSwitcherModal.tsx +++ b/src/renderer/components/TabSwitcherModal.tsx @@ -379,7 +379,11 @@ export function TabSwitcherModal({ }, [search, viewMode]); const toggleViewMode = () => { - setViewMode(prev => prev === 'open' ? 'all-named' : 'open'); + setViewMode(prev => { + if (prev === 'open') return 'all-named'; + if (prev === 'all-named') return 'starred'; + return 'open'; + }); }; const handleItemSelect = (item: ListItem) => { @@ -437,7 +441,7 @@ export function TabSwitcherModal({ setSearch(e.target.value)} @@ -671,7 +675,7 @@ export function TabSwitcherModal({ {filteredItems.length === 0 && (
- {viewMode === 'open' ? 'No open tabs' : 'No named sessions found'} + {viewMode === 'open' ? 'No open tabs' : viewMode === 'starred' ? 'No starred sessions' : 'No named sessions found'}
)} @@ -681,7 +685,7 @@ export function TabSwitcherModal({ className="px-4 py-2 border-t text-xs flex items-center justify-between" style={{ borderColor: theme.colors.border, color: theme.colors.textDim }} > - {filteredItems.length} {viewMode === 'open' ? 'tabs' : 'sessions'} + {filteredItems.length} {viewMode === 'open' ? 'tabs' : viewMode === 'starred' ? 'starred' : 'sessions'} โ†‘โ†“ navigate โ€ข Enter select โ€ข โŒ˜1-9 quick select From 54ffb7e42738a6b5671fcae42ff84dedad625b54 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Fri, 12 Dec 2025 09:22:51 -0600 Subject: [PATCH 6/9] feat: Add Maestro website link and various UI improvements - Add Globe icon to About Modal header linking to runmaestro.ai - Add "Maestro Website" menu item in hamburger menus (both expanded and collapsed sidebar) - Add "Off" option for toast notifications to disable them entirely - Fix SettingsModal memo comparator that prevented prop updates - Simplify AgentSessionsBrowser escape handling (single Escape closes modal) - Fix TabSwitcherModal "All Named" filter to only show tabs with custom names - Update Leaderboard "Active" badge styling for better contrast Claude ID: f67864c5-6863-496d-9e9a-28d745977704 Maestro ID: b9bc0d08-5be2-4fdf-93cd-5618a8d53b35 --- .../renderer/components/AboutModal.test.tsx | 6 +++++ .../components/AgentSessionsBrowser.test.tsx | 20 +++----------- .../renderer/components/SessionList.test.tsx | 2 ++ .../components/SettingsModal.test.tsx | 27 +++++++++++++++++++ .../renderer/contexts/ToastContext.test.tsx | 27 +++++++++++++++++++ src/renderer/components/AboutModal.tsx | 16 ++++++++--- .../components/AgentSessionsBrowser.tsx | 13 +++------ src/renderer/components/SessionList.tsx | 24 ++++++++++++++++- src/renderer/components/SettingsModal.tsx | 21 +++++++++------ src/renderer/components/TabSwitcherModal.tsx | 9 +++---- src/renderer/contexts/ToastContext.tsx | 13 ++++++--- 11 files changed, 131 insertions(+), 47 deletions(-) diff --git a/src/__tests__/renderer/components/AboutModal.test.tsx b/src/__tests__/renderer/components/AboutModal.test.tsx index 8309bb19..8b5b2bfc 100644 --- a/src/__tests__/renderer/components/AboutModal.test.tsx +++ b/src/__tests__/renderer/components/AboutModal.test.tsx @@ -29,6 +29,12 @@ vi.mock('lucide-react', () => ({ Loader2: ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( โณ ), + Trophy: ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( + ๐Ÿ† + ), + Globe: ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( + ๐ŸŒ + ), })); // Mock the avatar import diff --git a/src/__tests__/renderer/components/AgentSessionsBrowser.test.tsx b/src/__tests__/renderer/components/AgentSessionsBrowser.test.tsx index 583a8332..dbac89a9 100644 --- a/src/__tests__/renderer/components/AgentSessionsBrowser.test.tsx +++ b/src/__tests__/renderer/components/AgentSessionsBrowser.test.tsx @@ -1506,28 +1506,14 @@ describe('AgentSessionsBrowser', () => { await vi.runAllTimersAsync(); }); - // First Escape closes search panel (component defaults to search panel view) + // Escape should close modal directly (search panel no longer intercepts Escape) await act(async () => { - const escapeEvent1 = new KeyboardEvent('keydown', { + const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true, }); - window.dispatchEvent(escapeEvent1); - await vi.runAllTimersAsync(); - }); - - // First Escape should NOT close modal (closes search panel instead) - expect(onClose).not.toHaveBeenCalled(); - - // Second Escape should close modal - await act(async () => { - const escapeEvent2 = new KeyboardEvent('keydown', { - key: 'Escape', - bubbles: true, - cancelable: true, - }); - window.dispatchEvent(escapeEvent2); + window.dispatchEvent(escapeEvent); await vi.runAllTimersAsync(); }); diff --git a/src/__tests__/renderer/components/SessionList.test.tsx b/src/__tests__/renderer/components/SessionList.test.tsx index 4e06010d..bef42b8d 100644 --- a/src/__tests__/renderer/components/SessionList.test.tsx +++ b/src/__tests__/renderer/components/SessionList.test.tsx @@ -51,6 +51,8 @@ vi.mock('lucide-react', () => ({ Edit3: () => , FolderInput: () => , Download: () => , + Compass: () => , + Globe: () => , })); // Mock gitService diff --git a/src/__tests__/renderer/components/SettingsModal.test.tsx b/src/__tests__/renderer/components/SettingsModal.test.tsx index d04d9171..96454c82 100644 --- a/src/__tests__/renderer/components/SettingsModal.test.tsx +++ b/src/__tests__/renderer/components/SettingsModal.test.tsx @@ -1054,6 +1054,30 @@ describe('SettingsModal', () => { expect(setOsNotificationsEnabled).toHaveBeenCalledWith(false); }); + it('should update checkbox state when prop changes (regression test for memo bug)', async () => { + // This test ensures the component re-renders when props change + // A previous bug had an overly restrictive memo comparator that prevented re-renders + const { rerender } = render(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + + // Verify initial checked state + const checkbox = screen.getByText('Enable OS Notifications').closest('label')?.querySelector('input[type="checkbox"]') as HTMLInputElement; + expect(checkbox.checked).toBe(true); + + // Rerender with changed prop (simulating what happens after onChange) + rerender(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + // The checkbox should now be unchecked - this would fail with the old memo comparator + expect(checkbox.checked).toBe(false); + }); + it('should test notification when button is clicked', async () => { render(); @@ -1137,6 +1161,9 @@ describe('SettingsModal', () => { await vi.advanceTimersByTimeAsync(100); }); + fireEvent.click(screen.getByRole('button', { name: 'Off' })); + expect(setToastDuration).toHaveBeenCalledWith(-1); + fireEvent.click(screen.getByRole('button', { name: '5s' })); expect(setToastDuration).toHaveBeenCalledWith(5); diff --git a/src/__tests__/renderer/contexts/ToastContext.test.tsx b/src/__tests__/renderer/contexts/ToastContext.test.tsx index 2fe0e565..147997d6 100644 --- a/src/__tests__/renderer/contexts/ToastContext.test.tsx +++ b/src/__tests__/renderer/contexts/ToastContext.test.tsx @@ -177,6 +177,33 @@ describe('ToastContext', () => { expect(contextValue!.toasts).toHaveLength(1); }); + it('duration of -1 disables toast UI but still logs and notifies', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + let contextValue: ReturnType | null = null; + + renderWithProvider( + { contextValue = ctx; }} />, + { defaultDuration: -1 } // -1 = toasts disabled + ); + + await act(async () => { + contextValue!.addToast({ + type: 'success', + title: 'Hidden Toast', + message: 'Should not appear in UI', + }); + }); + + // Toast should NOT be in the visible toasts array + expect(contextValue!.toasts).toHaveLength(0); + + // But logging should still happen + expect(window.maestro.logger.toast).toHaveBeenCalledWith('Hidden Toast', expect.any(Object)); + + // And OS notification should still be shown (if enabled) + expect(window.maestro.notification.show).toHaveBeenCalled(); + }); + it('logs toast via window.maestro.logger.toast', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); let contextValue: ReturnType | null = null; diff --git a/src/renderer/components/AboutModal.tsx b/src/renderer/components/AboutModal.tsx index 477b2092..892195b0 100644 --- a/src/renderer/components/AboutModal.tsx +++ b/src/renderer/components/AboutModal.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState, useCallback } from 'react'; -import { X, Wand2, ExternalLink, FileCode, BarChart3, Loader2, Trophy } from 'lucide-react'; +import { X, Wand2, ExternalLink, FileCode, BarChart3, Loader2, Trophy, Globe } from 'lucide-react'; import type { Theme, Session, AutoRunStats } from '../types'; import { useLayerStack } from '../contexts/LayerStackContext'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; @@ -138,7 +138,17 @@ export function AboutModal({ theme, sessions, autoRunStats, onClose, onOpenLeade >
-

About Maestro

+
+

About Maestro

+ +
@@ -309,7 +319,7 @@ export function AboutModal({ theme, sessions, autoRunStats, onClose, onOpenLeade
{isLeaderboardRegistered ? ( - Active + Active ) : ( )} diff --git a/src/renderer/components/AgentSessionsBrowser.tsx b/src/renderer/components/AgentSessionsBrowser.tsx index 145e1bfd..37670e55 100644 --- a/src/renderer/components/AgentSessionsBrowser.tsx +++ b/src/renderer/components/AgentSessionsBrowser.tsx @@ -113,8 +113,6 @@ export function AgentSessionsBrowser({ onCloseRef.current = onClose; const viewingSessionRef = useRef(viewingSession); viewingSessionRef.current = viewingSession; - const showSearchPanelRef = useRef(showSearchPanel); - showSearchPanelRef.current = showSearchPanel; const autoJumpedRef = useRef(null); // Track which session we've auto-jumped to const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack(); @@ -135,12 +133,10 @@ export function AgentSessionsBrowser({ focusTrap: 'lenient', ariaLabel: 'Agent Sessions Browser', onEscape: () => { + // If viewing a session detail, go back to list; otherwise close the panel if (viewingSessionRef.current) { setViewingSession(null); setMessages([]); - } else if (showSearchPanelRef.current) { - // If in search panel, switch back to graph first - setShowSearchPanel(false); } else { onCloseRef.current(); } @@ -154,22 +150,19 @@ export function AgentSessionsBrowser({ }; }, [registerLayer, unregisterLayer]); - // Update handler when viewingSession or showSearchPanel changes + // Update handler when viewingSession changes useEffect(() => { if (layerIdRef.current) { updateLayerHandler(layerIdRef.current, () => { if (viewingSessionRef.current) { setViewingSession(null); setMessages([]); - } else if (showSearchPanelRef.current) { - // If in search panel, switch back to graph first - setShowSearchPanel(false); } else { onCloseRef.current(); } }); } - }, [viewingSession, showSearchPanel, updateLayerHandler]); + }, [viewingSession, updateLayerHandler]); // Restore focus and scroll position when returning from detail view to list view const prevViewingSessionRef = useRef(null); diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx index ef3a06ef..29a1d29b 100644 --- a/src/renderer/components/SessionList.tsx +++ b/src/renderer/components/SessionList.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { Wand2, Plus, Settings, ChevronRight, ChevronDown, Activity, X, Keyboard, Radio, Copy, ExternalLink, PanelLeftClose, PanelLeftOpen, Folder, Info, FileText, GitBranch, Bot, Clock, - ScrollText, Cpu, Menu, Bookmark, Trophy, Trash2, Edit3, FolderInput, Download, Compass + ScrollText, Cpu, Menu, Bookmark, Trophy, Trash2, Edit3, FolderInput, Download, Compass, Globe } from 'lucide-react'; import { QRCodeSVG } from 'qrcode.react'; import type { Session, Group, Theme, Shortcut, AutoRunStats } from '../types'; @@ -1177,6 +1177,17 @@ export function SessionList(props: SessionListProps) {
Version, Credits, Stats
+ )} @@ -1302,6 +1313,17 @@ export function SessionList(props: SessionListProps) {
Version, Credits, Stats
+ )} diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx index d198ee31..d14301c0 100644 --- a/src/renderer/components/SettingsModal.tsx +++ b/src/renderer/components/SettingsModal.tsx @@ -1623,6 +1623,18 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro Toast Notification Duration
+

- How long toast notifications remain on screen. "Never" means they stay until manually dismissed. + How long toast notifications remain on screen. "Off" disables them entirely. "Never" means they stay until manually dismissed.

@@ -1712,11 +1724,4 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro ); -}, (prevProps, nextProps) => { - // Custom comparator: only re-render if key display props change - // Callbacks are stable (wrapped in useCallback in App.tsx) - return prevProps.isOpen === nextProps.isOpen && - prevProps.theme === nextProps.theme && - prevProps.activeThemeId === nextProps.activeThemeId && - prevProps.initialTab === nextProps.initialTab; }); diff --git a/src/renderer/components/TabSwitcherModal.tsx b/src/renderer/components/TabSwitcherModal.tsx index dcdc0ce6..759afd60 100644 --- a/src/renderer/components/TabSwitcherModal.tsx +++ b/src/renderer/components/TabSwitcherModal.tsx @@ -308,14 +308,13 @@ export function TabSwitcherModal({ return items; } else { - // All Named mode - show sessions with claudeSessionId for the CURRENT PROJECT (including open ones) + // All Named mode - show only sessions that have been given a custom name // For open tabs, use the 'open' type so we get usage stats; for closed ones use 'named' const items: ListItem[] = []; - // Add open tabs that have a Claude session (whether named or not) - // This ensures all active sessions are searchable, not just those with custom names + // Add open tabs that have a custom name (not just UUID-based display names) for (const tab of tabs) { - if (tab.claudeSessionId) { + if (tab.claudeSessionId && tab.name) { items.push({ type: 'open' as const, tab }); } } @@ -482,7 +481,7 @@ export function TabSwitcherModal({ color: viewMode === 'all-named' ? theme.colors.accentForeground : theme.colors.textDim }} > - All Named ({tabs.filter(t => t.claudeSessionId).length + namedSessions.filter(s => s.projectPath === projectRoot && !openTabSessionIds.has(s.claudeSessionId)).length}) + All Named ({tabs.filter(t => t.claudeSessionId && t.name).length + namedSessions.filter(s => s.projectPath === projectRoot && !openTabSessionIds.has(s.claudeSessionId)).length}) + ) )} + {/* Replay button for user messages in AI mode */} + {isUserMessage && isAIMode && onReplayMessage && ( + + )} {/* Copy to Clipboard Button */} + )} + {/* Show remote images toggle - only in preview mode */} + {!markdownEditMode && ( + + )} + {/* Toggle between edit and preview mode */} - )} @@ -804,7 +873,7 @@ export function FilePreview({ file, onClose, theme, markdownRawMode, setMarkdown style={{ color: theme.colors.textDim }} title="Copy full path to clipboard" > - +