diff --git a/docs/about/overview.md b/docs/about/overview.md index 711e5f2a..db1a0948 100644 --- a/docs/about/overview.md +++ b/docs/about/overview.md @@ -11,7 +11,7 @@ Maestro hones fractured attention into focused intent. It is built for developer Maestro enables a **specification-first approach** to AI-assisted development. Instead of ad-hoc prompting, you collaboratively build detailed specs with the AI, then execute them systematically: 1. **PLAN** — Discuss the feature with the AI agent -2. **SPECIFY** — Create markdown docs with task checklists in the Auto Run folder +2. **SPECIFY** — Create markdown docs with task checklists in the Auto Run document folder 3. **EXECUTE** — Auto Run works through tasks, spawning a fresh session per task 4. **REFINE** — Review results, update specs, and repeat @@ -25,7 +25,7 @@ Maestro enables a **specification-first approach** to AI-assisted development. I 1. **Plan**: In the AI Terminal, discuss your feature: *"I want to add user authentication with OAuth support"* 2. **Specify**: Ask the AI to help create a spec: *"Create a markdown checklist for implementing this feature"* -3. **Save**: Copy the spec to your Auto Run folder (or have the AI write it directly) +3. **Save**: Copy the spec to your Auto Run document folder (or have the AI write it directly) 4. **Execute**: Switch to Auto Run tab, select the doc, click Run — Maestro handles the rest 5. **Review**: Check the History tab for results, refine specs as needed diff --git a/docs/context-management.md b/docs/context-management.md index 12322f2e..cdac4e86 100644 --- a/docs/context-management.md +++ b/docs/context-management.md @@ -12,7 +12,7 @@ Hover over any tab with an established session to access the tab menu overlay: | Action | Description | |--------|-------------| -| **Copy Session ID** | Copy the Claude Code session ID to clipboard | +| **Copy Session ID** | Copy the session ID to clipboard (for session continuity) | | **Star Session** | Bookmark this session for quick access | | **Rename Tab** | Give the tab a descriptive name | | **Mark as Unread** | Add unread indicator to the tab | diff --git a/docs/contributing.md b/docs/contributing.md deleted file mode 100644 index 85db0113..00000000 --- a/docs/contributing.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: Contributing -description: How to contribute to Maestro development. -icon: code-pull-request ---- - -We welcome contributions to Maestro! For development setup, architecture details, and contribution guidelines, see the full guide on GitHub: - - - Development setup, architecture overview, and contribution guidelines - - -## Quick Links - -- [GitHub Repository](https://github.com/pedramamini/Maestro) -- [Issue Tracker](https://github.com/pedramamini/Maestro/issues) -- [Discord Community](https://discord.gg/SrBsykvG) - -## Contributors - -- [@pedramamini](https://github.com/pedramamini) — Creator and maintainer -- [@mattjay](https://github.com/mattjay) — Contributor and tester diff --git a/docs/docs.json b/docs/docs.json index a9f78de3..dce6aa56 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -78,9 +78,7 @@ "pages": [ "configuration", "remote-access", - "troubleshooting", - "contributing", - "license" + "troubleshooting" ] } ] diff --git a/docs/features.md b/docs/features.md index 5726251e..f42dd1bf 100644 --- a/docs/features.md +++ b/docs/features.md @@ -11,14 +11,14 @@ icon: sparkles - 💬 **[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. - 💻 **[Command Line Interface](./cli)** - Full CLI (`maestro-cli`) for headless operation. List agents/groups, run playbooks from cron jobs or CI/CD pipelines, with human-readable or JSONL output for scripting. -- 🚀 **Multi-Instance Management** - Run unlimited agents in parallel. Each agent has its own workspace, conversation history, and isolated context. +- 🚀 **Multi-Agent Management** - Run unlimited agents in parallel. Each agent has its own workspace, conversation history, and isolated context. - 📬 **Message Queueing** - Queue messages while AI is busy; they're sent automatically when the agent becomes ready. Never lose a thought. ## Core Features - 🔄 **Dual-Mode Sessions** - Each agent has both an AI Terminal and Command Terminal. Switch seamlessly between AI conversation and shell commands with `Cmd+J` / `Ctrl+J`. - ⌨️ **[Keyboard-First Design](./keyboard-shortcuts)** - Full keyboard control with customizable shortcuts and [mastery tracking](./achievements) that rewards you for leveling up. `Cmd+K` / `Ctrl+K` quick actions, rapid agent switching, and focus management designed for flow state. -- 📋 **Session Discovery** - Automatically discovers and imports all Claude Code sessions, including conversations from before Maestro was installed. Browse, search, star, rename, and resume any session. +- 📋 **Session Discovery** - Automatically discovers and imports existing sessions from all supported providers, including conversations from before Maestro was installed. Browse, search, star, rename, and resume any session. - 🔀 **Git Integration** - Automatic repo detection, branch display, diff viewer, commit logs, and git-aware file completion. Work with git without leaving the app. - 📁 **[File Explorer](./general-usage)** - Browse project files with syntax highlighting, markdown preview, and image viewing. Reference files in prompts with `@` mentions. - 🔍 **[Powerful Output Filtering](./general-usage)** - Search and filter AI output with include/exclude modes, regex support, and per-response local filters. diff --git a/docs/license.md b/docs/license.md deleted file mode 100644 index 86cc1e51..00000000 --- a/docs/license.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -title: License -description: Maestro is licensed under AGPL-3.0. -icon: scale-balanced ---- - -Maestro is open source software licensed under the **GNU Affero General Public License v3.0 (AGPL-3.0)**. - - - View the full AGPL-3.0 license text on GitHub - - -## What This Means - -The AGPL-3.0 license allows you to: - -- ✅ Use Maestro for any purpose -- ✅ Study and modify the source code -- ✅ Distribute copies -- ✅ Distribute modified versions - -With the following conditions: - -- Source code must be made available when distributing -- Modifications must be released under the same license -- Network use counts as distribution (if you run a modified version as a service, you must provide source access) -- License and copyright notices must be preserved - -For the complete license terms, see the [full license text](https://github.com/pedramamini/Maestro/blob/main/LICENSE). diff --git a/src/__tests__/renderer/components/TabBar.test.tsx b/src/__tests__/renderer/components/TabBar.test.tsx index 10b8f80f..efb01553 100644 --- a/src/__tests__/renderer/components/TabBar.test.tsx +++ b/src/__tests__/renderer/components/TabBar.test.tsx @@ -36,6 +36,24 @@ vi.mock('lucide-react', () => ({ ArrowRightCircle: ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( ), + Minimize2: ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( + + ), + Download: ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( + + ), + Clipboard: ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( + 📎 + ), + Minus: ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( + + ), + ArrowLeftToLine: ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( + + ), + ArrowRightToLine: ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( + + ), })); // Mock react-dom createPortal diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 95c30362..23f6da3c 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -69,17 +69,49 @@ Object.defineProperty(window, 'matchMedia', { }); // Mock ResizeObserver using a proper class-like constructor +// Simulates a 1000px width by default which ensures all responsive UI elements are visible class MockResizeObserver { callback: ResizeObserverCallback; constructor(callback: ResizeObserverCallback) { this.callback = callback; } - observe = vi.fn(); + observe = vi.fn((target: Element) => { + // Immediately call callback with a reasonable width to simulate layout + // This ensures responsive breakpoints work correctly in tests + const entry: ResizeObserverEntry = { + target, + contentRect: { + width: 1000, + height: 500, + top: 0, + left: 0, + bottom: 500, + right: 1000, + x: 0, + y: 0, + toJSON: () => ({}), + }, + borderBoxSize: [{ blockSize: 500, inlineSize: 1000 }], + contentBoxSize: [{ blockSize: 500, inlineSize: 1000 }], + devicePixelContentBoxSize: [{ blockSize: 500, inlineSize: 1000 }], + }; + // Use setTimeout to simulate async behavior like the real ResizeObserver + setTimeout(() => this.callback([entry], this as unknown as ResizeObserver), 0); + }); unobserve = vi.fn(); disconnect = vi.fn(); } global.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; +// Mock offsetWidth to return reasonable values for responsive breakpoint tests +// This ensures components that check element dimensions work correctly in jsdom +Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + get() { + return 1000; // Default to wide enough for all responsive features to show + }, +}); + // Mock IntersectionObserver global.IntersectionObserver = vi.fn().mockImplementation(() => ({ observe: vi.fn(), diff --git a/src/main/index.ts b/src/main/index.ts index bb8a2677..ce53f0b2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1122,6 +1122,26 @@ function setupIpcHandlers() { } }); + // Fetch image from URL and return as base64 data URL (avoids CORS issues) + ipcMain.handle('fs:fetchImageAsBase64', async (_, url: string) => { + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + const base64 = buffer.toString('base64'); + // Determine mime type from content-type header or URL + const contentType = response.headers.get('content-type') || 'image/png'; + return `data:${contentType};base64,${base64}`; + } catch (error) { + // Return null on failure - let caller handle gracefully + logger.warn(`Failed to fetch image from ${url}: ${error}`, 'fs:fetchImageAsBase64'); + return null; + } + }); + // Live session management - toggle sessions as live/offline in web interface ipcMain.handle('live:toggle', async (_, sessionId: string, agentSessionId?: string) => { if (!webServer) { diff --git a/src/main/preload.ts b/src/main/preload.ts index 8c90efd6..7fb00a24 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -466,6 +466,8 @@ contextBridge.exposeInMainWorld('maestro', { writeFile: (filePath: string, content: string) => ipcRenderer.invoke('fs:writeFile', filePath, content) as Promise<{ success: boolean }>, stat: (filePath: string) => ipcRenderer.invoke('fs:stat', filePath), + fetchImageAsBase64: (url: string) => + ipcRenderer.invoke('fs:fetchImageAsBase64', url) as Promise, }, // Web Server API @@ -1488,6 +1490,7 @@ export interface MaestroAPI { isDirectory: boolean; isFile: boolean; }>; + fetchImageAsBase64: (url: string) => Promise; }; webserver: { getUrl: () => Promise; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9eec5552..94a260d7 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useMemo, useCallback, useDeferredValue } from 'react'; +import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { SettingsModal } from './components/SettingsModal'; import { SessionList } from './components/SessionList'; import { RightPanel, RightPanelHandle } from './components/RightPanel'; @@ -364,12 +364,6 @@ function MaestroConsoleInner() { commandHistorySelectedIndex, setCommandHistorySelectedIndex, } = useInputContext(); - // PERFORMANCE: Defer filter values to allow typing to stay responsive - // The suggestions will trail slightly behind the input, but typing won't lag - const deferredAtMentionFilter = useDeferredValue(atMentionFilter); - const deferredTabCompletionFilter = useDeferredValue(tabCompletionFilter); - const deferredCommandHistoryFilter = useDeferredValue(commandHistoryFilter); - // UI State const [leftSidebarOpen, setLeftSidebarOpen] = useState(true); const [rightPanelOpen, setRightPanelOpen] = useState(true); @@ -398,7 +392,6 @@ function MaestroConsoleInner() { // Note: All modal states are now managed by ModalContext // See useModalContext() destructuring above for modal states - const [closeAllTabsConfirmOpen, setCloseAllTabsConfirmOpen] = useState(false); // Stable callbacks for memoized modals (prevents re-renders from callback reference changes) // NOTE: These must be declared AFTER the state they reference @@ -3328,22 +3321,21 @@ function MaestroConsoleInner() { // Tab completion suggestions (must be after inputValue is defined) // PERF: Depend on specific session properties, not the entire activeSession object - // PERFORMANCE: Use deferred filter values for suggestions to keep typing responsive const tabCompletionSuggestions = useMemo(() => { if (!tabCompletionOpen || !activeSessionId || activeSessionInputMode !== 'terminal') { return []; } - return getTabCompletionSuggestions(inputValue, deferredTabCompletionFilter); - }, [tabCompletionOpen, activeSessionId, activeSessionInputMode, inputValue, deferredTabCompletionFilter, getTabCompletionSuggestions]); + return getTabCompletionSuggestions(inputValue, tabCompletionFilter); + }, [tabCompletionOpen, activeSessionId, activeSessionInputMode, inputValue, tabCompletionFilter, getTabCompletionSuggestions]); // @ mention suggestions for AI mode - // PERF: Use deferred filter to allow typing to stay responsive + // PERF: Depend on specific session properties, not the entire activeSession object const atMentionSuggestions = useMemo(() => { if (!atMentionOpen || !activeSessionId || activeSessionInputMode !== 'ai') { return []; } - return getAtMentionSuggestions(deferredAtMentionFilter); - }, [atMentionOpen, activeSessionId, activeSessionInputMode, deferredAtMentionFilter, getAtMentionSuggestions]); + return getAtMentionSuggestions(atMentionFilter); + }, [atMentionOpen, activeSessionId, activeSessionInputMode, atMentionFilter, getAtMentionSuggestions]); // Sync file tree selection to match tab completion suggestion // This highlights the corresponding file/folder in the right panel when navigating tab completion @@ -5695,119 +5687,6 @@ function MaestroConsoleInner() { })); }, [activeSession]); - // Close all tabs (creates one new empty tab after closing all) - const handleCloseAllTabs = useCallback(() => { - if (!activeSession) return; - - // Show confirmation modal - setCloseAllTabsConfirmOpen(true); - }, [activeSession]); - - // Close all tabs except the currently active one - const handleCloseOtherTabs = useCallback(() => { - if (!activeSession || activeSession.aiTabs.length <= 1) return; - - const activeTab = getActiveTab(activeSession); - if (!activeTab) return; - - const closedTabs = activeSession.aiTabs.filter(t => t.id !== activeTab.id); - - setSessions(prev => prev.map(s => { - if (s.id !== activeSession.id) return s; - return { - ...s, - aiTabs: [activeTab], - closedTabHistory: [...(s.closedTabHistory || []), ...closedTabs.map((tab, index) => ({ - tab, - index: activeSession.aiTabs.findIndex(t => t.id === tab.id), - closedAt: Date.now() - }))].slice(-25) // Keep last 25 closed tabs - }; - })); - }, [activeSession]); - - // Close all tabs to the left of the active tab - const handleCloseTabsLeft = useCallback(() => { - if (!activeSession) return; - - const activeTabIndex = activeSession.aiTabs.findIndex(t => t.id === activeSession.activeTabId); - if (activeTabIndex <= 0) return; // No tabs to the left - - const closedTabs = activeSession.aiTabs.slice(0, activeTabIndex); - const remainingTabs = activeSession.aiTabs.slice(activeTabIndex); - - setSessions(prev => prev.map(s => { - if (s.id !== activeSession.id) return s; - return { - ...s, - aiTabs: remainingTabs, - closedTabHistory: [...(s.closedTabHistory || []), ...closedTabs.map((tab, index) => ({ - tab, - index, - closedAt: Date.now() - }))].slice(-25) // Keep last 25 closed tabs - }; - })); - }, [activeSession]); - - // Close all tabs to the right of the active tab - const handleCloseTabsRight = useCallback(() => { - if (!activeSession) return; - - const activeTabIndex = activeSession.aiTabs.findIndex(t => t.id === activeSession.activeTabId); - if (activeTabIndex < 0 || activeTabIndex >= activeSession.aiTabs.length - 1) return; // No tabs to the right - - const remainingTabs = activeSession.aiTabs.slice(0, activeTabIndex + 1); - const closedTabs = activeSession.aiTabs.slice(activeTabIndex + 1); - - setSessions(prev => prev.map(s => { - if (s.id !== activeSession.id) return s; - return { - ...s, - aiTabs: remainingTabs, - closedTabHistory: [...(s.closedTabHistory || []), ...closedTabs.map((tab, index) => ({ - tab, - index: activeTabIndex + 1 + index, - closedAt: Date.now() - }))].slice(-25) // Keep last 25 closed tabs - }; - })); - }, [activeSession]); - - // Confirm and execute close all tabs - const confirmCloseAllTabs = useCallback(() => { - if (!activeSession) return; - - const closedTabs = activeSession.aiTabs; - const newTab: AITab = { - id: generateId(), - agentSessionId: null, - name: null, - starred: false, - logs: [], - inputValue: '', - stagedImages: [], - createdAt: Date.now(), - state: 'idle' - }; - - setSessions(prev => prev.map(s => { - if (s.id !== activeSession.id) return s; - return { - ...s, - aiTabs: [newTab], - activeTabId: newTab.id, - closedTabHistory: [...(s.closedTabHistory || []), ...closedTabs.map((tab, index) => ({ - tab, - index, - closedAt: Date.now() - }))].slice(-25) // Keep last 25 closed tabs - }; - })); - - setCloseAllTabsConfirmOpen(false); - }, [activeSession]); - // Toggle global live mode (enables web interface for all sessions) const toggleGlobalLive = async () => { try { @@ -6296,14 +6175,6 @@ function MaestroConsoleInner() { } // Substitute template variables in the system prompt - console.log('[processQueuedItem] Template substitution context:', { - sessionId: session.id, - sessionName: session.name, - autoRunFolderPath: session.autoRunFolderPath, - fullPath: session.fullPath, - cwd: session.cwd, - parentSessionId: session.parentSessionId, - }); const substitutedSystemPrompt = substituteTemplateVariables(maestroSystemPrompt, { session, gitBranch, @@ -7928,8 +7799,6 @@ function MaestroConsoleInner() { setTabSwitcherOpen, showUnreadOnly, stagedImages, handleSetLightboxImage, setMarkdownEditMode, toggleTabStar, toggleTabUnread, setPromptComposerOpen, openWizardModal, rightPanelRef, setFuzzyFileSearchOpen, setShowNewGroupChatModal, deleteGroupChatWithConfirmation, - // Tab close operations - handleCloseAllTabs, handleCloseOtherTabs, handleCloseTabsLeft, handleCloseTabsRight, // Group chat context activeGroupChatId, groupChatInputRef, groupChatStagedImages, setGroupChatRightTab, // Navigation handlers from useKeyboardNavigation hook @@ -8213,104 +8082,255 @@ function MaestroConsoleInner() { )} - {lightboxImage && ( - 0 ? lightboxImages : stagedImages} - onClose={() => { - setLightboxImage(null); - setLightboxImages([]); - setLightboxSource('history'); - lightboxIsGroupChatRef.current = false; - lightboxAllowDeleteRef.current = false; - // Return focus to input after closing carousel - setTimeout(() => inputRef.current?.focus(), 0); - }} - onNavigate={(img) => setLightboxImage(img)} - // Use ref for delete permission - refs are set synchronously before React batches state updates - // This ensures Cmd+Y and click both correctly enable delete when source is 'staged' - onDelete={lightboxAllowDeleteRef.current ? (img: string) => { - // Use ref for group chat check too, for consistency - if (lightboxIsGroupChatRef.current) { - setGroupChatStagedImages(prev => prev.filter(i => i !== img)); - } else { - setStagedImages(prev => prev.filter(i => i !== img)); - } - } : undefined} - theme={theme} - /> - )} - - {/* --- GIT DIFF VIEWER --- */} - {gitDiffPreview && activeSession && ( - - )} - - {/* --- GIT LOG VIEWER --- */} - {gitLogOpen && activeSession && ( - - )} - - {/* --- SHORTCUTS HELP MODAL --- */} - {shortcutsHelpOpen && ( - setShortcutsHelpOpen(false)} - hasNoAgents={hasNoAgents} - keyboardMasteryStats={keyboardMasteryStats} - /> - )} - - {/* --- ABOUT MODAL --- */} - {aboutModalOpen && ( - setAboutModalOpen(false)} - onOpenLeaderboardRegistration={() => { - setAboutModalOpen(false); - setLeaderboardRegistrationOpen(true); - }} - isLeaderboardRegistered={isLeaderboardRegistered} - /> - )} - - {/* --- LEADERBOARD REGISTRATION MODAL --- */} - {leaderboardRegistrationOpen && ( - setLeaderboardRegistrationOpen(false)} - onSave={(registration) => { - setLeaderboardRegistration(registration); - }} - onOptOut={() => { - setLeaderboardRegistration(null); - }} - /> - )} - - {/* --- UPDATE CHECK MODAL --- */} - {updateCheckModalOpen && ( - setUpdateCheckModalOpen(false)} - /> - )} + {/* --- UNIFIED MODALS (all modal groups consolidated into AppModals) --- */} + c.id === activeGroupChatId)?.draftMessage || '') + : inputValue} + onPromptComposerSubmit={handlePromptComposerSubmit} + onPromptComposerSend={handlePromptComposerSend} + promptComposerSessionName={activeGroupChatId + ? groupChats.find(c => c.id === activeGroupChatId)?.name + : activeSession?.name} + promptComposerStagedImages={activeGroupChatId ? groupChatStagedImages : (canAttachImages ? stagedImages : [])} + setPromptComposerStagedImages={activeGroupChatId ? setGroupChatStagedImages : (canAttachImages ? setStagedImages : undefined)} + onPromptImageAttachBlocked={activeGroupChatId || !blockCodexResumeImages ? undefined : showImageAttachBlockedNotice} + onPromptOpenLightbox={handleSetLightboxImage} + promptTabSaveToHistory={activeGroupChatId ? false : (activeSession ? getActiveTab(activeSession)?.saveToHistory ?? false : false)} + onPromptToggleTabSaveToHistory={activeGroupChatId ? undefined : handlePromptToggleTabSaveToHistory} + promptTabReadOnlyMode={activeGroupChatId ? groupChatReadOnlyMode : (activeSession ? getActiveTab(activeSession)?.readOnlyMode ?? false : false)} + onPromptToggleTabReadOnlyMode={handlePromptToggleTabReadOnlyMode} + promptTabShowThinking={activeGroupChatId ? false : (activeSession ? getActiveTab(activeSession)?.showThinking ?? false : false)} + onPromptToggleTabShowThinking={activeGroupChatId ? undefined : handlePromptToggleTabShowThinking} + promptSupportsThinking={!activeGroupChatId && hasActiveSessionCapability('supportsThinkingDisplay')} + promptEnterToSend={enterToSendAI} + onPromptToggleEnterToSend={handlePromptToggleEnterToSend} + queueBrowserOpen={queueBrowserOpen} + onCloseQueueBrowser={handleCloseQueueBrowser} + onRemoveQueueItem={handleRemoveQueueItem} + onSwitchQueueSession={handleSwitchQueueSession} + onReorderQueueItems={handleReorderQueueItems} + // AppGroupChatModals props + showNewGroupChatModal={showNewGroupChatModal} + onCloseNewGroupChatModal={handleCloseNewGroupChatModal} + onCreateGroupChat={handleCreateGroupChat} + showDeleteGroupChatModal={showDeleteGroupChatModal} + onCloseDeleteGroupChatModal={handleCloseDeleteGroupChatModal} + onConfirmDeleteGroupChat={handleConfirmDeleteGroupChat} + showRenameGroupChatModal={showRenameGroupChatModal} + onCloseRenameGroupChatModal={handleCloseRenameGroupChatModal} + onRenameGroupChatFromModal={handleRenameGroupChatFromModal} + showEditGroupChatModal={showEditGroupChatModal} + onCloseEditGroupChatModal={handleCloseEditGroupChatModal} + onUpdateGroupChat={handleUpdateGroupChat} + showGroupChatInfo={showGroupChatInfo} + groupChatMessages={groupChatMessages} + onCloseGroupChatInfo={handleCloseGroupChatInfo} + onOpenModeratorSession={handleOpenModeratorSession} + // AppAgentModals props + leaderboardRegistrationOpen={leaderboardRegistrationOpen} + onCloseLeaderboardRegistration={handleCloseLeaderboardRegistration} + leaderboardRegistration={leaderboardRegistration} + onSaveLeaderboardRegistration={handleSaveLeaderboardRegistration} + onLeaderboardOptOut={handleLeaderboardOptOut} + errorSession={errorSession} + recoveryActions={recoveryActions} + onDismissAgentError={handleCloseAgentErrorModal} + groupChatError={groupChatError} + groupChatRecoveryActions={groupChatRecoveryActions} + onClearGroupChatError={handleClearGroupChatError} + mergeSessionModalOpen={mergeSessionModalOpen} + onCloseMergeSession={handleCloseMergeSession} + onMerge={handleMerge} + transferState={transferState} + transferProgress={transferProgress} + transferSourceAgent={transferSourceAgent} + transferTargetAgent={transferTargetAgent} + onCancelTransfer={handleCancelTransfer} + onCompleteTransfer={handleCompleteTransfer} + sendToAgentModalOpen={sendToAgentModalOpen} + onCloseSendToAgent={handleCloseSendToAgent} + onSendToAgent={handleSendToAgent} + /> {/* --- DEBUG PACKAGE MODAL --- */} component above */} - {/* --- CLOSE ALL TABS CONFIRMATION MODAL --- */} - {closeAllTabsConfirmOpen && activeSession && ( - setCloseAllTabsConfirmOpen(false)} - /> - )} - {/* --- EMPTY STATE VIEW (when no sessions) --- */} {sessions.length === 0 && !isMobileLandscape ? ( => { - return new Promise((resolve) => { - const img = new Image(); - img.crossOrigin = 'anonymous'; - const timeout = setTimeout(() => { - img.onload = null; - img.onerror = null; - resolve(null); - }, timeoutMs); - img.onload = () => { - clearTimeout(timeout); - resolve(img); - }; - img.onerror = () => { - clearTimeout(timeout); - resolve(null); - }; - img.src = url; - }); + // Helper to load an image from URL - fetches via main process to avoid CORS issues + const loadImage = useCallback(async (url: string): Promise => { + try { + // Use IPC to fetch the image from main process (avoids CORS) + const base64DataUrl = await window.maestro.fs.fetchImageAsBase64(url); + if (!base64DataUrl) { + return null; + } + // Create image from the base64 data URL + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => resolve(null); + img.src = base64DataUrl; + }); + } catch (error) { + console.error('Failed to load image:', error); + return null; + } }, []); // Generate shareable achievement card as canvas @@ -477,9 +476,9 @@ export function AchievementCard({ theme, autoRunStats, globalStats, usageStats, ctx.roundRect(-2, -2, width + 4, height + 4, 22); ctx.stroke(); - // Avatar/Trophy icon - larger with more vertical space + // Avatar/Trophy icon - larger with more vertical space at top const iconX = width / 2; - const iconY = 60; // Moved down slightly + const iconY = 70; // More breathing room at top const iconRadius = 40; // Larger radius if (avatarImage) { @@ -492,49 +491,42 @@ export function AchievementCard({ theme, autoRunStats, globalStats, usageStats, ctx.drawImage(avatarImage, iconX - iconRadius, iconY - iconRadius, iconRadius * 2, iconRadius * 2); ctx.restore(); - // Add a gold border around the avatar + // Add a bright gold border around the avatar ctx.beginPath(); ctx.arc(iconX, iconY, iconRadius + 2, 0, Math.PI * 2); ctx.strokeStyle = '#FFD700'; ctx.lineWidth = 3; ctx.stroke(); - // Add a larger trophy badge in the bottom-right corner - const badgeRadius = 18; // Larger badge + // Add a larger trophy badge in the bottom-right corner - BRIGHT and vibrant + const badgeRadius = 18; const badgeX = iconX + iconRadius - 6; const badgeY = iconY + iconRadius - 6; + + // Draw badge background - pure bright gold, no dimming ctx.beginPath(); ctx.arc(badgeX, badgeY, badgeRadius, 0, Math.PI * 2); - // Bright gold gradient for trophy badge - const badgeGradient = ctx.createRadialGradient(badgeX, badgeY - 5, 0, badgeX, badgeY, badgeRadius); - badgeGradient.addColorStop(0, '#FFD700'); // Bright gold - badgeGradient.addColorStop(0.5, '#FFC107'); - badgeGradient.addColorStop(1, '#FF9800'); - ctx.fillStyle = badgeGradient; + ctx.fillStyle = '#FFD700'; // Solid bright gold - no gradient that might dim it ctx.fill(); - // Badge border - ctx.strokeStyle = '#B8860B'; + // Bright gold border + ctx.strokeStyle = '#FFE55C'; // Even brighter gold border ctx.lineWidth = 2; ctx.stroke(); - // Trophy emoji - larger + // Trophy emoji - larger and centered ctx.font = '20px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('🏆', badgeX, badgeY + 1); } else { - // Default trophy icon - bright gold circle + // Default trophy icon - BRIGHT gold circle, no overlay effects ctx.beginPath(); ctx.arc(iconX, iconY, iconRadius, 0, Math.PI * 2); - // Bright, vibrant gold gradient - const trophyGradient = ctx.createRadialGradient(iconX, iconY - 15, 0, iconX, iconY, iconRadius); - trophyGradient.addColorStop(0, '#FFD700'); // Bright gold center - trophyGradient.addColorStop(0.6, '#FFC107'); - trophyGradient.addColorStop(1, '#FF9800'); // Darker gold edge - ctx.fillStyle = trophyGradient; + // Pure bright gold - solid color instead of gradient to avoid dimming + ctx.fillStyle = '#FFD700'; ctx.fill(); - // Add border for definition - ctx.strokeStyle = '#B8860B'; - ctx.lineWidth = 2; + // Bright border for extra pop + ctx.strokeStyle = '#FFE55C'; + ctx.lineWidth = 3; ctx.stroke(); // Trophy emoji - larger @@ -546,7 +538,7 @@ export function AchievementCard({ theme, autoRunStats, globalStats, usageStats, // Title - show display name if personalized, otherwise generic title // Positioned with more breathing room after larger icon - const titleY = iconY + iconRadius + 28; + const titleY = iconY + iconRadius + 32; ctx.font = '600 18px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; ctx.fillStyle = '#F472B6'; ctx.textAlign = 'center'; @@ -695,9 +687,10 @@ export function AchievementCard({ theme, autoRunStats, globalStats, usageStats, drawPeakStat(30 + row3ColWidth * 2.5, maxQueries, 'Parallel Queries'); drawPeakStat(30 + row3ColWidth * 3.5, maxQueue, 'Queue Depth'); - // --- Social Handles Row (if personalized) --- + // --- Social Handles Row (if personalized) - positioned closer to footer --- if (hasSocialHandles) { - const socialY = row3Y + row3Height + rowGap + 4; + // Position social handles right above the footer, not after the stats + const socialY = height - 70; // 70px from bottom, leaving room for footer const socialHeight = 20; // Draw social handles centered @@ -723,20 +716,32 @@ export function AchievementCard({ theme, autoRunStats, globalStats, usageStats, const halfSize = size / 2; if (icon === 'github') { - // GitHub mark - octocat silhouette approximation as a circle with cutout + // GitHub mark - the invertocat/octocat logo silhouette + // Draw white circle background ctx.fillStyle = '#FFFFFF'; ctx.beginPath(); ctx.arc(x, y, halfSize, 0, Math.PI * 2); ctx.fill(); - // Draw simplified octocat shape + // Draw the GitHub cat face shape in dark color ctx.fillStyle = '#1a1a2e'; + const s = halfSize * 0.85; // Scale factor ctx.beginPath(); - // Body - ctx.arc(x, y + 1, halfSize * 0.6, 0, Math.PI * 2); - ctx.fill(); - // Head bump - ctx.beginPath(); - ctx.arc(x, y - halfSize * 0.3, halfSize * 0.5, 0, Math.PI, true); + // Start from top center, draw the cat head shape + ctx.moveTo(x, y - s * 0.75); // Top center + // Left ear + ctx.lineTo(x - s * 0.4, y - s * 0.9); + ctx.lineTo(x - s * 0.55, y - s * 0.55); + // Left side of face + ctx.quadraticCurveTo(x - s * 0.95, y - s * 0.2, x - s * 0.75, y + s * 0.4); + // Bottom left + ctx.quadraticCurveTo(x - s * 0.5, y + s * 0.85, x, y + s * 0.75); + // Bottom right (mirror) + ctx.quadraticCurveTo(x + s * 0.5, y + s * 0.85, x + s * 0.75, y + s * 0.4); + // Right side of face + ctx.quadraticCurveTo(x + s * 0.95, y - s * 0.2, x + s * 0.55, y - s * 0.55); + // Right ear + ctx.lineTo(x + s * 0.4, y - s * 0.9); + ctx.closePath(); ctx.fill(); } else if (icon === 'twitter') { // X/Twitter - simple X shape @@ -761,27 +766,41 @@ export function AchievementCard({ theme, autoRunStats, globalStats, usageStats, ctx.textBaseline = 'middle'; ctx.fillText('in', x, y + 1); } else if (icon === 'discord') { - // Discord - rounded square with controller/game icon hint + // Discord Clyde logo - the controller/gamepad face ctx.fillStyle = '#5865F2'; ctx.beginPath(); ctx.roundRect(x - halfSize, y - halfSize, size, size, 3); ctx.fill(); - // Draw simplified Discord logo (two circles for eyes, curved bottom) + // Draw the Discord Clyde face (simplified game controller shape) ctx.fillStyle = '#FFFFFF'; + const s = halfSize * 0.8; + // Main body shape (rounded trapezoid/controller) + ctx.beginPath(); + ctx.moveTo(x - s * 0.8, y - s * 0.3); + // Top left curve going up + ctx.quadraticCurveTo(x - s * 0.7, y - s * 0.7, x - s * 0.3, y - s * 0.55); + // Top center dip + ctx.quadraticCurveTo(x, y - s * 0.45, x + s * 0.3, y - s * 0.55); + // Top right curve + ctx.quadraticCurveTo(x + s * 0.7, y - s * 0.7, x + s * 0.8, y - s * 0.3); + // Right side down + ctx.quadraticCurveTo(x + s * 0.9, y + s * 0.2, x + s * 0.5, y + s * 0.65); + // Bottom + ctx.quadraticCurveTo(x, y + s * 0.75, x - s * 0.5, y + s * 0.65); + // Left side up + ctx.quadraticCurveTo(x - s * 0.9, y + s * 0.2, x - s * 0.8, y - s * 0.3); + ctx.closePath(); + ctx.fill(); + // Cut out eyes (draw background color circles) + ctx.fillStyle = '#5865F2'; // Left eye ctx.beginPath(); - ctx.ellipse(x - halfSize * 0.35, y - halfSize * 0.1, halfSize * 0.2, halfSize * 0.25, 0, 0, Math.PI * 2); + ctx.ellipse(x - s * 0.35, y - s * 0.05, s * 0.18, s * 0.22, 0, 0, Math.PI * 2); ctx.fill(); // Right eye ctx.beginPath(); - ctx.ellipse(x + halfSize * 0.35, y - halfSize * 0.1, halfSize * 0.2, halfSize * 0.25, 0, 0, Math.PI * 2); + ctx.ellipse(x + s * 0.35, y - s * 0.05, s * 0.18, s * 0.22, 0, 0, Math.PI * 2); ctx.fill(); - // Smile/body curve - ctx.beginPath(); - ctx.arc(x, y + halfSize * 0.5, halfSize * 0.5, Math.PI * 0.2, Math.PI * 0.8); - ctx.strokeStyle = '#FFFFFF'; - ctx.lineWidth = 1.5; - ctx.stroke(); } ctx.restore(); }; diff --git a/src/renderer/components/FileExplorerPanel.tsx b/src/renderer/components/FileExplorerPanel.tsx index 807a4310..91a52411 100644 --- a/src/renderer/components/FileExplorerPanel.tsx +++ b/src/renderer/components/FileExplorerPanel.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import React, { useEffect, useRef, useState, useCallback, useMemo, memo } from 'react'; import { createPortal } from 'react-dom'; import { useVirtualizer } from '@tanstack/react-virtual'; import { ChevronRight, ChevronDown, ChevronUp, Folder, RefreshCw, Check, Eye, EyeOff } from 'lucide-react'; @@ -54,7 +54,7 @@ interface FileExplorerPanelProps { setShowHiddenFiles: (value: boolean) => void; } -export function FileExplorerPanel(props: FileExplorerPanelProps) { +function FileExplorerPanelInner(props: FileExplorerPanelProps) { const { session, theme, fileTreeFilter, setFileTreeFilter, fileTreeFilterOpen, setFileTreeFilterOpen, filteredFileTree, selectedFileIndex, setSelectedFileIndex, activeFocus, activeRightTab, @@ -548,3 +548,5 @@ export function FileExplorerPanel(props: FileExplorerPanelProps) { ); } + +export const FileExplorerPanel = memo(FileExplorerPanelInner); diff --git a/src/renderer/components/HistoryDetailModal.tsx b/src/renderer/components/HistoryDetailModal.tsx index 16eeb984..b77a2724 100644 --- a/src/renderer/components/HistoryDetailModal.tsx +++ b/src/renderer/components/HistoryDetailModal.tsx @@ -342,7 +342,7 @@ export function HistoryDetailModal({ return (
-
+
( }; }, []); - // Responsive breakpoints for hiding/simplifying widgets + // Responsive breakpoints for hiding/simplifying widgets (progressive reduction as space shrinks) + // At widest: full display with "CONTEXT WINDOW" label and wide gauge + // Below 800px: "CONTEXT" label, gauge stays full width + // Below 700px: compact git widget (file count only) + // Below 650px: narrow context gauge (w-16 instead of w-24) + // Below 600px: git branch shows icon only (no text) + // Below 550px: hide UUID pill + // Below 500px: hide cost widget const showCostWidget = panelWidth > 500; + const showUuidPill = panelWidth > 550; + const useIconOnlyGitBranch = panelWidth < 600; + const useNarrowContextGauge = panelWidth < 650; const useCompactGitWidget = panelWidth < 700; const useCompactContextLabel = panelWidth < 800; @@ -529,9 +539,12 @@ export const MainPanel = React.memo(forwardRef( {activeSession.isGitRepo ? ( <> - - {gitInfo?.branch || 'GIT'} - + {/* Hide branch name text at narrow widths, show on hover via title */} + {!useIconOnlyGitBranch && ( + + {gitInfo?.branch || 'GIT'} + + )} ) : 'LOCAL'} @@ -725,8 +738,8 @@ export const MainPanel = React.memo(forwardRef( )}
- {/* Session UUID Pill - click to copy full UUID, left-most of session stats */} - {activeSession.inputMode === 'ai' && activeTab?.agentSessionId && hasCapability('supportsSessionId') && ( + {/* Session UUID Pill - click to copy full UUID, left-most of session stats, hidden at narrow widths */} + {showUuidPill && activeSession.inputMode === 'ai' && activeTab?.agentSessionId && hasCapability('supportsSessionId') && ( @@ -600,7 +612,7 @@ function Tab({ style={{ color: theme.colors.textMain }} disabled={isFirstTab} > - + Close Tabs to the Left @@ -613,7 +625,7 @@ function Tab({ style={{ color: theme.colors.textMain }} disabled={isLastTab} > - + Close Tabs to the Right
@@ -629,7 +641,7 @@ function Tab({ * Shows tabs for each Claude Code conversation within a Maestro session. * Appears only in AI mode (hidden in terminal mode). */ -export function TabBar({ +function TabBarInner({ tabs, activeTabId, theme, @@ -931,3 +943,5 @@ export function TabBar({
); } + +export const TabBar = memo(TabBarInner); diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 67147b37..966e1924 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -367,6 +367,7 @@ interface MaestroAPI { isDirectory: boolean; isFile: boolean; }>; + fetchImageAsBase64: (url: string) => Promise; }; webserver: { getUrl: () => Promise;