diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index f3c1b8aeaf8..014188ec6a1 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -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' @@ -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()) { diff --git a/src/main/system-idle-seconds.test.ts b/src/main/system-idle-seconds.test.ts new file mode 100644 index 00000000000..231dd21982d --- /dev/null +++ b/src/main/system-idle-seconds.test.ts @@ -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() + }) +}) diff --git a/src/main/system-idle-seconds.ts b/src/main/system-idle-seconds.ts new file mode 100644 index 00000000000..027dbac1f0b --- /dev/null +++ b/src/main/system-idle-seconds.ts @@ -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 ``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 { + try { + const idle = monitor.getSystemIdleTime() + return Number.isFinite(idle) && idle >= 0 ? idle : null + } catch { + return null + } +} diff --git a/src/preload/api/agent-awake-bridge.ts b/src/preload/api/agent-awake-bridge.ts index eff4263398e..fd5faac457d 100644 --- a/src/preload/api/agent-awake-bridge.ts +++ b/src/preload/api/agent-awake-bridge.ts @@ -4,6 +4,8 @@ import type { PreloadApi } from '../api-types' export const agentAwakeApi = { getStatus: (): Promise => ipcRenderer.invoke('agentAwake:getStatus'), + getSystemIdleSeconds: (): Promise => + ipcRenderer.invoke('agentAwake:getSystemIdleSeconds'), onChanged: (callback: (status: ComputerAwakeStatus) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, status: ComputerAwakeStatus): void => callback(status) diff --git a/src/preload/api/agent-status-api.ts b/src/preload/api/agent-status-api.ts index 7c538a990f1..66175afa217 100644 --- a/src/preload/api/agent-status-api.ts +++ b/src/preload/api/agent-status-api.ts @@ -59,4 +59,6 @@ export type AgentTrustApi = { export type AgentAwakeApi = { getStatus: () => Promise onChanged: (callback: (status: ComputerAwakeStatus) => void) => () => void + /** Seconds since the last OS-level input; null when the platform cannot report it. */ + getSystemIdleSeconds: () => Promise } diff --git a/src/renderer/src/app-shell/AppRootSurfaces.tsx b/src/renderer/src/app-shell/AppRootSurfaces.tsx index 59e6c67ee5c..6fa8d82d11a 100644 --- a/src/renderer/src/app-shell/AppRootSurfaces.tsx +++ b/src/renderer/src/app-shell/AppRootSurfaces.tsx @@ -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' @@ -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')) @@ -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) @@ -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 ( <> @@ -272,6 +277,14 @@ export function AppRootSurfaces(props: { ) : null} + {/* Why: the scene owns its own idle timer, so it stays mounted (rendering null) whenever auto-start is configured. */} + {shouldMountSleepyMode ? ( + + + + + + ) : null} {/* Why: mount only after UI hydration, else a hidden pet flashes while the store still holds default visibility. */} {renderPetOverlay ? ( diff --git a/src/renderer/src/app-shell/app-root-surface-settings.ts b/src/renderer/src/app-shell/app-root-surface-settings.ts index 6da68f2e0d5..fd214b5dfa4 100644 --- a/src/renderer/src/app-shell/app-root-surface-settings.ts +++ b/src/renderer/src/app-shell/app-root-surface-settings.ts @@ -1,4 +1,5 @@ import type { AppState } from '../store/types' +import { normalizeSleepyModeIdleMinutes } from '../../../shared/sleepy-mode-settings' type AppRootSurfaceSettingsState = Pick @@ -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' { diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index e0d1791606b..eca11f1194d 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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 diff --git a/src/renderer/src/components/pet/PetOverlay.tsx b/src/renderer/src/components/pet/PetOverlay.tsx index 3ab9f0d2d76..f87bf00aaca 100644 --- a/src/renderer/src/components/pet/PetOverlay.tsx +++ b/src/renderer/src/components/pet/PetOverlay.tsx @@ -1,233 +1,13 @@ -import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion' -import { usePetUrl } from './usePetUrl' -import type { DetectedSpriteCacheEntry } from './pet-blob-cache' -import type { CustomPet } from '../../../../shared/pet-types' import { useAppStore } from '../../store' -import { getAgentStatusEpochNow } from '@/lib/agent-status-epoch-clock' -import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' -import { - selectPetAnimationName, - type PetAnimationName, - type PetDragAnimation -} from './pet-agent-state' import { usePetPointerInteraction } from './usePetPointerInteraction' -import { buildSpriteAnimationCss } from './sprite-animation-css' - -type Sprite = NonNullable - -function usePetAnimationName( - dragging: boolean, - dragAnimation: PetDragAnimation, - hovering: boolean -): PetAnimationName { - const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) - const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) - const retainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey) - // Re-render when the freshness scheduler ticks so stale live states stop - // driving pet animations even if no other store value changes, and read the - // boundary clock in render so the stale frame never paints. - const agentStatusNow = getAgentStatusEpochNow(agentStatusEpoch) - - return selectPetAnimationName({ - entries: Object.values(agentStatusByPaneKey), - retainedCount: Object.keys(retainedAgentsByPaneKey).length, - dragging, - dragAnimation, - hovering, - now: agentStatusNow, - staleAfterMs: AGENT_STATUS_STALE_AFTER_MS - }) -} - -// Why: pet bundles ship a sprite sheet — animate by stepping a CSS background -// across the cells of one row. We pick the row from the live pet state -// when the manifest provides that animation, then fall back to the bundle's -// default animation. imageRendering: 'pixelated' keeps edges crisp even when -// scale is fractional (needed when frames exceed maxSize). -function SpriteFrame({ - url, - sprite, - animate, - maxSize, - animationName, - restartKey -}: { - url: string - sprite: Sprite - animate: boolean - maxSize: number - animationName: PetAnimationName - // Why: folded into the keyframes name, so bumping it mints a fresh animation - // that restarts from frame 0 even when the state row is unchanged. - restartKey: number -}): React.JSX.Element { - const baseId = useId().replace(/[^a-zA-Z0-9_-]/g, '') - const anim = - sprite.animations?.[animationName] || - (sprite.defaultAnimation && sprite.animations?.[sprite.defaultAnimation]) || - (sprite.animations ? Object.values(sprite.animations)[0] : undefined) - const row = anim?.row ?? 0 - // Why: clamp to >=1 so an empty/invalid manifest can't produce steps(0), - // which is rejected as invalid CSS and freezes the animation. - const frames = Math.max(1, anim?.frames ?? sprite.columns ?? 1) - // Why: name the @keyframes by the RESOLVED track (+restartKey for same-row - // grabs), so a genuine row change starts at frame 0 while a state that falls - // back to the same row (e.g. hover on a pet without a jumping row) doesn't - // needlessly restart. - const animKeyframesId = `${baseId}-${row}-${frames}-${restartKey}` - // Why: allow fractional downscaling so frames larger than maxSize shrink to - // fit instead of overflowing the overlay; mirrors DetectedSpriteFrame's math. - const scale = Math.min(maxSize / sprite.frameWidth, maxSize / sprite.frameHeight) - const renderedW = sprite.frameWidth * scale - const renderedH = sprite.frameHeight * scale - const bgW = sprite.sheetWidth * scale - const bgH = sprite.sheetHeight * scale - const startX = 0 - const startY = -(row * sprite.frameHeight * scale) - const { keyframesCss, animationCss } = buildSpriteAnimationCss({ - keyframesId: animKeyframesId, - frames, - fps: sprite.fps, - frameWidth: sprite.frameWidth, - scale, - rowOffsetY: startY, - frameDurationsMs: anim?.frameDurationsMs - }) - return ( - <> - -
- - ) -} - -// Why: when the manifest doesn't declare frame size, we auto-detect frames -// from the keyed sheet. Render via canvas because the frames may be different -// sizes; we scale each one to fit the overlay box and step through them at a -// fixed fps. requestAnimationFrame is paused when `animate` is false so the -// overlay respects reduced motion / hidden window. -function DetectedSpriteFrame({ - detected, - animate, - maxSize -}: { - detected: DetectedSpriteCacheEntry - animate: boolean - maxSize: number -}): React.JSX.Element { - const canvasRef = useRef(null) - const frameIndexRef = useRef(0) - const lastTimeRef = useRef(0) - // Why: honor manifest fps captured at import time so bundles play at their - // intended speed; default to 8 only when the manifest didn't declare one. - const fps = detected.fps > 0 ? detected.fps : 8 - - // Why: size the canvas to one fixed footprint bounding the largest scaled - // frame so the drag wrapper hugs the pet instead of a maxSize square. A - // single size across frames avoids the jitter a per-frame resize would cause. - const { footprintW, footprintH } = useMemo(() => { - let w = 0 - let h = 0 - for (const f of detected.frames) { - const s = Math.min(maxSize / f.w, maxSize / f.h) - w = Math.max(w, f.w * s) - h = Math.max(h, f.h * s) - } - return { footprintW: Math.max(1, Math.round(w)), footprintH: Math.max(1, Math.round(h)) } - }, [detected, maxSize]) - - useEffect(() => { - const canvas = canvasRef.current - if (!canvas) { - return - } - const ctx = canvas.getContext('2d') - if (!ctx) { - return - } - canvas.width = footprintW - canvas.height = footprintH - // Why: reset playback when the underlying sprite changes so the new - // animation starts from frame 0 rather than wherever the prior one stopped. - frameIndexRef.current = 0 - lastTimeRef.current = 0 - if (detected.frames.length === 0) { - ctx.clearRect(0, 0, canvas.width, canvas.height) - return - } - let raf = 0 - const draw = (): void => { - const f = detected.frames[frameIndexRef.current % detected.frames.length] - const bmp = detected.bitmaps[frameIndexRef.current % detected.bitmaps.length] - if (!f || !bmp) { - return - } - ctx.imageSmoothingEnabled = false - ctx.clearRect(0, 0, canvas.width, canvas.height) - const scale = Math.min(maxSize / f.w, maxSize / f.h) - const w = f.w * scale - const h = f.h * scale - // Why: center each frame within the fixed footprint so frames of differing - // sizes stay aligned without resizing the canvas per frame. - ctx.drawImage(bmp, (footprintW - w) / 2, (footprintH - h) / 2, w, h) - } - const tick = (now: number): void => { - const dt = now - lastTimeRef.current - if (dt >= 1000 / fps) { - lastTimeRef.current = now - frameIndexRef.current = (frameIndexRef.current + 1) % detected.frames.length - draw() - } - if (animate) { - raf = requestAnimationFrame(tick) - } - } - draw() - if (animate) { - lastTimeRef.current = performance.now() - raf = requestAnimationFrame(tick) - } - return () => { - if (raf) { - cancelAnimationFrame(raf) - } - } - }, [detected, animate, footprintW, footprintH, maxSize, fps]) - - return ( - - ) -} - -function useDocumentVisible(): boolean { - const [visible, setVisible] = useState(() => - typeof document === 'undefined' ? true : document.visibilityState === 'visible' - ) - useEffect(() => { - const onChange = (): void => { - setVisible(document.visibilityState === 'visible') - } - document.addEventListener('visibilitychange', onChange) - return () => document.removeEventListener('visibilitychange', onChange) - }, []) - return visible -} +import { + PET_BOB_KEYFRAMES_CSS, + PetSprite, + useDocumentVisible, + usePetAnimationName +} from './PetSprite' // Why: keep a default for the cached helpers below; the live size now comes // from the store so the user can resize from the status-bar menu. @@ -307,14 +87,9 @@ function defaultPosition(size: number = SIZE): Position { ) } -// Why: the bob float is runtime CSS, not user-visible copy; keep CSS keywords -// out of i18n so translated locales cannot invalidate the keyframes. -const PET_BOB_KEYFRAMES_CSS = - '@keyframes pet-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }' export function PetOverlay(): React.JSX.Element { const documentVisible = useDocumentVisible() const reducedMotion = usePrefersReducedMotion() - const { url, sprite, detected } = usePetUrl() const size = useAppStore((s) => s.petSize) const [positionState, setPositionState] = useState<{ @@ -409,33 +184,12 @@ export function PetOverlay(): React.JSX.Element { }} > - {sprite ? ( - // Why: remount per pet so a switched-to sprite starts a fresh - // animation instead of inheriting the prior pet's currentTime. - - ) : detected ? ( - - ) : ( - // Why: cap explicitly at the pet size — the w-fit/h-fit wrapper is - // fit-content, so max-w/h-full has no fixed box to resolve against - // and the image would otherwise render at its intrinsic size and - // overflow the persisted size box that clamping still assumes. - - )} +
diff --git a/src/renderer/src/components/pet/PetSprite.tsx b/src/renderer/src/components/pet/PetSprite.tsx new file mode 100644 index 00000000000..1c814193f59 --- /dev/null +++ b/src/renderer/src/components/pet/PetSprite.tsx @@ -0,0 +1,280 @@ +import { useEffect, useId, useMemo, useRef, useState } from 'react' +import type { DetectedSpriteCacheEntry } from './pet-blob-cache' +import type { CustomPet } from '../../../../shared/pet-types' +import { useAppStore } from '../../store' +import { getAgentStatusEpochNow } from '@/lib/agent-status-epoch-clock' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' +import { + selectPetAnimationName, + type PetAnimationName, + type PetDragAnimation +} from './pet-agent-state' +import { buildSpriteAnimationCss } from './sprite-animation-css' +import { usePetUrl } from './usePetUrl' + +type Sprite = NonNullable + +export function usePetAnimationName( + dragging: boolean, + dragAnimation: PetDragAnimation, + hovering: boolean +): PetAnimationName { + const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) + const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) + const retainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey) + // Re-render when the freshness scheduler ticks so stale live states stop + // driving pet animations even if no other store value changes, and read the + // boundary clock in render so the stale frame never paints. + const agentStatusNow = getAgentStatusEpochNow(agentStatusEpoch) + + return selectPetAnimationName({ + entries: Object.values(agentStatusByPaneKey), + retainedCount: Object.keys(retainedAgentsByPaneKey).length, + dragging, + dragAnimation, + hovering, + now: agentStatusNow, + staleAfterMs: AGENT_STATUS_STALE_AFTER_MS + }) +} + +// Why: pet bundles ship a sprite sheet — animate by stepping a CSS background +// across the cells of one row. We pick the row from the live pet state +// when the manifest provides that animation, then fall back to the bundle's +// default animation. imageRendering: 'pixelated' keeps edges crisp even when +// scale is fractional (needed when frames exceed maxSize). +function SpriteFrame({ + url, + sprite, + animate, + maxSize, + animationName, + restartKey +}: { + url: string + sprite: Sprite + animate: boolean + maxSize: number + animationName: PetAnimationName + // Why: folded into the keyframes name, so bumping it mints a fresh animation + // that restarts from frame 0 even when the state row is unchanged. + restartKey: number +}): React.JSX.Element { + const baseId = useId().replace(/[^a-zA-Z0-9_-]/g, '') + const anim = + sprite.animations?.[animationName] || + (sprite.defaultAnimation && sprite.animations?.[sprite.defaultAnimation]) || + (sprite.animations ? Object.values(sprite.animations)[0] : undefined) + const row = anim?.row ?? 0 + // Why: clamp to >=1 so an empty/invalid manifest can't produce steps(0), + // which is rejected as invalid CSS and freezes the animation. + const frames = Math.max(1, anim?.frames ?? sprite.columns ?? 1) + // Why: name the @keyframes by the RESOLVED track (+restartKey for same-row + // grabs), so a genuine row change starts at frame 0 while a state that falls + // back to the same row (e.g. hover on a pet without a jumping row) doesn't + // needlessly restart. + const animKeyframesId = `${baseId}-${row}-${frames}-${restartKey}` + // Why: allow fractional downscaling so frames larger than maxSize shrink to + // fit instead of overflowing the overlay; mirrors DetectedSpriteFrame's math. + const scale = Math.min(maxSize / sprite.frameWidth, maxSize / sprite.frameHeight) + const renderedW = sprite.frameWidth * scale + const renderedH = sprite.frameHeight * scale + const bgW = sprite.sheetWidth * scale + const bgH = sprite.sheetHeight * scale + const startX = 0 + const startY = -(row * sprite.frameHeight * scale) + const { keyframesCss, animationCss } = buildSpriteAnimationCss({ + keyframesId: animKeyframesId, + frames, + fps: sprite.fps, + frameWidth: sprite.frameWidth, + scale, + rowOffsetY: startY, + frameDurationsMs: anim?.frameDurationsMs + }) + return ( + <> + +
+ + ) +} + +// Why: when the manifest doesn't declare frame size, we auto-detect frames +// from the keyed sheet. Render via canvas because the frames may be different +// sizes; we scale each one to fit the overlay box and step through them at a +// fixed fps. requestAnimationFrame is paused when `animate` is false so the +// overlay respects reduced motion / hidden window. +function DetectedSpriteFrame({ + detected, + animate, + maxSize +}: { + detected: DetectedSpriteCacheEntry + animate: boolean + maxSize: number +}): React.JSX.Element { + const canvasRef = useRef(null) + const frameIndexRef = useRef(0) + const lastTimeRef = useRef(0) + // Why: honor manifest fps captured at import time so bundles play at their + // intended speed; default to 8 only when the manifest didn't declare one. + const fps = detected.fps > 0 ? detected.fps : 8 + + // Why: size the canvas to one fixed footprint bounding the largest scaled + // frame so the drag wrapper hugs the pet instead of a maxSize square. A + // single size across frames avoids the jitter a per-frame resize would cause. + const { footprintW, footprintH } = useMemo(() => { + let w = 0 + let h = 0 + for (const f of detected.frames) { + const s = Math.min(maxSize / f.w, maxSize / f.h) + w = Math.max(w, f.w * s) + h = Math.max(h, f.h * s) + } + return { footprintW: Math.max(1, Math.round(w)), footprintH: Math.max(1, Math.round(h)) } + }, [detected, maxSize]) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) { + return + } + const ctx = canvas.getContext('2d') + if (!ctx) { + return + } + canvas.width = footprintW + canvas.height = footprintH + // Why: reset playback when the underlying sprite changes so the new + // animation starts from frame 0 rather than wherever the prior one stopped. + frameIndexRef.current = 0 + lastTimeRef.current = 0 + if (detected.frames.length === 0) { + ctx.clearRect(0, 0, canvas.width, canvas.height) + return + } + let raf = 0 + const draw = (): void => { + const f = detected.frames[frameIndexRef.current % detected.frames.length] + const bmp = detected.bitmaps[frameIndexRef.current % detected.bitmaps.length] + if (!f || !bmp) { + return + } + ctx.imageSmoothingEnabled = false + ctx.clearRect(0, 0, canvas.width, canvas.height) + const scale = Math.min(maxSize / f.w, maxSize / f.h) + const w = f.w * scale + const h = f.h * scale + // Why: center each frame within the fixed footprint so frames of differing + // sizes stay aligned without resizing the canvas per frame. + ctx.drawImage(bmp, (footprintW - w) / 2, (footprintH - h) / 2, w, h) + } + const tick = (now: number): void => { + const dt = now - lastTimeRef.current + if (dt >= 1000 / fps) { + lastTimeRef.current = now + frameIndexRef.current = (frameIndexRef.current + 1) % detected.frames.length + draw() + } + if (animate) { + raf = requestAnimationFrame(tick) + } + } + draw() + if (animate) { + lastTimeRef.current = performance.now() + raf = requestAnimationFrame(tick) + } + return () => { + if (raf) { + cancelAnimationFrame(raf) + } + } + }, [detected, animate, footprintW, footprintH, maxSize, fps]) + + return ( + + ) +} + +export function useDocumentVisible(): boolean { + const [visible, setVisible] = useState(() => + typeof document === 'undefined' ? true : document.visibilityState === 'visible' + ) + useEffect(() => { + const onChange = (): void => { + setVisible(document.visibilityState === 'visible') + } + document.addEventListener('visibilitychange', onChange) + return () => document.removeEventListener('visibilitychange', onChange) + }, []) + return visible +} + +// Why: the bob float is runtime CSS, not user-visible copy; keep CSS keywords +// out of i18n so translated locales cannot invalidate the keyframes. +export const PET_BOB_KEYFRAMES_CSS = + '@keyframes pet-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }' + +/** The pet artwork alone — no positioning, dragging, or bob. Callers own the box it sits in. */ +export function PetSprite({ + size, + animate, + animationName, + restartKey = 0 +}: { + size: number + animate: boolean + animationName: PetAnimationName + restartKey?: number +}): React.JSX.Element { + const { url, sprite, detected } = usePetUrl() + + if (sprite) { + // Why: remount per pet so a switched-to sprite starts a fresh + // animation instead of inheriting the prior pet's currentTime. + return ( + + ) + } + if (detected) { + return + } + // Why: cap explicitly at the pet size — the w-fit/h-fit wrapper is + // fit-content, so max-w/h-full has no fixed box to resolve against + // and the image would otherwise render at its intrinsic size and + // overflow the persisted size box that clamping still assumes. + return ( + + ) +} diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index 59e139973ad..52b3f399196 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -6,6 +6,7 @@ import { getAgentCatalog } from '@/lib/agent-catalog' import { useDetectedAgents, type AgentDetectionTarget } from '@/hooks/useDetectedAgents' import { useAppStore } from '@/store' import { AgentAwakeSetting } from './AgentAwakeSetting' +import { SleepyModeSetting } from './SleepyModeSetting' import { AgentCacheTimerSection } from './AgentCacheTimerSection' import { AgentRuntimeSetting } from './AgentRuntimeSetting' import { buildCodexSessionSourceHomeControl } from './codex-session-source-home-control' @@ -257,7 +258,10 @@ export function AgentsPane({ {!isPairedWebClientWindow() ? ( - + <> + + + ) : null} ) => void +} + +export function SleepyModeSetting({ + settings, + updateSettings +}: SleepyModeSettingProps): React.JSX.Element { + const title = getSleepyModeTitle() + const description = getSleepyModeDescription() + const setSleepyModeActive = useAppStore((state) => state.setSleepyModeActive) + const idleMinutes = normalizeSleepyModeIdleMinutes(settings.sleepyModeIdleMinutes) + + return ( +
+ +
+
+ +

