tests passing

This commit is contained in:
Pedram Amini
2025-12-27 18:35:37 -06:00
parent 5d46af20f1
commit 367fdddf1e
19 changed files with 473 additions and 391 deletions

View File

@@ -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

View File

@@ -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 |

View File

@@ -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

View File

@@ -78,9 +78,7 @@
"pages": [
"configuration",
"remote-access",
"troubleshooting",
"contributing",
"license"
"troubleshooting"
]
}
]

View File

@@ -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.

View File

@@ -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).

View File

@@ -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

View File

@@ -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(),

View File

@@ -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) {

View File

@@ -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>;

View File

@@ -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

View File

@@ -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();
};

View File

@@ -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);

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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>
);
});
}));

View File

@@ -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);

View File

@@ -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);

View File

@@ -367,6 +367,7 @@ interface MaestroAPI {
isDirectory: boolean;
isFile: boolean;
}>;
fetchImageAsBase64: (url: string) => Promise<string | null>;
};
webserver: {
getUrl: () => Promise<string>;