mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
Reference in New Issue
Block a user