Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,15 @@ export default defineConfig(({ mode }) => ({
resolve: { alias: [...headlessAlias, ...Object.entries(projectAlias).map(([find, replacement]) => ({ find, replacement }))] },
build: {
rollupOptions: {
input: resolve(__dirname, 'src/preload/index.ts')
// Two preload bundles: the full bridge for the main window and a
// least-privilege pick for the agent-status overlay window (see
// src/preload/overlay.ts). Entry keys become out/preload/<key>.mjs
// — the runtime paths in mainWindow.ts / overlayWindow.ts depend
// on those names.
input: {
index: resolve(__dirname, 'src/preload/index.ts'),
overlay: resolve(__dirname, 'src/preload/overlay.ts'),
}
}
}
},
Expand All @@ -200,7 +208,15 @@ export default defineConfig(({ mode }) => ({
resolve: { alias: [...headlessAlias, ...Object.entries(projectAlias).map(([find, replacement]) => ({ find, replacement }))] },
build: {
rollupOptions: {
input: resolve(__dirname, 'src/renderer/index.html')
// Two windows, two HTML entries, one Vite renderer project.
// `overlay` is the floating agent-status window (a tiny React app
// sharing styles.css and the preload bundle with the main window).
// Key names become output chunk prefixes only; the runtime load
// paths are the .html files (see mainWindow.ts / overlayWindow.ts).
input: {
index: resolve(__dirname, 'src/renderer/index.html'),
overlay: resolve(__dirname, 'src/renderer/overlay.html'),
}
}
},
plugins: [react(), tailwindcss()],
Expand Down
19 changes: 17 additions & 2 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ import {
} from '@main/storage/debugRetention.js'
import { cleanupClaudeImageCacheDir } from '@main/storage/claudeImageCache.js'
import { acquireStateProcessLock, type StateProcessLock } from '@main/storage/processLock.js'
import { createMainWindow, focusMainWindow, sendToMainWindow } from '@main/window/mainWindow.js'
import { createMainWindow, focusMainWindow, hasMainWindow, sendToMainWindow } from '@main/window/mainWindow.js'
import { initAgentOverlay, syncAgentOverlayWindow } from '@main/window/overlayWindow.js'
import { wireSessionForwarder } from '@main/sessions/forwarder.js'
import { SessionRecorderManager } from '@main/recording/SessionRecorderManager.js'
import { setOutboundObserver } from '@main/window/mainWindow.js'
Expand Down Expand Up @@ -625,10 +626,24 @@ async function startApp(): Promise<void> {
// Install the application menu right after the window exists — the File
// items dispatch command ids to THIS window's renderer (issue #148).
Menu.setApplicationMenu(buildAppMenu())
// Floating agent-status overlay: restores its persisted enabled state
// (recreating the always-on-top window if it was on last run) and wires
// the app-level focus/blur auto-hide. After createMainWindow so its
// enabled-state push has a main renderer to land on.
initAgentOverlay()
performanceService.mark('app.main.window.created')

app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createMainWindow()
// Main-window-specific check, NOT getAllWindows().length: the
// agent-status overlay is a second BrowserWindow, so a raw window
// count would see "1 window" after the main window closed and never
// recreate it from the Dock (PR #514 review finding 1). The overlay
// itself is destroyed on main-window close (its data source is the
// main renderer), so recreate it here alongside the main window.
if (!hasMainWindow()) {
createMainWindow()
syncAgentOverlayWindow()
}
})
}

Expand Down
128 changes: 128 additions & 0 deletions src/main/ipc/agentOverlay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { ipcMain } from 'electron'

import type {
AgentOverlaySnapshot,
OverlayAgentActivity,
OverlayAgentRow,
} from '@shared/types/agentOverlay.js'
import { focusMainWindow, sendToMainWindow } from '@main/window/mainWindow.js'
import {
isAgentOverlayEnabled,
persistAgentOverlayExpanded,
publishAgentOverlaySnapshot,
resizeAgentOverlayContent,
toggleAgentOverlay,
} from '@main/window/overlayWindow.js'

// IPC for the floating agent-status overlay. Three distinct flows share
// the agent-overlay:* prefix — worth keeping straight:
//
// main renderer → main: report (snapshot publishing), toggle/get-enabled
// overlay → main: set-expanded, resize, focus-session
// main → overlay: agent-overlay:state (sent by overlayWindow.ts,
// NOT from here — it goes to the overlay window's
// webContents, not through sendToMainWindow)
// main → main renderer: enabled-changed, focus-session relay
//
// send (fire-and-forget) vs handle (invoke) split: everything on the hot
// path (snapshot reports, resize on every render) is send — the sender
// never needs an answer and awaiting one would just serialize the stream.

// Runtime bounds for the report payload. The sender is our own renderer,
// but this data crosses the IPC trust boundary, gets CACHED in main
// (lastSnapshot), and is REPLAYED to the overlay window on every load —
// so a malformed or oversized payload wouldn't be a one-off glitch, it
// would be persistent main-process memory and a crash the overlay
// re-triggers on every open. Bound everything; never trust a cast
// (PR #514 review finding 5).
const OVERLAY_ACTIVITIES: ReadonlySet<string> = new Set([
'starting',
'working',
'running',
'idle',
'exited',
] satisfies OverlayAgentActivity[])
const MAX_AGENTS = 200
const MAX_THEME_JSON_CHARS = 32_000

function capString(value: unknown, max: number): string | null {
return typeof value === 'string' ? value.slice(0, max) : null
}

function sanitizeSnapshot(raw: unknown): AgentOverlaySnapshot | null {
if (typeof raw !== 'object' || raw === null) return null
const agentsRaw = (raw as { agents?: unknown }).agents
if (!Array.isArray(agentsRaw)) return null

const agents: OverlayAgentRow[] = []
for (const item of agentsRaw.slice(0, MAX_AGENTS)) {
if (typeof item !== 'object' || item === null) continue
const record = item as Record<string, unknown>
const sessionId = capString(record['sessionId'], 128)
if (!sessionId) continue
const activityRaw = record['activity']
agents.push({
sessionId,
title: capString(record['title'], 200) ?? '',
projectTitle: capString(record['projectTitle'], 120) ?? '',
pinned: record['pinned'] === true,
activity:
typeof activityRaw === 'string' && OVERLAY_ACTIVITIES.has(activityRaw)
? (activityRaw as OverlayAgentActivity)
: 'idle',
attentionLabel: capString(record['attentionLabel'], 80),
statusText: capString(record['statusText'], 160),
})
}

// Theme is renderer-owned opaque JSON (see shared/types/agentOverlay.ts)
// — shape-validate to "plain bounded object" and nothing more; dropping
// it degrades the overlay to default theme, never to a crash.
let theme: Record<string, unknown> | null = null
const themeRaw = (raw as { theme?: unknown }).theme
if (themeRaw && typeof themeRaw === 'object' && !Array.isArray(themeRaw)) {
try {
if (JSON.stringify(themeRaw).length <= MAX_THEME_JSON_CHARS) {
theme = themeRaw as Record<string, unknown>
}
} catch {
// Circular/unserializable theme → drop it.
}
}

return { agents, theme }
}

export function registerAgentOverlayIpc(): void {
ipcMain.handle('agent-overlay:toggle', () => toggleAgentOverlay())
ipcMain.handle('agent-overlay:get-enabled', () => isAgentOverlayEnabled())

ipcMain.on('agent-overlay:report', (_event, raw: unknown) => {
const snapshot = sanitizeSnapshot(raw)
if (snapshot) publishAgentOverlaySnapshot(snapshot)
})

ipcMain.on('agent-overlay:set-expanded', (_event, expanded: unknown) => {
persistAgentOverlayExpanded(expanded === true)
})

ipcMain.on('agent-overlay:resize', (_event, size: { width?: unknown; height?: unknown }) => {
// Defensive narrowing rather than a cast: this arrives from renderer JS
// and feeds straight into window geometry — NaN here means an invisible
// or unusable window with no error anywhere.
const width = typeof size?.width === 'number' && Number.isFinite(size.width) ? size.width : null
const height = typeof size?.height === 'number' && Number.isFinite(size.height) ? size.height : null
if (width === null || height === null) return
resizeAgentOverlayContent({ width, height })
})

ipcMain.on('agent-overlay:focus-session', (_event, payload: { sessionId?: unknown }) => {
const sessionId = typeof payload?.sessionId === 'string' ? payload.sessionId : null
if (!sessionId) return
// Main owns the OS-level part (raise + focus the app window); the
// renderer owns the workspace part (which tab/pane that session lives
// in) — main has no idea about tabs, so it relays.
focusMainWindow()
sendToMainWindow('agent-overlay:focus-session', { sessionId })
})
}
2 changes: 2 additions & 0 deletions src/main/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import type { RemoteController } from '@main/remote/RemoteController.js'
import type { AppRunJournal } from '@main/incident/AppRunJournal.js'
import { registerIncidentIpc } from '@main/ipc/incident.js'
import { registerUsageIpc } from '@main/ipc/usage.js'
import { registerAgentOverlayIpc } from '@main/ipc/agentOverlay.js'

// IPC registration aggregator.
//
Expand Down Expand Up @@ -91,4 +92,5 @@ export function registerAllIpc(deps: IpcDeps): void {
registerRemoteIpc(deps.remoteController)
registerIncidentIpc(deps.appRunJournal)
registerUsageIpc()
registerAgentOverlayIpc()
}
41 changes: 41 additions & 0 deletions src/main/window/mainWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,32 @@ export function focusMainWindow(): void {
mainWindow.focus()
}

/**
* "Does a live main window exist?" — for lifecycle decisions that must
* NOT count auxiliary windows. The macOS `activate` handler used to
* check `BrowserWindow.getAllWindows().length === 0`, which broke the
* moment the agent-status overlay became a second window: close the
* main window with the overlay alive and Dock activation would never
* recreate it (PR #514 review finding).
*/
export function hasMainWindow(): boolean {
return mainWindow !== null && !mainWindow.isDestroyed()
}

// Main-window close hooks. Auxiliary windows (currently only the
// agent-status overlay) key their lifetime off the MAIN window, not the
// app: the overlay's data source is the main renderer, so a closed main
// window means frozen snapshots — and a surviving overlay would also
// block `window-all-closed` cleanup forever. A callback registry
// (instead of the overlay importing the window object) keeps the import
// direction one-way: overlayWindow.ts → mainWindow.ts, never back.
type MainWindowClosedHandler = () => void
const mainWindowClosedHandlers = new Set<MainWindowClosedHandler>()

export function onMainWindowClosed(handler: MainWindowClosedHandler): void {
mainWindowClosedHandlers.add(handler)
}

/**
* Send an IPC message to the renderer. No-op when the window is gone
* — callers shouldn't have to guard lifecycle around every event.
Expand Down Expand Up @@ -176,6 +202,21 @@ export function createMainWindow(): void {
pushTrafficLightInset()
})

mainWindow.on('closed', () => {
// Null the module ref so hasMainWindow() answers correctly between
// close and any later recreate via `activate`. Handlers must never
// break window teardown — they're lifecycle conveniences, not
// load-bearing cleanup (that stays on `window-all-closed`).
mainWindow = null
for (const handler of mainWindowClosedHandlers) {
try {
handler()
} catch {
/* see above */
}
}
})

// Recompute the traffic light inset whenever the window geometry
// changes — zoom level, display scale, fullscreen toggle. Electron
// doesn't offer a "traffic light moved" event, but resize covers
Expand Down
Loading