Skip to content
Merged
601 changes: 601 additions & 0 deletions docs/superpowers/plans/2026-08-30-multi-window-workspaces.md

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions src/main/agentManagement/AgentManagementBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@ import type { ManagedAgentRendererDescriptor } from '@mcp/shared/agentManagement
const sentRendererRequests: unknown[] = []
const resolveProviderTranscriptPath = vi.fn(async () => '/tmp/provider-agent.jsonl')

vi.mock('@main/window/mainWindow.js', () => ({
sendToMainWindow: (_channel: string, request: unknown) => {
// See the note in OrchestrationBridge.test.ts: routing is stubbed to one
// always-resolvable window so these tests can be about the bridge's own
// serialization and inventory behavior.
const sessionWindowOwner = vi.fn((_sessionId: string): string | null => 'test-window')

vi.mock('@main/window/windowRegistry.js', () => ({
sendToWindow: (_windowId: string, _channel: string, request: unknown) => {
sentRendererRequests.push(request)
},
windowForSession: (sessionId: string) => sessionWindowOwner(sessionId),
}))

vi.mock('@main/providerSwitch/shared.js', () => ({
Expand Down
17 changes: 15 additions & 2 deletions src/main/agentManagement/AgentManagementBridge.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto'
import { stat } from 'node:fs/promises'

import { sendToMainWindow } from '@main/window/mainWindow.js'
import { sendToWindow, windowForSession } from '@main/window/windowRegistry.js'
import {
findCodexRolloutPathsBySessionIds,
resolveProviderTranscriptPath,
Expand Down Expand Up @@ -366,7 +366,20 @@ export class AgentManagementBridge {
// read-only: each crosses React's renderer-owned workspace model and bulk
// reads may page durable history. Backpressure here prevents an MCP burst
// from racing workspace mutations or starving the renderer event loop.
sendToMainWindow('agent-management:request', request)
//
// WHY the caller's window is resolved here and a miss THROWS: the request
// is answered from one window's workspace inventory, and this tool's whole
// contract is that it reports the caller's project. Throwing inside this
// try reuses the existing failure path (timer cleared, pending dropped,
// slot released), which is exactly the fail-closed behavior the timeout
// comment above describes.
const target = windowForSession(request.callerSessionId)
if (!target) {
throw new Error(
`No Agent Code window owns agent-management caller session ${request.callerSessionId}`,
)
}
sendToWindow(target, 'agent-management:request', request)
} catch (error) {
clearTimeout(timer)
this.pending.delete(request.requestId)
Expand Down
21 changes: 16 additions & 5 deletions src/main/dictation/hotkey.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
import { globalShortcut } from 'electron'

import { sendToMainWindow } from '@main/window/mainWindow.js'
import { sendToFocusedWindow } from '@main/window/windowRegistry.js'
import { startMacDictationHotkeyHelper, stopMacDictationHotkeyHelper } from '@main/dictation/macHotkeyHelper.js'

let currentBinding = ''
let registeredElectronHotkey = ''
let electronHotkeyRecording = false

// WHY every edge here goes to the FOCUSED window rather than a fixed one:
// the dictation hotkey is a global accelerator, so it fires no matter which
// window the user is in — and with several windows open, "start listening" can
// only sensibly mean the composer they are looking at. `sendToFocusedWindow`
// falls back to the most recently focused window, which matters because a
// global shortcut can arrive while Agent Code is not even frontmost, and
// `BrowserWindow.getFocusedWindow()` returns null in exactly that case.
//
// Down and up edges are NOT re-resolved independently: see the release note on
// `releaseElectronHotkeyRecording`.

/**
* Electron exposes an accelerator activation callback, not a physical key-up
* callback. Pretending one activation was both down and up made every quiet
Expand All @@ -23,7 +34,7 @@ let electronHotkeyRecording = false
function releaseElectronHotkeyRecording(binding: string): void {
if (!electronHotkeyRecording) return
electronHotkeyRecording = false
sendToMainWindow('dictation:hotkey-up', { binding })
sendToFocusedWindow('dictation:hotkey-up', { binding })
}

// WHY a binding-classifier lives here (packaged-app fix):
Expand Down Expand Up @@ -154,7 +165,7 @@ export async function configureDictationHotkey(binding: string): Promise<{
return
}
electronHotkeyRecording = true
sendToMainWindow('dictation:hotkey-down', { binding: currentBinding })
sendToFocusedWindow('dictation:hotkey-down', { binding: currentBinding })
})
if (ok) registeredElectronHotkey = classification.accelerator
return { ok, binding: currentBinding, native: false }
Expand Down Expand Up @@ -187,12 +198,12 @@ export async function configureDictationHotkey(binding: string): Promise<{
// lifecycle problems without guessing from Deepgram logs.
// eslint-disable-next-line no-console
console.debug('[dictation:hotkey] down', { binding: currentBinding, at: Date.now() })
sendToMainWindow('dictation:hotkey-down', { binding: currentBinding })
sendToFocusedWindow('dictation:hotkey-down', { binding: currentBinding })
},
onRelease: () => {
// eslint-disable-next-line no-console
console.debug('[dictation:hotkey] up', { binding: currentBinding, at: Date.now() })
sendToMainWindow('dictation:hotkey-up', { binding: currentBinding })
sendToFocusedWindow('dictation:hotkey-up', { binding: currentBinding })
},
})
return {
Expand Down
2 changes: 1 addition & 1 deletion src/main/incident/installWindowIncidentHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { execFile } from 'node:child_process'
import { monitorEventLoopDelay } from 'node:perf_hooks'

import type { AppRunJournal } from '@main/incident/AppRunJournal.js'
import { getOutboundIpcDiagnostics } from '@main/window/mainWindow.js'
import { getOutboundIpcDiagnostics } from '@main/window/windowRegistry.js'
import type { RendererFreezeHeartbeat } from '@shared/incident/rendererFreeze.js'

const HEARTBEAT_STALL_MS = 4_000
Expand Down
Loading