mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
tests passing
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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:
|
||||
|
||||
<Card title="CONTRIBUTING.md" icon="github" href="https://github.com/pedramamini/Maestro/blob/main/CONTRIBUTING.md">
|
||||
Development setup, architecture overview, and contribution guidelines
|
||||
</Card>
|
||||
|
||||
## 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
|
||||
@@ -78,9 +78,7 @@
|
||||
"pages": [
|
||||
"configuration",
|
||||
"remote-access",
|
||||
"troubleshooting",
|
||||
"contributing",
|
||||
"license"
|
||||
"troubleshooting"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)**.
|
||||
|
||||
<Card title="LICENSE" icon="github" href="https://github.com/pedramamini/Maestro/blob/main/LICENSE">
|
||||
View the full AGPL-3.0 license text on GitHub
|
||||
</Card>
|
||||
|
||||
## 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).
|
||||
@@ -36,6 +36,24 @@ vi.mock('lucide-react', () => ({
|
||||
ArrowRightCircle: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
||||
<span data-testid="arrow-right-circle-icon" className={className} style={style}>→</span>
|
||||
),
|
||||
Minimize2: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
||||
<span data-testid="minimize-icon" className={className} style={style}>⊟</span>
|
||||
),
|
||||
Download: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
||||
<span data-testid="download-icon" className={className} style={style}>↓</span>
|
||||
),
|
||||
Clipboard: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
||||
<span data-testid="clipboard-icon" className={className} style={style}>📎</span>
|
||||
),
|
||||
Minus: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
||||
<span data-testid="minus-icon" className={className} style={style}>−</span>
|
||||
),
|
||||
ArrowLeftToLine: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
||||
<span data-testid="arrow-left-to-line-icon" className={className} style={style}>⇤</span>
|
||||
),
|
||||
ArrowRightToLine: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
||||
<span data-testid="arrow-right-to-line-icon" className={className} style={style}>⇥</span>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock react-dom createPortal
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string | null>,
|
||||
},
|
||||
|
||||
// Web Server API
|
||||
@@ -1488,6 +1490,7 @@ export interface MaestroAPI {
|
||||
isDirectory: boolean;
|
||||
isFile: boolean;
|
||||
}>;
|
||||
fetchImageAsBase64: (url: string) => Promise<string | null>;
|
||||
};
|
||||
webserver: {
|
||||
getUrl: () => Promise<string>;
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lightboxImage && (
|
||||
<LightboxModal
|
||||
image={lightboxImage}
|
||||
stagedImages={lightboxImages.length > 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 && (
|
||||
<GitDiffViewer
|
||||
diffText={gitDiffPreview}
|
||||
cwd={gitViewerCwd}
|
||||
theme={theme}
|
||||
onClose={handleCloseGitDiff}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* --- GIT LOG VIEWER --- */}
|
||||
{gitLogOpen && activeSession && (
|
||||
<GitLogViewer
|
||||
cwd={gitViewerCwd}
|
||||
theme={theme}
|
||||
onClose={handleCloseGitLog}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* --- SHORTCUTS HELP MODAL --- */}
|
||||
{shortcutsHelpOpen && (
|
||||
<ShortcutsHelpModal
|
||||
theme={theme}
|
||||
shortcuts={shortcuts}
|
||||
tabShortcuts={tabShortcuts}
|
||||
onClose={() => setShortcutsHelpOpen(false)}
|
||||
hasNoAgents={hasNoAgents}
|
||||
keyboardMasteryStats={keyboardMasteryStats}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* --- ABOUT MODAL --- */}
|
||||
{aboutModalOpen && (
|
||||
<AboutModal
|
||||
theme={theme}
|
||||
sessions={sessions}
|
||||
autoRunStats={autoRunStats}
|
||||
onClose={() => setAboutModalOpen(false)}
|
||||
onOpenLeaderboardRegistration={() => {
|
||||
setAboutModalOpen(false);
|
||||
setLeaderboardRegistrationOpen(true);
|
||||
}}
|
||||
isLeaderboardRegistered={isLeaderboardRegistered}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* --- LEADERBOARD REGISTRATION MODAL --- */}
|
||||
{leaderboardRegistrationOpen && (
|
||||
<LeaderboardRegistrationModal
|
||||
theme={theme}
|
||||
autoRunStats={autoRunStats}
|
||||
keyboardMasteryStats={keyboardMasteryStats}
|
||||
existingRegistration={leaderboardRegistration}
|
||||
onClose={() => setLeaderboardRegistrationOpen(false)}
|
||||
onSave={(registration) => {
|
||||
setLeaderboardRegistration(registration);
|
||||
}}
|
||||
onOptOut={() => {
|
||||
setLeaderboardRegistration(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* --- UPDATE CHECK MODAL --- */}
|
||||
{updateCheckModalOpen && (
|
||||
<UpdateCheckModal
|
||||
theme={theme}
|
||||
onClose={() => setUpdateCheckModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{/* --- UNIFIED MODALS (all modal groups consolidated into AppModals) --- */}
|
||||
<AppModals
|
||||
// Common props
|
||||
theme={theme}
|
||||
sessions={sessions}
|
||||
setSessions={setSessions}
|
||||
activeSessionId={activeSessionId}
|
||||
activeSession={activeSession}
|
||||
groups={groups}
|
||||
setGroups={setGroups}
|
||||
groupChats={groupChats}
|
||||
shortcuts={shortcuts}
|
||||
tabShortcuts={tabShortcuts}
|
||||
// AppInfoModals props
|
||||
shortcutsHelpOpen={shortcutsHelpOpen}
|
||||
onCloseShortcutsHelp={handleCloseShortcutsHelp}
|
||||
hasNoAgents={hasNoAgents}
|
||||
keyboardMasteryStats={keyboardMasteryStats}
|
||||
aboutModalOpen={aboutModalOpen}
|
||||
onCloseAboutModal={handleCloseAboutModal}
|
||||
autoRunStats={autoRunStats}
|
||||
usageStats={usageStats}
|
||||
onOpenLeaderboardRegistration={handleOpenLeaderboardRegistrationFromAbout}
|
||||
isLeaderboardRegistered={isLeaderboardRegistered}
|
||||
updateCheckModalOpen={updateCheckModalOpen}
|
||||
onCloseUpdateCheckModal={handleCloseUpdateCheckModal}
|
||||
processMonitorOpen={processMonitorOpen}
|
||||
onCloseProcessMonitor={handleCloseProcessMonitor}
|
||||
onNavigateToSession={handleProcessMonitorNavigateToSession}
|
||||
onNavigateToGroupChat={handleProcessMonitorNavigateToGroupChat}
|
||||
// AppConfirmModals props
|
||||
confirmModalOpen={confirmModalOpen}
|
||||
confirmModalMessage={confirmModalMessage}
|
||||
confirmModalOnConfirm={confirmModalOnConfirm}
|
||||
onCloseConfirmModal={handleCloseConfirmModal}
|
||||
quitConfirmModalOpen={quitConfirmModalOpen}
|
||||
onConfirmQuit={handleConfirmQuit}
|
||||
onCancelQuit={handleCancelQuit}
|
||||
// AppSessionModals props
|
||||
newInstanceModalOpen={newInstanceModalOpen}
|
||||
onCloseNewInstanceModal={handleCloseNewInstanceModal}
|
||||
onCreateSession={createNewSession}
|
||||
existingSessions={sessionsForValidation}
|
||||
editAgentModalOpen={editAgentModalOpen}
|
||||
onCloseEditAgentModal={handleCloseEditAgentModal}
|
||||
onSaveEditAgent={handleSaveEditAgent}
|
||||
editAgentSession={editAgentSession}
|
||||
renameSessionModalOpen={renameInstanceModalOpen}
|
||||
renameSessionValue={renameInstanceValue}
|
||||
setRenameSessionValue={setRenameInstanceValue}
|
||||
onCloseRenameSessionModal={handleCloseRenameSessionModal}
|
||||
renameSessionTargetId={renameInstanceSessionId}
|
||||
onAfterRename={flushSessionPersistence}
|
||||
renameTabModalOpen={renameTabModalOpen}
|
||||
renameTabId={renameTabId}
|
||||
renameTabInitialName={renameTabInitialName}
|
||||
onCloseRenameTabModal={handleCloseRenameTabModal}
|
||||
onRenameTab={handleRenameTab}
|
||||
// AppGroupModals props
|
||||
createGroupModalOpen={createGroupModalOpen}
|
||||
onCloseCreateGroupModal={handleCloseCreateGroupModal}
|
||||
renameGroupModalOpen={renameGroupModalOpen}
|
||||
renameGroupId={renameGroupId}
|
||||
renameGroupValue={renameGroupValue}
|
||||
setRenameGroupValue={setRenameGroupValue}
|
||||
renameGroupEmoji={renameGroupEmoji}
|
||||
setRenameGroupEmoji={setRenameGroupEmoji}
|
||||
onCloseRenameGroupModal={handleCloseRenameGroupModal}
|
||||
// AppWorktreeModals props
|
||||
worktreeConfigModalOpen={worktreeConfigModalOpen}
|
||||
onCloseWorktreeConfigModal={handleCloseWorktreeConfigModal}
|
||||
onSaveWorktreeConfig={handleSaveWorktreeConfig}
|
||||
onCreateWorktreeFromConfig={handleCreateWorktreeFromConfig}
|
||||
onDisableWorktreeConfig={handleDisableWorktreeConfig}
|
||||
createWorktreeModalOpen={createWorktreeModalOpen}
|
||||
createWorktreeSession={createWorktreeSession}
|
||||
onCloseCreateWorktreeModal={handleCloseCreateWorktreeModal}
|
||||
onCreateWorktree={handleCreateWorktree}
|
||||
createPRModalOpen={createPRModalOpen}
|
||||
createPRSession={createPRSession}
|
||||
onCloseCreatePRModal={handleCloseCreatePRModal}
|
||||
onPRCreated={handlePRCreated}
|
||||
deleteWorktreeModalOpen={deleteWorktreeModalOpen}
|
||||
deleteWorktreeSession={deleteWorktreeSession}
|
||||
onCloseDeleteWorktreeModal={handleCloseDeleteWorktreeModal}
|
||||
onConfirmDeleteWorktree={handleConfirmDeleteWorktree}
|
||||
onConfirmAndDeleteWorktreeOnDisk={handleConfirmAndDeleteWorktreeOnDisk}
|
||||
// AppUtilityModals props
|
||||
quickActionOpen={quickActionOpen}
|
||||
quickActionInitialMode={quickActionInitialMode}
|
||||
setQuickActionOpen={setQuickActionOpen}
|
||||
setActiveSessionId={setActiveSessionId}
|
||||
addNewSession={addNewSession}
|
||||
setRenameInstanceValue={setRenameInstanceValue}
|
||||
setRenameInstanceModalOpen={setRenameInstanceModalOpen}
|
||||
setRenameGroupId={setRenameGroupId}
|
||||
setRenameGroupValueForQuickActions={setRenameGroupValue}
|
||||
setRenameGroupEmojiForQuickActions={setRenameGroupEmoji}
|
||||
setRenameGroupModalOpenForQuickActions={setRenameGroupModalOpen}
|
||||
setCreateGroupModalOpenForQuickActions={setCreateGroupModalOpen}
|
||||
setLeftSidebarOpen={setLeftSidebarOpen}
|
||||
setRightPanelOpen={setRightPanelOpen}
|
||||
toggleInputMode={toggleInputMode}
|
||||
deleteSession={deleteSession}
|
||||
setSettingsModalOpen={setSettingsModalOpen}
|
||||
setSettingsTab={setSettingsTab}
|
||||
setShortcutsHelpOpen={setShortcutsHelpOpen}
|
||||
setAboutModalOpen={setAboutModalOpen}
|
||||
setLogViewerOpen={setLogViewerOpen}
|
||||
setProcessMonitorOpen={setProcessMonitorOpen}
|
||||
setActiveRightTab={setActiveRightTab}
|
||||
setAgentSessionsOpen={setAgentSessionsOpen}
|
||||
setActiveAgentSessionId={setActiveAgentSessionId}
|
||||
setGitDiffPreview={setGitDiffPreview}
|
||||
setGitLogOpen={setGitLogOpen}
|
||||
isAiMode={activeSession?.inputMode === 'ai'}
|
||||
onQuickActionsRenameTab={handleQuickActionsRenameTab}
|
||||
onQuickActionsToggleReadOnlyMode={handleQuickActionsToggleReadOnlyMode}
|
||||
onQuickActionsToggleTabShowThinking={handleQuickActionsToggleTabShowThinking}
|
||||
onQuickActionsOpenTabSwitcher={handleQuickActionsOpenTabSwitcher}
|
||||
setPlaygroundOpen={setPlaygroundOpen}
|
||||
onQuickActionsRefreshGitFileState={handleQuickActionsRefreshGitFileState}
|
||||
onQuickActionsDebugReleaseQueuedItem={handleQuickActionsDebugReleaseQueuedItem}
|
||||
markdownEditMode={markdownEditMode}
|
||||
onQuickActionsToggleMarkdownEditMode={handleQuickActionsToggleMarkdownEditMode}
|
||||
setUpdateCheckModalOpenForQuickActions={setUpdateCheckModalOpen}
|
||||
openWizard={openWizardModal}
|
||||
wizardGoToStep={wizardGoToStep}
|
||||
setDebugWizardModalOpen={setDebugWizardModalOpen}
|
||||
setDebugPackageModalOpen={setDebugPackageModalOpen}
|
||||
startTour={handleQuickActionsStartTour}
|
||||
setFuzzyFileSearchOpen={setFuzzyFileSearchOpen}
|
||||
onEditAgent={handleQuickActionsEditAgent}
|
||||
onNewGroupChat={handleQuickActionsNewGroupChat}
|
||||
onOpenGroupChat={handleOpenGroupChat}
|
||||
onCloseGroupChat={handleCloseGroupChat}
|
||||
onDeleteGroupChat={deleteGroupChatWithConfirmation}
|
||||
activeGroupChatId={activeGroupChatId}
|
||||
hasActiveSessionCapability={hasActiveSessionCapability}
|
||||
onOpenMergeSession={handleQuickActionsOpenMergeSession}
|
||||
onOpenSendToAgent={handleQuickActionsOpenSendToAgent}
|
||||
onOpenCreatePR={handleQuickActionsOpenCreatePR}
|
||||
onSummarizeAndContinue={handleQuickActionsSummarizeAndContinue}
|
||||
canSummarizeActiveTab={activeSession ? canSummarize(activeSession.contextUsage) : false}
|
||||
onToggleRemoteControl={handleQuickActionsToggleRemoteControl}
|
||||
autoRunSelectedDocument={activeSession?.autoRunSelectedFile ?? null}
|
||||
autoRunCompletedTaskCount={rightPanelRef.current?.getAutoRunCompletedTaskCount() ?? 0}
|
||||
onAutoRunResetTasks={handleQuickActionsAutoRunResetTasks}
|
||||
lightboxImage={lightboxImage}
|
||||
lightboxImages={lightboxImages}
|
||||
stagedImages={stagedImages}
|
||||
onCloseLightbox={handleCloseLightbox}
|
||||
onNavigateLightbox={handleNavigateLightbox}
|
||||
onDeleteLightboxImage={lightboxAllowDeleteRef.current ? handleDeleteLightboxImage : undefined}
|
||||
gitDiffPreview={gitDiffPreview}
|
||||
gitViewerCwd={gitViewerCwd}
|
||||
onCloseGitDiff={handleCloseGitDiff}
|
||||
gitLogOpen={gitLogOpen}
|
||||
onCloseGitLog={handleCloseGitLog}
|
||||
autoRunSetupModalOpen={autoRunSetupModalOpen}
|
||||
onCloseAutoRunSetup={handleCloseAutoRunSetup}
|
||||
onAutoRunFolderSelected={handleAutoRunFolderSelected}
|
||||
batchRunnerModalOpen={batchRunnerModalOpen}
|
||||
onCloseBatchRunner={handleCloseBatchRunner}
|
||||
onStartBatchRun={handleStartBatchRun}
|
||||
onSaveBatchPrompt={handleSaveBatchPrompt}
|
||||
showConfirmation={showConfirmation}
|
||||
autoRunDocumentList={autoRunDocumentList}
|
||||
autoRunDocumentTree={autoRunDocumentTree}
|
||||
getDocumentTaskCount={getDocumentTaskCount}
|
||||
onAutoRunRefresh={handleAutoRunRefresh}
|
||||
tabSwitcherOpen={tabSwitcherOpen}
|
||||
onCloseTabSwitcher={handleCloseTabSwitcher}
|
||||
onTabSelect={handleUtilityTabSelect}
|
||||
onNamedSessionSelect={handleNamedSessionSelect}
|
||||
fuzzyFileSearchOpen={fuzzyFileSearchOpen}
|
||||
filteredFileTree={filteredFileTree}
|
||||
onCloseFileSearch={handleCloseFileSearch}
|
||||
onFileSearchSelect={handleFileSearchSelect}
|
||||
promptComposerOpen={promptComposerOpen}
|
||||
onClosePromptComposer={handleClosePromptComposer}
|
||||
promptComposerInitialValue={activeGroupChatId
|
||||
? (groupChats.find(c => 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 --- */}
|
||||
<DebugPackageModal
|
||||
@@ -8352,16 +8372,6 @@ function MaestroConsoleInner() {
|
||||
|
||||
{/* NOTE: All modals are now rendered via the unified <AppModals /> component above */}
|
||||
|
||||
{/* --- CLOSE ALL TABS CONFIRMATION MODAL --- */}
|
||||
{closeAllTabsConfirmOpen && activeSession && (
|
||||
<ConfirmModal
|
||||
theme={theme}
|
||||
message={`Close all ${activeSession.aiTabs.length} ${activeSession.aiTabs.length === 1 ? 'tab' : 'tabs'}? A new empty tab will be created.`}
|
||||
onConfirm={confirmCloseAllTabs}
|
||||
onClose={() => setCloseAllTabsConfirmOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* --- EMPTY STATE VIEW (when no sessions) --- */}
|
||||
{sessions.length === 0 && !isMobileLandscape ? (
|
||||
<EmptyStateView
|
||||
|
||||
@@ -379,26 +379,25 @@ export function AchievementCard({ theme, autoRunStats, globalStats, usageStats,
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
// Helper to load an image from URL with timeout
|
||||
const loadImage = useCallback((url: string, timeoutMs: number = 5000): Promise<HTMLImageElement | null> => {
|
||||
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<HTMLImageElement | null> => {
|
||||
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();
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const FileExplorerPanel = memo(FileExplorerPanelInner);
|
||||
|
||||
@@ -342,7 +342,7 @@ export function HistoryDetailModal({
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-32 h-2 rounded-full overflow-hidden" style={{ backgroundColor: theme.colors.border }}>
|
||||
<div className="w-24 h-2 rounded-full overflow-hidden" style={{ backgroundColor: theme.colors.border }}>
|
||||
<div
|
||||
className="h-full transition-all duration-500 ease-out"
|
||||
style={{
|
||||
|
||||
@@ -377,8 +377,18 @@ export const MainPanel = React.memo(forwardRef<MainPanelHandle, MainPanelProps>(
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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<MainPanelHandle, MainPanelProps>(
|
||||
{activeSession.isGitRepo ? (
|
||||
<>
|
||||
<GitBranch className="w-3 h-3" />
|
||||
<span className="max-w-[120px] truncate">
|
||||
{gitInfo?.branch || 'GIT'}
|
||||
</span>
|
||||
{/* Hide branch name text at narrow widths, show on hover via title */}
|
||||
{!useIconOnlyGitBranch && (
|
||||
<span className="max-w-[120px] truncate">
|
||||
{gitInfo?.branch || 'GIT'}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : 'LOCAL'}
|
||||
</span>
|
||||
@@ -725,8 +738,8 @@ export const MainPanel = React.memo(forwardRef<MainPanelHandle, MainPanelProps>(
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 justify-end shrink-0">
|
||||
{/* 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') && (
|
||||
<button
|
||||
className="text-[10px] font-mono font-bold px-2 py-0.5 rounded-full border transition-colors hover:opacity-80"
|
||||
style={{ backgroundColor: theme.colors.accent + '20', color: theme.colors.accent, borderColor: theme.colors.accent + '30' }}
|
||||
@@ -755,7 +768,8 @@ export const MainPanel = React.memo(forwardRef<MainPanelHandle, MainPanelProps>(
|
||||
{...contextTooltip.triggerHandlers}
|
||||
>
|
||||
<span className="text-[10px] font-bold uppercase" style={{ color: theme.colors.textDim }}>{useCompactContextLabel ? 'Context' : 'Context Window'}</span>
|
||||
<div className="w-24 h-1.5 rounded-full mt-1 overflow-hidden" style={{ backgroundColor: theme.colors.border }}>
|
||||
{/* Gauge width: w-24 (96px) normally, w-16 (64px) when narrow */}
|
||||
<div className={`${useNarrowContextGauge ? 'w-16' : 'w-24'} h-1.5 rounded-full mt-1 overflow-hidden`} style={{ backgroundColor: theme.colors.border }}>
|
||||
<div
|
||||
className="h-full transition-all duration-500 ease-out"
|
||||
style={{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useEffect, useImperativeHandle, forwardRef, useState, useCallback } from 'react';
|
||||
import React, { useRef, useEffect, useImperativeHandle, forwardRef, useState, useCallback, memo } from 'react';
|
||||
import { PanelRightClose, PanelRightOpen, Loader2, GitBranch } from 'lucide-react';
|
||||
import type { Session, Theme, RightPanelTab, Shortcut, BatchRunState, FocusArea } from '../types';
|
||||
import type { FileTreeChanges } from '../utils/fileExplorer';
|
||||
@@ -99,7 +99,7 @@ interface RightPanelProps {
|
||||
onFileClick?: (path: string) => void;
|
||||
}
|
||||
|
||||
export const RightPanel = forwardRef<RightPanelHandle, RightPanelProps>(function RightPanel(props, ref) {
|
||||
export const RightPanel = memo(forwardRef<RightPanelHandle, RightPanelProps>(function RightPanel(props, ref) {
|
||||
const {
|
||||
session, theme, shortcuts, rightPanelOpen, setRightPanelOpen, rightPanelWidth,
|
||||
setRightPanelWidthState, activeRightTab, setActiveRightTab, activeFocus, setActiveFocus,
|
||||
@@ -560,4 +560,4 @@ export const RightPanel = forwardRef<RightPanelHandle, RightPanelProps>(function
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, memo } from 'react';
|
||||
import {
|
||||
Wand2, Plus, Settings, ChevronRight, ChevronDown, ChevronUp, X, Keyboard,
|
||||
Radio, Copy, ExternalLink, PanelLeftClose, PanelLeftOpen, Folder, Info, GitBranch, Bot, Clock,
|
||||
@@ -751,7 +751,7 @@ interface SessionListProps {
|
||||
allGroupChatParticipantStates?: Map<string, Map<string, 'idle' | 'working'>>;
|
||||
}
|
||||
|
||||
export function SessionList(props: SessionListProps) {
|
||||
function SessionListInner(props: SessionListProps) {
|
||||
const {
|
||||
theme, sessions, groups, sortedSessions, activeSessionId, leftSidebarOpen,
|
||||
leftSidebarWidthState, activeFocus, selectedSidebarIndex, editingGroupId,
|
||||
@@ -2167,3 +2167,5 @@ export function SessionList(props: SessionListProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const SessionList = memo(SessionListInner);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import React, { useState, useRef, useCallback, useEffect, memo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, Plus, Star, Copy, Edit2, Mail, Pencil, Search, GitMerge, ArrowRightCircle, Minimize2 } from 'lucide-react';
|
||||
import { X, Plus, Star, Copy, Edit2, Mail, Pencil, Search, GitMerge, ArrowRightCircle, Minimize2, Download, Clipboard, Minus, ArrowLeftToLine, ArrowRightToLine } from 'lucide-react';
|
||||
import type { AITab, Theme } from '../types';
|
||||
import { hasDraft } from '../utils/tabHelpers';
|
||||
|
||||
@@ -275,6 +275,18 @@ function Tab({
|
||||
setOverlayOpen(false);
|
||||
};
|
||||
|
||||
const handleCopyContextClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onCopyContext?.();
|
||||
setOverlayOpen(false);
|
||||
};
|
||||
|
||||
const handleExportHtmlClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onExportHtml?.();
|
||||
setOverlayOpen(false);
|
||||
};
|
||||
|
||||
const displayName = getTabDisplayName(tab);
|
||||
|
||||
// Browser-style tab: all tabs have borders, active tab "connects" to content
|
||||
@@ -587,7 +599,7 @@ function Tab({
|
||||
style={{ color: theme.colors.textMain }}
|
||||
disabled={hasOnlyOneTab}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
|
||||
<Minus className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
|
||||
Close Others
|
||||
</button>
|
||||
|
||||
@@ -600,7 +612,7 @@ function Tab({
|
||||
style={{ color: theme.colors.textMain }}
|
||||
disabled={isFirstTab}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
|
||||
<ArrowLeftToLine className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
|
||||
Close Tabs to the Left
|
||||
</button>
|
||||
|
||||
@@ -613,7 +625,7 @@ function Tab({
|
||||
style={{ color: theme.colors.textMain }}
|
||||
disabled={isLastTab}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
|
||||
<ArrowRightToLine className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
|
||||
Close Tabs to the Right
|
||||
</button>
|
||||
</div>
|
||||
@@ -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({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const TabBar = memo(TabBarInner);
|
||||
|
||||
1
src/renderer/global.d.ts
vendored
1
src/renderer/global.d.ts
vendored
@@ -367,6 +367,7 @@ interface MaestroAPI {
|
||||
isDirectory: boolean;
|
||||
isFile: boolean;
|
||||
}>;
|
||||
fetchImageAsBase64: (url: string) => Promise<string | null>;
|
||||
};
|
||||
webserver: {
|
||||
getUrl: () => Promise<string>;
|
||||
|
||||
Reference in New Issue
Block a user