{description}

+
+
+ + value={idleMinutes} + onChange={(minutes) => updateSettings({ sleepyModeIdleMinutes: minutes })} + ariaLabel={title} + size="sm" + options={SLEEPY_MODE_IDLE_MINUTES.map((minutes) => ({ + value: minutes, + label: getSleepyModeIdleLabel(minutes) + }))} + /> + +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/sleepy-mode-copy.ts b/src/renderer/src/components/settings/sleepy-mode-copy.ts new file mode 100644 index 00000000000..cb24e777d26 --- /dev/null +++ b/src/renderer/src/components/settings/sleepy-mode-copy.ts @@ -0,0 +1,38 @@ +import { translate } from '@/i18n/i18n' +import type { SleepyModeIdleMinutes } from '../../../../shared/sleepy-mode-settings' +import { searchKeywords } from './settings-search-keywords' + +export function getSleepyModeTitle(): string { + return translate('auto.components.settings.sleepy-mode-copy.title', 'Sleepy Mode') +} + +export function getSleepyModeDescription(): string { + return translate( + 'auto.components.settings.sleepy-mode-copy.description', + 'After this long with no input, cover the window with a clock and what the fleet is doing. Any key wakes it. This is a screen cover, not a lock — display sleep still follows Keep computer awake.' + ) +} + +export function getSleepyModeStartLabel(): string { + return translate('auto.components.settings.sleepy-mode-copy.start', 'Start Sleepy Mode') +} + +export function getSleepyModeIdleLabel(minutes: SleepyModeIdleMinutes): string { + if (minutes === 0) { + return translate('auto.components.settings.sleepy-mode-copy.never', 'Never') + } + return translate('auto.components.settings.sleepy-mode-copy.minutes', '{{count}} min', { + count: minutes + }) +} + +export function getSleepyModeSearchKeywords(): string[] { + return searchKeywords([ + { key: 'auto.components.settings.sleepy-mode.search.sleepy', fallback: 'sleepy' }, + { key: 'auto.components.settings.sleepy-mode.search.screensaver', fallback: 'screensaver' }, + { key: 'auto.components.settings.sleepy-mode.search.idle', fallback: 'idle' }, + { key: 'auto.components.settings.sleepy-mode.search.clock', fallback: 'clock' }, + { key: 'auto.components.settings.sleepy-mode.search.overnight', fallback: 'overnight' }, + { key: 'auto.components.settings.sleepy-mode.search.privacy', fallback: 'privacy' } + ]) +} diff --git a/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.test.tsx b/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.test.tsx new file mode 100644 index 00000000000..cba68379a63 --- /dev/null +++ b/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.test.tsx @@ -0,0 +1,173 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import SleepyModeOverlay from './SleepyModeOverlay' + +type SleepyStoreState = { + sleepyModeActive: boolean + setSleepyModeActive: (active: boolean) => void + settings: { sleepyModeIdleMinutes: number } + agentStatusByPaneKey: Record + agentStatusEpoch: number + retainedAgentsByPaneKey: Record + petVisible: boolean + petId: string + customPets: never[] +} + +const storeMocks = vi.hoisted(() => ({ + state: { + sleepyModeActive: false, + setSleepyModeActive: vi.fn(), + settings: { sleepyModeIdleMinutes: 0 }, + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + retainedAgentsByPaneKey: {}, + petVisible: true, + petId: 'claude-the-mage', + customPets: [] + } +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: SleepyStoreState) => unknown) => selector(storeMocks.state) +})) + +vi.mock('@/hooks/usePrefersReducedMotion', () => ({ + usePrefersReducedMotion: () => true +})) + +vi.mock('@/lib/agent-status-epoch-clock', () => ({ + getAgentStatusEpochNow: () => 1_000 +})) + +/** Seconds the fake OS clock reports; the hook polls this instead of listening for DOM events. */ +let systemIdleSeconds: number | null = 0 + +function entry(paneKey: string, state: AgentStatusEntry['state']): AgentStatusEntry { + return { state, prompt: '', updatedAt: 1_000, stateStartedAt: 1_000, paneKey, stateHistory: [] } +} + +function setState(next: Partial): void { + Object.assign(storeMocks.state, next) +} + +describe('SleepyModeOverlay', () => { + beforeEach(() => { + vi.useFakeTimers() + systemIdleSeconds = 0 + setState({ + sleepyModeActive: false, + setSleepyModeActive: vi.fn(), + settings: { sleepyModeIdleMinutes: 0 }, + agentStatusByPaneKey: {} + }) + Object.defineProperty(window, 'api', { + configurable: true, + writable: true, + value: { + agentAwake: { + getSystemIdleSeconds: vi.fn(async () => systemIdleSeconds) + } + } + }) + }) + + afterEach(() => { + cleanup() + vi.useRealTimers() + }) + + it('renders nothing until it is active', () => { + render() + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('starts once the OS reports the configured idle delay, and not before', async () => { + setState({ settings: { sleepyModeIdleMinutes: 5 } }) + render() + + systemIdleSeconds = 299 + await vi.advanceTimersByTimeAsync(30_000) + expect(storeMocks.state.setSleepyModeActive).not.toHaveBeenCalled() + + systemIdleSeconds = 300 + await vi.advanceTimersByTimeAsync(15_000) + expect(storeMocks.state.setSleepyModeActive).toHaveBeenCalledWith(true) + }) + + it('does not start when auto-start is off', async () => { + systemIdleSeconds = 10_000 + render() + await vi.advanceTimersByTimeAsync(60 * 60_000) + expect(storeMocks.state.setSleepyModeActive).not.toHaveBeenCalled() + }) + + it('never starts when the platform cannot report idle time', async () => { + setState({ settings: { sleepyModeIdleMinutes: 5 } }) + systemIdleSeconds = null + render() + + await vi.advanceTimersByTimeAsync(60 * 60_000) + expect(storeMocks.state.setSleepyModeActive).not.toHaveBeenCalled() + }) + + it('keeps waiting while work in a terminal or browser pane keeps the OS clock low', async () => { + setState({ settings: { sleepyModeIdleMinutes: 5 } }) + render() + + for (let minute = 0; minute < 20; minute += 1) { + systemIdleSeconds = 4 + await vi.advanceTimersByTimeAsync(60_000) + } + expect(storeMocks.state.setSleepyModeActive).not.toHaveBeenCalled() + }) + + it('shows the live fleet and wakes on a keypress', () => { + setState({ + sleepyModeActive: true, + agentStatusByPaneKey: { a: entry('a', 'working'), b: entry('b', 'waiting') } + }) + render() + + expect(screen.getByRole('dialog')).toBeTruthy() + expect(screen.getByText('1 working · 1 waiting')).toBeTruthy() + + fireEvent.keyDown(window, { key: 'a' }) + expect(storeMocks.state.setSleepyModeActive).toHaveBeenCalledWith(false) + }) + + // Why dispatch at a real element: an event dispatched on `window` never travels through the + // workspace, so a listener below it could not fire either way and the assertion would be empty. + const wakeEvents: { name: string; build: () => Event }[] = [ + { + name: 'keydown', + build: () => new KeyboardEvent('keydown', { key: 'Enter', cancelable: true, bubbles: true }) + }, + { + name: 'pointerdown', + build: () => new MouseEvent('pointerdown', { cancelable: true, bubbles: true }) + }, + { name: 'wheel', build: () => new WheelEvent('wheel', { cancelable: true, bubbles: true }) } + ] + + it.each(wakeEvents)('wakes on $name and never lets it reach the workspace', ({ name, build }) => { + setState({ sleepyModeActive: true }) + render() + + const workspace = document.createElement('input') + document.body.append(workspace) + const reachedWorkspace = vi.fn() + workspace.addEventListener(name, reachedWorkspace) + + const event = build() + workspace.dispatchEvent(event) + + expect(storeMocks.state.setSleepyModeActive).toHaveBeenCalledWith(false) + expect(event.defaultPrevented).toBe(true) + expect(reachedWorkspace).not.toHaveBeenCalled() + workspace.remove() + }) +}) diff --git a/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.tsx b/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.tsx new file mode 100644 index 00000000000..f10bf1f55fd --- /dev/null +++ b/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.tsx @@ -0,0 +1,196 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { translate } from '@/i18n/i18n' +import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion' +import { getAgentStatusEpochNow } from '@/lib/agent-status-epoch-clock' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' +import { + normalizeSleepyModeIdleMinutes, + sleepyModeIdleDelayMs +} from '../../../../shared/sleepy-mode-settings' +import { + PET_BOB_KEYFRAMES_CSS, + PetSprite, + useDocumentVisible, + usePetAnimationName +} from '../pet/PetSprite' +import { useAppStore } from '../../store' +import { summarizeSleepyModeFleet } from './sleepy-mode-fleet-summary' +import { useSleepyModeIdleTrigger } from './use-sleepy-mode-idle-trigger' + +const CLOCK_TICK_MS = 1_000 + +function useClock(running: boolean): Date { + const [now, setNow] = useState(() => new Date()) + useEffect(() => { + if (!running) { + return + } + setNow(new Date()) + const timer = window.setInterval(() => setNow(new Date()), CLOCK_TICK_MS) + return () => window.clearInterval(timer) + }, [running]) + return now +} + +function FleetLine(): React.JSX.Element { + const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) + const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) + // Why: read the boundary clock in render so a turn that went stale while the scene + // was up stops being reported as live work. + const summary = summarizeSleepyModeFleet({ + entries: Object.values(agentStatusByPaneKey), + now: getAgentStatusEpochNow(agentStatusEpoch), + staleAfterMs: AGENT_STATUS_STALE_AFTER_MS + }) + const reducedMotion = usePrefersReducedMotion() + + const parts: string[] = [] + if (summary.working > 0) { + parts.push( + translate('auto.components.sleepy-mode.SleepyModeOverlay.working', '{{count}} working', { + count: summary.working + }) + ) + } + if (summary.waiting > 0) { + parts.push( + translate('auto.components.sleepy-mode.SleepyModeOverlay.waiting', '{{count}} waiting', { + count: summary.waiting + }) + ) + } + if (summary.done > 0) { + parts.push( + translate('auto.components.sleepy-mode.SleepyModeOverlay.done', '{{count}} done', { + count: summary.done + }) + ) + } + + if (parts.length === 0) { + return ( +

+ {translate('auto.components.sleepy-mode.SleepyModeOverlay.quiet', 'No agents working')} +

+ ) + } + + return ( +

+ 0 && !reducedMotion + ? 'size-1.5 animate-pulse rounded-full bg-foreground' + : 'size-1.5 rounded-full bg-muted-foreground' + } + /> + {parts.join(' · ')} +

+ ) +} + +const SCENE_PET_SIZE = 260 + +/** The pet, centred and idle-bobbing, animating off the same live agent state the corner overlay reads. */ +function ScenePet(): React.JSX.Element | null { + const documentVisible = useDocumentVisible() + const reducedMotion = usePrefersReducedMotion() + // Why: not gated on experimentalPet — that flag owns the draggable corner overlay, not the + // artwork. An explicit "Hide pet" is a preference about the pet itself, so the scene honours it. + const petHidden = useAppStore((s) => s.petVisible === false) + const animationName = usePetAnimationName(false, null, false) + const animate = documentVisible && !reducedMotion + + if (petHidden) { + return null + } + + return ( +
+ +
+ +
+
+ ) +} + +/** + * A full-window resting screen for a fleet left running. It covers the workspace (and whatever + * the agents have on screen) with a clock and a live fleet summary, and gets out of the way on + * the first input. Display sleep is not touched here — "Keep computer awake" owns that. + */ +export default function SleepyModeOverlay(): React.JSX.Element | null { + const active = useAppStore((s) => s.sleepyModeActive) + const setActive = useAppStore((s) => s.setSleepyModeActive) + const idleMinutes = useAppStore((s) => s.settings?.sleepyModeIdleMinutes) + + const delayMs = sleepyModeIdleDelayMs(normalizeSleepyModeIdleMinutes(idleMinutes)) + const start = useCallback(() => setActive(true), [setActive]) + useSleepyModeIdleTrigger({ delayMs, suspended: active, onIdle: start }) + + const now = useClock(active) + const timeFormat = useMemo( + () => new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }), + [] + ) + const dateFormat = useMemo( + () => new Intl.DateTimeFormat(undefined, { weekday: 'long', month: 'long', day: 'numeric' }), + [] + ) + + useEffect(() => { + if (!active) { + return + } + // Why: consume the wake input. Without this the key that dismisses the scene still + // reaches the terminal or editor underneath and types, submits, or fires a shortcut. + const wake = (event: Event): void => { + event.preventDefault() + event.stopImmediatePropagation() + setActive(false) + } + // Capture phase: xterm and Monaco stop plenty of events before they reach window. + const options = { capture: true, passive: false } as const + window.addEventListener('keydown', wake, options) + window.addEventListener('pointerdown', wake, options) + window.addEventListener('wheel', wake, options) + return () => { + window.removeEventListener('keydown', wake, options) + window.removeEventListener('pointerdown', wake, options) + window.removeEventListener('wheel', wake, options) + } + }, [active, setActive]) + + if (!active) { + return null + } + + return ( +
+ +

+ {timeFormat.format(now)} +

+

{dateFormat.format(now)}

+ +

+ {translate( + 'auto.components.sleepy-mode.SleepyModeOverlay.dismissHint', + 'Press any key to wake' + )} +

+
+ ) +} diff --git a/src/renderer/src/components/sleepy-mode/sleepy-mode-fleet-summary.test.ts b/src/renderer/src/components/sleepy-mode/sleepy-mode-fleet-summary.test.ts new file mode 100644 index 00000000000..55763d666cf --- /dev/null +++ b/src/renderer/src/components/sleepy-mode/sleepy-mode-fleet-summary.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types' +import { summarizeSleepyModeFleet } from './sleepy-mode-fleet-summary' + +const NOW = 1_000 +const STALE_AFTER_MS = 500 + +function entry( + state: AgentStatusState, + overrides: Partial = {} +): AgentStatusEntry { + return { + state, + prompt: '', + updatedAt: NOW, + stateStartedAt: NOW, + paneKey: `tab:${state}`, + stateHistory: [], + ...overrides + } +} + +function summarize(entries: AgentStatusEntry[]) { + return summarizeSleepyModeFleet({ entries, now: NOW, staleAfterMs: STALE_AFTER_MS }) +} + +describe('summarizeSleepyModeFleet', () => { + it('counts nothing for an empty fleet', () => { + expect(summarize([])).toEqual({ working: 0, waiting: 0, done: 0 }) + }) + + it('counts blocked alongside waiting', () => { + expect(summarize([entry('waiting'), entry('blocked', { paneKey: 'tab:blocked' })])).toEqual({ + working: 0, + waiting: 2, + done: 0 + }) + }) + + it('ignores stale rows so a finished night does not read as busy', () => { + expect(summarize([entry('working', { updatedAt: NOW - STALE_AFTER_MS - 1 })])).toEqual({ + working: 0, + waiting: 0, + done: 0 + }) + }) + + it('does not count monitoring turns as work', () => { + expect(summarize([entry('working', { workingMode: 'monitoring' })])).toEqual({ + working: 0, + waiting: 0, + done: 0 + }) + }) + + it('counts a mixed fleet', () => { + expect( + summarize([ + entry('working', { paneKey: 'a' }), + entry('working', { paneKey: 'b' }), + entry('waiting', { paneKey: 'c' }), + entry('done', { paneKey: 'd' }) + ]) + ).toEqual({ working: 2, waiting: 1, done: 1 }) + }) +}) diff --git a/src/renderer/src/components/sleepy-mode/sleepy-mode-fleet-summary.ts b/src/renderer/src/components/sleepy-mode/sleepy-mode-fleet-summary.ts new file mode 100644 index 00000000000..f3ff3ced3cf --- /dev/null +++ b/src/renderer/src/components/sleepy-mode/sleepy-mode-fleet-summary.ts @@ -0,0 +1,36 @@ +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { isExplicitAgentStatusFresh } from '@/lib/agent-status' + +export type SleepyModeFleetSummary = { + working: number + waiting: number + done: number +} + +/** Counts the live fleet the same way the pet reads it: stale rows and monitoring turns don't count as work. */ +export function summarizeSleepyModeFleet({ + entries, + now, + staleAfterMs +}: { + entries: AgentStatusEntry[] + now: number + staleAfterMs: number +}): SleepyModeFleetSummary { + const summary: SleepyModeFleetSummary = { working: 0, waiting: 0, done: 0 } + + for (const entry of entries) { + if (!isExplicitAgentStatusFresh(entry, now, staleAfterMs)) { + continue + } + if (entry.state === 'blocked' || entry.state === 'waiting') { + summary.waiting += 1 + } else if (entry.state === 'working' && entry.workingMode !== 'monitoring') { + summary.working += 1 + } else if (entry.state === 'done') { + summary.done += 1 + } + } + + return summary +} diff --git a/src/renderer/src/components/sleepy-mode/use-sleepy-mode-idle-trigger.ts b/src/renderer/src/components/sleepy-mode/use-sleepy-mode-idle-trigger.ts new file mode 100644 index 00000000000..df9994e9da9 --- /dev/null +++ b/src/renderer/src/components/sleepy-mode/use-sleepy-mode-idle-trigger.ts @@ -0,0 +1,46 @@ +import { useEffect } from 'react' + +/** How often the OS input clock is sampled. A screensaver may start this late; that is fine. */ +const POLL_MS = 15_000 + +/** + * Fires `onIdle` once the OS reports `delayMs` without input. Disabled while `delayMs` is 0 + * (auto-start off) or `suspended` (the scene is already up). + * + * Why the OS clock rather than renderer events: browser panes are ``s in their own + * process, and xterm and Monaco stop propagation on what they handle, so a listener on the + * document misses real work and would cover the window mid-use. An unknown idle time (a + * platform that cannot measure it, or a paired web client) never starts the scene. + */ +export function useSleepyModeIdleTrigger({ + delayMs, + suspended, + onIdle +}: { + delayMs: number + suspended: boolean + onIdle: () => void +}): void { + useEffect(() => { + if (delayMs <= 0 || suspended) { + return + } + + let cancelled = false + const sample = async (): Promise => { + const idleSeconds = await window.api.agentAwake.getSystemIdleSeconds().catch(() => null) + if (cancelled || idleSeconds === null) { + return + } + if (idleSeconds * 1_000 >= delayMs) { + onIdle() + } + } + + const timer = window.setInterval(() => void sample(), Math.min(POLL_MS, delayMs)) + return () => { + cancelled = true + window.clearInterval(timer) + } + }, [delayMs, suspended, onIdle]) +} diff --git a/src/renderer/src/components/status-bar/CaffeinateStatusSegment.localization.test.tsx b/src/renderer/src/components/status-bar/CaffeinateStatusSegment.localization.test.tsx index c7caec6cdf9..b79fde93745 100644 --- a/src/renderer/src/components/status-bar/CaffeinateStatusSegment.localization.test.tsx +++ b/src/renderer/src/components/status-bar/CaffeinateStatusSegment.localization.test.tsx @@ -46,6 +46,9 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ {children}
), + DropdownMenuItem: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), DropdownMenuSeparator: () =>
})) diff --git a/src/renderer/src/components/status-bar/CaffeinateStatusSegment.tsx b/src/renderer/src/components/status-bar/CaffeinateStatusSegment.tsx index 89c6f0266bb..d22f1f3b3ff 100644 --- a/src/renderer/src/components/status-bar/CaffeinateStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/CaffeinateStatusSegment.tsx @@ -3,6 +3,7 @@ import { Coffee } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, @@ -14,6 +15,7 @@ import { useAppStore } from '@/store' import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' import { translate } from '@/i18n/i18n' import { getAgentAwakeModeLabel, getAgentAwakeTitle } from '../settings/agent-awake-copy' +import { getSleepyModeStartLabel } from '../settings/sleepy-mode-copy' import { computerAwakeSettingsForMode, normalizeComputerAwakeMode, @@ -39,6 +41,7 @@ export function CaffeinateStatusSegment({ }): React.JSX.Element | null { const settings = useAppStore((state) => state.settings) const updateSettings = useAppStore((state) => state.updateSettings) + const setSleepyModeActive = useAppStore((state) => state.setSleepyModeActive) const configuredMode = normalizeComputerAwakeMode( settings?.computerAwakeMode, settings?.keepComputerAwakeWhileAgentsRun @@ -160,6 +163,10 @@ export function CaffeinateStatusSegment({ + + setSleepyModeActive(true)}> + {getSleepyModeStartLabel()} + ) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index b5e8431c573..b04cf661433 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -1764,6 +1764,16 @@ "refreshLinks": "Refresh", "showButton": "Show Skills button" }, + "sleepy-mode": { + "search": { + "clock": "clock", + "idle": "idle", + "overnight": "overnight", + "privacy": "privacy", + "screensaver": "screensaver", + "sleepy": "sleepy" + } + }, "ssh": { "search": { "62826efbe9": "Add a new remote SSH target.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2ddea555dab..2990e3b1321 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -12134,6 +12134,23 @@ "TerminalRenderingSection": { "6d4c55bacc": "Inline Images", "fffab5890b": "Display images directly in the terminal using SIXEL, iTerm2 (IIP), and Kitty graphics protocols." + }, + "sleepy-mode-copy": { + "title": "Sleepy Mode", + "description": "After this long with no input, cover the window with a clock and what the fleet is doing. Any key wakes it. This is a screen cover, not a lock — display sleep still follows Keep computer awake.", + "start": "Start Sleepy Mode", + "never": "Never", + "minutes": "{{count}} min" + }, + "sleepy-mode": { + "search": { + "sleepy": "sleepy", + "screensaver": "screensaver", + "idle": "idle", + "clock": "clock", + "overnight": "overnight", + "privacy": "privacy" + } } }, "right": { @@ -17256,6 +17273,16 @@ "dispatchRejected": "The agent refused the “continue” message. Open the chat and send one yourself.", "unsupported": "This chat can’t be resumed by Orca. Open it to see where it stopped.", "fallback": "Orca couldn’t resume this chat. Open it to continue manually." + }, + "sleepy-mode": { + "SleepyModeOverlay": { + "label": "Sleepy Mode", + "working": "{{count}} working", + "waiting": "{{count}} waiting", + "done": "{{count}} done", + "quiet": "No agents working", + "dismissHint": "Press any key to wake" + } } }, "i18n": { diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts index 3fb49ea5afc..0e0e4d8c179 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts @@ -122,6 +122,9 @@ export type UISliceSurfaces = { ) => void setWorkspacePortScanForKey: (key: string, result: WorkspacePortScanResult | null) => void setWorkspacePortScanRefreshing: (refreshing: boolean) => void + /** Whether the Sleepy Mode resting screen is covering the window. Transient — a restart should never come back asleep. */ + sleepyModeActive: boolean + setSleepyModeActive: (active: boolean) => void /** Whether the pet overlay is currently visible. Persisted so "Hide pet" survives reload. Independent of the experimentalPet flag (which gates whether it can render at all). */ petVisible: boolean setPetVisible: (v: boolean) => void diff --git a/src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts index 7157a243d7a..e89df6abc3a 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts @@ -87,6 +87,10 @@ export function createUiSurfaceActions(set: UISliceSet, _get: UISliceGet): Parti : { workspacePortScanRefreshing: refreshing } ), + sleepyModeActive: false, + setSleepyModeActive: (active) => + set((state) => (state.sleepyModeActive === active ? state : { sleepyModeActive: active })), + // Why: default true so enabling experimentalPet shows the pet immediately (persisted; "Hide pet" flips it false). petVisible: true, setPetVisible: (v) => { diff --git a/src/renderer/src/web/preload-api/web-settings-api.ts b/src/renderer/src/web/preload-api/web-settings-api.ts index f032b0fa9ff..2c394ff1c35 100644 --- a/src/renderer/src/web/preload-api/web-settings-api.ts +++ b/src/renderer/src/web/preload-api/web-settings-api.ts @@ -112,7 +112,9 @@ export function createWebSettingsApi(): Partial { active: false } }, - onChanged: () => noopUnsubscribe + onChanged: () => noopUnsubscribe, + // Why: a paired web client cannot read the desktop's input clock; unknown, not idle. + getSystemIdleSeconds: async () => null } } } diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index 2fd16df1c4f..2267f33a5c5 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -224,6 +224,7 @@ export function buildDefaultSettings(args: { confirmClosePinnedTab: true, editorPreviewTabsEnabled: true, keepComputerAwakeWhileAgentsRun: false, + sleepyModeIdleMinutes: 0, // Why: 'auto' probes keyboard layout so non-US users can type Option chars like @/€/[ out of the box (issue #903). See src/renderer/src/lib/keyboard-layout/*. terminalMacOptionAsAlt: 'auto', terminalMacOptionAsAltMigrated: false, diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 1e0410f4c22..a5f03b68592 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -429,6 +429,8 @@ export type GlobalSettings = { keepComputerAwakeWhileAgentsRun: boolean /** Optional for mixed-version compatibility; the legacy boolean maps true to Auto. */ computerAwakeMode?: ComputerAwakeMode + /** Minutes of no input before Sleepy Mode covers the window; 0 (default) starts it only on request. */ + sleepyModeIdleMinutes?: number /** macOS Option key: compose layout chars (@ German, € French) vs act as Meta/Esc for readline. * 'auto' (default) = layout-aware via navigator.keyboard.getLayoutMap() (US → Meta, else compose); * 'false' = compose; 'true' = Meta on both Option keys; 'left'/'right' = only that key is Meta. diff --git a/src/shared/sleepy-mode-settings.test.ts b/src/shared/sleepy-mode-settings.test.ts new file mode 100644 index 00000000000..5695cac5a89 --- /dev/null +++ b/src/shared/sleepy-mode-settings.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { normalizeSleepyModeIdleMinutes, sleepyModeIdleDelayMs } from './sleepy-mode-settings' + +describe('normalizeSleepyModeIdleMinutes', () => { + it('keeps offered options', () => { + expect(normalizeSleepyModeIdleMinutes(15)).toBe(15) + }) + + it('falls back to off for unknown, missing, or hostile values', () => { + expect(normalizeSleepyModeIdleMinutes(7)).toBe(0) + expect(normalizeSleepyModeIdleMinutes(undefined)).toBe(0) + expect(normalizeSleepyModeIdleMinutes(Number.NaN)).toBe(0) + expect(normalizeSleepyModeIdleMinutes(-30)).toBe(0) + expect(normalizeSleepyModeIdleMinutes('15')).toBe(0) + }) + + it('converts minutes to milliseconds', () => { + expect(sleepyModeIdleDelayMs(5)).toBe(300_000) + expect(sleepyModeIdleDelayMs(0)).toBe(0) + }) +}) diff --git a/src/shared/sleepy-mode-settings.ts b/src/shared/sleepy-mode-settings.ts new file mode 100644 index 00000000000..713bde8b558 --- /dev/null +++ b/src/shared/sleepy-mode-settings.ts @@ -0,0 +1,19 @@ +/** Idle delays offered for Sleepy Mode. 0 means it only ever starts on request. */ +export const SLEEPY_MODE_IDLE_MINUTES = [0, 5, 15, 30] as const + +export type SleepyModeIdleMinutes = (typeof SLEEPY_MODE_IDLE_MINUTES)[number] + +const DEFAULT_SLEEPY_MODE_IDLE_MINUTES: SleepyModeIdleMinutes = 0 + +/** Persisted values come from older builds and hand-edited settings, so snap to a known option. */ +export function normalizeSleepyModeIdleMinutes(value: unknown): SleepyModeIdleMinutes { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return DEFAULT_SLEEPY_MODE_IDLE_MINUTES + } + const match = SLEEPY_MODE_IDLE_MINUTES.find((option) => option === value) + return match ?? DEFAULT_SLEEPY_MODE_IDLE_MINUTES +} + +export function sleepyModeIdleDelayMs(minutes: SleepyModeIdleMinutes): number { + return minutes * 60_000 +} diff --git a/tests/e2e/sleepy-mode.spec.ts b/tests/e2e/sleepy-mode.spec.ts new file mode 100644 index 00000000000..39a21bf232c --- /dev/null +++ b/tests/e2e/sleepy-mode.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' + +test('Sleepy Mode covers the window from the status bar and wakes on a keypress', async ({ + orcaPage +}) => { + await waitForSessionReady(orcaPage) + + const beforePath = process.env.ORCA_SLEEPY_MODE_BEFORE_PROOF_PATH + if (beforePath) { + await orcaPage.screenshot({ path: beforePath }) + } + + const awakeStatus = orcaPage.getByRole('button', { name: /^Keep computer awake/ }) + await expect(awakeStatus).toBeVisible() + await awakeStatus.click() + + await orcaPage.getByRole('menuitem', { name: 'Start Sleepy Mode' }).click() + + const scene = orcaPage.getByRole('dialog', { name: 'Sleepy Mode' }) + await expect(scene).toBeVisible() + await expect(scene.getByText('No agents working')).toBeVisible() + await expect(scene.getByText('Press any key to wake')).toBeVisible() + + const proofPath = process.env.ORCA_SLEEPY_MODE_PROOF_PATH + if (proofPath) { + await orcaPage.screenshot({ path: proofPath }) + } + + await orcaPage.keyboard.press('Space') + await expect(scene).toBeHidden() +})