diff --git a/src/__tests__/renderer/components/AboutModal.test.tsx b/src/__tests__/renderer/components/AboutModal.test.tsx index c8d9e0d3f..aa3230b77 100644 --- a/src/__tests__/renderer/components/AboutModal.test.tsx +++ b/src/__tests__/renderer/components/AboutModal.test.tsx @@ -17,6 +17,17 @@ vi.mock('lucide-react', () => ({ × ), + MessageSquarePlus: ({ + className, + style, + }: { + className?: string; + style?: React.CSSProperties; + }) => ( + + ✉ + + ), Wand2: ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( 🪄 diff --git a/src/main/ipc/handlers/feedback.ts b/src/main/ipc/handlers/feedback.ts new file mode 100644 index 000000000..3824bea70 --- /dev/null +++ b/src/main/ipc/handlers/feedback.ts @@ -0,0 +1,150 @@ +/** + * Feedback IPC Handlers + * + * This module handles: + * - Checking GitHub CLI availability and authentication + * - Submitting feedback text to the selected agent as a structured prompt + */ + +import { ipcMain, app } from 'electron'; +import fs from 'fs/promises'; +import path from 'path'; +import { logger } from '../../utils/logger'; +import { withIpcErrorLogging, CreateHandlerOptions } from '../../utils/ipcHandler'; +import { + isGhInstalled, + setCachedGhStatus, + getCachedGhStatus, + getExpandedEnv, +} from '../../utils/cliDetection'; +import { execFileNoThrow } from '../../utils/execFile'; +import { ProcessManager } from '../../process-manager'; + +const LOG_CONTEXT = '[Feedback]'; + +const GH_NOT_INSTALLED_MESSAGE = + 'GitHub CLI (gh) is not installed. Install it from https://cli.github.com'; +const GH_NOT_AUTHENTICATED_MESSAGE = + 'GitHub CLI is not authenticated. Run "gh auth login" in your terminal.'; + +function getPromptPath(): string { + if (app.isPackaged) { + return path.join(process.resourcesPath, 'prompts', 'feedback.md'); + } + + return path.join(app.getAppPath(), 'src', 'prompts', 'feedback.md'); +} + +/** + * Helper to create handler options with consistent context + */ +const handlerOpts = ( + operation: string, + extra?: Partial +): Pick => ({ + context: LOG_CONTEXT, + operation, + ...extra, +}); + +/** + * Dependencies required for feedback handler registration + */ +export interface FeedbackHandlerDependencies { + getProcessManager: () => ProcessManager | null; +} + +/** + * Register feedback IPC handlers. + */ +export function registerFeedbackHandlers(deps: FeedbackHandlerDependencies): void { + const { getProcessManager } = deps; + + logger.info('Registering feedback IPC handlers', LOG_CONTEXT); + + // Check if GitHub CLI is installed and authenticated + ipcMain.handle( + 'feedback:check-gh-auth', + withIpcErrorLogging( + handlerOpts('check-gh-auth'), + async (): Promise<{ authenticated: boolean; message?: string }> => { + // Prefer cache when available + const cached = getCachedGhStatus(); + if (cached) { + if (!cached.installed) { + return { authenticated: false, message: GH_NOT_INSTALLED_MESSAGE }; + } + if (!cached.authenticated) { + return { authenticated: false, message: GH_NOT_AUTHENTICATED_MESSAGE }; + } + return { authenticated: true }; + } + + // Check if gh is installed + const installed = await isGhInstalled(); + if (!installed) { + setCachedGhStatus(false, false); + return { authenticated: false, message: GH_NOT_INSTALLED_MESSAGE }; + } + + // Check auth status (command output ignored; exit code is the signal) + const authResult = await execFileNoThrow( + 'gh', + ['auth', 'status'], + undefined, + getExpandedEnv() + ); + const authenticated = authResult.exitCode === 0; + setCachedGhStatus(true, authenticated); + + if (!authenticated) { + return { authenticated: false, message: GH_NOT_AUTHENTICATED_MESSAGE }; + } + + return { authenticated: true }; + } + ) + ); + + // Submit feedback by writing to an active process + ipcMain.handle( + 'feedback:submit', + withIpcErrorLogging( + handlerOpts('submit'), + async ({ + sessionId, + feedbackText, + }: { + sessionId: string; + feedbackText: string; + }): Promise<{ success: boolean; error?: string }> => { + if (!sessionId || typeof sessionId !== 'string') { + return { success: false, error: 'No target agent was selected.' }; + } + + const trimmedFeedback = typeof feedbackText === 'string' ? feedbackText.trim() : ''; + if (!trimmedFeedback) { + return { success: false, error: 'Feedback cannot be empty.' }; + } + if (trimmedFeedback.length > 5000) { + return { success: false, error: 'Feedback exceeds the maximum length (5000).' }; + } + + const processManager = getProcessManager(); + if (!processManager) { + return { success: false, error: 'Agent process not available' }; + } + + const promptTemplate = await fs.readFile(getPromptPath(), 'utf-8'); + const finalPrompt = promptTemplate.replace('{{FEEDBACK}}', trimmedFeedback); + const writeSuccess = processManager.write(sessionId, `${finalPrompt}\n`); + + if (!writeSuccess) { + return { success: false, error: 'Agent process not available' }; + } + + return { success: true }; + } + ) + ); +} diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts index ba41c326b..2b501bcd2 100644 --- a/src/main/ipc/handlers/index.ts +++ b/src/main/ipc/handlers/index.ts @@ -53,6 +53,7 @@ import { registerAgentErrorHandlers } from './agent-error'; import { registerTabNamingHandlers, TabNamingHandlerDependencies } from './tabNaming'; import { registerDirectorNotesHandlers, DirectorNotesHandlerDependencies } from './director-notes'; import { registerWakatimeHandlers } from './wakatime'; +import { registerFeedbackHandlers } from './feedback'; import { AgentDetector } from '../../agents'; import { ProcessManager } from '../../process-manager'; import { WebServer } from '../../web-server'; @@ -97,6 +98,7 @@ export type { TabNamingHandlerDependencies }; export { registerDirectorNotesHandlers }; export type { DirectorNotesHandlerDependencies }; export { registerWakatimeHandlers }; +export { registerFeedbackHandlers }; export type { AgentsHandlerDependencies }; export type { ProcessHandlerDependencies }; export type { PersistenceHandlerDependencies }; @@ -280,6 +282,10 @@ export function registerAllHandlers(deps: HandlerDependencies): void { getProcessManager: deps.getProcessManager, getAgentDetector: deps.getAgentDetector, }); + // Register Feedback handlers (gh auth + feedback submission) + registerFeedbackHandlers({ + getProcessManager: deps.getProcessManager, + }); // Setup logger event forwarding to renderer setupLoggerEventForwarding(deps.getMainWindow); } diff --git a/src/main/preload/feedback.ts b/src/main/preload/feedback.ts new file mode 100644 index 000000000..0237f85fd --- /dev/null +++ b/src/main/preload/feedback.ts @@ -0,0 +1,51 @@ +/** + * Preload API for feedback submission + * + * Provides the window.maestro.feedback namespace for: + * - Checking GitHub CLI auth status for feedback submission + * - Submitting structured feedback to an active agent session + */ + +import { ipcRenderer } from 'electron'; + +/** + * Feedback auth check response + */ +export interface FeedbackAuthResponse { + authenticated: boolean; + message?: string; +} + +/** + * Feedback submission response + */ +export interface FeedbackSubmitResponse { + success: boolean; + error?: string; +} + +/** + * Feedback API + */ +export interface FeedbackApi { + /** + * Check whether gh CLI is available and authenticated + */ + checkGhAuth: () => Promise; + /** + * Submit user feedback to an active agent session + */ + submit: (sessionId: string, feedbackText: string) => Promise; +} + +/** + * Creates the feedback API object for preload exposure + */ +export function createFeedbackApi() { + return { + checkGhAuth: (): Promise => ipcRenderer.invoke('feedback:check-gh-auth'), + + submit: (sessionId: string, feedbackText: string): Promise => + ipcRenderer.invoke('feedback:submit', { sessionId, feedbackText }), + }; +} diff --git a/src/main/preload/index.ts b/src/main/preload/index.ts index e91a1f1f8..871f54dfd 100644 --- a/src/main/preload/index.ts +++ b/src/main/preload/index.ts @@ -44,6 +44,7 @@ import { createLeaderboardApi } from './leaderboard'; import { createAttachmentsApi } from './attachments'; import { createProcessApi } from './process'; import { createGitApi } from './git'; +import { createFeedbackApi } from './feedback'; import { createFsApi } from './fs'; import { createAgentsApi } from './agents'; import { createSymphonyApi } from './symphony'; @@ -65,6 +66,7 @@ contextBridge.exposeInMainWorld('maestro', { // Process/Session API process: createProcessApi(), + feedback: createFeedbackApi(), // Agent Error Handling API agentError: createAgentErrorApi(), @@ -250,6 +252,8 @@ export { createAttachmentsApi, // Process createProcessApi, + // Feedback + createFeedbackApi, // Git createGitApi, // Filesystem @@ -396,6 +400,12 @@ export type { AttachmentListResponse, AttachmentPathResponse, } from './attachments'; +export type { + // From feedback + FeedbackApi, + FeedbackAuthResponse, + FeedbackSubmitResponse, +} from './feedback'; export type { // From process ProcessApi, diff --git a/src/prompts/feedback.md b/src/prompts/feedback.md new file mode 100644 index 000000000..4b6fc25fb --- /dev/null +++ b/src/prompts/feedback.md @@ -0,0 +1,33 @@ +# Feedback Issue Authoring Instructions + +You are creating a GitHub issue from user feedback for RunMaestro. + +User-provided feedback: +{{FEEDBACK}} + +Do not ask for clarification. Use the text as-is and proceed. + +1. Classify feedback type as one of: + +- Bug report +- Feature request +- Improvement +- General feedback + +2. Write a concise GitHub issue title prefixed with the type, e.g., "Bug: ...". + +3. Write issue body with these sections: + +- Description +- Expected vs Current Behavior +- Steps to Reproduce (for bug reports; if unavailable, clearly note "Not provided") +- Proposed Solution (for feature/improvement items) +- Impact and Priority (brief) + +4. Run: + `gh label create "Maestro-feedback" --repo RunMaestro/Maestro --description "User feedback submitted via Maestro" --color "0E8A16"` + +5. Then run: + `gh issue create --repo RunMaestro/Maestro --label "Maestro-feedback" --title "TITLE" --body "BODY"` + +6. Reply with only the created issue URL. diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 2fc9ef89d..167c6c0cf 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -218,6 +218,8 @@ function MaestroConsoleInner() { // About Modal aboutModalOpen, setAboutModalOpen, + feedbackModalOpen, + setFeedbackModalOpen, // Update Check Modal setUpdateCheckModalOpen, // standingOvationData, firstRunCelebrationData — now self-sourced in AppOverlays (Tier 1A) @@ -838,6 +840,7 @@ function MaestroConsoleInner() { handleCloseDebugPackage, handleCloseShortcutsHelp, handleCloseAboutModal, + handleCloseFeedbackModal, handleCloseUpdateCheckModal, handleCloseProcessMonitor, handleCloseLogViewer, @@ -2468,10 +2471,13 @@ function MaestroConsoleInner() { hasNoAgents={hasNoAgents} keyboardMasteryStats={keyboardMasteryStats} onCloseAboutModal={handleCloseAboutModal} + feedbackModalOpen={feedbackModalOpen} + onCloseFeedbackModal={handleCloseFeedbackModal} autoRunStats={autoRunStats} usageStats={usageStats} handsOnTimeMs={totalActiveTimeMs} onOpenLeaderboardRegistration={handleOpenLeaderboardRegistrationFromAbout} + onSwitchToSession={setActiveSessionId} isLeaderboardRegistered={isLeaderboardRegistered} onCloseUpdateCheckModal={handleCloseUpdateCheckModal} onCloseProcessMonitor={handleCloseProcessMonitor} @@ -2551,6 +2557,7 @@ function MaestroConsoleInner() { setSettingsTab={setSettingsTab} setShortcutsHelpOpen={setShortcutsHelpOpen} setAboutModalOpen={setAboutModalOpen} + setFeedbackModalOpen={setFeedbackModalOpen} setLogViewerOpen={setLogViewerOpen} setProcessMonitorOpen={setProcessMonitorOpen} setUsageDashboardOpen={setUsageDashboardOpen} diff --git a/src/renderer/components/AboutModal.tsx b/src/renderer/components/AboutModal.tsx index 7c32efff5..0c4a6ac1f 100644 --- a/src/renderer/components/AboutModal.tsx +++ b/src/renderer/components/AboutModal.tsx @@ -10,23 +10,34 @@ import { Globe, Check, BookOpen, + MessageSquarePlus, + ArrowLeft, } from 'lucide-react'; -import type { Theme, AutoRunStats, MaestroUsageStats, LeaderboardRegistration } from '../types'; +import type { + Theme, + AutoRunStats, + MaestroUsageStats, + LeaderboardRegistration, + Session, +} from '../types'; import type { GlobalAgentStats } from '../../shared/types'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; import pedramAvatar from '../assets/pedram-avatar.png'; import { AchievementCard } from './AchievementCard'; import { formatTokensCompact } from '../utils/formatters'; import { Modal } from './ui/Modal'; +import { FeedbackView } from './FeedbackView'; interface AboutModalProps { theme: Theme; autoRunStats: AutoRunStats; usageStats?: MaestroUsageStats | null; + sessions: Session[]; /** Global hands-on time in milliseconds (from settings, persists across sessions) */ handsOnTimeMs: number; onClose: () => void; onOpenLeaderboardRegistration?: () => void; + onSwitchToSession: (sessionId: string) => void; isLeaderboardRegistered?: boolean; leaderboardRegistration?: LeaderboardRegistration | null; } @@ -35,15 +46,18 @@ export function AboutModal({ theme, autoRunStats, usageStats, + sessions, handsOnTimeMs, onClose, onOpenLeaderboardRegistration, + onSwitchToSession, isLeaderboardRegistered, leaderboardRegistration, }: AboutModalProps) { const [globalStats, setGlobalStats] = useState(null); const [loading, setLoading] = useState(true); const [isStatsComplete, setIsStatsComplete] = useState(false); + const [view, setView] = useState<'about' | 'feedback'>('about'); const badgeEscapeHandlerRef = useRef<(() => boolean) | null>(null); // Use ref to avoid re-registering layer when onClose changes @@ -111,355 +125,414 @@ export function AboutModal({ badgeEscapeHandlerRef.current(); return; } + if (view === 'feedback') { + setView('about'); + return; + } // Otherwise close the modal onCloseRef.current(); - }, []); // No dependencies - uses refs + }, [view]); // Custom header with Globe and Discord buttons (includes close button) - const customHeader = ( -
-
-

- About Maestro -

- + const customHeader = + view === 'feedback' ? ( +
+
+ +

+ Send Feedback +

+
+
+ ) : ( +
+
+

+ About Maestro +

+ + + + +
- -
- ); + ); return ( -
- {/* Logo and Title */} -
- -
-
-

- MAESTRO -

- - v{__APP_VERSION__} - {__COMMIT_HASH__ && ` (${__COMMIT_HASH__})`} - + {view === 'about' ? ( +
+ {/* Logo and Title */} +
+ +
+
+

+ MAESTRO +

+ + v{__APP_VERSION__} + {__COMMIT_HASH__ && ` (${__COMMIT_HASH__})`} + +
+

+ Agent Orchestration Command Center +

-

- Agent Orchestration Command Center -

-
- {/* Achievements Section */} - { - badgeEscapeHandlerRef.current = handler; - }} - /> + {/* Achievements Section */} + { + badgeEscapeHandlerRef.current = handler; + }} + /> - {/* Global Usage Stats - show loading or stats from all Claude projects */} -
-
- - - Global Statistics - - {!isStatsComplete && ( - - )} -
- {loading ? ( -
- - - Loading stats... + {/* Global Usage Stats - show loading or stats from all Claude projects */} +
+
+ + + Global Statistics + {!isStatsComplete && ( + + )}
- ) : globalStats ? ( -
- {/* Totals Grid */} -
- {/* Sessions & Messages */} -
- Sessions - - {formatTokensCompact(globalStats.totalSessions)} - -
-
- Messages - - {formatTokensCompact(globalStats.totalMessages)} - -
+ {loading ? ( +
+ + + Loading stats... + +
+ ) : globalStats ? ( +
+ {/* Totals Grid */} +
+ {/* Sessions & Messages */} +
+ Sessions + + {formatTokensCompact(globalStats.totalSessions)} + +
+
+ Messages + + {formatTokensCompact(globalStats.totalMessages)} + +
- {/* Tokens */} -
- Input Tokens - - {formatTokensCompact(globalStats.totalInputTokens)} - -
-
- Output Tokens - - {formatTokensCompact(globalStats.totalOutputTokens)} - -
+ {/* Tokens */} +
+ Input Tokens + + {formatTokensCompact(globalStats.totalInputTokens)} + +
+
+ Output Tokens + + {formatTokensCompact(globalStats.totalOutputTokens)} + +
- {/* Cache Tokens (if any) */} - {(globalStats.totalCacheReadTokens > 0 || - globalStats.totalCacheCreationTokens > 0) && ( - <> -
- Cache Read - - {formatTokensCompact(globalStats.totalCacheReadTokens)} - -
-
- Cache Creation - - {formatTokensCompact(globalStats.totalCacheCreationTokens)} - -
- - )} + {/* Cache Tokens (if any) */} + {(globalStats.totalCacheReadTokens > 0 || + globalStats.totalCacheCreationTokens > 0) && ( + <> +
+ Cache Read + + {formatTokensCompact(globalStats.totalCacheReadTokens)} + +
+
+ Cache Creation + + {formatTokensCompact(globalStats.totalCacheCreationTokens)} + +
+ + )} - {/* Active Time & Total Cost - show cost only if we have cost data */} - {(handsOnTimeMs > 0 || globalStats.hasCostData) && ( -
- {handsOnTimeMs > 0 && ( - - Hands-on Time: {formatDuration(handsOnTimeMs)} - - )} - {!handsOnTimeMs && globalStats.hasCostData && ( - Total Cost - )} - {globalStats.hasCostData && ( - - $ - {(globalStats.totalCostUsd ?? 0).toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} - - )} -
- )} + {/* Active Time & Total Cost - show cost only if we have cost data */} + {(handsOnTimeMs > 0 || globalStats.hasCostData) && ( +
+ {handsOnTimeMs > 0 && ( + + Hands-on Time: {formatDuration(handsOnTimeMs)} + + )} + {!handsOnTimeMs && globalStats.hasCostData && ( + Total Cost + )} + {globalStats.hasCostData && ( + + $ + {(globalStats.totalCostUsd ?? 0).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + )} +
+ )} +
-
- ) : ( -
- No sessions found -
- )} -
- - {/* Action Links */} -
- {/* Project Link */} - + ) : ( +
+ No sessions found +
+ )} +
- {/* Leaderboard Registration */} - {onOpenLeaderboardRegistration && ( + {/* Action Links */} +
+ {/* Project Link */} - )} -
- {/* Divider */} -
+ {/* Leaderboard Registration */} + {onOpenLeaderboardRegistration && ( + + )} +
+ + {/* Divider */} +
- {/* Creator Section - side by side layout */} -
- {/* Left side - Creator info */} -
- Pedram Amini -
-
- Pedram Amini -
-
- Founder, Hacker, Investor, Advisor -
-
- - · - + {/* Creator Section - side by side layout */} +
+ {/* Left side - Creator info */} +
+ Pedram Amini +
+
+ Pedram Amini +
+
+ Founder, Hacker, Investor, Advisor +
+
+ + · + +
-
- {/* Vertical divider */} -
+ {/* Vertical divider */} +
- {/* Right side - Made in Austin */} -
- - Made in Austin, TX - - {/* Texas Flag - Lone Star Flag */} - + {/* Right side - Made in Austin */} +
+ + Made in Austin, TX + + {/* Texas Flag - Lone Star Flag */} + +
-
+ ) : ( + setView('about')} + onSubmitSuccess={(sessionId) => { + onSwitchToSession(sessionId); + onCloseRef.current(); + }} + /> + )} ); } diff --git a/src/renderer/components/AppModals.tsx b/src/renderer/components/AppModals.tsx index edaed2a48..d300dcc84 100644 --- a/src/renderer/components/AppModals.tsx +++ b/src/renderer/components/AppModals.tsx @@ -50,6 +50,7 @@ import type { GroomingProgress, MergeResult } from '../types/contextMerge'; // Info/Display Modal Components import { AboutModal } from './AboutModal'; +import { FeedbackModal } from './FeedbackModal'; import { ShortcutsHelpModal } from './ShortcutsHelpModal'; import { UpdateCheckModal } from './UpdateCheckModal'; @@ -134,8 +135,11 @@ export interface AppInfoModalsProps { // About Modal aboutModalOpen: boolean; onCloseAboutModal: () => void; + feedbackModalOpen: boolean; + onCloseFeedbackModal: () => void; autoRunStats: AutoRunStats; usageStats?: MaestroUsageStats | null; + onSwitchToSession: (sessionId: string) => void; /** Global hands-on time in milliseconds (from settings) */ handsOnTimeMs: number; onOpenLeaderboardRegistration: () => void; @@ -189,8 +193,11 @@ export const AppInfoModals = memo(function AppInfoModals({ // About Modal aboutModalOpen, onCloseAboutModal, + feedbackModalOpen, + onCloseFeedbackModal, autoRunStats, usageStats, + onSwitchToSession, handsOnTimeMs, onOpenLeaderboardRegistration, isLeaderboardRegistered, @@ -232,7 +239,9 @@ export const AppInfoModals = memo(function AppInfoModals({ theme={theme} autoRunStats={autoRunStats} usageStats={usageStats} + sessions={sessions} handsOnTimeMs={handsOnTimeMs} + onSwitchToSession={onSwitchToSession} onClose={onCloseAboutModal} onOpenLeaderboardRegistration={onOpenLeaderboardRegistration} isLeaderboardRegistered={isLeaderboardRegistered} @@ -240,6 +249,16 @@ export const AppInfoModals = memo(function AppInfoModals({ /> )} + {/* --- FEEDBACK MODAL --- */} + {feedbackModalOpen && ( + + )} + {/* --- UPDATE CHECK MODAL --- */} {updateCheckModalOpen && } @@ -794,6 +813,7 @@ export interface AppUtilityModalsProps { setSettingsTab: (tab: SettingsTab) => void; setShortcutsHelpOpen: (open: boolean) => void; setAboutModalOpen: (open: boolean) => void; + setFeedbackModalOpen: (open: boolean) => void; setLogViewerOpen: (open: boolean) => void; setProcessMonitorOpen: (open: boolean) => void; setUsageDashboardOpen: (open: boolean) => void; @@ -1006,6 +1026,7 @@ export const AppUtilityModals = memo(function AppUtilityModals({ setSettingsTab, setShortcutsHelpOpen, setAboutModalOpen, + setFeedbackModalOpen, setLogViewerOpen, setProcessMonitorOpen, setUsageDashboardOpen, @@ -1167,6 +1188,7 @@ export const AppUtilityModals = memo(function AppUtilityModals({ setSettingsTab={setSettingsTab} setShortcutsHelpOpen={setShortcutsHelpOpen} setAboutModalOpen={setAboutModalOpen} + setFeedbackModalOpen={setFeedbackModalOpen} setLogViewerOpen={setLogViewerOpen} setProcessMonitorOpen={setProcessMonitorOpen} setUsageDashboardOpen={setUsageDashboardOpen} @@ -1777,7 +1799,10 @@ export interface AppModalsProps { hasNoAgents: boolean; keyboardMasteryStats: KeyboardMasteryStats; onCloseAboutModal: () => void; + feedbackModalOpen: boolean; + onCloseFeedbackModal: () => void; autoRunStats: AutoRunStats; + onSwitchToSession: (sessionId: string) => void; usageStats?: MaestroUsageStats | null; /** Global hands-on time in milliseconds (from settings) */ handsOnTimeMs: number; @@ -1901,6 +1926,7 @@ export interface AppModalsProps { setSettingsTab: (tab: SettingsTab) => void; setShortcutsHelpOpen: (open: boolean) => void; setAboutModalOpen: (open: boolean) => void; + setFeedbackModalOpen: (open: boolean) => void; setLogViewerOpen: (open: boolean) => void; setProcessMonitorOpen: (open: boolean) => void; setUsageDashboardOpen: (open: boolean) => void; @@ -2193,7 +2219,10 @@ export const AppModals = memo(function AppModals(props: AppModalsProps) { hasNoAgents, keyboardMasteryStats, onCloseAboutModal, + feedbackModalOpen, + onCloseFeedbackModal, autoRunStats, + onSwitchToSession, usageStats, handsOnTimeMs, onOpenLeaderboardRegistration, @@ -2277,6 +2306,7 @@ export const AppModals = memo(function AppModals(props: AppModalsProps) { setSettingsTab, setShortcutsHelpOpen, setAboutModalOpen, + setFeedbackModalOpen, setLogViewerOpen, setProcessMonitorOpen, setUsageDashboardOpen, @@ -2444,8 +2474,11 @@ export const AppModals = memo(function AppModals(props: AppModalsProps) { keyboardMasteryStats={keyboardMasteryStats} aboutModalOpen={aboutModalOpen} onCloseAboutModal={onCloseAboutModal} + feedbackModalOpen={feedbackModalOpen} + onCloseFeedbackModal={onCloseFeedbackModal} autoRunStats={autoRunStats} usageStats={usageStats} + onSwitchToSession={onSwitchToSession} handsOnTimeMs={handsOnTimeMs} onOpenLeaderboardRegistration={onOpenLeaderboardRegistration} isLeaderboardRegistered={isLeaderboardRegistered} @@ -2582,6 +2615,7 @@ export const AppModals = memo(function AppModals(props: AppModalsProps) { setSettingsTab={setSettingsTab} setShortcutsHelpOpen={setShortcutsHelpOpen} setAboutModalOpen={setAboutModalOpen} + setFeedbackModalOpen={setFeedbackModalOpen} setLogViewerOpen={setLogViewerOpen} setProcessMonitorOpen={setProcessMonitorOpen} setUsageDashboardOpen={setUsageDashboardOpen} diff --git a/src/renderer/components/FeedbackModal.tsx b/src/renderer/components/FeedbackModal.tsx new file mode 100644 index 000000000..e72592076 --- /dev/null +++ b/src/renderer/components/FeedbackModal.tsx @@ -0,0 +1,33 @@ +import type { Session, Theme } from '../types'; +import { MODAL_PRIORITIES } from '../constants/modalPriorities'; +import { Modal } from './ui/Modal'; +import { FeedbackView } from './FeedbackView'; + +interface FeedbackModalProps { + theme: Theme; + sessions: Session[]; + onClose: () => void; + onSwitchToSession: (sessionId: string) => void; +} + +export function FeedbackModal({ theme, sessions, onClose, onSwitchToSession }: FeedbackModalProps) { + return ( + + { + onSwitchToSession(sessionId); + onClose(); + }} + /> + + ); +} diff --git a/src/renderer/components/FeedbackView.tsx b/src/renderer/components/FeedbackView.tsx new file mode 100644 index 000000000..ba7593c20 --- /dev/null +++ b/src/renderer/components/FeedbackView.tsx @@ -0,0 +1,285 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import type { Theme, Session } from '../types'; + +interface FeedbackViewProps { + theme: Theme; + sessions: Session[]; + onCancel: () => void; + onSubmitSuccess: (sessionId: string) => void; +} + +interface FeedbackAuthState { + checking: boolean; + authenticated: boolean; + message?: string; +} + +const MAX_FEEDBACK_LENGTH = 5000; +const CHAR_COUNT_WARNING_THRESHOLD = 4000; + +function isRunningSession(session: Session): boolean { + if (session.toolType === 'terminal') { + return false; + } + + return ( + session.state === 'idle' || + session.state === 'busy' || + session.state === 'waiting_input' || + session.state === 'connecting' + ); +} + +export function FeedbackView({ theme, sessions, onCancel, onSubmitSuccess }: FeedbackViewProps) { + const [feedbackText, setFeedbackText] = useState(''); + const [selectedSessionId, setSelectedSessionId] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [authState, setAuthState] = useState({ + checking: true, + authenticated: false, + }); + const [submitError, setSubmitError] = useState(''); + + const runningSessions = useMemo(() => { + return sessions.filter(isRunningSession); + }, [sessions]); + + const authCheck = useCallback(async () => { + setAuthState((prev) => ({ ...prev, checking: true, authenticated: false })); + + try { + const result = await window.maestro.feedback.checkGhAuth(); + + setAuthState({ + checking: false, + authenticated: result.authenticated, + message: result.message, + }); + } catch (error) { + setAuthState({ + checking: false, + authenticated: false, + message: error instanceof Error ? error.message : 'Unable to verify GitHub authentication.', + }); + } + }, []); + + const isSubmittingDisabled = submitting || authState.checking; + const isFormDisabled = isSubmittingDisabled || !authState.authenticated; + + const canSubmit = + !submitting && + selectedSessionId.length > 0 && + feedbackText.trim().length > 0 && + runningSessions.length > 0 && + authState.authenticated; + + useEffect(() => { + void authCheck(); + }, [authCheck]); + + useEffect(() => { + if (runningSessions.length === 0) { + setSelectedSessionId(''); + return; + } + + if (!runningSessions.find((session) => session.id === selectedSessionId)) { + setSelectedSessionId(runningSessions[0].id); + } + }, [runningSessions, selectedSessionId]); + + const handleSubmit = useCallback(async () => { + if (!canSubmit) { + return; + } + + setSubmitError(''); + setSubmitting(true); + + try { + const authResult = await window.maestro.feedback.checkGhAuth(); + setAuthState((prev) => ({ + ...prev, + checking: false, + authenticated: authResult.authenticated, + message: authResult.message, + })); + + if (!authResult.authenticated) { + setSubmitting(false); + return; + } + + const result = await window.maestro.feedback.submit(selectedSessionId, feedbackText.trim()); + + if (!result.success) { + setSubmitError( + result.error || 'The selected agent is no longer running. Please select another agent.' + ); + setSubmitting(false); + return; + } + + onSubmitSuccess(selectedSessionId); + } catch (error) { + setSubmitError( + error instanceof Error + ? error.message + : 'An unexpected error occurred while sending feedback.' + ); + setSubmitting(false); + } + }, [canSubmit, selectedSessionId, feedbackText, onSubmitSuccess]); + + const handleTextareaKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && event.key === 'Enter' && canSubmit) { + event.preventDefault(); + void handleSubmit(); + } + }, + [canSubmit, handleSubmit] + ); + + if (authState.checking) { + return ( +
+ +
+ ); + } + + return ( +
+ {!authState.authenticated && ( +

+ {authState.message || 'GitHub authentication is required to send feedback.'} +

+ )} + +
+ {runningSessions.length === 0 ? ( +
+ No running agents available. Start an agent first, then try again. +
+ ) : ( + <> +
+ + +
+ +
+ +