Merge pull request #297 from pedramamini/0.15.0-polish

Round of Polishing
This commit is contained in:
Raza Rauf
2026-02-04 10:06:22 -06:00
committed by GitHub
18 changed files with 272 additions and 23 deletions

View File

@@ -252,3 +252,28 @@ const isRemote = !!session.sshRemoteId;
// CORRECT
const isRemote = !!session.sshRemoteId || !!session.sessionSshRemoteConfig?.enabled;
```
## 11. UI Bug Debugging Checklist
When debugging visual issues (tooltips clipped, elements not visible, scroll behavior):
1. **CSS First:** Check parent container properties before code logic:
- `overflow: hidden` on ancestors (clipping issues)
- `z-index` stacking context conflicts
- `position` mismatches (fixed/absolute/relative)
2. **Scroll Issues:** Use `scrollIntoView({ block: 'nearest' })` not centering
3. **Portal Escape:** For overlays/tooltips that get clipped, use `createPortal(el, document.body)` to escape stacking context
4. **Fixed Positioning:** Elements with `position: fixed` inside transformed parents won't position relative to viewport—check ancestor transforms
**Common fixes:**
```typescript
// Tooltip/overlay escaping parent overflow
import { createPortal } from 'react-dom';
{isOpen && createPortal(<Overlay />, document.body)}
// Scroll element into view without centering
element.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
```

View File

@@ -64,6 +64,12 @@ Use these terms consistently in code, comments, and documentation:
---
## Code Style
This codebase uses **tabs for indentation**, not spaces. Always match existing file indentation when editing.
---
## Project Overview
Maestro is an Electron desktop app for managing multiple AI coding assistants simultaneously with a keyboard-first interface.
@@ -260,6 +266,20 @@ See [[CLAUDE-PATTERNS.md]] for detailed SSH patterns.
## Debugging
### Root Cause Verification (Before Implementing Fixes)
Initial hypotheses are often wrong. Before implementing any fix:
1. **IPC issues:** Verify handler is registered in `src/main/index.ts` before modifying caller code
2. **UI rendering bugs:** Check CSS properties (overflow, z-index, position) on element AND parent containers before changing component logic
3. **State not updating:** Trace the data flow from source to consumer; check if the setter is being called vs if re-render is suppressed
4. **Feature not working:** Verify the code path is actually being executed (add temporary `console.log`, check output, then remove)
**Historical patterns that wasted time:**
- Tab naming bug: Modal coordination was "fixed" when the actual issue was an unregistered IPC handler
- Tooltip clipping: Attempted `overflow: visible` on element when parent container had `overflow: hidden`
- Session validation: Fixed renderer calls when handler wasn't wired in main process
### Focus Not Working
1. Add `tabIndex={0}` or `tabIndex={-1}`
2. Add `outline-none` class

View File

@@ -12,6 +12,8 @@ Collaborate with AI to create detailed specification documents, then let Auto Ru
Run multiple agents in parallel with a Linear/Superhuman-level responsive interface. Currently supporting **Claude Code**, **OpenAI Codex**, **OpenCode**, and **Factory Droid** with plans for additional agentic coding tools (Gemini CLI, Qwen3 Coder) based on user demand.
> **How It Works:** Maestro is a pass-through to your AI provider. Whatever MCP tools, skills, permissions, or authentication you have configured in Claude Code, Codex, or OpenCode works identically in Maestro. The only difference is we're not running interactively—each task gets a prompt and returns a response, whether it's a new session or resuming a prior one.
<div align="center">
<a href="https://youtu.be/fmwwTOg7cyA?si=dJ89K54tGflKa5G4">
<img src="https://github.com/user-attachments/assets/deaf601d-1898-4ede-bf5a-42e46874ebb3"

View File

@@ -49,3 +49,4 @@ This approach mirrors methodologies like [Spec-Kit](https://github.com/github/sp
| **History** | Timestamped log of all actions (user commands, AI responses, Auto Run completions) with session links. |
| **Remote Control** | Web interface for mobile access. Local network or remote via Cloudflare tunnel. |
| **CLI** | Headless command-line tool for scripting, automation, and CI/CD integration. |
| **Provider Pass-Through** | Maestro delegates all AI work to your installed provider (Claude Code, Codex, OpenCode). Your MCP tools, custom skills, permissions, and authentication all carry over—Maestro runs them in batch mode (prompt in, response out) rather than interactive mode. |

View File

@@ -14,6 +14,10 @@ Follow the [Installation](./installation) instructions for your platform, then l
Maestro supports **Claude Code**, **Codex** (OpenAI), and **OpenCode** as providers. Make sure at least one is installed and authenticated.
<Note>
Maestro is a pass-through to your provider. Your MCP tools, custom skills, permissions, and authentication all work in Maestro exactly as they do when running the provider directly. The only difference is batch mode execution—Maestro sends a prompt and receives a response rather than running an interactive session.
</Note>
**Option A: Quick Setup**
Create your first agent manually using the **+** button in the sidebar.

View File

@@ -23,6 +23,10 @@ Download the latest release for your platform from the [Releases](https://github
- [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Qwen3 Coder](https://github.com/QwenLM/Qwen-Agent) — Planned support
- Git (optional, for git-aware features)
<Note>
Maestro is a pass-through to your provider. Your MCP tools, custom skills, permissions, and authentication all work in Maestro exactly as they do when running the provider directly—Maestro just orchestrates the conversation flow in batch mode.
</Note>
## WSL2 Users (Windows Subsystem for Linux)
<Warning>

View File

@@ -4,6 +4,18 @@ description: System logs, process monitor, debug packages, and how to get help w
icon: life-ring
---
## Frequently Asked Questions
**Do my MCP tools, skills, and permissions work in Maestro?**
Yes. Maestro is a pass-through—it calls your provider (Claude Code, Codex, OpenCode) in batch mode rather than interactive mode. Whatever works when you run the provider directly will work in Maestro. Your MCP servers, custom skills, authentication, and tool permissions all carry over automatically.
**What's the difference between running the provider directly vs. through Maestro?**
The only difference is execution mode. When you run Claude Code directly, it's interactive—you send a message, watch it work, and respond in real-time. Maestro runs in batch mode: it sends a prompt, the provider processes it fully, and returns the response. This enables unattended automation via Auto Run and parallel agent management. Everything else—your tools, permissions, context—remains identical.
---
## System Logs
Maestro maintains detailed system logs that help diagnose issues. Access them via:

View File

@@ -222,10 +222,17 @@ describe('Symphony Integration Tests', () => {
},
} as unknown as BrowserWindow;
// Setup mock sessions store (returns empty by default - no sessions)
const mockSessionsStore = {
get: vi.fn().mockReturnValue([]),
set: vi.fn(),
};
// Setup dependencies
mockDeps = {
app: mockApp,
getMainWindow: () => mockMainWindow,
sessionsStore: mockSessionsStore as any,
};
// Default fetch mock (successful responses)

View File

@@ -64,6 +64,7 @@ describe('Symphony IPC handlers', () => {
let mockApp: App;
let mockMainWindow: BrowserWindow;
let mockDeps: SymphonyHandlerDependencies;
let mockSessionsStore: { get: ReturnType<typeof vi.fn>; set: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.clearAllMocks();
@@ -88,10 +89,17 @@ describe('Symphony IPC handlers', () => {
},
} as unknown as BrowserWindow;
// Setup mock sessions store (exposed for individual tests to modify)
mockSessionsStore = {
get: vi.fn().mockReturnValue([]),
set: vi.fn(),
};
// Setup dependencies
mockDeps = {
app: mockApp,
getMainWindow: () => mockMainWindow,
sessionsStore: mockSessionsStore as any,
};
// Default mock for fs operations
@@ -1209,7 +1217,7 @@ describe('Symphony IPC handlers', () => {
expect(result.state.stats.repositoriesContributed).toEqual([]);
});
it('should return persisted state from disk', async () => {
it('should return persisted state from disk (with valid sessions)', async () => {
const persistedState = {
active: [
{
@@ -1267,17 +1275,49 @@ describe('Symphony IPC handlers', () => {
},
};
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(persistedState));
// Mock the session to exist so the active contribution is included
mockSessionsStore.get.mockReturnValue([{ id: 'session-123', name: 'Test Session' }]);
const handler = handlers.get('symphony:getState');
const result = await handler!({} as any);
expect(result.state).toEqual(persistedState);
expect(result.state.active).toHaveLength(1);
expect(result.state.active[0].id).toBe('contrib_123');
expect(result.state.history).toHaveLength(1);
expect(result.state.stats.totalContributions).toBe(1);
});
it('should filter out active contributions with missing sessions', async () => {
const persistedState = {
active: [
{
id: 'contrib_with_session',
repoSlug: 'owner/repo',
sessionId: 'session-exists',
status: 'running',
},
{
id: 'contrib_orphaned',
repoSlug: 'owner/repo2',
sessionId: 'session-gone',
status: 'running',
},
],
history: [],
stats: { totalContributions: 0 },
};
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(persistedState));
// Only one session exists
mockSessionsStore.get.mockReturnValue([{ id: 'session-exists', name: 'Existing' }]);
const handler = handlers.get('symphony:getState');
const result = await handler!({} as any);
// Only the contribution with an existing session should be returned
expect(result.state.active).toHaveLength(1);
expect(result.state.active[0].id).toBe('contrib_with_session');
});
it('should handle file read errors gracefully', async () => {
vi.mocked(fs.readFile).mockRejectedValue(new Error('Permission denied'));
@@ -1302,7 +1342,7 @@ describe('Symphony IPC handlers', () => {
expect(result.contributions).toEqual([]);
});
it('should return all active contributions from state', async () => {
it('should return active contributions that have matching sessions', async () => {
const stateWithActive = {
active: [
{
@@ -1310,18 +1350,25 @@ describe('Symphony IPC handlers', () => {
repoSlug: 'owner/repo1',
issueNumber: 1,
status: 'running',
sessionId: 'session_1',
},
{
id: 'contrib_2',
repoSlug: 'owner/repo2',
issueNumber: 2,
status: 'paused',
sessionId: 'session_2',
},
],
history: [],
stats: {},
};
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(stateWithActive));
// Mock sessions store to return matching sessions
mockSessionsStore.get.mockReturnValue([
{ id: 'session_1', name: 'Session 1' },
{ id: 'session_2', name: 'Session 2' },
]);
const handler = handlers.get('symphony:getActive');
const result = await handler!({} as any);
@@ -1330,6 +1377,39 @@ describe('Symphony IPC handlers', () => {
expect(result.contributions[0].id).toBe('contrib_1');
expect(result.contributions[1].id).toBe('contrib_2');
});
it('should filter out contributions whose sessions no longer exist', async () => {
const stateWithActive = {
active: [
{
id: 'contrib_1',
repoSlug: 'owner/repo1',
issueNumber: 1,
status: 'running',
sessionId: 'session_exists',
},
{
id: 'contrib_2',
repoSlug: 'owner/repo2',
issueNumber: 2,
status: 'paused',
sessionId: 'session_gone',
},
],
history: [],
stats: {},
};
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(stateWithActive));
// Only return session_exists, session_gone is missing
mockSessionsStore.get.mockReturnValue([{ id: 'session_exists', name: 'Existing Session' }]);
const handler = handlers.get('symphony:getActive');
const result = await handler!({} as any);
// Only contrib_1 should be returned since contrib_2's session is gone
expect(result.contributions).toHaveLength(1);
expect(result.contributions[0].id).toBe('contrib_1');
});
});
describe('symphony:getCompleted', () => {

View File

@@ -876,7 +876,7 @@ describe('AutoRun', () => {
// Use getAllByText since the refresh button exists in both document selector and empty state
expect(screen.getAllByText('Refresh').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('Select Folder')).toBeInTheDocument();
expect(screen.getByText('Change Folder')).toBeInTheDocument();
});
it('calls onRefresh when clicking Refresh in empty state', async () => {
@@ -897,8 +897,8 @@ describe('AutoRun', () => {
const props = createDefaultProps({ documentList: [], selectedFile: null });
renderWithProvider(<AutoRun {...props} />);
// Get the Select Folder button in the empty state
fireEvent.click(screen.getByText('Select Folder'));
// Get the Change Folder button in the empty state
fireEvent.click(screen.getByText('Change Folder'));
await waitFor(() => {
expect(props.onOpenSetup).toHaveBeenCalled();

View File

@@ -608,6 +608,7 @@ function setupIpcHandlers() {
registerSymphonyHandlers({
app,
getMainWindow: () => mainWindow,
sessionsStore,
});
// Register tab naming handlers for automatic tab naming

View File

@@ -259,6 +259,7 @@ export function registerAllHandlers(deps: HandlerDependencies): void {
registerSymphonyHandlers({
app: deps.app,
getMainWindow: deps.getMainWindow,
sessionsStore: deps.sessionsStore,
});
// Register agent error handlers (error state management)
registerAgentErrorHandlers();

View File

@@ -11,10 +11,12 @@
*/
import { ipcMain, App, BrowserWindow } from 'electron';
import type Store from 'electron-store';
import fs from 'fs/promises';
import path from 'path';
import { logger } from '../../utils/logger';
import { isWebContentsAvailable } from '../../utils/safe-send';
import type { SessionsData, StoredSession } from '../../stores/types';
import { createIpcHandler, CreateHandlerOptions } from '../../utils/ipcHandler';
import { execFileNoThrow } from '../../utils/execFile';
import { getExpandedEnv } from '../../agents/path-prober';
@@ -194,6 +196,7 @@ function validateContributionParams(params: {
export interface SymphonyHandlerDependencies {
app: App;
getMainWindow: () => BrowserWindow | null;
sessionsStore: Store<SessionsData>;
}
// ============================================================================
@@ -865,6 +868,39 @@ function broadcastSymphonyUpdate(getMainWindow: () => BrowserWindow | null): voi
}
}
/**
* Filter out orphaned contributions whose sessions no longer exist.
* Returns only contributions that have a corresponding session in the sessions store.
*/
function filterOrphanedContributions(
contributions: ActiveContribution[],
sessionsStore: Store<SessionsData>
): ActiveContribution[] {
const sessions = sessionsStore.get('sessions', []) as StoredSession[];
const sessionIds = new Set(sessions.map((s) => s.id));
const validContributions: ActiveContribution[] = [];
const orphanedIds: string[] = [];
for (const contribution of contributions) {
if (sessionIds.has(contribution.sessionId)) {
validContributions.push(contribution);
} else {
orphanedIds.push(contribution.id);
}
}
if (orphanedIds.length > 0) {
logger.info(
`Filtering ${orphanedIds.length} orphaned contribution(s) with missing sessions`,
LOG_CONTEXT,
{ orphanedIds }
);
}
return validContributions;
}
// ============================================================================
// Handler Options Helper
// ============================================================================
@@ -882,6 +918,7 @@ const handlerOpts = (operation: string, logSuccess = true): CreateHandlerOptions
export function registerSymphonyHandlers({
app,
getMainWindow,
sessionsStore,
}: SymphonyHandlerDependencies): void {
// ─────────────────────────────────────────────────────────────────────────
// Registry Operations
@@ -1031,6 +1068,7 @@ export function registerSymphonyHandlers({
/**
* Get current symphony state.
* Filters out contributions whose sessions no longer exist.
*/
ipcMain.handle(
'symphony:getState',
@@ -1038,6 +1076,8 @@ export function registerSymphonyHandlers({
handlerOpts('getState', false),
async (): Promise<{ state: SymphonyState }> => {
const state = await readState(app);
// Filter out orphaned contributions whose sessions are gone
state.active = filterOrphanedContributions(state.active, sessionsStore);
return { state };
}
)
@@ -1045,6 +1085,7 @@ export function registerSymphonyHandlers({
/**
* Get active contributions.
* Filters out contributions whose sessions no longer exist.
*/
ipcMain.handle(
'symphony:getActive',
@@ -1052,7 +1093,8 @@ export function registerSymphonyHandlers({
handlerOpts('getActive', false),
async (): Promise<{ contributions: ActiveContribution[] }> => {
const state = await readState(app);
return { contributions: state.active };
const validContributions = filterOrphanedContributions(state.active, sessionsStore);
return { contributions: validContributions };
}
)
);

View File

@@ -13780,6 +13780,11 @@ You are taking over this conversation. Based on the context above, provide a bri
theme={theme}
isOpen={symphonyModalOpen}
onClose={() => setSymphonyModalOpen(false)}
sessions={sessions}
onSelectSession={(sessionId) => {
setActiveSessionId(sessionId);
setSymphonyModalOpen(false);
}}
onStartContribution={async (data: SymphonyContributionData) => {
console.log('[Symphony] Creating session for contribution:', data);

View File

@@ -1983,7 +1983,7 @@ const AutoRunInner = forwardRef<AutoRunHandle, AutoRunProps>(function AutoRunInn
The selected folder doesn't contain any markdown (.md) files.
</p>
<p className="mb-6 max-w-xs text-xs" style={{ color: theme.colors.textDim }}>
Create a markdown file in the folder to get started, or select a different folder.
Create a markdown file in the folder to get started, or change to a different folder.
</p>
<div className="flex gap-3">
<button
@@ -2007,7 +2007,7 @@ const AutoRunInner = forwardRef<AutoRunHandle, AutoRunProps>(function AutoRunInn
}}
>
<FolderOpen className="w-4 h-4" />
Select Folder
Change Folder
</button>
</div>
</div>

View File

@@ -10,7 +10,7 @@
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { X, Settings, ChevronDown, Check } from 'lucide-react';
import { Settings, ChevronDown, Check } from 'lucide-react';
import type { Theme, AgentConfig, ModeratorConfig, GroupChat } from '../types';
import type { SshRemoteConfig, AgentSshRemoteConfig } from '../../shared/types';
import { MODAL_PRIORITIES } from '../constants/modalPriorities';

View File

@@ -38,8 +38,9 @@ import {
ChevronDown,
HelpCircle,
Github,
Terminal,
} from 'lucide-react';
import type { Theme } from '../types';
import type { Theme, Session } from '../types';
import type {
RegisteredRepository,
SymphonyIssue,
@@ -79,6 +80,8 @@ export interface SymphonyModalProps {
isOpen: boolean;
onClose: () => void;
onStartContribution: (data: SymphonyContributionData) => void;
sessions: Session[];
onSelectSession: (sessionId: string) => void;
}
type ModalTab = 'projects' | 'active' | 'history' | 'stats';
@@ -862,12 +865,16 @@ function ActiveContributionCard({
onFinalize,
onSync,
isSyncing,
sessionName,
onNavigateToSession,
}: {
contribution: ActiveContribution;
theme: Theme;
onFinalize: () => void;
onSync: () => void;
isSyncing: boolean;
sessionName: string | null;
onNavigateToSession: () => void;
}) {
const statusInfo = getStatusInfo(contribution.status);
const docProgress =
@@ -902,6 +909,17 @@ function ActiveContributionCard({
<p className="text-xs truncate" style={{ color: theme.colors.textDim }}>
{contribution.repoSlug}
</p>
{sessionName && (
<button
onClick={onNavigateToSession}
className="flex items-center gap-1 text-xs mt-0.5 hover:underline cursor-pointer"
style={{ color: theme.colors.accent }}
title={`Go to session: ${sessionName}`}
>
<Terminal className="w-3 h-3" />
<span className="truncate">{sessionName}</span>
</button>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<button
@@ -1178,7 +1196,14 @@ function AchievementCard({ achievement, theme }: { achievement: Achievement; the
// Main SymphonyModal
// ============================================================================
export function SymphonyModal({ theme, isOpen, onClose, onStartContribution }: SymphonyModalProps) {
export function SymphonyModal({
theme,
isOpen,
onClose,
onStartContribution,
sessions,
onSelectSession,
}: SymphonyModalProps) {
const { registerLayer, unregisterLayer } = useLayerStack();
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
@@ -1949,16 +1974,32 @@ export function SymphonyModal({ theme, isOpen, onClose, onStartContribution }: S
</div>
) : (
<div className="grid grid-cols-2 gap-4">
{activeContributions.map((contribution) => (
<ActiveContributionCard
key={contribution.id}
contribution={contribution}
theme={theme}
onFinalize={() => handleFinalize(contribution.id)}
onSync={() => handleSyncContribution(contribution.id)}
isSyncing={syncingContributionId === contribution.id}
/>
))}
{activeContributions.map((contribution) => {
const session = sessions.find(
(s) => s.id === contribution.sessionId
);
return (
<ActiveContributionCard
key={contribution.id}
contribution={contribution}
theme={theme}
onFinalize={() => handleFinalize(contribution.id)}
onSync={() =>
handleSyncContribution(contribution.id)
}
isSyncing={
syncingContributionId === contribution.id
}
sessionName={session?.name ?? null}
onNavigateToSession={() => {
if (session) {
onSelectSession(session.id);
onClose();
}
}}
/>
);
})}
</div>
)}
</div>

View File

@@ -89,7 +89,7 @@ export function WelcomeContent({ theme, showGetStarted = false }: WelcomeContent
{/* How it works section */}
<div
className="text-sm leading-relaxed p-4 rounded-lg text-left"
className="text-sm leading-relaxed p-4 rounded-lg text-left space-y-2"
style={{
backgroundColor: theme.colors.bgActivity,
color: theme.colors.textDim,
@@ -97,6 +97,10 @@ export function WelcomeContent({ theme, showGetStarted = false }: WelcomeContent
>
<p>
<strong style={{ color: theme.colors.textMain }}>How it works:</strong>{' '}
Maestro is a pass-through to your AI provider. Your MCP tools, skills,
and permissions work exactly as they do when running the provider directly.
</p>
<p>
Agents run in auto-approve mode with tool calls accepted automatically.
Toggle Read-Only mode for guardrails.
</p>