From 7dce0442abdadbb611db8e487eeb71b8af9e2e7d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:52:11 -0700 Subject: [PATCH] Add Caffeinate controls to the status bar (#13480) * feat: add caffeinate status controls * refactor: compact caffeinate status * fix: harden caffeinate readiness --- src/main/agent-awake-service.test.ts | 36 ++++ src/main/agent-awake-service.ts | 60 +++++- src/main/index.ts | 8 +- src/main/ipc/settings.test.ts | 15 +- src/main/ipc/settings.ts | 41 +++- src/preload/api-types.ts | 5 + src/preload/index.ts | 11 ++ .../components/feature-wall/KeepAwakeCard.tsx | 12 +- .../components/settings/AgentAwakeSetting.tsx | 57 +++--- .../components/settings/AgentsPane.test.tsx | 48 ++--- .../src/components/settings/AgentsPane.tsx | 5 +- .../components/settings/agent-awake-copy.ts | 14 +- .../src/components/settings/agents-search.ts | 13 +- .../status-bar/CaffeinateStatusSegment.tsx | 177 ++++++++++++++++++ .../src/components/status-bar/StatusBar.tsx | 3 + .../hooks/useSettingsNavigationMetadata.ts | 5 +- src/renderer/src/i18n/locales/en.json | 22 ++- src/renderer/src/web/web-preload-api.ts | 35 ++++ src/shared/computer-awake-mode.test.ts | 33 ++++ src/shared/computer-awake-mode.ts | 36 ++++ src/shared/types.ts | 3 + tests/e2e/settings-agent-awake.spec.ts | 34 ++-- tests/e2e/status-bar-caffeinate.spec.ts | 70 +++++++ 23 files changed, 647 insertions(+), 96 deletions(-) create mode 100644 src/renderer/src/components/status-bar/CaffeinateStatusSegment.tsx create mode 100644 src/shared/computer-awake-mode.test.ts create mode 100644 src/shared/computer-awake-mode.ts create mode 100644 tests/e2e/status-bar-caffeinate.spec.ts diff --git a/src/main/agent-awake-service.test.ts b/src/main/agent-awake-service.test.ts index 12ae01f3fc7..6a85e45c41a 100644 --- a/src/main/agent-awake-service.test.ts +++ b/src/main/agent-awake-service.test.ts @@ -122,6 +122,42 @@ describe('AgentAwakeService', () => { expect(linuxAssertion.start).toHaveBeenCalledTimes(1) }) + it('stays awake in On mode without a working agent', () => { + const blocker = createBlocker() + const service = createService(() => 1_000, blocker) + + service.setMode('on') + + expect(blocker.start).toHaveBeenCalledWith('prevent-display-sleep') + expect(service.getStatus()).toEqual({ mode: 'on', active: true }) + }) + + it('publishes Auto activity changes to status subscribers', () => { + const service = createService(() => 1_000) + const listener = vi.fn() + service.subscribe(listener) + + service.setMode('auto') + service.setStatuses([workingStatus()]) + service.setStatuses([workingStatus(), workingStatus()]) + service.setStatuses([workingStatus()]) + service.setStatuses([]) + + expect(listener).toHaveBeenNthCalledWith(1, { + mode: 'auto', + active: false + }) + expect(listener).toHaveBeenNthCalledWith(2, { + mode: 'auto', + active: true + }) + expect(listener).toHaveBeenNthCalledWith(3, { + mode: 'auto', + active: false + }) + expect(listener).toHaveBeenCalledTimes(3) + }) + it('starts and stops from settings flips around an already-running status', () => { const blocker = createBlocker() const macosAssertion = createMacosAssertion() diff --git a/src/main/agent-awake-service.ts b/src/main/agent-awake-service.ts index a0cffcf27c4..adba93e7ea1 100644 --- a/src/main/agent-awake-service.ts +++ b/src/main/agent-awake-service.ts @@ -1,5 +1,10 @@ import { powerMonitor, powerSaveBlocker } from 'electron' import type { AgentStatusState } from '../shared/agent-status-types' +import { + normalizeComputerAwakeMode, + type ComputerAwakeMode, + type ComputerAwakeStatus +} from '../shared/computer-awake-mode' import { LinuxLidSleepAssertion } from './linux-lid-sleep-assertion' import { MacosSystemSleepAssertion } from './macos-system-sleep-assertion' @@ -40,10 +45,12 @@ type AgentAwakeServiceOptions = { } export class AgentAwakeService { - private enabled = false + private mode: ComputerAwakeMode = 'off' private statuses: AgentAwakeStatus[] = [] private blockerId: number | null = null private staleTimer: ReturnType | null = null + private readonly statusListeners = new Set<(status: ComputerAwakeStatus) => void>() + private lastPublishedStatus: ComputerAwakeStatus | null = null private readonly blocker: PowerSaveBlocker private readonly linuxAssertion: PlatformAwakeAssertion private readonly logger: Logger @@ -82,10 +89,15 @@ export class AgentAwakeService { } setEnabled(enabled: boolean): void { - if (this.enabled === enabled) { + this.setMode(enabled ? 'auto' : 'off') + } + + setMode(mode: ComputerAwakeMode): void { + const normalized = normalizeComputerAwakeMode(mode) + if (this.mode === normalized) { return } - this.enabled = enabled + this.mode = normalized this.refresh('settings-change') } @@ -94,6 +106,19 @@ export class AgentAwakeService { this.refresh('status-change') } + getStatus(): ComputerAwakeStatus { + const workingAgentCount = this.getEligibleRunningStatusCount() + return { + mode: this.mode, + active: this.mode === 'on' || (this.mode === 'auto' && workingAgentCount > 0) + } + } + + subscribe(listener: (status: ComputerAwakeStatus) => void): () => void { + this.statusListeners.add(listener) + return () => this.statusListeners.delete(listener) + } + dispose(): void { this.clearStaleTimer() this.unsubscribeResume?.() @@ -105,7 +130,7 @@ export class AgentAwakeService { private refresh(reason: string): void { this.scheduleStaleTimer() const runningStatusCount = this.getEligibleRunningStatusCount() - const shouldBlock = this.enabled && runningStatusCount > 0 + const shouldBlock = this.mode === 'on' || (this.mode === 'auto' && runningStatusCount > 0) if (shouldBlock) { this.startBlocker(reason, runningStatusCount) this.startMacosAssertion(reason) @@ -115,6 +140,21 @@ export class AgentAwakeService { this.stopMacosAssertion(reason) this.stopLinuxAssertion(reason) } + this.publishStatus(shouldBlock) + } + + private publishStatus(active: boolean): void { + const status = { mode: this.mode, active } + if ( + this.lastPublishedStatus?.mode === status.mode && + this.lastPublishedStatus.active === status.active + ) { + return + } + this.lastPublishedStatus = status + for (const listener of this.statusListeners) { + listener(status) + } } private getEligibleRunningStatusCount(): number { @@ -182,7 +222,7 @@ export class AgentAwakeService { } catch (err) { this.logger.warn('[agent-awake] failed to start blocker', { reason, - enabled: this.enabled, + mode: this.mode, runningStatusCount, error: err }) @@ -195,7 +235,7 @@ export class AgentAwakeService { } catch (err) { this.logger.warn('[agent-awake] failed to start macOS system sleep assertion', { reason, - enabled: this.enabled, + mode: this.mode, error: err }) } @@ -207,7 +247,7 @@ export class AgentAwakeService { } catch (err) { this.logger.warn('[agent-awake] failed to start Linux lid sleep assertion', { reason, - enabled: this.enabled, + mode: this.mode, error: err }) } @@ -219,7 +259,7 @@ export class AgentAwakeService { } catch (err) { this.logger.warn('[agent-awake] failed to stop macOS system sleep assertion', { reason, - enabled: this.enabled, + mode: this.mode, error: err }) } @@ -231,7 +271,7 @@ export class AgentAwakeService { } catch (err) { this.logger.warn('[agent-awake] failed to stop Linux lid sleep assertion', { reason, - enabled: this.enabled, + mode: this.mode, error: err }) } @@ -247,7 +287,7 @@ export class AgentAwakeService { } catch (err) { this.logger.warn('[agent-awake] failed to stop blocker', { reason, - enabled: this.enabled, + mode: this.mode, runningStatusCount, blockerId: id, error: err diff --git a/src/main/index.ts b/src/main/index.ts index ddf79fd9455..0a4be2562c8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -267,6 +267,7 @@ import { AutomationService } from './automations/service' import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/headless-dispatch' import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headless-workspace-create' import { AgentAwakeService } from './agent-awake-service' +import { normalizeComputerAwakeMode } from '../shared/computer-awake-mode' import { registerSystemResumeBroadcast } from './system-resume-broadcast' import { settleTeardownWithinDeadline } from './quit-teardown-deadline' import { quitTeardownStartGate } from './quit-teardown-start-gate' @@ -2216,7 +2217,12 @@ void app.whenReady().then(async () => { }) unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast() agentAwakeService = new AgentAwakeService() - agentAwakeService.setEnabled(store.getSettings().keepComputerAwakeWhileAgentsRun) + agentAwakeService.setMode( + normalizeComputerAwakeMode( + store.getSettings().computerAwakeMode, + store.getSettings().keepComputerAwakeWhileAgentsRun + ) + ) // Why: start from empty — disk-hydrated status rows are UI continuity only; only this runtime's hook events keep the computer awake. agentAwakeService.setStatuses([]) const collectChangedProviderSessionWorktrees = createHookProviderSessionInvalidator() diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index 88199db7e41..af7117b086f 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -344,7 +344,7 @@ describe('registerSettingsHandlers', () => { }) it('updates the agent awake service when the keep-awake setting changes', () => { - const agentAwakeService = { setEnabled: vi.fn() } + const agentAwakeService = { setMode: vi.fn() } store.getSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: false }) store.updateSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: true }) registerSettingsHandlers(store as never, agentAwakeService as never) @@ -356,11 +356,18 @@ describe('registerSettingsHandlers', () => { handler(settingsInvokeEvent, { keepComputerAwakeWhileAgentsRun: true }) - expect(agentAwakeService.setEnabled).toHaveBeenCalledWith(true) + expect(store.updateSettings).toHaveBeenCalledWith( + { + computerAwakeMode: 'auto', + keepComputerAwakeWhileAgentsRun: true + }, + expect.any(Object) + ) + expect(agentAwakeService.setMode).toHaveBeenCalledWith('auto') }) it('does not notify the agent awake service for unrelated setting changes', () => { - const agentAwakeService = { setEnabled: vi.fn() } + const agentAwakeService = { setMode: vi.fn() } store.getSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: false }) store.updateSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: false }) registerSettingsHandlers(store as never, agentAwakeService as never) @@ -372,7 +379,7 @@ describe('registerSettingsHandlers', () => { handler(settingsInvokeEvent, { defaultTuiAgent: 'codex' }) - expect(agentAwakeService.setEnabled).not.toHaveBeenCalled() + expect(agentAwakeService.setMode).not.toHaveBeenCalled() }) it('prepares local worktree roots when workspace directory changes', async () => { diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index df3817c949c..98e918300ab 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -29,6 +29,10 @@ import { normalizeMobilePairingCustomAddress, normalizeMobilePairingCustomAddresses } from '../../shared/mobile-pairing-custom-address' +import { + computerAwakeSettingsForMode, + normalizeComputerAwakeMode +} from '../../shared/computer-awake-mode' // Why: the whitelist is the source-of-truth for which keys we emit on. Casting // to a Set once at module load lets the IPC handler's per-key membership @@ -65,6 +69,18 @@ export function registerSettingsHandlers( store: Store, agentAwakeService?: AgentAwakeService ): void { + ipcMain.handle( + 'agentAwake:getStatus', + () => agentAwakeService?.getStatus() ?? { mode: 'off', active: false } + ) + agentAwakeService?.subscribe?.((status) => { + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send('agentAwake:changed', status) + } + } + }) + store.onSettingsChanged((updates, _settings, originWebContentsId) => { for (const window of BrowserWindow.getAllWindows()) { const isOrigin = @@ -109,6 +125,22 @@ export function registerSettingsHandlers( // Why: Floating Workspace grants are trusted only when written by the // main-process directory picker, never by renderer-provided settings IPC. delete sanitizedArgs.floatingTerminalTrustedCwds + if ('computerAwakeMode' in sanitizedArgs) { + Object.assign( + sanitizedArgs, + computerAwakeSettingsForMode( + normalizeComputerAwakeMode( + sanitizedArgs.computerAwakeMode, + sanitizedArgs.keepComputerAwakeWhileAgentsRun + ) + ) + ) + } else if ('keepComputerAwakeWhileAgentsRun' in sanitizedArgs) { + Object.assign( + sanitizedArgs, + computerAwakeSettingsForMode(sanitizedArgs.keepComputerAwakeWhileAgentsRun ? 'auto' : 'off') + ) + } if (typeof args.floatingTerminalCwd === 'string') { sanitizedArgs.floatingTerminalCwd = await sanitizeFloatingWorkspaceDirectorySetting( store, @@ -161,8 +193,13 @@ export function registerSettingsHandlers( notifyListeners: true, originWebContentsId: event.sender.id }) - if ('keepComputerAwakeWhileAgentsRun' in sanitizedArgs) { - agentAwakeService?.setEnabled(result.keepComputerAwakeWhileAgentsRun) + if ( + 'computerAwakeMode' in sanitizedArgs || + 'keepComputerAwakeWhileAgentsRun' in sanitizedArgs + ) { + agentAwakeService?.setMode( + normalizeComputerAwakeMode(result.computerAwakeMode, result.keepComputerAwakeWhileAgentsRun) + ) } const hookSettingChanged = ('agentStatusHooksEnabled' in sanitizedArgs && diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 87c8fce5901..1cae8cd2359 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -9,6 +9,7 @@ import type { HostedReviewProvider } from '../shared/hosted-review' import type { NativeFileDropPayload } from '../shared/native-file-drop' +import type { ComputerAwakeStatus } from '../shared/computer-awake-mode' import type { BrowserFindSource } from '../shared/browser-find-source' import type { DashboardRevealAgentArgs, @@ -2425,6 +2426,10 @@ export type PreloadApi = { /** Subscribe to out-of-band settings updates (e.g. View > Appearance toggles) to stay in sync with main. */ onChanged: (callback: (updates: Partial) => void) => () => void } + agentAwake: { + getStatus: () => Promise + onChanged: (callback: (status: ComputerAwakeStatus) => void) => () => void + } localhostWorktreeLabels: { register: (args: LocalhostWorktreeLabelRoute) => Promise } diff --git a/src/preload/index.ts b/src/preload/index.ts index 2cf88323b23..179ce5ecc2a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,6 +4,7 @@ import { electronAPI } from '@electron-toolkit/preload' import { preloadE2EConfig } from './e2e-config' import { glApi } from './gitlab' import type { AppIdentity } from '../shared/app-identity' +import type { ComputerAwakeStatus } from '../shared/computer-awake-mode' import type { DashboardRevealAgentArgs, DashboardSleepWorkspaceArgs, @@ -2047,6 +2048,16 @@ const api = { } }, + agentAwake: { + getStatus: (): Promise => ipcRenderer.invoke('agentAwake:getStatus'), + onChanged: (callback: (status: ComputerAwakeStatus) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, status: ComputerAwakeStatus): void => + callback(status) + ipcRenderer.on('agentAwake:changed', listener) + return () => ipcRenderer.removeListener('agentAwake:changed', listener) + } + } satisfies PreloadApi['agentAwake'], + localhostWorktreeLabels: { register: (args: LocalhostWorktreeLabelRoute): Promise => ipcRenderer.invoke('localhostWorktreeLabels:register', args) diff --git a/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx b/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx index 2a7b57adc67..40d25ebf8bb 100644 --- a/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx +++ b/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx @@ -3,13 +3,21 @@ import { cn } from '@/lib/utils' import { getAgentAwakeDescription, getAgentAwakeTitle } from '../settings/agent-awake-copy' import type { GlobalSettings } from '../../../../shared/types' import { translate } from '@/i18n/i18n' +import { + computerAwakeSettingsForMode, + normalizeComputerAwakeMode +} from '../../../../shared/computer-awake-mode' export function KeepAwakeCard(props: { settings: GlobalSettings updateSettings: (updates: Partial) => void }): JSX.Element { const { settings, updateSettings } = props - const enabled = settings.keepComputerAwakeWhileAgentsRun + const enabled = + normalizeComputerAwakeMode( + settings.computerAwakeMode, + settings.keepComputerAwakeWhileAgentsRun + ) !== 'off' const title = getAgentAwakeTitle() return (
@@ -29,7 +37,7 @@ export function KeepAwakeCard(props: { role="switch" aria-label={title} aria-checked={enabled} - onClick={() => updateSettings({ keepComputerAwakeWhileAgentsRun: !enabled })} + onClick={() => updateSettings(computerAwakeSettingsForMode(enabled ? 'off' : 'auto'))} className={cn( 'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors', enabled ? 'bg-foreground' : 'bg-muted-foreground/30' diff --git a/src/renderer/src/components/settings/AgentAwakeSetting.tsx b/src/renderer/src/components/settings/AgentAwakeSetting.tsx index 28cd7cc0114..7df934be0c7 100644 --- a/src/renderer/src/components/settings/AgentAwakeSetting.tsx +++ b/src/renderer/src/components/settings/AgentAwakeSetting.tsx @@ -6,6 +6,13 @@ import { getAgentAwakeTitle } from './agent-awake-copy' import { SearchableSetting } from './SearchableSetting' +import { SettingsSegmentedControl } from './SettingsFormControls' +import { + computerAwakeSettingsForMode, + normalizeComputerAwakeMode, + type ComputerAwakeMode +} from '../../../../shared/computer-awake-mode' +import { translate } from '@/i18n/i18n' type AgentAwakeSettingProps = { settings: GlobalSettings @@ -18,6 +25,13 @@ export function AgentAwakeSetting({ }: AgentAwakeSettingProps): React.JSX.Element { const title = getAgentAwakeTitle() const description = getAgentAwakeDescription() + const mode = normalizeComputerAwakeMode( + settings.computerAwakeMode, + settings.keepComputerAwakeWhileAgentsRun + ) + const setMode = (nextMode: ComputerAwakeMode): void => { + updateSettings(computerAwakeSettingsForMode(nextMode)) + } return (
@@ -31,29 +45,26 @@ export function AgentAwakeSetting({

{description}

- {/* Why: this button is read directly from the React element tree by tests - that walk props (without rendering), so the role/aria attributes - must remain on a literal + diff --git a/src/renderer/src/components/settings/AgentsPane.test.tsx b/src/renderer/src/components/settings/AgentsPane.test.tsx index 4a8428ca892..af821f94920 100644 --- a/src/renderer/src/components/settings/AgentsPane.test.tsx +++ b/src/renderer/src/components/settings/AgentsPane.test.tsx @@ -117,19 +117,6 @@ function visit(node: unknown, cb: (node: ReactElementLike) => void): void { } } -function findSwitch(node: unknown, ariaLabel: string): ReactElementLike { - let found: ReactElementLike | null = null - visit(node, (entry) => { - if (entry.props.role === 'switch' && entry.props['aria-label'] === ariaLabel) { - found = entry - } - }) - if (!found) { - throw new Error('switch not found') - } - return found -} - function findSwitchRow(node: unknown, ariaLabel: string): ReactElementLike { let found: ReactElementLike | null = null visit(node, (entry) => { @@ -249,17 +236,30 @@ describe('AgentsPane', () => { expect(agentRuntimeSettingMock.lastRefresh).not.toBe(detectedAgentsMock.refresh) }) - it('renders the keep-awake toggle from settings', () => { + it('renders the keep-awake modes from settings', () => { const markup = renderPane(getDefaultSettings('/tmp')) expect(markup).not.toContain('Agent location') expect(markup).not.toContain('Agent runtime') expect(markup).not.toContain('aria-label="Agent runtime"') - expect(markup).toContain('Keep computer awake while agents are working') + expect(markup).toContain('Keep computer awake') expect(markup).toContain( - 'Keeps this computer and display awake while agents are working. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.' + 'Choose On, Auto while agents are working, or Off. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.' ) - expect(markup).toContain('aria-checked="false"') + expect(markup).toContain('role="radiogroup"') + expect(markup).toContain('>Auto<') + }) + + it('hides desktop-only awake modes in paired web clients', () => { + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + try { + expect(renderPane(getDefaultSettings('/tmp'))).not.toContain('Keep computer awake') + expect( + matchesSettingsSearch('awake', getAgentsPaneSearchEntries({ includeAgentAwake: false })) + ).toBe(false) + } finally { + delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ + } }) it('renders the agent runtime control on Windows-class hosts', () => { @@ -316,11 +316,11 @@ describe('AgentsPane', () => { it('describes Windows lid behavior according to the device', () => { expect(getAgentAwakeDescription('Windows')).toBe( - "Keeps this computer and display awake while agents are working. Lid-close behavior follows this device's power settings." + "Choose On, Auto while agents are working, or Off. Lid-close behavior follows this device's power settings." ) }) - it('toggles the keep-awake setting with the next value', () => { + it('updates the keep-awake mode with its legacy fallback', () => { const updateSettings = vi.fn() const element = AgentAwakeSetting({ settings: { @@ -331,14 +331,14 @@ describe('AgentsPane', () => { }) const keepAwakeTitle = getAgentAwakeTitle() - const keepAwakeSwitch = findSwitch(element, keepAwakeTitle) - expect(keepAwakeSwitch.props['aria-label']).toBe(keepAwakeTitle) - expect(keepAwakeSwitch.props['aria-checked']).toBe(false) + const keepAwakeControl = findSegmentedControl(element, keepAwakeTitle) + expect(keepAwakeControl.props.value).toBe('off') - const onClick = keepAwakeSwitch.props.onClick as () => void - onClick() + const onChange = keepAwakeControl.props.onChange as (mode: 'auto') => void + onChange('auto') expect(updateSettings).toHaveBeenCalledWith({ + computerAwakeMode: 'auto', keepComputerAwakeWhileAgentsRun: true }) }) diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index 21f50bede40..70ba5f64c3f 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -56,6 +56,7 @@ import { getSettingOwnershipSummary } from './setting-ownership' import { translate } from '@/i18n/i18n' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import { parseAgentDefaultEnvDraft, stringifyAgentDefaultEnvDraft } from './agent-default-env-draft' +import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' export { getAgentsPaneSearchEntries } from './agents-search' @@ -867,7 +868,9 @@ export function AgentsPane({ - + {!isPairedWebClientWindow() ? ( + + ) : null} diff --git a/src/renderer/src/components/settings/agent-awake-copy.ts b/src/renderer/src/components/settings/agent-awake-copy.ts index 10d629b58d1..1076312ed18 100644 --- a/src/renderer/src/components/settings/agent-awake-copy.ts +++ b/src/renderer/src/components/settings/agent-awake-copy.ts @@ -1,12 +1,14 @@ import { translate } from '@/i18n/i18n' import { searchKeywords } from './settings-search-keywords' -const AGENT_AWAKE_TITLE_KEY = 'auto.components.settings.agent-awake-copy.e5995ce268' -const AGENT_AWAKE_DESCRIPTION_WINDOWS_KEY = 'auto.components.settings.agent-awake-copy.95d3031db2' -const AGENT_AWAKE_DESCRIPTION_DEFAULT_KEY = 'auto.components.settings.agent-awake-copy.a42f6fbdd8' +const AGENT_AWAKE_TITLE_KEY = 'auto.components.settings.agent-awake-copy.modeTitle' +const AGENT_AWAKE_DESCRIPTION_WINDOWS_KEY = + 'auto.components.settings.agent-awake-copy.modeDescriptionWindows' +const AGENT_AWAKE_DESCRIPTION_DEFAULT_KEY = + 'auto.components.settings.agent-awake-copy.modeDescriptionDefault' export function getAgentAwakeTitle(): string { - return translate(AGENT_AWAKE_TITLE_KEY, 'Keep computer awake while agents are working') + return translate(AGENT_AWAKE_TITLE_KEY, 'Keep computer awake') } export function getAgentAwakeDescription( @@ -15,13 +17,13 @@ export function getAgentAwakeDescription( if (userAgent.includes('Windows')) { return translate( AGENT_AWAKE_DESCRIPTION_WINDOWS_KEY, - "Keeps this computer and display awake while agents are working. Lid-close behavior follows this device's power settings." + "Choose On, Auto while agents are working, or Off. Lid-close behavior follows this device's power settings." ) } return translate( AGENT_AWAKE_DESCRIPTION_DEFAULT_KEY, - 'Keeps this computer and display awake while agents are working. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.' + 'Choose On, Auto while agents are working, or Off. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.' ) } diff --git a/src/renderer/src/components/settings/agents-search.ts b/src/renderer/src/components/settings/agents-search.ts index 5e378d732d1..ed353d5acbf 100644 --- a/src/renderer/src/components/settings/agents-search.ts +++ b/src/renderer/src/components/settings/agents-search.ts @@ -60,9 +60,11 @@ function expandAgentSearchText(value: string): string[] { } type AgentsPaneSearchOptions = { + includeAgentAwake?: boolean includeAgentRuntime?: boolean } +const AGENT_AWAKE_SEARCH_ENTRY_ID = 'agent-awake' const AGENT_RUNTIME_SEARCH_ENTRY_ID = 'agent-runtime' const getAllAgentsPaneSearchEntries = createLocalizedCatalog(() => [ @@ -113,6 +115,7 @@ const getAllAgentsPaneSearchEntries = createLocalizedCatalog(() => [ }, { title: getAgentAwakeTitle(), + id: AGENT_AWAKE_SEARCH_ENTRY_ID, description: getAgentAwakeDescription(), keywords: getAgentAwakeSearchKeywords() }, @@ -141,11 +144,13 @@ const getAllAgentsPaneSearchEntries = createLocalizedCatalog(() => [ ]) export function getAgentsPaneSearchEntries({ + includeAgentAwake = true, includeAgentRuntime = true }: AgentsPaneSearchOptions = {}) { const entries = getAllAgentsPaneSearchEntries() - if (includeAgentRuntime) { - return entries - } - return entries.filter((entry) => !('id' in entry) || entry.id !== AGENT_RUNTIME_SEARCH_ENTRY_ID) + return entries.filter( + (entry) => + (!('id' in entry) || entry.id !== AGENT_RUNTIME_SEARCH_ENTRY_ID || includeAgentRuntime) && + (!('id' in entry) || entry.id !== AGENT_AWAKE_SEARCH_ENTRY_ID || includeAgentAwake) + ) } diff --git a/src/renderer/src/components/status-bar/CaffeinateStatusSegment.tsx b/src/renderer/src/components/status-bar/CaffeinateStatusSegment.tsx new file mode 100644 index 00000000000..b991e63a096 --- /dev/null +++ b/src/renderer/src/components/status-bar/CaffeinateStatusSegment.tsx @@ -0,0 +1,177 @@ +import { useEffect, useState } from 'react' +import { Coffee } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useAppStore } from '@/store' +import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' +import { translate } from '@/i18n/i18n' +import { + computerAwakeSettingsForMode, + normalizeComputerAwakeMode, + type ComputerAwakeMode, + type ComputerAwakeStatus +} from '../../../../shared/computer-awake-mode' +import { STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS } from './status-bar-context-menu-policy' + +const INACTIVE_STATUS: ComputerAwakeStatus = { + mode: 'off', + active: false +} + +function modeLabel(mode: ComputerAwakeMode): string { + if (mode === 'on') { + return translate('auto.components.status.bar.CaffeinateStatusSegment.on', 'On') + } + if (mode === 'auto') { + return translate('auto.components.status.bar.CaffeinateStatusSegment.auto', 'Auto') + } + return translate('auto.components.status.bar.CaffeinateStatusSegment.off', 'Off') +} + +function activityLabel(active: boolean): string { + return active + ? translate('auto.components.status.bar.CaffeinateStatusSegment.active', 'Active') + : translate('auto.components.status.bar.CaffeinateStatusSegment.inactive', 'Inactive') +} + +export function CaffeinateStatusSegment({ + iconOnly +}: { + iconOnly: boolean +}): React.JSX.Element | null { + const settings = useAppStore((state) => state.settings) + const updateSettings = useAppStore((state) => state.updateSettings) + const configuredMode = normalizeComputerAwakeMode( + settings?.computerAwakeMode, + settings?.keepComputerAwakeWhileAgentsRun + ) + const [serviceStatus, setServiceStatus] = useState(INACTIVE_STATUS) + + useEffect(() => { + let mounted = true + const unsubscribe = window.api.agentAwake.onChanged((status) => { + if (mounted) { + setServiceStatus(status) + } + }) + void window.api.agentAwake + .getStatus() + .then((status) => { + if (mounted) { + setServiceStatus(status) + } + }) + .catch(() => {}) + return () => { + mounted = false + unsubscribe() + } + }, []) + + if (isPairedWebClientWindow()) { + return null + } + + const mode = serviceStatus.mode === configuredMode ? serviceStatus.mode : configuredMode + const active = + serviceStatus.mode === configuredMode ? serviceStatus.active : configuredMode === 'on' + const statusText = `${modeLabel(mode)} · ${activityLabel(active)}` + const ariaLabel = translate( + 'auto.components.status.bar.CaffeinateStatusSegment.ariaLabel', + 'Caffeinate, {{status}}', + { status: statusText } + ) + + const setMode = (nextMode: string): void => { + void updateSettings(computerAwakeSettingsForMode(normalizeComputerAwakeMode(nextMode))) + } + + return ( + + + + + + + + + {ariaLabel} + + + + + + {translate('auto.components.status.bar.CaffeinateStatusSegment.title', 'Caffeinate')} + + {statusText} + + + + + + {modeLabel('on')} + + {translate( + 'auto.components.status.bar.CaffeinateStatusSegment.onDescription', + 'Keep this computer awake continuously' + )} + + + + + + {modeLabel('auto')} + + {translate( + 'auto.components.status.bar.CaffeinateStatusSegment.autoDescription', + 'Stay awake while an agent is working' + )} + + + + + + {modeLabel('off')} + + {translate( + 'auto.components.status.bar.CaffeinateStatusSegment.offDescription', + 'Allow normal system sleep behavior' + )} + + + + + + + ) +} diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 1b3075e305b..1c4af8fdff8 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -71,6 +71,7 @@ import { } from '@/lib/codex-session-restart' import { UpdateStatusSegment } from './UpdateStatusSegment' import { SkillUpdateStatusSegment } from './SkillUpdateStatusSegment' +import { CaffeinateStatusSegment } from './CaffeinateStatusSegment' import { RemoteServerUpdateStatusSegment } from './RemoteServerUpdateStatusSegment' import { isStatusBarItemAvailable } from './status-bar-agent-gating' import { getVisibleUsageProvider, isUsageEmptyState } from './status-bar-provider-visibility' @@ -84,6 +85,7 @@ import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { FloatingTerminalIconContextMenu } from '@/components/floating-terminal/FloatingTerminalIconContextMenu' import { summarizeCodexRestartStatus } from './codex-restart-status-summary' +import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' import { getWindowsTerminalCapabilityOwnerKey, useWindowsTerminalCapabilities @@ -2349,6 +2351,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
+ {!isPairedWebClientWindow() ? : null} diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 17f535457ee..364d525298b 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -165,7 +165,10 @@ export function buildSettingsNavigationMetadata({ 'Manage AI agents, set a default, and customize commands.' ), icon: Bot, - searchEntries: getAgentsPaneSearchEntries({ includeAgentRuntime: isLocalWindowsHost }), + searchEntries: getAgentsPaneSearchEntries({ + includeAgentAwake: !isWebClient, + includeAgentRuntime: isLocalWindowsHost + }), group: 'capabilities' }, { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index baadf663b56..1aaead6e8f0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3635,6 +3635,18 @@ } } } + }, + "CaffeinateStatusSegment": { + "on": "On", + "auto": "Auto", + "off": "Off", + "active": "Active", + "inactive": "Inactive", + "ariaLabel": "Caffeinate, {{status}}", + "title": "Caffeinate", + "onDescription": "Keep this computer awake continuously", + "autoDescription": "Stay awake while an agent is working", + "offDescription": "Allow normal system sleep behavior" } } }, @@ -9455,7 +9467,10 @@ "agent-awake-copy": { "e5995ce268": "Keep computer awake while agents are working", "95d3031db2": "Keeps this computer and display awake while agents are working. Lid-close behavior follows this device's power settings.", - "a42f6fbdd8": "Keeps this computer and display awake while agents are working. Orca also asks this device to stay awake when the lid is closed, subject to its power policy." + "a42f6fbdd8": "Keeps this computer and display awake while agents are working. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.", + "modeTitle": "Keep computer awake", + "modeDescriptionWindows": "Choose On, Auto while agents are working, or Off. Lid-close behavior follows this device's power settings.", + "modeDescriptionDefault": "Choose On, Auto while agents are working, or Off. Orca also asks this device to stay awake when the lid is closed, subject to its power policy." }, "agent-status-hooks-copy": { "7707c15abb": "Agent status hooks", @@ -10514,6 +10529,11 @@ "openAutomationsDescription": "Create schedules and inspect recent runs.", "title": "Automations", "description": "Schedule agent work and choose whether Automations appears in the sidebar." + }, + "AgentAwakeSetting": { + "on": "On", + "auto": "Auto", + "off": "Off" } }, "right": { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 038e05e1373..77f9a40dfdb 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -92,6 +92,10 @@ import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-t import { normalizeUiLanguage } from '../../../shared/ui-language' import { normalizeUsagePercentageDisplay } from '../../../shared/usage-percentage-display' import { normalizeStatusBarUsageMode } from '../../../shared/status-bar-usage-mode' +import { + computerAwakeSettingsForMode, + normalizeComputerAwakeMode +} from '../../../shared/computer-awake-mode' import type { RateLimitState } from '../../../shared/rate-limit-types' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../../shared/runtime-types' import { assertFileMutationOwnershipCapability } from '../../../shared/file-mutation-ownership' @@ -680,6 +684,24 @@ function createWebPreloadApi(): Partial { set: async (updates) => { const sanitizedUpdates = { ...updates } delete sanitizedUpdates.activeRuntimeEnvironmentId + if ('computerAwakeMode' in sanitizedUpdates) { + Object.assign( + sanitizedUpdates, + computerAwakeSettingsForMode( + normalizeComputerAwakeMode( + sanitizedUpdates.computerAwakeMode, + sanitizedUpdates.keepComputerAwakeWhileAgentsRun + ) + ) + ) + } else if ('keepComputerAwakeWhileAgentsRun' in sanitizedUpdates) { + Object.assign( + sanitizedUpdates, + computerAwakeSettingsForMode( + sanitizedUpdates.keepComputerAwakeWhileAgentsRun ? 'auto' : 'off' + ) + ) + } if ('autoRenameBranchFromWorkDefaultedOn' in sanitizedUpdates) { sanitizedUpdates.autoRenameBranchFromWorkDefaultedOn = true } @@ -704,6 +726,19 @@ function createWebPreloadApi(): Partial { listFonts: () => Promise.resolve([]), onChanged: () => noopUnsubscribe } satisfies Partial as unknown as WebSettingsApi, + agentAwake: { + getStatus: async () => { + const settings = getStoredSettings() + return { + mode: normalizeComputerAwakeMode( + settings.computerAwakeMode, + settings.keepComputerAwakeWhileAgentsRun + ), + active: false + } + }, + onChanged: () => noopUnsubscribe + }, keybindings: createWebKeybindingsApi(), ui: createWebUiApi(), crashReports: { diff --git a/src/shared/computer-awake-mode.test.ts b/src/shared/computer-awake-mode.test.ts new file mode 100644 index 00000000000..5a0920f4c0c --- /dev/null +++ b/src/shared/computer-awake-mode.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { computerAwakeSettingsForMode, normalizeComputerAwakeMode } from './computer-awake-mode' + +describe('computer awake mode', () => { + it('maps the legacy enabled setting to Auto', () => { + expect(normalizeComputerAwakeMode(undefined, true)).toBe('auto') + expect(normalizeComputerAwakeMode(undefined, false)).toBe('off') + }) + + it('preserves explicit modes when the legacy projection agrees or is absent', () => { + expect(normalizeComputerAwakeMode('on')).toBe('on') + expect(normalizeComputerAwakeMode('on', true)).toBe('on') + expect(normalizeComputerAwakeMode('off', false)).toBe('off') + expect(normalizeComputerAwakeMode('auto', true)).toBe('auto') + }) + + it('honors legacy changes made by a rollback build', () => { + expect(normalizeComputerAwakeMode('on', false)).toBe('off') + expect(normalizeComputerAwakeMode('auto', false)).toBe('off') + expect(normalizeComputerAwakeMode('off', true)).toBe('auto') + }) + + it('writes a safe legacy approximation', () => { + expect(computerAwakeSettingsForMode('on')).toEqual({ + computerAwakeMode: 'on', + keepComputerAwakeWhileAgentsRun: true + }) + expect(computerAwakeSettingsForMode('off')).toEqual({ + computerAwakeMode: 'off', + keepComputerAwakeWhileAgentsRun: false + }) + }) +}) diff --git a/src/shared/computer-awake-mode.ts b/src/shared/computer-awake-mode.ts new file mode 100644 index 00000000000..0c598fd25d5 --- /dev/null +++ b/src/shared/computer-awake-mode.ts @@ -0,0 +1,36 @@ +export const COMPUTER_AWAKE_MODES = ['on', 'off', 'auto'] as const + +export type ComputerAwakeMode = (typeof COMPUTER_AWAKE_MODES)[number] + +export type ComputerAwakeStatus = { + mode: ComputerAwakeMode + active: boolean +} + +export function normalizeComputerAwakeMode( + mode: unknown, + legacyAutoEnabled?: boolean +): ComputerAwakeMode { + const explicitMode = COMPUTER_AWAKE_MODES.includes(mode as ComputerAwakeMode) + ? (mode as ComputerAwakeMode) + : null + if (!explicitMode) { + return legacyAutoEnabled === true ? 'auto' : 'off' + } + if (typeof legacyAutoEnabled === 'boolean' && legacyAutoEnabled !== (explicitMode !== 'off')) { + // Older builds can only change the legacy boolean, so disagreement means it was written later. + return legacyAutoEnabled ? 'auto' : 'off' + } + return explicitMode +} + +export function computerAwakeSettingsForMode(mode: ComputerAwakeMode): { + computerAwakeMode: ComputerAwakeMode + keepComputerAwakeWhileAgentsRun: boolean +} { + return { + computerAwakeMode: mode, + // Older Orca versions approximate On with their supported Auto behavior. + keepComputerAwakeWhileAgentsRun: mode !== 'off' + } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 9eb3de46c3b..284782625e9 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -53,6 +53,7 @@ import type { CodexResetCreditAttemptLedger } from './codex-reset-credit-attempt import type { TaskSourceContext } from './task-source-context' import type { SetupRunnerShell } from './setup-runner-command' import type { AiVaultSessionTitle } from './ai-vault-session-title' +import type { ComputerAwakeMode } from './computer-awake-mode' // Re-exported for backward compat with renderer call sites that import // `WorkspaceCreateTelemetrySource` from '../../../shared/types'. @@ -3050,6 +3051,8 @@ export type GlobalSettings = { confirmClosePinnedTab: boolean /** When true, Orca requests local awake assertions while hook-reported agents are working. */ keepComputerAwakeWhileAgentsRun: boolean + /** Optional for mixed-version compatibility; the legacy boolean maps true to Auto. */ + computerAwakeMode?: ComputerAwakeMode /** macOS Option key: compose layout chars (@ German, € French) vs act as Meta/Esc for readline. * 'auto' (default) = layout-aware via navigator.keyboard.getLayoutMap() (US → Meta, else compose); * 'false' = compose; 'true' = Meta on both Option keys; 'left'/'right' = only that key is Meta. diff --git a/tests/e2e/settings-agent-awake.spec.ts b/tests/e2e/settings-agent-awake.spec.ts index e35aed89c39..0432970b39c 100644 --- a/tests/e2e/settings-agent-awake.spec.ts +++ b/tests/e2e/settings-agent-awake.spec.ts @@ -139,37 +139,37 @@ test.describe('Agent awake setting', () => { await waitForSessionReady(orcaPage) }) - test('can be toggled from Agents settings and persists through IPC', async ({ orcaPage }) => { + test('can be changed from Agents settings and persists through IPC', async ({ orcaPage }) => { await openSettings(orcaPage) await dismissTransientAnnouncement(orcaPage) await orcaPage.getByPlaceholder('Search settings').fill('awake') - await expect( - orcaPage.getByText('Keep computer awake while agents are working').first() - ).toBeVisible() + await expect(orcaPage.getByText('Keep computer awake').first()).toBeVisible() - const keepAwakeSwitch = orcaPage.getByRole('switch', { - name: 'Keep computer awake while agents are working' + const keepAwakeModes = orcaPage.getByRole('radiogroup', { + name: 'Keep computer awake' }) + const offMode = keepAwakeModes.getByRole('radio', { name: 'Off' }) + const autoMode = keepAwakeModes.getByRole('radio', { name: 'Auto' }) - await expect(keepAwakeSwitch).toHaveAttribute('aria-checked', 'false') - await keepAwakeSwitch.click() - await expect(keepAwakeSwitch).toHaveAttribute('aria-checked', 'true') + await expect(offMode).toHaveAttribute('aria-checked', 'true') + await autoMode.click() + await expect(autoMode).toHaveAttribute('aria-checked', 'true') await expect - .poll(async () => (await getSettings(orcaPage)).keepComputerAwakeWhileAgentsRun, { + .poll(async () => (await getSettings(orcaPage)).computerAwakeMode, { timeout: 5_000, - message: 'keep-awake setting did not persist after enabling' + message: 'keep-awake mode did not persist after selecting Auto' }) - .toBe(true) + .toBe('auto') - await keepAwakeSwitch.click() - await expect(keepAwakeSwitch).toHaveAttribute('aria-checked', 'false') + await offMode.click() + await expect(offMode).toHaveAttribute('aria-checked', 'true') await expect - .poll(async () => (await getSettings(orcaPage)).keepComputerAwakeWhileAgentsRun, { + .poll(async () => (await getSettings(orcaPage)).computerAwakeMode, { timeout: 5_000, - message: 'keep-awake setting did not persist after disabling' + message: 'keep-awake mode did not persist after selecting Off' }) - .toBe(false) + .toBe('off') }) test('keeps the OS awake only while a hook-reported agent is working', async ({ diff --git a/tests/e2e/status-bar-caffeinate.spec.ts b/tests/e2e/status-bar-caffeinate.spec.ts new file mode 100644 index 00000000000..2e28770213d --- /dev/null +++ b/tests/e2e/status-bar-caffeinate.spec.ts @@ -0,0 +1,70 @@ +import { randomUUID } from 'node:crypto' +import type { ElectronApplication } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import { readHookEndpoint } from './helpers/agent-hook-endpoint' + +async function postCodexHookEvent( + electronApp: ElectronApplication, + paneKey: string, + eventName: 'UserPromptSubmit' | 'Stop' +): Promise { + const endpoint = await readHookEndpoint(electronApp) + const response = await fetch(`http://127.0.0.1:${endpoint.port}/hook/codex`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': endpoint.token + }, + body: JSON.stringify({ + paneKey, + tabId: 'e2e-caffeinate-tab', + worktreeId: 'e2e-caffeinate-worktree', + env: endpoint.env, + version: endpoint.version, + payload: { hook_event_name: eventName, prompt: 'e2e caffeinate prompt' } + }) + }) + expect(response.status).toBe(204) +} + +test('shows Caffeinate mode and Auto activity in the status bar', async ({ + electronApp, + orcaPage +}) => { + await waitForSessionReady(orcaPage) + + const offStatus = orcaPage.getByRole('button', { name: 'Caffeinate, Off · Inactive' }) + await expect(offStatus).toBeVisible() + await expect(offStatus).toHaveText('Off') + await offStatus.click() + await expect(orcaPage.getByRole('menuitemradio', { name: /^On/ })).toBeVisible() + await expect(orcaPage.getByRole('menuitemradio', { name: /^Auto/ })).toBeVisible() + await expect(orcaPage.getByRole('menuitemradio', { name: /^Off/ })).toBeVisible() + const menuProofPath = process.env.ORCA_CAFFEINATE_MENU_PROOF_PATH + if (menuProofPath) { + await orcaPage.screenshot({ path: menuProofPath }) + } + await orcaPage.getByRole('menuitemradio', { name: /^Auto/ }).click() + + const autoInactiveStatus = orcaPage.getByRole('button', { + name: 'Caffeinate, Auto · Inactive' + }) + await expect(autoInactiveStatus).toBeVisible() + + const paneKey = `e2e-caffeinate-tab:${randomUUID()}` + await postCodexHookEvent(electronApp, paneKey, 'UserPromptSubmit') + const autoActiveStatus = orcaPage.getByRole('button', { + name: 'Caffeinate, Auto · Active' + }) + await expect(autoActiveStatus).toBeVisible() + await expect(autoActiveStatus).toHaveText('Auto') + + const proofPath = process.env.ORCA_CAFFEINATE_PROOF_PATH + if (proofPath) { + await orcaPage.screenshot({ path: proofPath }) + } + + await postCodexHookEvent(electronApp, paneKey, 'Stop') + await expect(autoInactiveStatus).toBeVisible() +})