Skip to content

[WIP] Symphony: Feature Request: multiple windows (#133)#1028

Open
DRAZY wants to merge 43 commits into
RunMaestro:mainfrom
DRAZY:symphony/issue-133-mpeky97a
Open

[WIP] Symphony: Feature Request: multiple windows (#133)#1028
DRAZY wants to merge 43 commits into
RunMaestro:mainfrom
DRAZY:symphony/issue-133-mpeky97a

Conversation

@DRAZY
Copy link
Copy Markdown

@DRAZY DRAZY commented May 20, 2026

Maestro Symphony Contribution

Closes #133

Contributed via Maestro Symphony.

Status: In Progress
Started: 2026-05-20T21:34:53.665Z


This PR will be updated automatically when the Auto Run completes.

Summary by CodeRabbit

Release Notes

  • New Features

    • Full multi-window support: Move agents to new windows, focus windows, and manage sessions across multiple windows.
    • Window state persistence: Window positions, sizes, and agent ownership automatically saved and restored.
    • Cross-window awareness: Visual window badges indicate when agents are open in other windows.
    • Enhanced notifications: OS notifications can target specific windows and focus them on click.
  • Documentation

    • New multi-window usage guide and updated feature documentation.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 20, 2026

📝 Walkthrough

Walkthrough

Adds full multi-window support across main, preload, and renderer with a WindowRegistry, IPC handlers/APIs, renderer WindowContext, state migration/restore, notifications metadata, quit/restore flows, telemetry, and comprehensive tests and docs updates.

Changes

Multi-window Feature and Telemetry

Layer / File(s) Summary
Multi-window core, APIs, UI, persistence, telemetry
src/main/..., src/main/preload/..., src/renderer/..., src/shared/..., src/web/..., docs/*, src/__tests__/*
Implements WindowRegistry, window manager updates, IPC handlers and preload APIs, renderer WindowContext and UI behaviors (drag-to-window, badges, scoped state), multi-window store schema/migration/restore, quit-time save, notifications metadata, stats telemetry and migration, plus extensive tests and docs.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant User as User
  participant Renderer as Renderer (TabBar/WindowContext)
  participant Preload as Preload (Windows API)
  participant Main as Main (Windows Handlers)
  participant Registry as WindowRegistry
  participant Store as WindowState Store
  User->>Renderer: Drag tab outside window
  Renderer->>Preload: findWindowAtPoint(x,y)
  Preload->>Main: windows:findWindowAtPoint
  Main->>Registry: enumerate windows
  Registry-->>Main: window entries
  Main-->>Preload: target WindowInfo|null
  Preload-->>Renderer: target WindowInfo|null
  Renderer->>Preload: moveSession(sessionId, fromId, toId?) or create(bounds)
  Preload->>Main: windows:moveSession / windows:create
  Main->>Registry: move/create, update ownership
  Main->>Store: upsert window state (bounds, activeSessionId)
  Main-->>Renderer: windows:sessionMoved broadcast
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested labels

refactor

Poem

A rabbit split the panes in two,
Hopped tabs between the windows new;
With gentle paws it saved the state,
Restored the views, a perfect gait.
Stats were logged, toasts did sing—
W-numbered burrows, everything! 🐇✨

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

MAXIMUS added 28 commits May 20, 2026 14:38
@DRAZY DRAZY marked this pull request as ready for review May 21, 2026 05:05
@DRAZY
Copy link
Copy Markdown
Author

DRAZY commented May 21, 2026

Symphony Contribution Summary

This pull request was created using Maestro Symphony - connecting AI-powered contributors with open source projects.

Contribution Stats

Metric Value
Input Tokens 177,567,318
Output Tokens 540,392
Total Tokens 178,107,710
Estimated Cost $0.00
Time Spent 7h 3m 43s
Documents Processed 6
Tasks Completed 43

Powered by MaestroLearn about Symphony

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/main/ipc/handlers/process.ts (1)

629-661: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Move window-session assignment after a successful spawn.

At Line 629, window/session ownership is updated before processManager.spawn(...). If spawn fails, the session is still persisted and broadcast as moved, which leaves stale UI/store ownership.

Proposed fix
-				assignSpawnSessionToSenderWindow(event, config.sessionId, windowManager, windowStateStore);
-
 				const result = processManager.spawn({
 					...config,
 					command: commandToSpawn,
@@
 					sshStdinScript,
 				});
+
+				if (result.success) {
+					assignSpawnSessionToSenderWindow(
+						event,
+						config.sessionId,
+						windowManager,
+						windowStateStore
+					);
+				}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/ipc/handlers/process.ts` around lines 629 - 661, The code currently
calls assignSpawnSessionToSenderWindow(event, config.sessionId, windowManager,
windowStateStore) before invoking processManager.spawn(...), which can
persist/broadcast a moved session even if spawn fails; move the call so it runs
only after a successful spawn (i.e., after processManager.spawn resolves without
error), and wrap the spawn call in a try/catch so failures do not trigger the
window/session assignment or leave stale ownership; reference
assignSpawnSessionToSenderWindow, processManager.spawn, config.sessionId,
windowManager, and windowStateStore when making the change.
src/renderer/components/TabBar.tsx (1)

1901-2041: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep file-tab drags out of the session-move path.

FileTab now uses the same drag lifecycle as AI tabs, but the outside-window branch always acts on windowContext?.activeSessionId. Dragging a file preview outside the window will therefore create/move the active session instead of the file tab the user actually grabbed. Carry the dragged item type through the drag payload, or skip the cross-window branch for file tabs.

Also applies to: 2455-2465

🧹 Nitpick comments (1)
src/main/app-lifecycle/window-manager.ts (1)

376-383: 💤 Low value

Auto-reload after crash could cause reload loop if crash is deterministic.

If the renderer crashes due to a deterministic bug (e.g., bad initialization code), the 1-second delay auto-reload will create an infinite loop. Consider adding a crash counter with a max reload threshold (e.g., 3 crashes within 30 seconds).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/app-lifecycle/window-manager.ts` around lines 376 - 383, The
auto-reload after renderer crash (inside the handler that checks details.reason
and calls mainWindow.webContents.reload()) can cause a reload loop for
deterministic crashes; add a crash tracking mechanism (e.g., a crash counter and
timestamp list) scoped near the window manager globals that records each crash
for mainWindow and only allows reload if crashes in the last 30 seconds are
below a threshold (suggested max 3). Update the existing block that currently
calls setTimeout(... mainWindow.webContents.reload()) to check the crash counter
before scheduling a reload, increment and prune timestamps on each crash, and
when the threshold is exceeded stop reloading and log/warn or show a recovery UI
instead; reference symbols: mainWindow, details.reason, and the reload call
(mainWindow.webContents.reload()) when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/main/stats/multi-window-recorder.ts`:
- Around line 50-53: The catch block that currently logs and returns null in
multi-window-recorder.ts (the catch handling around the function that records
multi-window telemetry) must differentiate recoverable DB errors from unexpected
exceptions: import and use captureException/captureMessage from
src/utils/sentry.ts, keep returning null for known/recoverable DB errors
(identify via the DB error class/type checks used elsewhere), but for any
other/unexpected error call captureException with contextual data (include
LOG_CONTEXT and any relevant payload), then re-throw the error instead of
returning null so Sentry and upper layers can handle it; update the catch that
currently calls logger.warn(`Failed to record multi-window telemetry: ${error}`,
LOG_CONTEXT) to implement this behavior.

In `@src/renderer/components/SessionList/SessionList.tsx`:
- Around line 140-148: The current filter for visibleGroupChats wrongly excludes
chats that have an initiatorWindowId when no windowId is provided; update the
filter in the SessionList component so that if windowId is null/undefined it
does not filter out initiator-bound chats. Concretely, change the predicate used
to derive visibleGroupChats (the useMemo over groupChats) to allow all chats
when windowId is falsy, otherwise apply the existing check (e.g.
!chat.initiatorWindowId || chat.initiatorWindowId === windowId). Keep references
to visibleGroupChats, groupChats, windowId, and initiatorWindowId intact so
visibleActiveGroupChatId logic continues to work.

In `@src/renderer/components/TabBar.tsx`:
- Around line 1792-1805: The fire-and-forget IPC calls (e.g.,
window.maestro?.windows?.highlightDropZone in setHighlightedDropZone and
clearHighlightedDropZone, plus
highlightDropZone/findWindowAtPoint/getWindowBounds/create/moveSession usages
referenced) must not silently swallow rejections; update each call to properly
await the returned Promise (or attach a .catch), handle expected/recoverable
errors explicitly, and for unexpected failures call the Sentry helper
(captureException or captureMessage from src/utils/sentry.ts) with contextual
info (function name, windowId/point/session id) and then re-throw so Sentry can
capture it; ensure you reference the existing functions setHighlightedDropZone,
clearHighlightedDropZone, and any callbacks that call window.maestro to locate
and fix each site.

In `@src/renderer/contexts/WindowContext.tsx`:
- Around line 195-210: The moveSessionToNewWindow function uses the stale
closed-over sessionIds when computing the fallback active session; change the
logic so the next active session is computed from the up-to-date state inside
the setter callback instead of the outer sessionIds variable—specifically, after
creating newWindow, call setSessionIds with a functional updater that removes
sessionId and then compute getNextActiveSessionId using that currentSessionIds
result (or invoke setActiveSessionId from within that same updater by deriving
the new fallback) so setActiveSessionId never uses the outdated sessionIds
captured earlier; reference moveSessionToNewWindow, setSessionIds,
setActiveSessionId, and getNextActiveSessionId when making the change.

In `@src/renderer/hooks/agent/useAgentListeners.ts`:
- Around line 210-220: The helper is incorrectly returning true for any
sessionId starting with 'group-chat-', bypassing per-window scoping and causing
group-chat updates to reach all windows; update isSessionInCurrentWindow so it
does not special-case 'group-chat-*' — always derive the window-scoped id via
getWindowScopedSessionId(sessionId) and check membership against
windowSessionIdsRef.current (and still return true only if
windowSessionIdsRef.current or windowIdRef.current are null), so onAgentError
and useGroupChatStore only update the owning window. Reference:
isSessionInCurrentWindow, windowSessionIdsRef, windowIdRef,
getWindowScopedSessionId, onAgentError, useGroupChatStore.

In `@src/renderer/hooks/groupChat/useGroupChatHandlers.ts`:
- Around line 124-129: The current isGroupChatVisibleInWindow function hides
chats when windowId is null but chat.initiatorWindowId is set; change the logic
to treat a missing windowId as permissive: if windowId === null return true
(allow visibility), otherwise keep the existing behavior (return true when
chat.initiatorWindowId is falsy or equals windowId). Update the function
isGroupChatVisibleInWindow to check windowId first and then fall back to the
current initiatorWindowId equality check.
- Around line 417-426: The catch in the start-moderator block currently swallows
all errors; change it to import captureException and captureMessage from
src/utils/sentry.ts, then in the catch distinguish expected failures (e.g.,
inspect error.code or error.message for known conditions like deleted chat) and
handle those with a console.warn and early return, but for any other/unexpected
error call captureException/captureMessage with context about the group chat id
and re-throw the error so it surfaces; keep existing setGroupChats logic
unchanged and reference window.maestro.groupChat.startModerator and
setGroupChats when applying the changes.

---

Outside diff comments:
In `@src/main/ipc/handlers/process.ts`:
- Around line 629-661: The code currently calls
assignSpawnSessionToSenderWindow(event, config.sessionId, windowManager,
windowStateStore) before invoking processManager.spawn(...), which can
persist/broadcast a moved session even if spawn fails; move the call so it runs
only after a successful spawn (i.e., after processManager.spawn resolves without
error), and wrap the spawn call in a try/catch so failures do not trigger the
window/session assignment or leave stale ownership; reference
assignSpawnSessionToSenderWindow, processManager.spawn, config.sessionId,
windowManager, and windowStateStore when making the change.

---

Nitpick comments:
In `@src/main/app-lifecycle/window-manager.ts`:
- Around line 376-383: The auto-reload after renderer crash (inside the handler
that checks details.reason and calls mainWindow.webContents.reload()) can cause
a reload loop for deterministic crashes; add a crash tracking mechanism (e.g., a
crash counter and timestamp list) scoped near the window manager globals that
records each crash for mainWindow and only allows reload if crashes in the last
30 seconds are below a threshold (suggested max 3). Update the existing block
that currently calls setTimeout(... mainWindow.webContents.reload()) to check
the crash counter before scheduling a reload, increment and prune timestamps on
each crash, and when the threshold is exceeded stop reloading and log/warn or
show a recovery UI instead; reference symbols: mainWindow, details.reason, and
the reload call (mainWindow.webContents.reload()) when making the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: be6cfcb0-5b5b-4fa4-bd04-08366f73288d

📥 Commits

Reviewing files that changed from the base of the PR and between 1006e3b and 471652a.

📒 Files selected for processing (97)
  • docs/context-management.md
  • docs/docs.json
  • docs/features.md
  • docs/multi-window.md
  • src/__tests__/main/app-lifecycle/quit-handler.test.ts
  • src/__tests__/main/app-lifecycle/window-close-policy.test.ts
  • src/__tests__/main/app-lifecycle/window-manager.test.ts
  • src/__tests__/main/app-lifecycle/window-state-restore.test.ts
  • src/__tests__/main/group-chat/group-chat-storage.test.ts
  • src/__tests__/main/ipc/handlers/groupChat.test.ts
  • src/__tests__/main/ipc/handlers/notifications.test.ts
  • src/__tests__/main/ipc/handlers/process.test.ts
  • src/__tests__/main/ipc/handlers/stats.test.ts
  • src/__tests__/main/ipc/handlers/windows.test.ts
  • src/__tests__/main/preload/groupChat.test.ts
  • src/__tests__/main/preload/notifications.test.ts
  • src/__tests__/main/preload/windows.test.ts
  • src/__tests__/main/stats/stats-db.test.ts
  • src/__tests__/main/stores/defaults.test.ts
  • src/__tests__/main/stores/instances.test.ts
  • src/__tests__/main/stores/types.test.ts
  • src/__tests__/main/utils/safe-send.test.ts
  • src/__tests__/main/window-registry.test.ts
  • src/__tests__/renderer/components/QuickActionsModal.test.tsx
  • src/__tests__/renderer/components/RightPanel.test.tsx
  • src/__tests__/renderer/components/ShortcutsHelpModal.test.tsx
  • src/__tests__/renderer/components/TabBar.test.tsx
  • src/__tests__/renderer/contexts/WindowContext.test.tsx
  • src/__tests__/renderer/hooks/ui/useWindowState.test.tsx
  • src/__tests__/renderer/hooks/useAgentListeners.test.ts
  • src/__tests__/renderer/hooks/useCycleSession.test.ts
  • src/__tests__/renderer/hooks/useGroupChatHandlers.test.ts
  • src/__tests__/renderer/stores/notificationStore.test.ts
  • src/__tests__/renderer/utils/windowSessionOwnership.test.ts
  • src/__tests__/renderer/utils/windowSessionScope.test.ts
  • src/__tests__/shared/group-chat-types.test.ts
  • src/__tests__/shared/window-types.test.ts
  • src/__tests__/web/hooks/useWebSocket.test.ts
  • src/main/app-lifecycle/index.ts
  • src/main/app-lifecycle/quit-handler.ts
  • src/main/app-lifecycle/window-close-policy.ts
  • src/main/app-lifecycle/window-manager.ts
  • src/main/app-lifecycle/window-state-restore.ts
  • src/main/group-chat/group-chat-storage.ts
  • src/main/index.ts
  • src/main/ipc/handlers/groupChat.ts
  • src/main/ipc/handlers/index.ts
  • src/main/ipc/handlers/notifications.ts
  • src/main/ipc/handlers/process.ts
  • src/main/ipc/handlers/windows.ts
  • src/main/preload/groupChat.ts
  • src/main/preload/index.ts
  • src/main/preload/notifications.ts
  • src/main/preload/stats.ts
  • src/main/preload/windows.ts
  • src/main/stats/aggregations.ts
  • src/main/stats/data-management.ts
  • src/main/stats/migrations.ts
  • src/main/stats/multi-window-recorder.ts
  • src/main/stats/multi-window.ts
  • src/main/stats/schema.ts
  • src/main/stats/stats-db.ts
  • src/main/stores/defaults.ts
  • src/main/stores/getters.ts
  • src/main/stores/instances.ts
  • src/main/stores/types.ts
  • src/main/utils/safe-send.ts
  • src/main/window-registry.ts
  • src/renderer/App.tsx
  • src/renderer/components/MainPanel.tsx
  • src/renderer/components/QuickActionsModal.tsx
  • src/renderer/components/RightPanel.tsx
  • src/renderer/components/SessionItem.tsx
  • src/renderer/components/SessionList/SessionList.tsx
  • src/renderer/components/SessionList/SkinnySidebar.tsx
  • src/renderer/components/ShortcutsHelpModal.tsx
  • src/renderer/components/TabBar.tsx
  • src/renderer/components/UsageDashboard/UsageDashboardModal.tsx
  • src/renderer/contexts/WindowContext.tsx
  • src/renderer/global.d.ts
  • src/renderer/hooks/agent/useAgentListeners.ts
  • src/renderer/hooks/groupChat/useGroupChatHandlers.ts
  • src/renderer/hooks/props/useMainPanelProps.ts
  • src/renderer/hooks/props/useRightPanelProps.ts
  • src/renderer/hooks/session/useCycleSession.ts
  • src/renderer/hooks/stats/useStats.ts
  • src/renderer/hooks/ui/index.ts
  • src/renderer/hooks/ui/useWindowState.ts
  • src/renderer/stores/notificationStore.ts
  • src/renderer/utils/windowSessionOwnership.ts
  • src/renderer/utils/windowSessionScope.ts
  • src/shared/group-chat-types.ts
  • src/shared/index.ts
  • src/shared/stats-types.ts
  • src/shared/types/index.ts
  • src/shared/types/window.ts
  • src/web/hooks/useWebSocket.ts

Comment thread src/main/stats/multi-window-recorder.ts
Comment on lines +140 to +148
const visibleGroupChats = useMemo(
() =>
groupChats.filter((chat) => !chat.initiatorWindowId || chat.initiatorWindowId === windowId),
[groupChats, windowId]
);
const activeGroupChatId = useGroupChatStore((s) => s.activeGroupChatId);
const visibleActiveGroupChatId = visibleGroupChats.some((chat) => chat.id === activeGroupChatId)
? activeGroupChatId
: null;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Preserve group chat visibility when window context is unavailable.

The current filter hides chats with an initiatorWindowId when no windowId is present, which breaks the optional-context fallback path.

Suggested fix
-	const visibleGroupChats = useMemo(
-		() =>
-			groupChats.filter((chat) => !chat.initiatorWindowId || chat.initiatorWindowId === windowId),
-		[groupChats, windowId]
-	);
+	const visibleGroupChats = useMemo(
+		() =>
+			!windowId
+				? groupChats
+				: groupChats.filter(
+						(chat) => !chat.initiatorWindowId || chat.initiatorWindowId === windowId
+					),
+		[groupChats, windowId]
+	);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const visibleGroupChats = useMemo(
() =>
groupChats.filter((chat) => !chat.initiatorWindowId || chat.initiatorWindowId === windowId),
[groupChats, windowId]
);
const activeGroupChatId = useGroupChatStore((s) => s.activeGroupChatId);
const visibleActiveGroupChatId = visibleGroupChats.some((chat) => chat.id === activeGroupChatId)
? activeGroupChatId
: null;
const visibleGroupChats = useMemo(
() =>
!windowId
? groupChats
: groupChats.filter(
(chat) => !chat.initiatorWindowId || chat.initiatorWindowId === windowId
),
[groupChats, windowId]
);
const activeGroupChatId = useGroupChatStore((s) => s.activeGroupChatId);
const visibleActiveGroupChatId = visibleGroupChats.some((chat) => chat.id === activeGroupChatId)
? activeGroupChatId
: null;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/components/SessionList/SessionList.tsx` around lines 140 - 148,
The current filter for visibleGroupChats wrongly excludes chats that have an
initiatorWindowId when no windowId is provided; update the filter in the
SessionList component so that if windowId is null/undefined it does not filter
out initiator-bound chats. Concretely, change the predicate used to derive
visibleGroupChats (the useMemo over groupChats) to allow all chats when windowId
is falsy, otherwise apply the existing check (e.g. !chat.initiatorWindowId ||
chat.initiatorWindowId === windowId). Keep references to visibleGroupChats,
groupChats, windowId, and initiatorWindowId intact so visibleActiveGroupChatId
logic continues to work.

Comment on lines +1792 to +1805
void window.maestro?.windows?.highlightDropZone?.(highlightedWindowId, false);
}, []);

const setHighlightedDropZone = useCallback(
(windowId: string | null) => {
if (highlightedWindowIdRef.current === windowId) {
return;
}

clearHighlightedDropZone();
highlightedWindowIdRef.current = windowId;
if (windowId) {
void window.maestro?.windows?.highlightDropZone?.(windowId, true);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle rejected window IPC calls instead of fire-and-forget.

The new highlightDropZone(), findWindowAtPoint(), getWindowBounds(), create(), and moveSession() calls are all launched without any rejection handling. A failed IPC call here will silently leave drag state/highlights wrong and give Sentry nothing useful. As per coding guidelines src/renderer/**/*.{ts,tsx}: Do not silently swallow errors. Let unhandled exceptions bubble up to Sentry for error tracking in production. Handle expected/recoverable errors explicitly (e.g., NETWORK_ERROR). For unexpected errors, re-throw them to allow Sentry to capture them. Use Sentry utilities (captureException, captureMessage) from src/utils/sentry.ts for explicit error reporting with context.

Also applies to: 1859-1870, 1935-1943, 1986-2021

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/components/TabBar.tsx` around lines 1792 - 1805, The
fire-and-forget IPC calls (e.g., window.maestro?.windows?.highlightDropZone in
setHighlightedDropZone and clearHighlightedDropZone, plus
highlightDropZone/findWindowAtPoint/getWindowBounds/create/moveSession usages
referenced) must not silently swallow rejections; update each call to properly
await the returned Promise (or attach a .catch), handle expected/recoverable
errors explicitly, and for unexpected failures call the Sentry helper
(captureException or captureMessage from src/utils/sentry.ts) with contextual
info (function name, windowId/point/session id) and then re-throw so Sentry can
capture it; ensure you reference the existing functions setHighlightedDropZone,
clearHighlightedDropZone, and any callbacks that call window.maestro to locate
and fix each site.

Comment on lines +195 to +210
const moveSessionToNewWindow = useCallback(
async (sessionId: string): Promise<WindowInfo> => {
if (!windowId) {
throw new Error('Cannot move session before window state is initialized');
}

const newWindow = await window.maestro.windows.create([sessionId]);
setSessionIds((currentSessionIds) =>
currentSessionIds.filter((currentSessionId) => currentSessionId !== sessionId)
);
setActiveSessionId((currentActiveSessionId) =>
getNextActiveSessionId(sessionIds, sessionId, currentActiveSessionId)
);
return newWindow;
},
[sessionIds, windowId]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Compute the fallback active session from current state, not the closed-over array.

moveSessionToNewWindow() awaits IPC and then calls getNextActiveSessionId(sessionIds, ...) with the stale sessionIds captured before the move started. If another window move lands before this promise resolves, activeSessionId can be set to a session that's already gone from this window.

Suggested fix
 const moveSessionToNewWindow = useCallback(
 	async (sessionId: string): Promise<WindowInfo> => {
 		if (!windowId) {
 			throw new Error('Cannot move session before window state is initialized');
 		}

 		const newWindow = await window.maestro.windows.create([sessionId]);
-		setSessionIds((currentSessionIds) =>
-			currentSessionIds.filter((currentSessionId) => currentSessionId !== sessionId)
-		);
-		setActiveSessionId((currentActiveSessionId) =>
-			getNextActiveSessionId(sessionIds, sessionId, currentActiveSessionId)
-		);
+		setSessionIds((currentSessionIds) => {
+			setActiveSessionId((currentActiveSessionId) =>
+				getNextActiveSessionId(currentSessionIds, sessionId, currentActiveSessionId)
+			);
+			return currentSessionIds.filter((currentSessionId) => currentSessionId !== sessionId);
+		});
 		return newWindow;
 	},
-	[sessionIds, windowId]
+	[windowId]
 );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/contexts/WindowContext.tsx` around lines 195 - 210, The
moveSessionToNewWindow function uses the stale closed-over sessionIds when
computing the fallback active session; change the logic so the next active
session is computed from the up-to-date state inside the setter callback instead
of the outer sessionIds variable—specifically, after creating newWindow, call
setSessionIds with a functional updater that removes sessionId and then compute
getNextActiveSessionId using that currentSessionIds result (or invoke
setActiveSessionId from within that same updater by deriving the new fallback)
so setActiveSessionId never uses the outdated sessionIds captured earlier;
reference moveSessionToNewWindow, setSessionIds, setActiveSessionId, and
getNextActiveSessionId when making the change.

Comment on lines +210 to +220
const isSessionInCurrentWindow = (sessionId: string) => {
const scopedSessionIds = windowSessionIdsRef.current;
if (!scopedSessionIds || windowIdRef.current == null) {
return true;
}
if (sessionId.startsWith('group-chat-')) {
return true;
}

return scopedSessionIds.includes(getWindowScopedSessionId(sessionId));
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't bypass window scoping for group-chat-* sessions.

This helper returns true for every group-chat session, so the group-chat branch in onAgentError will still update useGroupChatStore in every open window. That undoes the new per-window ownership model and will surface moderator/participant errors in the wrong renderer. Scope group-chat session IDs to their owning window here instead of blanket-allowing them.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/hooks/agent/useAgentListeners.ts` around lines 210 - 220, The
helper is incorrectly returning true for any sessionId starting with
'group-chat-', bypassing per-window scoping and causing group-chat updates to
reach all windows; update isSessionInCurrentWindow so it does not special-case
'group-chat-*' — always derive the window-scoped id via
getWindowScopedSessionId(sessionId) and check membership against
windowSessionIdsRef.current (and still return true only if
windowSessionIdsRef.current or windowIdRef.current are null), so onAgentError
and useGroupChatStore only update the owning window. Reference:
isSessionInCurrentWindow, windowSessionIdsRef, windowIdRef,
getWindowScopedSessionId, onAgentError, useGroupChatStore.

Comment on lines +124 to +129
function isGroupChatVisibleInWindow(
chat: { initiatorWindowId?: string | null },
windowId: string | null
): boolean {
return !chat.initiatorWindowId || chat.initiatorWindowId === windowId;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Allow fallback visibility when no window context is available.

With windowId === null, this currently hides any chat that has initiatorWindowId, which can block opening existing chats in optional/non-window contexts. Consider treating missing windowId as permissive.

💡 Proposed fix
 function isGroupChatVisibleInWindow(
 	chat: { initiatorWindowId?: string | null },
 	windowId: string | null
 ): boolean {
+	if (!windowId) return true;
 	return !chat.initiatorWindowId || chat.initiatorWindowId === windowId;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function isGroupChatVisibleInWindow(
chat: { initiatorWindowId?: string | null },
windowId: string | null
): boolean {
return !chat.initiatorWindowId || chat.initiatorWindowId === windowId;
}
function isGroupChatVisibleInWindow(
chat: { initiatorWindowId?: string | null },
windowId: string | null
): boolean {
if (!windowId) return true;
return !chat.initiatorWindowId || chat.initiatorWindowId === windowId;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/hooks/groupChat/useGroupChatHandlers.ts` around lines 124 - 129,
The current isGroupChatVisibleInWindow function hides chats when windowId is
null but chat.initiatorWindowId is set; change the logic to treat a missing
windowId as permissive: if windowId === null return true (allow visibility),
otherwise keep the existing behavior (return true when chat.initiatorWindowId is
falsy or equals windowId). Update the function isGroupChatVisibleInWindow to
check windowId first and then fall back to the current initiatorWindowId
equality check.

Comment on lines +417 to +426
try {
const moderatorSessionId = await window.maestro.groupChat.startModerator(id);
if (moderatorSessionId) {
setGroupChats((prev) =>
prev.map((c) => (c.id === id ? { ...c, moderatorSessionId } : c))
);
}
} catch (error) {
console.warn(`Failed to start moderator for group chat ${id}:`, error);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t swallow unexpected moderator-start failures.

This catch handles every failure with console.warn and continues. Expected failures (e.g., deleted chat) should be handled explicitly, but unexpected ones should be reported and re-thrown.

💡 Proposed fix
+import { captureException } from '../../utils/sentry';
...
 				try {
 					const moderatorSessionId = await window.maestro.groupChat.startModerator(id);
 					if (moderatorSessionId) {
 						setGroupChats((prev) =>
 							prev.map((c) => (c.id === id ? { ...c, moderatorSessionId } : c))
 						);
 					}
 				} catch (error) {
-					console.warn(`Failed to start moderator for group chat ${id}:`, error);
+					const message = error instanceof Error ? error.message : '';
+					const isRecoverableDeleteRace =
+						message.includes('deleted') || message.includes('not found');
+					if (isRecoverableDeleteRace) return;
+					captureException(error, {
+						tags: { feature: 'group-chat', operation: 'startModerator' },
+						extra: { groupChatId: id, windowId },
+					});
+					throw error;
 				}

As per coding guidelines src/**/*.{ts,tsx}: Do not silently swallow errors... For unexpected errors, re-throw them... Use Sentry utilities (captureException, captureMessage) from src/utils/sentry.ts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/hooks/groupChat/useGroupChatHandlers.ts` around lines 417 - 426,
The catch in the start-moderator block currently swallows all errors; change it
to import captureException and captureMessage from src/utils/sentry.ts, then in
the catch distinguish expected failures (e.g., inspect error.code or
error.message for known conditions like deleted chat) and handle those with a
console.warn and early return, but for any other/unexpected error call
captureException/captureMessage with context about the group chat id and
re-throw the error so it surfaces; keep existing setGroupChats logic unchanged
and reference window.maestro.groupChat.startModerator and setGroupChats when
applying the changes.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 21, 2026

Greptile Summary

This PR implements multiple-window support for Maestro, introducing a WindowRegistry, per-window session ownership, state persistence with legacy migration, drag-and-drop session movement between windows, and display-aware position restore on startup.

  • New WindowRegistry class tracks BrowserWindow ↔ windowId ↔ sessionId mappings; createWindowManager is refactored to create windows through the registry and expose it on the WindowManager interface.
  • New IPC surface (windows:*) handles window creation/close/focus, session moves with a serialised promise queue, drop-zone highlight signalling, and per-window state sync; safeSend is updated to broadcast to all open windows; notification clicks now focus the originating window.
  • New WindowContext React context in the renderer tracks the current window's session list and reacts to onSessionMoved / onSessionsMovedToPrimary push events.

Confidence Score: 3/5

The multi-window wiring is broadly correct, but one shutdown-path error handler silently discards all exceptions without Sentry capture, directly contravening a project-wide convention that exists specifically to prevent blind spots in production telemetry.

The window registry, state migration, session-movement serialisation, and display-position restore are well-designed. The main concern is that saveAllWindowStates in quit-handler.ts (and the empty catch in window-manager.ts saveWindowState wrapper) swallow every exception with only logger.warn, so any unexpected store or I/O error on shutdown disappears without a Sentry report. There is also a stale-closure edge case in moveSessionToNewWindow and a missing broadcast from windows:create, though those are lower-impact.

src/main/app-lifecycle/quit-handler.ts (saveAllWindowStates error handling), src/main/app-lifecycle/window-manager.ts (saveWindowState catch block), src/main/ipc/handlers/windows.ts (windows:create broadcast gap)

Important Files Changed

Filename Overview
src/main/app-lifecycle/quit-handler.ts Adds saveAllWindowStates on shutdown. Catch block swallows all errors with logger.warn, violating the Sentry error-handling guidelines in CLAUDE.md.
src/main/window-registry.ts New registry class managing BrowserWindow to windowId to sessionId mappings. saveWindowState has redundant x/y/width/height initialisation for non-maximised windows.
src/main/app-lifecycle/window-manager.ts Refactored to shared createBrowserWindow helper; close-event session-transfer logic correct; silent catch{} in saveWindowState swallows unexpected errors without Sentry.
src/main/ipc/handlers/windows.ts New IPC surface for window CRUD, session movement, and state sync. windows:moveSession is serialised via promise queue; windows:create does not broadcast source-window session removal.
src/renderer/contexts/WindowContext.tsx New React context for per-window session ownership state. moveSessionToNewWindow has stale-closure issue in active-session calculation after async call.
src/main/app-lifecycle/window-state-restore.ts Display-position recovery and session deduplication logic look correct.
src/main/stores/instances.ts Legacy migration correctly maps old flat WindowState to new windows array with all sessions assigned to primary.
src/main/ipc/handlers/process.ts process:spawn correctly switched to withErrorLogging to expose IpcMainInvokeEvent; session-window assignment is safe against double-assignment.
src/main/utils/safe-send.ts Refactored to broadcast to all open windows using deduplication via Set. Fallback to getMainWindow preserved for callers without getWindows.
src/main/index.ts Startup now calls restoreStartupWindows instead of a single createWindow. windowManager initialised inside app.whenReady.

Sequence Diagram

sequenceDiagram
    participant R as Renderer (Window A)
    participant P as Preload (windows API)
    participant M as Main (windows IPC)
    participant WR as WindowRegistry
    participant WS as windowStateStore
    participant RB as Renderer (Window B/new)

    Note over R,RB: moveSessionToNewWindow(sessionId)
    R->>P: windows.create([sessionId])
    P->>M: IPC invoke windows:create
    M->>WR: createSecondaryWindow(sessionIds, bounds)
    WR->>WR: setSessionsForWindow(newId, sessionIds)
    Note over WR: removes sessionId from Window A entry
    M->>WS: upsertStoredWindowState(newWindowId)
    M->>WS: upsertStoredWindowState(sourceWindowId)
    M-->>R: return WindowInfo
    R->>R: setSessionIds / setActiveSessionId

    Note over RB: New window loads
    RB->>M: windows:getState
    M->>WR: get(windowId).sessionIds
    M-->>RB: "WindowState {sessionIds:[sessionId]}"
    RB->>RB: WindowContext initialises

    Note over R,RB: windows:moveSession (drag-drop)
    R->>M: windows:moveSession (queued)
    M->>WR: moveSession(id, from, to)
    M->>WS: upsertStoredWindowState x2
    M->>R: windows:sessionMoved
    M->>RB: windows:sessionMoved

    Note over R,RB: Secondary window closes
    RB->>M: close event
    M->>WR: moveSessionsToPrimary
    M->>R: windows:sessionsMovedToPrimary
    R->>R: merge sessions + toast
Loading

Reviews (1): Last reviewed commit: "MAESTRO: Document multi-window support" | Re-trigger Greptile

Comment on lines +306 to +311

function createWindowState(
windowId: string,
entry: WindowRegistryEntry,
currentState: MultiWindowState
): WindowState {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Silent error swallowing hides bugs from Sentry

The catch block logs via logger.warn but never reports to Sentry, violating the project's error-handling convention from CLAUDE.md. Any unexpected error here (e.g., a corrupt store, disk I/O error unrelated to ENFILE/ENOSPC) is silently discarded and will never surface in production telemetry. Per the project guideline, unknown errors should be captured: call captureException(err, { operation: 'saveWindowStates' }) before returning rather than swallowing silently. This same pattern recurs in the saveWindowState helper inside window-manager.ts (the empty catch {} block at line ~234).

Context Used: CLAUDE.md (source)

Comment on lines +225 to +260
windowStateStore,
sourceWindowId,
sourceWindow,
sourceEntry.sessionIds,
{
activeSessionId: sourceMovedActiveSession
? getNextActiveSessionId(sourceSessionIds, sourceActiveSessionId)
: (sourceActiveSessionId ?? null),
}
);
}

recordMultiWindowUsage(settingsStore, windowRegistry, 'window_created');

return toWindowInfo(windowId, windowManager, activeSessionId);
}
);

ipcMain.handle('windows:close', async (_event, windowId: string): Promise<WindowCloseResult> => {
const entry = windowRegistry.get(windowId);
if (!entry) {
return { closed: false, reason: 'not-found' };
}
if (entry.isMain) {
return { closed: false, reason: 'primary-window' };
}

entry.browserWindow.close();
return { closed: true };
});

ipcMain.handle('windows:list', async (): Promise<WindowInfo[]> => {
return getWindowList(windowManager, windowStateStore);
});

ipcMain.handle(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 windows:create does not broadcast session-list changes to other open windows

windows:moveSession calls broadcastSessionMoved so every window's onSessionMoved listener fires. windows:create skips this broadcast entirely: the source window's renderer updates its own state manually (via moveSessionToNewWindow), but any other open window that has subscribed to onSessionMoved will not learn that the source window's session list changed. In a 3+-window setup this leaves the windows' cached session lists inconsistent until the next explicit windows:list call. Consider calling broadcastSessionMoved for each session moved out of the source window, mirroring what windows:moveSession does.

Comment on lines +195 to +211
const moveSessionToNewWindow = useCallback(
async (sessionId: string): Promise<WindowInfo> => {
if (!windowId) {
throw new Error('Cannot move session before window state is initialized');
}

const newWindow = await window.maestro.windows.create([sessionId]);
setSessionIds((currentSessionIds) =>
currentSessionIds.filter((currentSessionId) => currentSessionId !== sessionId)
);
setActiveSessionId((currentActiveSessionId) =>
getNextActiveSessionId(sessionIds, sessionId, currentActiveSessionId)
);
return newWindow;
},
[sessionIds, windowId]
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Stale sessionIds closure in active-session calculation after async call

getNextActiveSessionId(sessionIds, sessionId, ...) captures sessionIds from the closure, not the live state at the point the await window.maestro.windows.create resolves. If an onSessionMoved event arrives while waiting for the IPC round-trip, the index look-up in getNextActiveSessionId will use the outdated list and may pick the wrong replacement active session. Reading the live list inside the setSessionIds functional updater (and calling setActiveSessionId there) would ensure both updates share the same consistent snapshot.

Comment on lines +165 to +180
continue;
}
entry.sessionIds = entry.sessionIds.filter((id) => id !== sessionId);
}

if (!toWindow.sessionIds.includes(sessionId)) {
toWindow.sessionIds.push(sessionId);
}
}

saveWindowState(windowId: string): WindowState | null {
if (!this.windowStateStore) {
return null;
}

const entry = this.windows.get(windowId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Dead initialisation of x/y/width/height for the non-maximised path

nextWindowState is first populated with existingWindowState?.x ?? bounds.x (etc.), but for every non-maximised/non-fullscreen window the block below immediately overwrites those same fields with bounds.x (etc.). The existingWindowState?.x fallback only matters when the window IS maximised, so the initial assignment for the non-maximised case is dead code. Consider initialising directly from bounds.* and only falling back to existingWindowState.* inside the maximised branch, which makes the intent explicit.

@pedramamini
Copy link
Copy Markdown
Collaborator

@DRAZY thanks for the contribution — this is a substantial multi-window feature with thorough tests and docs. Greptile and CodeRabbit have both posted reviews; flagging the highest-impact items below so they can land before this comes out of WIP:

Correctness

  • src/renderer/contexts/WindowContext.tsx (~L195–211): moveSessionToNewWindow reads sessionIds from the closure after the await window.maestro.windows.create round-trip. If an onSessionMoved event arrives in between, getNextActiveSessionId will pick from a stale list. Compute the next active inside a functional updater so both state updates share one snapshot.
  • src/renderer/hooks/agent/useAgentListeners.ts (~L210–220): isSessionInCurrentWindow short-circuits to true for any id starting with group-chat-, bypassing per-window scoping so group-chat updates leak to every window. Drop the special case and check membership via getWindowScopedSessionId against windowSessionIdsRef.current.
  • src/renderer/components/SessionList/SessionList.tsx (~L140) and src/renderer/hooks/groupChat/useGroupChatHandlers.ts (~L124): the visibleGroupChats / isGroupChatVisibleInWindow filters hide initiator-bound chats when windowId is null. A missing windowId should be treated as permissive.
  • src/main/ipc/handlers/process.ts (~L629–661): assignSpawnSessionToSenderWindow runs before processManager.spawn. If spawn fails, the move is still persisted and broadcast. Assign only after a successful spawn.
  • src/renderer/components/TabBar.tsx (~L1901–2041, 2455–2465): file-tab drags go through the AI-tab session-move path and always act on windowContext?.activeSessionId, so dragging a file preview outside the window moves the active session instead. Carry the tab type in the drag payload or skip the cross-window branch for file tabs.
  • src/main/ipc/handlers/windows.ts (~L260): windows:create doesn't call broadcastSessionMoved, so any third window's cached session list goes stale until the next windows:list. Mirror what windows:moveSession does.

Error handling (per CLAUDE.md)

  • src/main/app-lifecycle/quit-handler.ts (~L311) and window-manager.ts (~L234 empty catch {}): unknown errors are swallowed instead of reaching Sentry. Use captureException for unexpected branches and only swallow known/recoverable ones.
  • src/main/stats/multi-window-recorder.ts (~L50–53): same pattern — differentiate recoverable DB errors from unexpected ones; the latter should captureException and re-throw.
  • src/renderer/components/TabBar.tsx (~L1792–1805): fire-and-forget IPC calls (highlightDropZone, findWindowAtPoint, getWindowBounds, etc.) silently drop rejections. Attach .catch with captureException for unexpected failures.
  • src/renderer/hooks/groupChat/useGroupChatHandlers.ts (~L417–426): start-moderator catch swallows all errors — split known vs unexpected.

Reliability

  • src/main/app-lifecycle/window-manager.ts (~L376–383): renderer-crash auto-reload has no crash counter, so a deterministic crash creates a reload loop. Add a counter (e.g. >3 crashes within 30s → stop and show recovery UI).

Minor

  • src/main/window-registry.ts (~L180): nextWindowState initializes x/y/width/height from existingWindowState, then the non-maximised branch overwrites them from bounds. The fallback only matters in the maximised branch — simplify by initialising from bounds.* and only consulting existingWindowState.* inside the maximised path.

No merge conflicts on main right now, so once the WIP items above are addressed this should be in good shape for a final pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: multiple windows

2 participants