Skip to content
Open
2 changes: 2 additions & 0 deletions src/main/ipc/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { rebuildAppMenu } from '../menu/register-app-menu'
import { track } from '../telemetry/client'
import { SETTINGS_CHANGED_WHITELIST, type SettingsChangedKey } from '../../shared/telemetry-events'
import type { AgentAwakeService } from '../agent-awake-service'
import { readSystemIdleSeconds } from '../system-idle-seconds'
import { sanitizeFloatingWorkspaceDirectorySetting } from './floating-workspace-directory'
import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry'
Expand Down Expand Up @@ -78,6 +79,7 @@ export function registerSettingsHandlers(
'agentAwake:getStatus',
() => agentAwakeService?.getStatus() ?? { mode: 'off', active: false }
)
ipcMain.handle('agentAwake:getSystemIdleSeconds', () => readSystemIdleSeconds())
agentAwakeService?.subscribe?.((status) => {
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) {
Expand Down
21 changes: 21 additions & 0 deletions src/main/system-idle-seconds.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { readSystemIdleSeconds } from './system-idle-seconds'

describe('readSystemIdleSeconds', () => {
it('passes through a reported idle time', () => {
expect(readSystemIdleSeconds({ getSystemIdleTime: () => 42 })).toBe(42)
expect(readSystemIdleSeconds({ getSystemIdleTime: () => 0 })).toBe(0)
})

it('reports unknown rather than idle when the platform cannot measure it', () => {
expect(readSystemIdleSeconds({ getSystemIdleTime: () => Number.NaN })).toBeNull()
expect(readSystemIdleSeconds({ getSystemIdleTime: () => -1 })).toBeNull()
expect(
readSystemIdleSeconds({
getSystemIdleTime: () => {
throw new Error('unsupported session type')
}
})
).toBeNull()
})
})
21 changes: 21 additions & 0 deletions src/main/system-idle-seconds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { powerMonitor } from 'electron'

type IdleMonitor = {
getSystemIdleTime: () => number
}

/**
* Seconds since the last OS-level input, or null when the platform cannot report it.
*
* Why the OS clock and not renderer events: browser panes are `<webview>`s in their own
* process and terminals/editors stop propagation, so renderer listeners miss real activity.
* Null (Wayland, or a throwing monitor) must read as "unknown", never as "idle".
*/
export function readSystemIdleSeconds(monitor: IdleMonitor = powerMonitor): number | null {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

readSystemIdleSeconds repeats the Number.isFinite(idle) && idle >= 0 guard already in readDesktopAwayState (src/main/notifications/desktop-away-state.ts:15). Defer-able, but extracting a shared raw-seconds reader would keep the "unknown vs idle" rule in one place if more callers appear.

try {
const idle = monitor.getSystemIdleTime()
return Number.isFinite(idle) && idle >= 0 ? idle : null
} catch {
return null
}
}
2 changes: 2 additions & 0 deletions src/preload/api/agent-awake-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type { PreloadApi } from '../api-types'

export const agentAwakeApi = {
getStatus: (): Promise<ComputerAwakeStatus> => ipcRenderer.invoke('agentAwake:getStatus'),
getSystemIdleSeconds: (): Promise<number | null> =>
ipcRenderer.invoke('agentAwake:getSystemIdleSeconds'),
onChanged: (callback: (status: ComputerAwakeStatus) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, status: ComputerAwakeStatus): void =>
callback(status)
Expand Down
2 changes: 2 additions & 0 deletions src/preload/api/agent-status-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,6 @@ export type AgentTrustApi = {
export type AgentAwakeApi = {
getStatus: () => Promise<ComputerAwakeStatus>
onChanged: (callback: (status: ComputerAwakeStatus) => void) => () => void
/** Seconds since the last OS-level input; null when the platform cannot report it. */
getSystemIdleSeconds: () => Promise<number | null>
}
13 changes: 13 additions & 0 deletions src/renderer/src/app-shell/AppRootSurfaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type { UpdateStatus } from '../../../shared/update-status-types'
import { useLazyModalMounts } from './use-lazy-modal-mounts'
import {
selectAppRootSurfacePetEnabled,
selectAppRootSurfaceSleepyModeAutoStart,
selectAppRootSurfaceTelemetryOptedIn,
selectAppRootSurfaceVoiceEnabled
} from './app-root-surface-settings'
Expand Down Expand Up @@ -85,6 +86,7 @@ const FloatingTerminalPanel = lazy(() =>
)
// Why: lazy so the WebP asset + overlay module aren't fetched unless the experimental flag is on.
const PetOverlay = lazy(() => import('../components/pet/PetOverlay'))
const SleepyModeOverlay = lazy(() => import('../components/sleepy-mode/SleepyModeOverlay'))
// Why: lazy so onboarding's step modules + assets aren't fetched for users past first-launch.
const OnboardingFlow = lazy(() => import('../components/onboarding/OnboardingFlow'))

Expand Down Expand Up @@ -142,6 +144,8 @@ export function AppRootSurfaces(props: {
const statusBarVisible = useAppStore((s) => s.statusBarVisible)
const persistedUIReady = useAppStore((s) => s.persistedUIReady)
const petVisible = useAppStore((s) => s.petVisible)
const sleepyModeActive = useAppStore((s) => s.sleepyModeActive)
const sleepyModeAutoStart = useAppStore(selectAppRootSurfaceSleepyModeAutoStart)
const dictationState = useAppStore((s) => s.dictationState)
const updateStatus = useAppStore((s) => s.updateStatus)
const activeContextualTourId = useAppStore((s) => s.activeContextualTourId)
Expand All @@ -151,6 +155,7 @@ export function AppRootSurfaces(props: {
const shouldMountUpdateCard = shouldMountUpdateCardForStatus(updateStatus)
const shouldMountDictationController = voiceEnabled || dictationState !== 'idle'
const renderPetOverlay = shouldRenderPetOverlay({ persistedUIReady, petEnabled, petVisible })
const shouldMountSleepyMode = sleepyModeAutoStart || sleepyModeActive

return (
<>
Expand Down Expand Up @@ -272,6 +277,14 @@ export function AppRootSurfaces(props: {
<ContextualTourOverlay />
</Suspense>
) : null}
{/* Why: the scene owns its own idle timer, so it stays mounted (rendering null) whenever auto-start is configured. */}
{shouldMountSleepyMode ? (
<Suspense fallback={null}>
<OverlayBoundary boundaryId="overlay.sleepy-mode" resetKey={sleepyModeActive}>
<SleepyModeOverlay />
</OverlayBoundary>
</Suspense>
) : null}
{/* Why: mount only after UI hydration, else a hidden pet flashes while the store still holds default visibility. */}
{renderPetOverlay ? (
<Suspense fallback={null}>
Expand Down
7 changes: 7 additions & 0 deletions src/renderer/src/app-shell/app-root-surface-settings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AppState } from '../store/types'
import { normalizeSleepyModeIdleMinutes } from '../../../shared/sleepy-mode-settings'

type AppRootSurfaceSettingsState = Pick<AppState, 'settings'>

Expand All @@ -10,6 +11,12 @@ export function selectAppRootSurfacePetEnabled(state: AppRootSurfaceSettingsStat
return state.settings?.experimentalPet === true
}

export function selectAppRootSurfaceSleepyModeAutoStart(
state: AppRootSurfaceSettingsState
): boolean {
return normalizeSleepyModeIdleMinutes(state.settings?.sleepyModeIdleMinutes) > 0
}

export function selectAppRootSurfaceTelemetryOptedIn(
state: AppRootSurfaceSettingsState
): boolean | 'unknown' {
Expand Down
19 changes: 19 additions & 0 deletions src/renderer/src/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -875,6 +875,25 @@ html.native-shell .app-layout {
height: calc(var(--browser-page-viewport-height) / var(--ui-zoom-factor, 1));
}

/* Why: the Sleepy Mode scene needs depth without color — a pale pool behind the
clock fading outward. Direction flips per theme: light mode can't go brighter
than #fff, so it vignettes the edges instead of lighting the middle. */
.sleepy-mode-scene {
background: radial-gradient(
125% 85% at 50% 42%,
var(--background) 0%,
color-mix(in srgb, var(--foreground) 6%, var(--background)) 100%
);
}

.dark .sleepy-mode-scene {
background: radial-gradient(
125% 85% at 50% 42%,
color-mix(in srgb, var(--foreground) 8%, var(--background)) 0%,
var(--background) 70%
);
}

/* Why: small identity anchor on desktop custom titlebars where native window
chrome is hidden. Sized to sit comfortably in the 36px titlebar with a
little horizontal breathing room. The SVG fill is white; light mode inverts
Expand Down
Loading