From a7e504e205c1d7b7cd8abc2465c34bef60061429 Mon Sep 17 00:00:00 2001
From: Pedram Amini
Date: Tue, 3 Feb 2026 23:21:06 -0600
Subject: [PATCH 1/5] =?UTF-8?q?##=20CHANGES=20-=20Symphony=20IPC=20now=20v?=
=?UTF-8?q?alidates=20active=20contributions=20against=20stored=20sessions?=
=?UTF-8?q?=20=F0=9F=A7=A9=20-=20Orphaned=20contributions=20auto-filtered?=
=?UTF-8?q?=20when=20sessions=20disappear,=20keeping=20UI=20clean=20?=
=?UTF-8?q?=F0=9F=A7=B9=20-=20`symphony:getState`=20returns=20only=20sessi?=
=?UTF-8?q?on-backed=20active=20items=20for=20accuracy=20=F0=9F=8E=AF=20-?=
=?UTF-8?q?=20`symphony:getActive`=20now=20excludes=20contributions=20tied?=
=?UTF-8?q?=20to=20missing=20sessions=20=F0=9F=9A=AB=20-=20Added=20reusabl?=
=?UTF-8?q?e=20`filterOrphanedContributions`=20helper=20with=20detailed=20?=
=?UTF-8?q?logging=20=F0=9F=AA=B5=20-=20Wired=20`sessionsStore`=20dependen?=
=?UTF-8?q?cy=20through=20main=20+=20handler=20registration=20flow=20?=
=?UTF-8?q?=F0=9F=94=8C=20-=20Integration=20tests=20now=20mock=20sessions?=
=?UTF-8?q?=20store=20for=20realistic=20state=20scenarios=20=F0=9F=A7=AA?=
=?UTF-8?q?=20-=20Expanded=20handler=20tests=20to=20cover=20missing-sessio?=
=?UTF-8?q?n=20filtering=20behavior=20=F0=9F=9B=A1=EF=B8=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../integration/symphony.integration.test.ts | 7 ++
.../main/ipc/handlers/symphony.test.ts | 86 ++++++++++++++++++-
src/main/index.ts | 1 +
src/main/ipc/handlers/index.ts | 1 +
src/main/ipc/handlers/symphony.ts | 44 +++++++++-
5 files changed, 135 insertions(+), 4 deletions(-)
diff --git a/src/__tests__/integration/symphony.integration.test.ts b/src/__tests__/integration/symphony.integration.test.ts
index b45c157b..5c63cfec 100644
--- a/src/__tests__/integration/symphony.integration.test.ts
+++ b/src/__tests__/integration/symphony.integration.test.ts
@@ -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)
diff --git a/src/__tests__/main/ipc/handlers/symphony.test.ts b/src/__tests__/main/ipc/handlers/symphony.test.ts
index df706b60..07df3890 100644
--- a/src/__tests__/main/ipc/handlers/symphony.test.ts
+++ b/src/__tests__/main/ipc/handlers/symphony.test.ts
@@ -64,6 +64,7 @@ describe('Symphony IPC handlers', () => {
let mockApp: App;
let mockMainWindow: BrowserWindow;
let mockDeps: SymphonyHandlerDependencies;
+ let mockSessionsStore: { get: ReturnType; set: ReturnType };
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', () => {
diff --git a/src/main/index.ts b/src/main/index.ts
index 6ea32e45..3208659b 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -608,6 +608,7 @@ function setupIpcHandlers() {
registerSymphonyHandlers({
app,
getMainWindow: () => mainWindow,
+ sessionsStore,
});
// Register tab naming handlers for automatic tab naming
diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts
index d26c5310..e5e89e00 100644
--- a/src/main/ipc/handlers/index.ts
+++ b/src/main/ipc/handlers/index.ts
@@ -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();
diff --git a/src/main/ipc/handlers/symphony.ts b/src/main/ipc/handlers/symphony.ts
index e72cb4cc..8004510b 100644
--- a/src/main/ipc/handlers/symphony.ts
+++ b/src/main/ipc/handlers/symphony.ts
@@ -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;
}
// ============================================================================
@@ -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
+): 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 };
}
)
);
From 17fed4f23235dcc3a0b349fafaa854cd56038174 Mon Sep 17 00:00:00 2001
From: Pedram Amini
Date: Tue, 3 Feb 2026 23:29:48 -0600
Subject: [PATCH 2/5] =?UTF-8?q?##=20CHANGES=20-=20Added=20Code=20Style=20s?=
=?UTF-8?q?ection=20to=20CLAUDE.md=20specifying=20tabs-for-indentation=20r?=
=?UTF-8?q?equirement=20=F0=9F=93=8F=20-=20Added=20Root=20Cause=20Verifica?=
=?UTF-8?q?tion=20section=20under=20Debugging=20with=20historical=20bug=20?=
=?UTF-8?q?patterns=20=F0=9F=94=8D=20-=20Added=20UI=20Bug=20Debugging=20Ch?=
=?UTF-8?q?ecklist=20to=20CLAUDE-PATTERNS.md=20(section=2011)=20?=
=?UTF-8?q?=F0=9F=8E=A8=20-=20Documents=20CSS-first=20debugging,=20portal?=
=?UTF-8?q?=20escapes,=20and=20fixed=20positioning=20pitfalls=20?=
=?UTF-8?q?=F0=9F=90=9B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
CLAUDE-PATTERNS.md | 25 +++++++++++++++++++++++++
CLAUDE.md | 20 ++++++++++++++++++++
2 files changed, 45 insertions(+)
diff --git a/CLAUDE-PATTERNS.md b/CLAUDE-PATTERNS.md
index 986963ae..a9ec4f5c 100644
--- a/CLAUDE-PATTERNS.md
+++ b/CLAUDE-PATTERNS.md
@@ -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(, document.body)}
+
+// Scroll element into view without centering
+element.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
+```
diff --git a/CLAUDE.md b/CLAUDE.md
index 893c28f6..f5375c7b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
From f38f6b66e6a49250cc7fc6f5b6ed9384afe9ff7a Mon Sep 17 00:00:00 2001
From: Pedram Amini
Date: Wed, 4 Feb 2026 00:15:13 -0600
Subject: [PATCH 3/5] ## CHANGES
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Renamed empty-state action from “Select Folder” to “Change Folder” for clarity 🧭
- Refreshed empty-state helper text to match the new folder-change flow 📝
- Cleaned up `EditGroupChatModal` icon imports by dropping the unused `X` 🎛️
---
src/__tests__/renderer/components/AutoRun.test.tsx | 6 +++---
src/renderer/components/AutoRun.tsx | 4 ++--
src/renderer/components/EditGroupChatModal.tsx | 2 +-
3 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/src/__tests__/renderer/components/AutoRun.test.tsx b/src/__tests__/renderer/components/AutoRun.test.tsx
index b9457532..d0b58ad6 100644
--- a/src/__tests__/renderer/components/AutoRun.test.tsx
+++ b/src/__tests__/renderer/components/AutoRun.test.tsx
@@ -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();
- // 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();
diff --git a/src/renderer/components/AutoRun.tsx b/src/renderer/components/AutoRun.tsx
index 20a72a5e..6fe3d4f0 100644
--- a/src/renderer/components/AutoRun.tsx
+++ b/src/renderer/components/AutoRun.tsx
@@ -1983,7 +1983,7 @@ const AutoRunInner = forwardRef(function AutoRunInn
The selected folder doesn't contain any markdown (.md) files.
- 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.
diff --git a/src/renderer/components/EditGroupChatModal.tsx b/src/renderer/components/EditGroupChatModal.tsx
index f6069a3d..d375cf93 100644
--- a/src/renderer/components/EditGroupChatModal.tsx
+++ b/src/renderer/components/EditGroupChatModal.tsx
@@ -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';
From 16cf35390c135c13878faaef8b0fcfa96bd0bddf Mon Sep 17 00:00:00 2001
From: Pedram Amini
Date: Wed, 4 Feb 2026 00:59:45 -0600
Subject: [PATCH 4/5] let users know about how maestro works
---
README.md | 2 ++
docs/about/overview.md | 1 +
docs/getting-started.md | 4 ++++
docs/installation.md | 4 ++++
docs/troubleshooting.md | 12 ++++++++++++
src/renderer/components/WelcomeContent.tsx | 6 +++++-
6 files changed, 28 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 8ae4c0f2..6e055522 100644
--- a/README.md
+++ b/README.md
@@ -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.
+