Add Caffeinate controls to the status bar (#13480)

* feat: add caffeinate status controls

* refactor: compact caffeinate status

* fix: harden caffeinate readiness
This commit is contained in:
Neil
2026-08-09 22:52:11 -07:00
committed by GitHub
parent 8859e73980
commit 7dce0442ab
23 changed files with 647 additions and 96 deletions
+36
View File
@@ -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()
+50 -10
View File
@@ -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<typeof setTimeout> | 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
+7 -1
View File
@@ -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()
+11 -4
View File
@@ -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 () => {
+39 -2
View File
@@ -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 &&
+5
View File
@@ -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<GlobalSettings>) => void) => () => void
}
agentAwake: {
getStatus: () => Promise<ComputerAwakeStatus>
onChanged: (callback: (status: ComputerAwakeStatus) => void) => () => void
}
localhostWorktreeLabels: {
register: (args: LocalhostWorktreeLabelRoute) => Promise<LocalhostWorktreeLabelResult>
}
+11
View File
@@ -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<ComputerAwakeStatus> => 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<LocalhostWorktreeLabelResult> =>
ipcRenderer.invoke('localhostWorktreeLabels:register', args)
@@ -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<GlobalSettings>) => void
}): JSX.Element {
const { settings, updateSettings } = props
const enabled = settings.keepComputerAwakeWhileAgentsRun
const enabled =
normalizeComputerAwakeMode(
settings.computerAwakeMode,
settings.keepComputerAwakeWhileAgentsRun
) !== 'off'
const title = getAgentAwakeTitle()
return (
<div className="rounded-xl border border-border bg-muted/20 p-4">
@@ -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'
@@ -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 (
<section className="space-y-3">
@@ -31,29 +45,26 @@ export function AgentAwakeSetting({
<Label>{title}</Label>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
{/* 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 <button>, not behind a component wrapper. */}
<button
type="button"
role="switch"
aria-label={title}
aria-checked={settings.keepComputerAwakeWhileAgentsRun}
onClick={() =>
updateSettings({
keepComputerAwakeWhileAgentsRun: !settings.keepComputerAwakeWhileAgentsRun
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.keepComputerAwakeWhileAgentsRun ? 'bg-foreground' : 'bg-muted-foreground/30'
} outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.keepComputerAwakeWhileAgentsRun ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
<SettingsSegmentedControl
value={mode}
onChange={setMode}
ariaLabel={title}
size="sm"
options={[
{
value: 'on',
label: translate('auto.components.settings.AgentAwakeSetting.on', 'On')
},
{
value: 'auto',
label: translate('auto.components.settings.AgentAwakeSetting.auto', 'Auto')
},
{
value: 'off',
label: translate('auto.components.settings.AgentAwakeSetting.off', 'Off')
}
]}
/>
</div>
</SearchableSetting>
</section>
@@ -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
})
})
@@ -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({
<AgentGeneratedTabTitlesSetting settings={settings} updateSettings={updateSettings} />
<AgentAwakeSetting settings={settings} updateSettings={updateSettings} />
{!isPairedWebClientWindow() ? (
<AgentAwakeSetting settings={settings} updateSettings={updateSettings} />
) : null}
<AgentCacheTimerSection settings={settings} updateSettings={updateSettings} />
@@ -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.'
)
}
@@ -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)
)
}
@@ -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<ComputerAwakeStatus>(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 (
<DropdownMenu modal={false}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
{...STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS}
className="inline-flex cursor-pointer items-center gap-1 rounded px-1 py-0.5 text-muted-foreground transition-colors hover:bg-accent/70 hover:text-foreground"
aria-label={ariaLabel}
>
<Coffee className={`size-3 ${active ? 'text-foreground' : ''}`} />
{!iconOnly ? (
<span className="text-[11px] font-medium">{modeLabel(mode)}</span>
) : null}
<span
aria-hidden
className={`size-1.5 rounded-full ${
active ? 'bg-foreground' : 'bg-muted-foreground/40'
}`}
/>
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{ariaLabel}
</TooltipContent>
</Tooltip>
<DropdownMenuContent
{...STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS}
side="top"
align="end"
sideOffset={8}
className="w-64"
>
<DropdownMenuLabel className="flex items-center justify-between gap-3">
<span>
{translate('auto.components.status.bar.CaffeinateStatusSegment.title', 'Caffeinate')}
</span>
<span className="font-normal text-muted-foreground">{statusText}</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup value={mode} onValueChange={setMode}>
<DropdownMenuRadioItem value="on" className="items-start py-1.5">
<span className="flex flex-col">
<span>{modeLabel('on')}</span>
<span className="text-[11px] font-normal text-muted-foreground">
{translate(
'auto.components.status.bar.CaffeinateStatusSegment.onDescription',
'Keep this computer awake continuously'
)}
</span>
</span>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="auto" className="items-start py-1.5">
<span className="flex flex-col">
<span>{modeLabel('auto')}</span>
<span className="text-[11px] font-normal text-muted-foreground">
{translate(
'auto.components.status.bar.CaffeinateStatusSegment.autoDescription',
'Stay awake while an agent is working'
)}
</span>
</span>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="off" className="items-start py-1.5">
<span className="flex flex-col">
<span>{modeLabel('off')}</span>
<span className="text-[11px] font-normal text-muted-foreground">
{translate(
'auto.components.status.bar.CaffeinateStatusSegment.offDescription',
'Allow normal system sleep behavior'
)}
</span>
</span>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -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
<div className="flex-1" />
<div className="flex items-center gap-3">
{!isPairedWebClientWindow() ? <CaffeinateStatusSegment iconOnly={iconOnly} /> : null}
<RemoteServerUpdateStatusSegment iconOnly={iconOnly} />
<SkillUpdateStatusSegment iconOnly={iconOnly} />
<UpdateStatusSegment compact={compact} iconOnly={iconOnly} />
@@ -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'
},
{
+21 -1
View File
@@ -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": {
+35
View File
@@ -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<PreloadApi> {
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<PreloadApi> {
listFonts: () => Promise.resolve([]),
onChanged: () => noopUnsubscribe
} satisfies Partial<WebSettingsApi> 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: {
+33
View File
@@ -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
})
})
})
+36
View File
@@ -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'
}
}
+3
View File
@@ -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.
+17 -17
View File
@@ -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 ({
+70
View File
@@ -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<void> {
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()
})