From f16bdbd58f0edf263ceb5ca44135dca39307fad7 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 9 Jul 2026 19:02:28 +0200 Subject: [PATCH 1/2] Reapply "Merge pull request #514 from Juliusolsson05/feat/agent-status-overlay" This reverts commit 7abf88789d5febfa4f9085c47d5aeee720e2ad2d. --- electron.vite.config.ts | 20 +- src/main/index.ts | 19 +- src/main/ipc/agentOverlay.ts | 128 ++++++ src/main/ipc/index.ts | 2 + src/main/window/mainWindow.ts | 41 ++ src/main/window/overlayWindow.ts | 386 ++++++++++++++++++ src/preload/api/agentOverlay.ts | 53 +++ src/preload/api/index.ts | 2 + src/preload/overlay.ts | 28 ++ src/renderer/overlay.html | 24 ++ src/renderer/src/app/App.tsx | 5 + .../commands/agentOverlayCommands.ts | 28 ++ .../agent-overlay/useAgentOverlayBridge.ts | 155 +++++++ .../src/features/command-palette/registry.ts | 2 + src/renderer/src/overlay/OverlayApp.tsx | 278 +++++++++++++ src/renderer/src/overlay/main.tsx | 20 + src/renderer/src/overlay/overlay.css | 36 ++ .../src/workspace/conditions/selectors.ts | 9 + .../workspace/dispatch/DispatchAgentList.tsx | 107 +---- .../workspace/dispatch/dispatchActivity.ts | 106 +++++ src/shared/types/agentOverlay.ts | 65 +++ 21 files changed, 1418 insertions(+), 96 deletions(-) create mode 100644 src/main/ipc/agentOverlay.ts create mode 100644 src/main/window/overlayWindow.ts create mode 100644 src/preload/api/agentOverlay.ts create mode 100644 src/preload/overlay.ts create mode 100644 src/renderer/overlay.html create mode 100644 src/renderer/src/features/agent-overlay/commands/agentOverlayCommands.ts create mode 100644 src/renderer/src/features/agent-overlay/useAgentOverlayBridge.ts create mode 100644 src/renderer/src/overlay/OverlayApp.tsx create mode 100644 src/renderer/src/overlay/main.tsx create mode 100644 src/renderer/src/overlay/overlay.css create mode 100644 src/renderer/src/workspace/dispatch/dispatchActivity.ts create mode 100644 src/shared/types/agentOverlay.ts diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 3171849d..11f5711c 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -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/.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'), + } } } }, @@ -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()], diff --git a/src/main/index.ts b/src/main/index.ts index a5237550..c8b64cd2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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' @@ -625,10 +626,24 @@ async function startApp(): Promise { // 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() + } }) } diff --git a/src/main/ipc/agentOverlay.ts b/src/main/ipc/agentOverlay.ts new file mode 100644 index 00000000..bfc54b4b --- /dev/null +++ b/src/main/ipc/agentOverlay.ts @@ -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 = 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 + 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 | 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 + } + } 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 }) + }) +} diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 0ea4a490..42a3b604 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -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. // @@ -91,4 +92,5 @@ export function registerAllIpc(deps: IpcDeps): void { registerRemoteIpc(deps.remoteController) registerIncidentIpc(deps.appRunJournal) registerUsageIpc() + registerAgentOverlayIpc() } diff --git a/src/main/window/mainWindow.ts b/src/main/window/mainWindow.ts index f56bb165..650a150d 100644 --- a/src/main/window/mainWindow.ts +++ b/src/main/window/mainWindow.ts @@ -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() + +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. @@ -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 diff --git a/src/main/window/overlayWindow.ts b/src/main/window/overlayWindow.ts new file mode 100644 index 00000000..d80087c1 --- /dev/null +++ b/src/main/window/overlayWindow.ts @@ -0,0 +1,386 @@ +import { app, BrowserWindow, screen } from 'electron' +import { mkdirSync, writeFileSync } from 'fs' +import { mkdir, readFile, writeFile } from 'fs/promises' +import { dirname, join } from 'path' +import { fileURLToPath } from 'url' + +import type { AgentOverlaySnapshot } from '@shared/types/agentOverlay.js' +import { STATE_DIR } from '@main/storage/paths.js' +import { onMainWindowClosed, sendToMainWindow } from '@main/window/mainWindow.js' + +// The floating agent-status overlay window (issue: "see agent status while +// in Chrome"). A tiny frameless always-on-top BrowserWindow that lives +// OUTSIDE the main window's lifecycle: it must stay visible when the main +// window is unfocused, hidden, or on another Space — that is its entire +// reason to exist. +// +// This module mirrors mainWindow.ts's module-scoped-singleton shape on +// purpose (same reload-safety rationale). It does NOT go through +// sendToMainWindow — the overlay has its own dedicated channels +// (agent-overlay:*) pushed directly at its webContents, so the +// single-window assumption in mainWindow.ts stays intact and the session +// forwarder never needs a fan-out refactor. + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +let overlayWindow: BrowserWindow | null = null + +// --------------------------------------------------------------------------- +// Persisted state. Tiny JSON beside workspace.json — position, pill/list +// mode, and whether the overlay is enabled at all, so an enabled overlay +// comes back after an app restart without re-running the palette command. +// --------------------------------------------------------------------------- + +const OVERLAY_STATE_FILE = join(STATE_DIR, 'agent-overlay.json') + +type PersistedOverlayState = { + enabled: boolean + expanded: boolean + position: { x: number; y: number } | null +} + +const state: PersistedOverlayState = { + enabled: false, + expanded: false, + position: null, +} + +// Snapshot cache. The main renderer reports continuously; the overlay +// window may not exist yet (created lazily on first toggle) or may be +// mid-reload. Caching the latest snapshot lets did-finish-load render +// real data immediately instead of waiting for the next store change in +// the main renderer — same late-subscriber rationale as SessionManager's +// per-session snapshot cache. +let lastSnapshot: AgentOverlaySnapshot | null = null + +// Serializes IPC against the async state restore. The agent-overlay IPC +// handlers are registered (registerAllIpc) BEFORE initAgentOverlay runs, +// so a very early toggle/get-enabled could act on the default state and +// then be silently overwritten when loadPersistedState resolves (PR #514 +// review finding 4). Every public read/write of `state.enabled` awaits +// this instead. Starts resolved so unit-style callers that never ran +// init don't deadlock. +let restoreDone: Promise = Promise.resolve() + +let persistTimer: NodeJS.Timeout | null = null + +function schedulePersist(): void { + // Debounced fire-and-forget. 'moved' fires on every drag tick, and this + // file is pure convenience state — losing the last 200ms of position on + // a crash is fine; blocking window events on fs writes is not. + if (persistTimer) clearTimeout(persistTimer) + persistTimer = setTimeout(() => { + persistTimer = null + void (async () => { + try { + await mkdir(STATE_DIR, { recursive: true }) + await writeFile(OVERLAY_STATE_FILE, JSON.stringify(state, null, 2)) + } catch { + // Persistence is best-effort; the overlay works fine with defaults. + } + })() + }, 200) +} + +/** + * Synchronous write-through for moments when the debounce would lose + * data: app quit (the 200ms timer dies with the process) and + * enabled-toggles (losing a drag position is cosmetic; losing the + * user's on/off choice breaks the "comes back after restart" contract). + */ +function flushPersistNow(): void { + if (persistTimer) { + clearTimeout(persistTimer) + persistTimer = null + } + try { + mkdirSync(STATE_DIR, { recursive: true }) + writeFileSync(OVERLAY_STATE_FILE, JSON.stringify(state, null, 2)) + } catch { + // Same best-effort contract as schedulePersist. + } +} + +async function loadPersistedState(): Promise { + try { + const raw = JSON.parse(await readFile(OVERLAY_STATE_FILE, 'utf8')) as Partial + state.enabled = raw.enabled === true + state.expanded = raw.expanded === true + if ( + raw.position && + typeof raw.position.x === 'number' && + typeof raw.position.y === 'number' + ) { + state.position = { x: raw.position.x, y: raw.position.y } + } + } catch { + // Missing/corrupt file → defaults (disabled). Never fail startup. + } +} + +// --------------------------------------------------------------------------- +// Window lifecycle +// --------------------------------------------------------------------------- + +// The renderer drives sizing (it measures its own content and asks for a +// resize), but main clamps to sane bounds so a renderer bug can never +// produce a 4000px or 0px window that the user can't grab. +const MIN_W = 120 +const MAX_W = 460 +const MIN_H = 30 +const MAX_H = 560 +const DEFAULT_W = 240 +const DEFAULT_H = 40 +const SCREEN_MARGIN = 16 + +function defaultPosition(): { x: number; y: number } { + // Top-right of the primary display's work area — out of the way of both + // the dock and the typical browser tab strip, and consistent with where + // most "status" pips live. The user can drag it anywhere; we persist it. + const area = screen.getPrimaryDisplay().workArea + return { + x: area.x + area.width - DEFAULT_W - SCREEN_MARGIN, + y: area.y + SCREEN_MARGIN, + } +} + +/** + * Keep the overlay grabbable. Two ways it can end up off-screen with no + * recovery path (it has no menu, no taskbar entry, no edges to grab): + * a persisted position on a display that no longer exists (monitor + * unplugged), and an expand-resize near a screen edge growing past the + * work area. Clamp against whichever display best matches the requested + * bounds so multi-monitor drags still land where the user put it. + */ +function clampBoundsToDisplay(bounds: Electron.Rectangle): Electron.Rectangle { + const area = screen.getDisplayMatching(bounds).workArea + const width = Math.min(bounds.width, area.width) + const height = Math.min(bounds.height, area.height) + const x = Math.min(Math.max(bounds.x, area.x), area.x + area.width - width) + const y = Math.min(Math.max(bounds.y, area.y), area.y + area.height - height) + return { x, y, width, height } +} + +function createOverlayWindow(): void { + const requested = state.position ?? defaultPosition() + const pos = clampBoundsToDisplay({ ...requested, width: DEFAULT_W, height: DEFAULT_H }) + overlayWindow = new BrowserWindow({ + width: DEFAULT_W, + height: DEFAULT_H, + x: pos.x, + y: pos.y, + show: false, + frame: false, + // Transparent + shadowless: the renderer draws its own rounded pill; + // the window is just an invisible rectangle around it. + transparent: true, + hasShadow: false, + // resizable:false blocks USER edge-resizing of the frameless window; + // programmatic resizes below temporarily flip it (see resizeOverlayContent). + resizable: false, + movable: true, + minimizable: false, + maximizable: false, + fullscreenable: false, + skipTaskbar: true, + // focusable:false is load-bearing: the overlay must never steal key + // focus from Chrome (or from Agent Code itself). Mouse events still + // arrive on non-focusable windows, so clicks to expand / focus an + // agent keep working. + focusable: false, + alwaysOnTop: true, + webPreferences: { + // The overlay gets its OWN slim preload (src/preload/overlay.ts), + // not the main window's index.mjs: the full bridge exposes every + // privileged API (fs, git, sessions, remote...) and this window + // needs exactly four overlay methods. Same runtime-path trap as + // mainWindow.ts though: this is a filesystem path resolved from + // out/main/ at runtime, NOT a vite alias. + preload: join(__dirname, '../preload/overlay.mjs'), + sandbox: false, + contextIsolation: true, + nodeIntegration: false, + }, + }) + + // 'floating' + visibleOnFullScreen keeps the overlay above normal app + // windows AND visible over a fullscreen Chrome Space on macOS — the + // "am I done yet?" glance from another app is the core use case. + overlayWindow.setAlwaysOnTop(true, 'floating') + overlayWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) + + overlayWindow.on('moved', () => { + if (!overlayWindow || overlayWindow.isDestroyed()) return + const [x, y] = overlayWindow.getPosition() + state.position = { x, y } + schedulePersist() + }) + + overlayWindow.on('closed', () => { + overlayWindow = null + }) + + // Same hardening stance as the main window (see mainWindow.ts): the + // overlay carries a preload bridge, so even though it only ever loads + // our own overlay.html, any navigation or window.open escape would + // hand that bridge to foreign content. Deny both outright — unlike the + // main window there is no legitimate external-link path here. + overlayWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) + overlayWindow.webContents.on('will-navigate', event => { + event.preventDefault() + }) + + overlayWindow.webContents.on('did-finish-load', () => { + // Initial state push: cached snapshot + persisted expanded mode. + // `expanded` is intentionally ONLY sent here — see AgentOverlayStateEvent. + sendToOverlay({ snapshot: lastSnapshot, expanded: state.expanded }) + }) + + overlayWindow.once('ready-to-show', () => { + // showInactive, never show(): even the first reveal must not pull key + // focus from whatever the user is doing. Guarded on enabled because a + // fast toggle-on→toggle-off can land the disable while the window is + // still loading — an unguarded reveal would resurrect it. + if (state.enabled) overlayWindow?.showInactive() + }) + + if (process.env['ELECTRON_RENDERER_URL']) { + void overlayWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/overlay.html`) + } else { + void overlayWindow.loadFile(join(__dirname, '../renderer/overlay.html')) + } +} + +function sendToOverlay(payload: { snapshot: AgentOverlaySnapshot | null; expanded?: boolean }): void { + if (overlayWindow && !overlayWindow.isDestroyed()) { + overlayWindow.webContents.send('agent-overlay:state', payload) + } +} + +// --------------------------------------------------------------------------- +// Public API (called from ipc/agentOverlay.ts and main/index.ts) +// --------------------------------------------------------------------------- + +export function initAgentOverlay(): void { + restoreDone = loadPersistedState().then(() => { + if (state.enabled) createOverlayWindow() + // Tell the main renderer the restored enabled state so the reporter + // hook starts publishing without a round-trip race: the renderer also + // pulls via agent-overlay:get-enabled on mount, but that can resolve + // before this async restore finishes — the push wins either way. + sendToMainWindow('agent-overlay:enabled-changed', { enabled: state.enabled }) + }) + + // The overlay's lifetime is keyed to the MAIN window, not the app: its + // data source is the main renderer, so once that closes the overlay + // could only show frozen state — and a surviving overlay would keep + // `window-all-closed` (session/service cleanup) from ever firing. + // destroy(), not close(): nothing in the overlay needs a close + // ceremony, and destroy is immune to any future close-prevention. + // Recreation on Dock re-activation goes through syncAgentOverlayWindow. + onMainWindowClosed(() => { + if (overlayWindow && !overlayWindow.isDestroyed()) overlayWindow.destroy() + overlayWindow = null + }) + + // Quit can arrive inside the persist debounce window; flush so a + // toggle-then-quit never loses the enabled flag. + app.on('before-quit', flushPersistNow) + + // Auto-hide while Agent Code itself is focused: the main window already + // shows richer status everywhere, so the overlay would be pure clutter. + // Window-level focus events (not app-level) because the overlay must + // stay up when the user merely switches between OTHER apps. + // + // Note the deliberate asymmetry with toggle-on: enabling the overlay + // while Agent Code is focused still shows it (feedback that the command + // did something); the auto-hide engages on the NEXT focus cycle. + app.on('browser-window-focus', (_event, win) => { + if (!state.enabled) return + if (win !== overlayWindow) overlayWindow?.hide() + }) + app.on('browser-window-blur', () => { + if (!state.enabled) return + // Blur fires before any successor focus. Defer one tick: if focus + // moved to another Agent Code window (devtools, main), the focus + // handler above runs and getFocusedWindow() is non-null here — keep + // hidden. If the user went to Chrome, nothing is focused — show. + setTimeout(() => { + if (!state.enabled || !overlayWindow || overlayWindow.isDestroyed()) return + if (BrowserWindow.getFocusedWindow() === null) overlayWindow.showInactive() + }, 80) + }) +} + +/** Toggle the overlay on/off. Returns the new enabled state. Awaits the + * persisted-state restore so an early palette toggle can never race it + * (finding 4) — invoke-based IPC makes the async shape free. */ +export async function toggleAgentOverlay(): Promise { + await restoreDone + state.enabled = !state.enabled + if (state.enabled) { + if (!overlayWindow || overlayWindow.isDestroyed()) { + createOverlayWindow() + } else { + overlayWindow.showInactive() + } + } else { + // Hide, don't destroy: re-toggling is instant and keeps the renderer's + // measured size/expanded UI state warm. The window dies with the main + // window (onMainWindowClosed above) or the app. + overlayWindow?.hide() + } + // Synchronous persist, not the debounce: the on/off choice is the one + // piece of state whose loss the user actually notices after a restart. + flushPersistNow() + sendToMainWindow('agent-overlay:enabled-changed', { enabled: state.enabled }) + return state.enabled +} + +export async function isAgentOverlayEnabled(): Promise { + await restoreDone + return state.enabled +} + +/** + * Recreate the overlay window if it should exist but doesn't — called + * from the macOS `activate` path after the main window is recreated + * (the overlay was destroyed together with the previous main window). + */ +export function syncAgentOverlayWindow(): void { + void restoreDone.then(() => { + if (!state.enabled) return + if (!overlayWindow || overlayWindow.isDestroyed()) createOverlayWindow() + }) +} + +export function publishAgentOverlaySnapshot(snapshot: AgentOverlaySnapshot): void { + lastSnapshot = snapshot + // Forward even while hidden — the overlay re-appears on main-window blur + // and must already be current at that instant, not one store-change later. + sendToOverlay({ snapshot }) +} + +export function persistAgentOverlayExpanded(expanded: boolean): void { + state.expanded = expanded + schedulePersist() +} + +export function resizeAgentOverlayContent(size: { width: number; height: number }): void { + if (!overlayWindow || overlayWindow.isDestroyed()) return + const width = Math.round(Math.min(MAX_W, Math.max(MIN_W, size.width))) + const height = Math.round(Math.min(MAX_H, Math.max(MIN_H, size.height))) + const [x, y] = overlayWindow.getPosition() + // resizable:false also blocks programmatic setBounds on some platforms + // (macOS honors it, Windows historically doesn't — the flag's semantics + // are user-resize but implementations disagree). Flipping it around the + // call costs nothing and removes the platform question entirely. + overlayWindow.setResizable(true) + // Keep the top-left corner anchored: the window grows down/right when + // the pill expands into the list, which matches the default top-right + // placement growing INTO the screen instead of off its top edge — + // then clamp, so an expand near a screen edge can't push the window + // (and its only grabbable area) past the work area. + overlayWindow.setBounds(clampBoundsToDisplay({ x, y, width, height })) + overlayWindow.setResizable(false) +} diff --git a/src/preload/api/agentOverlay.ts b/src/preload/api/agentOverlay.ts new file mode 100644 index 00000000..0f3e3f4f --- /dev/null +++ b/src/preload/api/agentOverlay.ts @@ -0,0 +1,53 @@ +import { ipcRenderer } from 'electron' + +import type { + AgentOverlaySnapshot, + AgentOverlayStateEvent, +} from '@shared/types/agentOverlay.js' +import { subscribe } from '@preload/api/ipc.js' +import type { Unsub } from '@preload/api/types.js' + +// Floating agent-status overlay bridge. Unusually for this API surface, +// TWO different renderers consume it: the main window (report / toggle / +// onEnabledChanged / onFocusSession relay) and the overlay window itself +// (onState / setExpanded / resize / focusSession). Both load the same +// preload bundle, so the split is by which methods each one calls, not by +// build artifact — see the flow map in main/ipc/agentOverlay.ts. + +export const agentOverlayApi = { + // -- main-window renderer side ------------------------------------------ + agentOverlayToggle: (): Promise => + ipcRenderer.invoke('agent-overlay:toggle'), + + agentOverlayGetEnabled: (): Promise => + ipcRenderer.invoke('agent-overlay:get-enabled'), + + /** Fire-and-forget on purpose: this rides every (throttled) store change + * in the main renderer; an invoke round-trip would buy nothing. */ + agentOverlayReport: (snapshot: AgentOverlaySnapshot): void => + ipcRenderer.send('agent-overlay:report', snapshot), + + onAgentOverlayEnabledChanged: ( + handler: (payload: { enabled: boolean }) => void, + ): Unsub => subscribe('agent-overlay:enabled-changed', handler), + + /** Main-window side of an overlay row click: main has already raised the + * app window; the workspace decides which tab/pane to focus. */ + onAgentOverlayFocusSession: ( + handler: (payload: { sessionId: string }) => void, + ): Unsub => subscribe('agent-overlay:focus-session', handler), + + // -- overlay-window renderer side ---------------------------------------- + onAgentOverlayState: ( + handler: (payload: AgentOverlayStateEvent) => void, + ): Unsub => subscribe('agent-overlay:state', handler), + + agentOverlaySetExpanded: (expanded: boolean): void => + ipcRenderer.send('agent-overlay:set-expanded', expanded), + + agentOverlayResize: (size: { width: number; height: number }): void => + ipcRenderer.send('agent-overlay:resize', size), + + agentOverlayFocusSession: (sessionId: string): void => + ipcRenderer.send('agent-overlay:focus-session', { sessionId }), +} diff --git a/src/preload/api/index.ts b/src/preload/api/index.ts index 8c4fd1a8..57e6ffb3 100644 --- a/src/preload/api/index.ts +++ b/src/preload/api/index.ts @@ -23,6 +23,7 @@ import { menuApi } from '@preload/api/menu.js' import { incidentApi } from '@preload/api/incident.js' import { remoteApi } from '@preload/api/remote.js' import { usageApi } from '@preload/api/usage.js' +import { agentOverlayApi } from '@preload/api/agentOverlay.js' // Composed preload API surface. // @@ -65,6 +66,7 @@ export const api = { ...incidentApi, ...remoteApi, ...usageApi, + ...agentOverlayApi, } export type Api = typeof api diff --git a/src/preload/overlay.ts b/src/preload/overlay.ts new file mode 100644 index 00000000..caa3958e --- /dev/null +++ b/src/preload/overlay.ts @@ -0,0 +1,28 @@ +import { contextBridge } from 'electron' + +import { agentOverlayApi } from '@preload/api/agentOverlay.js' + +// Slim preload for the agent-status overlay window — least privilege. +// +// The main window's preload (index.ts) exposes the ENTIRE flattened api +// surface: sessions, filesystem, git, remote control, debug tooling. The +// overlay is a status pip; handing it that bridge would make every +// renderer bug in a tiny always-on-top window as dangerous as one in the +// main app (PR #514 review finding 2). So it gets exactly the four +// methods it calls and nothing else. +// +// The overlay renderer still type-checks against the FULL `Api` global +// (there is one ambient window.api declaration for all renderer code). +// That means TypeScript would happily let overlay code call a method +// that doesn't exist here at runtime — if you add a window.api call to +// OverlayApp.tsx, you MUST add the method to this pick list too, or it +// throws only when the overlay actually runs. + +const overlayApi = { + onAgentOverlayState: agentOverlayApi.onAgentOverlayState, + agentOverlaySetExpanded: agentOverlayApi.agentOverlaySetExpanded, + agentOverlayResize: agentOverlayApi.agentOverlayResize, + agentOverlayFocusSession: agentOverlayApi.agentOverlayFocusSession, +} + +contextBridge.exposeInMainWorld('api', overlayApi) diff --git a/src/renderer/overlay.html b/src/renderer/overlay.html new file mode 100644 index 00000000..f6e0e3be --- /dev/null +++ b/src/renderer/overlay.html @@ -0,0 +1,24 @@ + + + + + + + + Agent Status + + +
+ + + diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index 1731b1cc..04247e31 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -10,6 +10,7 @@ import { useDevDebugConfigSync } from '@renderer/features/debug/devDebugConfig' import { useDebugAutosave } from '@renderer/features/debug/useDebugAutosave' import { useCaffeinateSync } from '@renderer/features/caffeinate/useCaffeinateSync' import { useDictationHotkeySync } from '@renderer/features/voice-dictation/useDictationHotkeySync' +import { useAgentOverlayBridge } from '@renderer/features/agent-overlay/useAgentOverlayBridge' import { usePathPickerRequests } from '@renderer/features/path-picker/usePathPickerRequests' import { GlobalModals } from '@renderer/app/surfaces/GlobalModals' import { GlobalOverlays } from '@renderer/app/surfaces/GlobalOverlays' @@ -59,6 +60,10 @@ export default function App() { const workspace = useWorkspace(dangerousAgentsEnabled, useProxyStreaming, defaultWorkspaceMode) useRenderedLeaseHygiene(workspace) useDebugAutosave(workspace) + // Reporter for the floating agent-status overlay window (a cross-cutting + // sync hook like the ones above, but workspace-dependent so it mounts + // here, after useWorkspace). + useAgentOverlayBridge(workspace) const { onNewTabRequest, onResumeRequest } = usePathPickerRequests() useKeybinds(workspace, onNewTabRequest, onResumeRequest, toggleCommandPalette) diff --git a/src/renderer/src/features/agent-overlay/commands/agentOverlayCommands.ts b/src/renderer/src/features/agent-overlay/commands/agentOverlayCommands.ts new file mode 100644 index 00000000..da69dce3 --- /dev/null +++ b/src/renderer/src/features/agent-overlay/commands/agentOverlayCommands.ts @@ -0,0 +1,28 @@ +import type { CommandDef } from '@renderer/features/command-palette/types' + +export const agentOverlayCommands: CommandDef[] = [ + { + id: 'toggle-agent-overlay', + surface: 'app', + title: 'Floating Agent Status', + description: + '**What it does:** Toggles the **floating agent status** pill — a small always-on-top window that stays visible over other apps.\n\n**Use when:** You are in the browser or another app and want to see when agents finish or need approval.\n\n**Notes:** Click the pill to expand per-agent rows; click a row to jump to that agent. Auto-hides while Agent Code is focused.', + keywords: [ + 'overlay', + 'floating', + 'pill', + 'status', + 'agents', + 'always on top', + 'picture in picture', + 'monitor', + 'widget', + ], + // Fire-and-forget straight at the preload bridge: the toggle's state + // of record lives in MAIN (it survives renderer reloads and drives + // the actual window), so there is no renderer flag to flip here. + run: () => { + void window.api.agentOverlayToggle() + }, + }, +] diff --git a/src/renderer/src/features/agent-overlay/useAgentOverlayBridge.ts b/src/renderer/src/features/agent-overlay/useAgentOverlayBridge.ts new file mode 100644 index 00000000..25d640e7 --- /dev/null +++ b/src/renderer/src/features/agent-overlay/useAgentOverlayBridge.ts @@ -0,0 +1,155 @@ +import { useCallback, useEffect, useRef } from 'react' + +import type { + AgentOverlaySnapshot, + OverlayAgentRow, +} from '@shared/types/agentOverlay' +import type { Settings } from '@renderer/app-state/settings/types' +import type { SessionRuntime } from '@renderer/session-runtime/state' +import type { WorkspaceState } from '@renderer/workspace/types' +import type { Workspace } from '@renderer/workspace/workspaceStore' +import { useAppStore } from '@renderer/app-state/hooks' +import { buildVisibleDispatchRows } from '@renderer/workspace/dispatch/dispatchSelectors' +import { dispatchActivity } from '@renderer/workspace/dispatch/dispatchActivity' +import { dispatchAttentionLabelFromConditions } from '@renderer/workspace/conditions/selectors' + +// Main-window half of the floating agent-status overlay: the reporter. +// +// This hook is WHY the overlay renderer can stay a dumb display — it runs +// where all the derivation already lives (workspace rows, session +// runtimes, condition selectors) and publishes a precomputed snapshot to +// main, which caches and forwards it to the overlay window. See +// shared/types/agentOverlay.ts for the full architecture note. +// +// Mounted exactly once from App.tsx, alongside the other cross-cutting +// sync hooks (theme, caffeinate, dictation). + +// Trailing throttle for reports. process-state / conditions events can +// burst (every tool call flips them), and each report is a full-snapshot +// IPC send. 200ms keeps the overlay comfortably "live" for a glance +// surface while coalescing bursts; the timer reads the LATEST inputs from +// a ref, so nothing that changes inside the window is lost. +const REPORT_THROTTLE_MS = 200 + +function buildSnapshot( + state: WorkspaceState, + runtimes: Record, + settings: Settings, +): AgentOverlaySnapshot { + // buildVisibleDispatchRows is the same selector the Dispatch list + // renders from, so the overlay inherits its ordering contract for free: + // pinned agents first, then project groups in tab order — and its + // dedupe/orphan handling. Terminals are excluded: they have no + // meaningful activity lifecycle for a "which agents need me?" glance. + const rows = buildVisibleDispatchRows(state) + const seen = new Set() + const agents: OverlayAgentRow[] = [] + for (const row of rows) { + if (row.kind === 'terminal') continue + if (seen.has(row.sessionId)) continue + seen.add(row.sessionId) + const runtime = runtimes[row.sessionId] + const activity = dispatchActivity(runtime ?? {}) + const attentionLabel = dispatchAttentionLabelFromConditions(runtime?.conditions ?? null) + const isActive = activity === 'working' || activity === 'running' + agents.push({ + sessionId: row.sessionId, + title: row.title, + projectTitle: row.tabTitle, + pinned: row.key.startsWith('pinned:'), + activity, + attentionLabel, + // activityStatus is a "latest verb" cache — it can retain "running + // Bash" long after the provider went idle. Only forward it while the + // agent is actually active, or the overlay would show stale verbs on + // idle rows. + statusText: isActive ? (runtime?.activityStatus ?? null) : null, + }) + } + return { + agents, + // Opaque pass-through: the overlay calls the same applyTheme() with + // this object. See the shared type for the byte-mover contract. + theme: settings as unknown as Record, + } +} + +export function useAgentOverlayBridge(workspace: Workspace): void { + const runtimes = useAppStore(state => state.workspaceRuntimes) + const settings = useAppStore(state => state.settings) + + // enabled lives in a ref, not state: it only gates the report path, and + // a toggle must not re-render the whole App tree this hook mounts under. + const enabledRef = useRef(false) + const lastSentJsonRef = useRef(null) + const timerRef = useRef | null>(null) + const inputsRef = useRef({ state: workspace.state, runtimes, settings }) + inputsRef.current = { state: workspace.state, runtimes, settings } + const workspaceRef = useRef(workspace) + workspaceRef.current = workspace + + const scheduleReport = useCallback(() => { + if (!enabledRef.current) return + if (timerRef.current) return + timerRef.current = setTimeout(() => { + timerRef.current = null + if (!enabledRef.current) return + const inputs = inputsRef.current + const snapshot = buildSnapshot(inputs.state, inputs.runtimes, inputs.settings) + // Dedupe on serialized content: most store churn (transcript entries, + // screen frames) doesn't change the overlay-visible fields at all, + // and skipping identical sends keeps the IPC channel quiet. + const json = JSON.stringify(snapshot) + if (json === lastSentJsonRef.current) return + lastSentJsonRef.current = json + window.api.agentOverlayReport(snapshot) + }, REPORT_THROTTLE_MS) + }, []) + + useEffect(() => { + let alive = true + // Pull + push for the enabled flag: the pull covers a renderer reload + // while the overlay is already on; the push covers main's async state + // restore finishing after this mount (initAgentOverlay broadcasts). + void window.api.agentOverlayGetEnabled().then(enabled => { + if (!alive) return + enabledRef.current = enabled + if (enabled) { + lastSentJsonRef.current = null + scheduleReport() + } + }) + const unsubscribe = window.api.onAgentOverlayEnabledChanged(({ enabled }) => { + enabledRef.current = enabled + if (enabled) { + // Reset the dedupe so the freshly-shown overlay always gets a + // snapshot, even if nothing changed since it was last on. + lastSentJsonRef.current = null + scheduleReport() + } + }) + return () => { + alive = false + unsubscribe() + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + } + }, [scheduleReport]) + + // Overlay row click → focus that agent. Main already raised the app + // window (see ipc/agentOverlay.ts); this resolves WHERE the session + // lives — same tab+session focus call the Agent Activity modal uses. + useEffect(() => { + return window.api.onAgentOverlayFocusSession(({ sessionId }) => { + const ws = workspaceRef.current + const row = buildVisibleDispatchRows(ws.state).find(r => r.sessionId === sessionId) + if (row) ws.focusSessionInTab(row.tabId, row.sessionId) + }) + }, []) + + useEffect(() => { + scheduleReport() + }, [workspace.state, runtimes, settings, scheduleReport]) +} diff --git a/src/renderer/src/features/command-palette/registry.ts b/src/renderer/src/features/command-palette/registry.ts index de72d8fd..dcf116e1 100644 --- a/src/renderer/src/features/command-palette/registry.ts +++ b/src/renderer/src/features/command-palette/registry.ts @@ -10,6 +10,7 @@ import { copyAssistantCommands } from '@renderer/features/copy-assistant/command import { copyCodeBlockCommands } from '@renderer/features/copy-code-block/commands/copyCodeBlockCommands' import { promptTemplateCommands } from '@renderer/features/prompt-templates/commands/promptTemplateCommands' import { agentStatusCommands } from '@renderer/features/agent-status/commands/agentStatusCommands' +import { agentOverlayCommands } from '@renderer/features/agent-overlay/commands/agentOverlayCommands' import { remoteCommands } from '@renderer/features/remote/commands/remoteCommands' import { usageCommands } from '@renderer/features/usage/commands/usageCommands' import { commandAllowedByRenderedViewPolicy } from '@renderer/workspace/agentDisplayMode' @@ -35,6 +36,7 @@ const commandDefs: CommandDef[] = [ ...copyCodeBlockCommands, ...promptTemplateCommands, ...agentStatusCommands, + ...agentOverlayCommands, ...remoteCommands, ...usageCommands, ] diff --git a/src/renderer/src/overlay/OverlayApp.tsx b/src/renderer/src/overlay/OverlayApp.tsx new file mode 100644 index 00000000..6b8d11ac --- /dev/null +++ b/src/renderer/src/overlay/OverlayApp.tsx @@ -0,0 +1,278 @@ +import { useEffect, useMemo, useRef, useState } from 'react' + +import type { + AgentOverlaySnapshot, + OverlayAgentRow, +} from '@shared/types/agentOverlay' +import type { Settings } from '@renderer/app-state/settings/types' +import { applyTheme } from '@renderer/app-state/settings/theme' +import { dispatchActivityDotClass } from '@renderer/workspace/dispatch/dispatchActivity' + +// Floating agent-status overlay UI. Two modes, one component: +// +// collapsed — a one-line pill of activity dots + counts ("am I needed?") +// expanded — per-agent rows; click a row to jump to that agent in the +// main window (main raises the app, the workspace focuses +// the pane — see main/ipc/agentOverlay.ts) +// +// Everything rendered here comes precomputed in the snapshot from the main +// renderer (see shared/types/agentOverlay.ts for why no derivation happens +// in this bundle). The one piece of shared code is dispatchActivityDotClass +// so these dots can never drift from the Dispatch list's palette. + +/** Buckets for the pill. `waiting` (a pending permission/trust prompt) + * outranks the activity value on purpose: an agent can be nominally + * "running" while blocked on the user, and blocked-on-you is the single + * most important thing this overlay exists to surface. */ +type Counts = { + waiting: number + active: number + starting: number + idle: number + exited: number +} + +function countAgents(agents: OverlayAgentRow[]): Counts { + const counts: Counts = { waiting: 0, active: 0, starting: 0, idle: 0, exited: 0 } + for (const agent of agents) { + if (agent.attentionLabel) counts.waiting += 1 + else if (agent.activity === 'working' || agent.activity === 'running') counts.active += 1 + else if (agent.activity === 'starting') counts.starting += 1 + else if (agent.activity === 'exited') counts.exited += 1 + else counts.idle += 1 + } + return counts +} + +export function OverlayApp() { + const [snapshot, setSnapshot] = useState(null) + const [expanded, setExpanded] = useState(false) + const lastThemeJsonRef = useRef(null) + + useEffect(() => { + return window.api.onAgentOverlayState(payload => { + if (payload.snapshot) { + setSnapshot(payload.snapshot) + // Theme rides the snapshot (the overlay has no settings store of its + // own). applyTheme is cheap but dispatches a window event and writes + // font CSS vars, so gate on actual change instead of every report. + if (payload.snapshot.theme) { + const themeJson = JSON.stringify(payload.snapshot.theme) + if (themeJson !== lastThemeJsonRef.current) { + lastThemeJsonRef.current = themeJson + applyTheme(payload.snapshot.theme as unknown as Settings) + } + } + } + // Present only on the initial post-load push — restores the persisted + // pill/list mode. See AgentOverlayStateEvent. + if (typeof payload.expanded === 'boolean') setExpanded(payload.expanded) + }) + }, []) + + // The renderer owns window size: it measures its actual content and asks + // main to fit the window around it (clamped there). This is what keeps + // the transparent window snug — leftover transparent area would still + // swallow clicks meant for whatever is underneath the overlay. + const rootRef = useRef(null) + useEffect(() => { + const el = rootRef.current + if (!el) return + let lastW = 0 + let lastH = 0 + const push = () => { + const rect = el.getBoundingClientRect() + const width = Math.ceil(rect.width) + const height = Math.ceil(rect.height) + if (width === lastW && height === lastH) return + lastW = width + lastH = height + window.api.agentOverlayResize({ width, height }) + } + const observer = new ResizeObserver(push) + observer.observe(el) + push() + return () => observer.disconnect() + }, []) + + const agents = snapshot?.agents ?? [] + const counts = useMemo(() => countAgents(agents), [agents]) + const projectCount = useMemo( + () => new Set(agents.map(agent => agent.projectTitle)).size, + [agents], + ) + + const toggleExpanded = (next: boolean) => { + setExpanded(next) + window.api.agentOverlaySetExpanded(next) + } + + return ( +
+ {expanded ? ( + 1} + onCollapse={() => toggleExpanded(false)} + /> + ) : ( + 0} onExpand={() => toggleExpanded(true)} /> + )} +
+ ) +} + +function CollapsedPill({ + counts, + hasAgents, + onExpand, +}: { + counts: Counts + hasAgents: boolean + onExpand: () => void +}) { + return ( + // The pill body is the drag handle (a frameless window needs SOME + // grabbable area, and the pill is nearly all of it); the content is a + // no-drag button so a plain click still expands. See overlay.css. +
+ +
+ ) +} + +function PillSegment({ + dotClass, + count, + textClass, +}: { + dotClass: string + count: number + textClass: string +}) { + return ( + + + {count} + + ) +} + +function ExpandedPanel({ + agents, + counts, + showProject, + onCollapse, +}: { + agents: OverlayAgentRow[] + counts: Counts + showProject: boolean + onCollapse: () => void +}) { + return ( +
+
+ Agents +
+ {counts.waiting > 0 ? ( + + + {counts.waiting} waiting + + ) : null} + +
+
+ {agents.length === 0 ? ( +
No agent sessions running.
+ ) : ( +
    + {agents.map(agent => ( + + ))} +
+ )} +
+ ) +} + +function AgentRow({ agent, showProject }: { agent: OverlayAgentRow; showProject: boolean }) { + const waiting = agent.attentionLabel !== null + const dotClass = waiting + ? 'bg-warning animate-pulse' + : dispatchActivityDotClass(agent.activity) + // Right-side text priority: blocked-on-you beats the provider verb beats + // the bare activity word — most-actionable information wins the glance. + const detail = agent.attentionLabel ?? agent.statusText ?? agent.activity + return ( +
  • + +
  • + ) +} diff --git a/src/renderer/src/overlay/main.tsx b/src/renderer/src/overlay/main.tsx new file mode 100644 index 00000000..e05715af --- /dev/null +++ b/src/renderer/src/overlay/main.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' + +// styles.css first, overlay.css second — overlay.css overrides the opaque +// html/body backgrounds for the transparent window (see its header comment). +import '@renderer/styles.css' +import './overlay.css' +import { OverlayApp } from '@renderer/overlay/OverlayApp' + +// Deliberately minimal compared to app/main.tsx: no performance client, no +// incident breadcrumb bridge, no feed/toast providers. The overlay is a +// dumb status display fed by one IPC channel; wiring the main window's +// startup machinery into it would double-report incidents (both windows +// share the main-process journal) for zero diagnostic value. + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/src/renderer/src/overlay/overlay.css b/src/renderer/src/overlay/overlay.css new file mode 100644 index 00000000..a29f31a6 --- /dev/null +++ b/src/renderer/src/overlay/overlay.css @@ -0,0 +1,36 @@ +/* + * Overlay-window-only overrides, imported AFTER styles.css in + * overlay/main.tsx so these rules win the cascade. + * + * WHY transparent backgrounds: the BrowserWindow is created with + * `transparent: true` and the React root draws its own rounded pill — + * styles.css paints html/body/#root with var(--theme-canvas), which + * would fill the whole window rectangle with an opaque slab and turn + * the "floating pill" into a floating brick. + */ +html, +body, +#root { + background: transparent !important; +} + +body { + overflow: hidden; + /* The overlay is a glanceable status pip, not a document — stray + * text selection from a sloppy click just looks broken. */ + user-select: none; +} + +/* + * Frameless-window drag plumbing. Electron treats app-region: drag + * elements as title-bar: the OS moves the window and the DOM never + * sees the click. Interactive children must explicitly opt back out + * or their onClick silently dies — which is exactly the kind of bug + * that only shows up in the packaged window, not in a browser tab. + */ +.app-region-drag { + -webkit-app-region: drag; +} +.app-region-no-drag { + -webkit-app-region: no-drag; +} diff --git a/src/renderer/src/workspace/conditions/selectors.ts b/src/renderer/src/workspace/conditions/selectors.ts index 081c92ab..8ad3e803 100644 --- a/src/renderer/src/workspace/conditions/selectors.ts +++ b/src/renderer/src/workspace/conditions/selectors.ts @@ -131,6 +131,15 @@ export function dispatchAttentionLabelFromConditions( for (const rule of policy.attentionLabels) { const record = conditions.conditions[rule.kind] if (!record) continue + // Same visibility gate as conditionRequiresAttention above: a record + // whose state says `visible: false` (e.g. a claude permission-prompt + // snapshot lingering after dismissal) is NOT blocking the user and + // must not produce a label. This selector was presence-only, which + // made the agent-status overlay's "waiting" pill pulse for hidden + // prompts (PR #514 review). Flagless states (codex approval, + // ask-user-question) stay presence-gated — absent flag means visible. + const state = record.state as { visible?: boolean } + if (typeof state?.visible === 'boolean' && !state.visible) continue const label = typeof rule.label === 'function' ? rule.label(record.state) : rule.label if (label) return label diff --git a/src/renderer/src/workspace/dispatch/DispatchAgentList.tsx b/src/renderer/src/workspace/dispatch/DispatchAgentList.tsx index 008f6e13..3fa4e1c2 100644 --- a/src/renderer/src/workspace/dispatch/DispatchAgentList.tsx +++ b/src/renderer/src/workspace/dispatch/DispatchAgentList.tsx @@ -20,6 +20,10 @@ import type { Entry } from '@shared/types/transcript' import type { ProviderConditionSnapshot } from '@shared/types/providerConditions' import { dispatchAttentionLabelFromConditions } from '@renderer/workspace/conditions/selectors' import { isSessionExited } from '@renderer/workspace/providerSessionIdentity' +import { + dispatchActivity, + dispatchActivityClasses, +} from '@renderer/workspace/dispatch/dispatchActivity' // WHY this module exists separately from DispatchLayout: // The full Dispatch index list (sections, pinned group, activity-colored @@ -29,7 +33,17 @@ import { isSessionExited } from '@renderer/workspace/providerSessionIdentity' // were extracted here so both the classic and tiled layouts render an // identical index. This is a pure move: behavior is unchanged. -export type DispatchAgentActivity = 'working' | 'running' | 'idle' | 'exited' | 'starting' +// Moved to ./dispatchActivity so the floating agent-status overlay (a +// separate renderer entry) can share the activity vocabulary without +// bundling this whole component graph. Re-exported here so the existing +// importers (DispatchMiniList, DispatchLayout, TiledDispatchLayout) keep +// working unchanged. +export { + dispatchActivity, + dispatchActivityClasses, + dispatchActivityDotClass, +} from '@renderer/workspace/dispatch/dispatchActivity' +export type { DispatchAgentActivity } from '@renderer/workspace/dispatch/dispatchActivity' const latestPromptTitleCache = new WeakMap< Entry[], @@ -426,97 +440,6 @@ function DispatchUnreadBadge({ ) } -// Exported for reuse by the Tiled Dispatch mini-list, which shows a -// compact activity dot derived from the same runtime state. -export function dispatchActivity(runtime: { - sessionStatus?: string - streamPhase?: string - exited?: number | null - processStatus?: string -}): DispatchAgentActivity { - if (runtime.sessionStatus === undefined) return 'starting' - if (isSessionExited(runtime)) return 'exited' - if (runtime.streamPhase && runtime.streamPhase !== 'idle') return 'working' - if (runtime.sessionStatus === 'running') return 'running' - return 'idle' -} - -// Exported so the Tiled Dispatch mini-list can render its index chips with -// the exact same activity background + accent-when-selected palette as the -// main index's chip cell — the two surfaces must read identically. -export function dispatchActivityClasses( - activity: DispatchAgentActivity, - active: boolean, -): { - row: string - index: string - title: string -} { - // Dispatch is a dense scanning surface, so full-row status backgrounds - // make every state compete with the actual content. The index cell is the - // one stable visual affordance every row already has, which makes it the - // right place for both active selection and process state. Active wins here - // because it answers "where am I focused?" while the text metadata still - // spells out whether the underlying session is running, working, or exited. - if (active) { - return { - row: 'bg-surface hover:bg-surface-hi text-ink', - index: 'bg-accent text-accent-fg', - title: '', - } - } - if (activity === 'working') { - return { - row: 'bg-surface hover:bg-surface-hi text-ink', - index: 'bg-success text-success-fg', - title: '', - } - } - if (activity === 'running') { - return { - row: 'bg-surface hover:bg-surface-hi text-ink', - index: 'bg-info text-info-fg', - title: '', - } - } - if (activity === 'starting') { - return { - row: 'bg-surface hover:bg-surface-hi text-ink', - index: 'bg-warning text-warning-fg', - title: '', - } - } - if (activity === 'exited') { - return { - row: 'bg-surface hover:bg-surface-hi text-muted opacity-75', - index: 'bg-danger text-danger-fg', - title: '', - } - } - return { - row: 'bg-surface hover:bg-surface-hi text-ink-dim', - index: 'bg-surface-hi text-muted', - title: '', - } -} - -// Activity → dot color for the compact mini-list. Mirrors the index-cell -// palette in dispatchActivityClasses so the two surfaces read the same. -export function dispatchActivityDotClass(activity: DispatchAgentActivity): string { - switch (activity) { - case 'working': - return 'bg-success' - case 'running': - return 'bg-info' - case 'starting': - return 'bg-warning' - case 'exited': - return 'bg-danger' - default: - return 'bg-muted' - } -} - export function DispatchEmpty({ message }: { message: string }) { return (
    diff --git a/src/renderer/src/workspace/dispatch/dispatchActivity.ts b/src/renderer/src/workspace/dispatch/dispatchActivity.ts new file mode 100644 index 00000000..3fc85efb --- /dev/null +++ b/src/renderer/src/workspace/dispatch/dispatchActivity.ts @@ -0,0 +1,106 @@ +import { isSessionExited } from '@renderer/workspace/providerSessionIdentity' + +// Activity classification + palette for agent status surfaces. +// +// WHY this lives in its own module instead of DispatchAgentList.tsx where +// it grew up: the floating agent-status overlay is a SEPARATE renderer +// entry (src/renderer/src/overlay/) whose bundle must not drag in the full +// Dispatch component graph (workspace store, tile tree, provider +// capabilities...) just to color five dots. These helpers are the shared +// activity vocabulary across DispatchAgentList, DispatchMiniList, and the +// overlay — one source of truth so all three surfaces read identically. +// DispatchAgentList re-exports them, so its existing importers are +// untouched. + +export type DispatchAgentActivity = 'working' | 'running' | 'idle' | 'exited' | 'starting' + +// Exported for reuse by the Tiled Dispatch mini-list, which shows a +// compact activity dot derived from the same runtime state. +export function dispatchActivity(runtime: { + sessionStatus?: string + streamPhase?: string + exited?: number | null + processStatus?: string +}): DispatchAgentActivity { + if (runtime.sessionStatus === undefined) return 'starting' + if (isSessionExited(runtime)) return 'exited' + if (runtime.streamPhase && runtime.streamPhase !== 'idle') return 'working' + if (runtime.sessionStatus === 'running') return 'running' + return 'idle' +} + +// Exported so the Tiled Dispatch mini-list can render its index chips with +// the exact same activity background + accent-when-selected palette as the +// main index's chip cell — the two surfaces must read identically. +export function dispatchActivityClasses( + activity: DispatchAgentActivity, + active: boolean, +): { + row: string + index: string + title: string +} { + // Dispatch is a dense scanning surface, so full-row status backgrounds + // make every state compete with the actual content. The index cell is the + // one stable visual affordance every row already has, which makes it the + // right place for both active selection and process state. Active wins here + // because it answers "where am I focused?" while the text metadata still + // spells out whether the underlying session is running, working, or exited. + if (active) { + return { + row: 'bg-surface hover:bg-surface-hi text-ink', + index: 'bg-accent text-accent-fg', + title: '', + } + } + if (activity === 'working') { + return { + row: 'bg-surface hover:bg-surface-hi text-ink', + index: 'bg-success text-success-fg', + title: '', + } + } + if (activity === 'running') { + return { + row: 'bg-surface hover:bg-surface-hi text-ink', + index: 'bg-info text-info-fg', + title: '', + } + } + if (activity === 'starting') { + return { + row: 'bg-surface hover:bg-surface-hi text-ink', + index: 'bg-warning text-warning-fg', + title: '', + } + } + if (activity === 'exited') { + return { + row: 'bg-surface hover:bg-surface-hi text-muted opacity-75', + index: 'bg-danger text-danger-fg', + title: '', + } + } + return { + row: 'bg-surface hover:bg-surface-hi text-ink-dim', + index: 'bg-surface-hi text-muted', + title: '', + } +} + +// Activity → dot color for the compact mini-list. Mirrors the index-cell +// palette in dispatchActivityClasses so the two surfaces read the same. +export function dispatchActivityDotClass(activity: DispatchAgentActivity): string { + switch (activity) { + case 'working': + return 'bg-success' + case 'running': + return 'bg-info' + case 'starting': + return 'bg-warning' + case 'exited': + return 'bg-danger' + default: + return 'bg-muted' + } +} diff --git a/src/shared/types/agentOverlay.ts b/src/shared/types/agentOverlay.ts new file mode 100644 index 00000000..d34df9a1 --- /dev/null +++ b/src/shared/types/agentOverlay.ts @@ -0,0 +1,65 @@ +// Floating agent-status overlay contract. +// +// The overlay is a second, always-on-top BrowserWindow that shows a +// compact "which agents are working / waiting / done" readout while the +// user is in another app (Chrome, editor, etc). It deliberately does NOT +// consume the session IPC stream (session:process-state, session:conditions, +// ...): deriving activity, attention labels, and human titles from raw +// events would duplicate the renderer's whole session-runtime layer in a +// second bundle and drift the moment either copy changed. +// +// Instead the MAIN renderer — which already owns all of that derivation +// (dispatchActivity, dispatchAttentionLabelFromConditions, sessionTitle) — +// publishes this precomputed snapshot to main, main caches-and-forwards it +// to the overlay window, and the overlay renders it verbatim. Main and the +// overlay are byte movers; the main renderer is the single source of truth +// for what each field means. Consequence to keep in mind: if the main +// window is closed or hung, the overlay goes stale — acceptable, because +// the overlay is meaningless without a running workspace behind it. + +/** Mirrors DispatchAgentActivity (renderer) value-for-value. Redeclared + * here rather than imported because shared/ must not depend on renderer + * code; the renderer-side reporter is the one place that converts. */ +export type OverlayAgentActivity = + | 'starting' + | 'working' + | 'running' + | 'idle' + | 'exited' + +export type OverlayAgentRow = { + sessionId: string + /** Human title, already resolved by the reporter (session title or cwd + * basename — same rule as the Dispatch list). */ + title: string + /** Owning project tab title. The overlay shows it as a small chip only + * when the snapshot spans more than one project. */ + projectTitle: string + pinned: boolean + activity: OverlayAgentActivity + /** Non-null when the agent is blocked on the user (permission prompt, + * trust dialog, ...). Takes visual priority over `activity` — this is + * the state the overlay exists to surface while you're in Chrome. */ + attentionLabel: string | null + /** Human activity verb from the provider ("running Bash"), if any. */ + statusText: string | null +} + +export type AgentOverlaySnapshot = { + agents: OverlayAgentRow[] + /** The renderer's Settings object, passed opaquely so the overlay can + * call the same applyTheme() the main window uses. Typed as an opaque + * record on purpose: the renderer owns the Settings shape (same + * "renderer owns the JSON, main is a byte mover" contract as + * workspace.json) and shared/ must not import renderer types. */ + theme: Record | null +} + +/** Pushed to the overlay window on `agent-overlay:state`. `expanded` is + * only present on the initial post-load push — it restores the persisted + * pill/list mode. Later pushes omit it so a snapshot update can never + * fight the user's in-flight expand/collapse. */ +export type AgentOverlayStateEvent = { + snapshot: AgentOverlaySnapshot | null + expanded?: boolean +} From 21a13eec585b99c555fbcac64e6ac1cffaa3975f Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 9 Jul 2026 19:02:28 +0200 Subject: [PATCH 2/2] Reapply "Merge pull request #516 from Juliusolsson05/fix/overlay-dock-icon" This reverts commit bf76779f0f2d7b8941ae5832b7110333b9e16171. --- src/main/window/overlayWindow.ts | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/main/window/overlayWindow.ts b/src/main/window/overlayWindow.ts index d80087c1..fd0db9ea 100644 --- a/src/main/window/overlayWindow.ts +++ b/src/main/window/overlayWindow.ts @@ -203,11 +203,26 @@ function createOverlayWindow(): void { }, }) - // 'floating' + visibleOnFullScreen keeps the overlay above normal app - // windows AND visible over a fullscreen Chrome Space on macOS — the - // "am I done yet?" glance from another app is the core use case. + // 'floating' keeps the overlay above normal app windows; all-workspaces + // makes it follow the user across desktops/Spaces. + // + // skipTransformProcessType is LOAD-BEARING, and visibleOnFullScreen is + // deliberately ABSENT. The first shipped version passed + // `{ visibleOnFullScreen: true }`, and on macOS that makes Electron + // transform the ENTIRE APP's activation policy between + // ForegroundApplication and UIElementApplication (accessory/LSUIElement). + // Observed result: the Dock icon vanished (and app.dock.show() can't + // reliably restore it — electron#26350), the menu bar disappeared, and + // the MAIN window stopped behaving like a normal window (couldn't be + // moved/focused properly; see also electron#37487 for overlay windows + // corrupting app state via this transform). A status pip must never + // change what kind of app Agent Code is, so we skip the transform + // entirely (electron#27200). The trade: the overlay won't float above + // fullscreen (green-button) Spaces — acceptable; it still floats over + // normal windows on every desktop. Do NOT reintroduce + // visibleOnFullScreen without solving the process-transform side effect. overlayWindow.setAlwaysOnTop(true, 'floating') - overlayWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) + overlayWindow.setVisibleOnAllWorkspaces(true, { skipTransformProcessType: true }) overlayWindow.on('moved', () => { if (!overlayWindow || overlayWindow.isDestroyed()) return