From dc7fcdff3a59d877c1aabc6885f9839b8b5717dc Mon Sep 17 00:00:00 2001 From: danny <> Date: Sun, 20 Sep 2026 02:13:34 +0700 Subject: [PATCH 1/8] feat(sleepy-mode): resting screen for a fleet left running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Sleepy Mode: after a configurable stretch with no input, the window is covered by a clock, the date, and a live count of what the fleet is doing. Any key, click, or scroll wakes it. - Settings → Agents: Never / 5 / 15 / 30 min, plus "Start Sleepy Mode" now. - Also startable from the Keep-computer-awake status bar menu. - The pet rises above the scene so the resting screen keeps an animated read on agent state. Display sleep is deliberately untouched — "Keep computer awake" already owns that, and Sleepy Mode composes with it instead of asserting its own blocker. --- .../src/app-shell/AppRootSurfaces.tsx | 13 ++ .../app-shell/app-root-surface-settings.ts | 7 + .../src/components/pet/PetOverlay.tsx | 7 +- .../src/components/settings/AgentsPane.tsx | 6 +- .../components/settings/SleepyModeSetting.tsx | 65 ++++++++ .../components/settings/sleepy-mode-copy.ts | 38 +++++ .../sleepy-mode/SleepyModeOverlay.test.tsx | 118 ++++++++++++++ .../sleepy-mode/SleepyModeOverlay.tsx | 152 ++++++++++++++++++ .../sleepy-mode-fleet-summary.test.ts | 66 ++++++++ .../sleepy-mode/sleepy-mode-fleet-summary.ts | 36 +++++ .../use-sleepy-mode-idle-trigger.ts | 41 +++++ ...feinateStatusSegment.localization.test.tsx | 3 + .../status-bar/CaffeinateStatusSegment.tsx | 7 + src/renderer/src/i18n/locales/en.json | 27 ++++ .../ui/ui-slice-contract-preferences.ts | 3 + .../slices/ui/ui-slice-surface-actions.ts | 4 + src/shared/default-global-settings.ts | 1 + src/shared/global-settings-types.ts | 2 + src/shared/sleepy-mode-settings.test.ts | 21 +++ src/shared/sleepy-mode-settings.ts | 19 +++ 20 files changed, 634 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/settings/SleepyModeSetting.tsx create mode 100644 src/renderer/src/components/settings/sleepy-mode-copy.ts create mode 100644 src/renderer/src/components/sleepy-mode/SleepyModeOverlay.test.tsx create mode 100644 src/renderer/src/components/sleepy-mode/SleepyModeOverlay.tsx create mode 100644 src/renderer/src/components/sleepy-mode/sleepy-mode-fleet-summary.test.ts create mode 100644 src/renderer/src/components/sleepy-mode/sleepy-mode-fleet-summary.ts create mode 100644 src/renderer/src/components/sleepy-mode/use-sleepy-mode-idle-trigger.ts create mode 100644 src/shared/sleepy-mode-settings.test.ts create mode 100644 src/shared/sleepy-mode-settings.ts 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/components/pet/PetOverlay.tsx b/src/renderer/src/components/pet/PetOverlay.tsx index 3ab9f0d2d76..1d0f3702cc7 100644 --- a/src/renderer/src/components/pet/PetOverlay.tsx +++ b/src/renderer/src/components/pet/PetOverlay.tsx @@ -316,6 +316,7 @@ export function PetOverlay(): React.JSX.Element { const reducedMotion = usePrefersReducedMotion() const { url, sprite, detected } = usePetUrl() const size = useAppStore((s) => s.petSize) + const sleepyModeActive = useAppStore((s) => s.sleepyModeActive) const [positionState, setPositionState] = useState<{ size: number @@ -385,7 +386,11 @@ export function PetOverlay(): React.JSX.Element { // content, so the grab/drag hit area hugs the pet, not the full square box.
{!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..05481808a7e --- /dev/null +++ b/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.test.tsx @@ -0,0 +1,118 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act } from '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 +} + +const storeMocks = vi.hoisted(() => ({ + state: { + sleepyModeActive: false, + setSleepyModeActive: vi.fn(), + settings: { sleepyModeIdleMinutes: 0 }, + agentStatusByPaneKey: {}, + agentStatusEpoch: 0 + } +})) + +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 +})) + +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() + setState({ + sleepyModeActive: false, + setSleepyModeActive: vi.fn(), + settings: { sleepyModeIdleMinutes: 0 }, + agentStatusByPaneKey: {} + }) + }) + + afterEach(() => { + cleanup() + vi.useRealTimers() + }) + + it('renders nothing until it is active', () => { + render() + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('starts after the configured idle delay and not before', () => { + setState({ settings: { sleepyModeIdleMinutes: 5 } }) + render() + + act(() => { + vi.advanceTimersByTime(299_000) + }) + expect(storeMocks.state.setSleepyModeActive).not.toHaveBeenCalled() + + act(() => { + vi.advanceTimersByTime(1_000) + }) + expect(storeMocks.state.setSleepyModeActive).toHaveBeenCalledWith(true) + }) + + it('does not start when auto-start is off', () => { + render() + act(() => { + vi.advanceTimersByTime(60 * 60_000) + }) + expect(storeMocks.state.setSleepyModeActive).not.toHaveBeenCalled() + }) + + it('restarts the countdown on input', () => { + setState({ settings: { sleepyModeIdleMinutes: 5 } }) + render() + + act(() => { + vi.advanceTimersByTime(299_000) + }) + fireEvent.keyDown(window, { key: 'a' }) + act(() => { + vi.advanceTimersByTime(299_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) + }) +}) 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..4313a22eb57 --- /dev/null +++ b/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.tsx @@ -0,0 +1,152 @@ +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 { 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(' · ')} +

+ ) +} + +/** + * 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 + } + const wake = (): void => setActive(false) + // Capture phase: xterm and Monaco stop plenty of events before they reach window. + const options = { capture: true } 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..ab5ccee96b2 --- /dev/null +++ b/src/renderer/src/components/sleepy-mode/use-sleepy-mode-idle-trigger.ts @@ -0,0 +1,41 @@ +import { useEffect } from 'react' + +/** Input events that count as "the user is still here". Pointer moves are included so a + * mouse nudge behaves like every other screensaver. */ +const ACTIVITY_EVENTS = ['keydown', 'pointerdown', 'pointermove', 'wheel', 'touchstart'] as const + +/** + * Fires `onIdle` once after `delayMs` without renderer input. Disabled while `delayMs` is 0 + * (auto-start off) or `suspended` (the scene is already up). + */ +export function useSleepyModeIdleTrigger({ + delayMs, + suspended, + onIdle +}: { + delayMs: number + suspended: boolean + onIdle: () => void +}): void { + useEffect(() => { + if (delayMs <= 0 || suspended) { + return + } + + // One controller owns every listener, so the cleanup can't miss one. + const controller = new AbortController() + let timer = window.setTimeout(onIdle, delayMs) + const restart = (): void => { + window.clearTimeout(timer) + timer = window.setTimeout(onIdle, delayMs) + } + + for (const event of ACTIVITY_EVENTS) { + window.addEventListener(event, restart, { passive: true, signal: controller.signal }) + } + return () => { + window.clearTimeout(timer) + controller.abort() + } + }, [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/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2e54c7d14c2..b63fd05c523 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -12003,6 +12003,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": { @@ -17085,6 +17102,16 @@ "9a4c6b2d7e": "Skill sharing", "3d8e5f1b9c": "Share skills behind an unlisted link and install them on any machine you use.", "c5b3e8a17d": "Sign in to Orca" + }, + "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/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index 7c532c3bfa0..4ad32c0c632 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -219,6 +219,7 @@ export function buildDefaultSettings(args: { tabAutoGenerateTitle: false, confirmClosePinnedTab: 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 48ac9122dfd..c6d6436c01f 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -416,6 +416,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 +} From 63999d12a042826a18b94c8ade94281792a3a5c9 Mon Sep 17 00:00:00 2001 From: danny <> Date: Sun, 20 Sep 2026 15:49:14 +0700 Subject: [PATCH 2/8] fix(sleepy-mode): raise the scene above tooltips, add an E2E MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tooltip left open by the pointer (z-[90]) painted over the resting screen, so the scene moves to z-[95] — above tooltips and menus, below onboarding and the blocking SSH/link dialogs. The pet follows at z-[96]. The E2E drives the real path: status bar menu → Start Sleepy Mode → scene covers the window → a keypress wakes it. --- .../src/components/pet/PetOverlay.tsx | 4 +-- .../sleepy-mode/SleepyModeOverlay.tsx | 2 +- tests/e2e/sleepy-mode.spec.ts | 27 +++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/sleepy-mode.spec.ts diff --git a/src/renderer/src/components/pet/PetOverlay.tsx b/src/renderer/src/components/pet/PetOverlay.tsx index 1d0f3702cc7..590a8b196af 100644 --- a/src/renderer/src/components/pet/PetOverlay.tsx +++ b/src/renderer/src/components/pet/PetOverlay.tsx @@ -386,10 +386,10 @@ export function PetOverlay(): React.JSX.Element { // content, so the grab/drag hit area hugs the pet, not the full square box.

{timeFormat.format(now)} diff --git a/tests/e2e/sleepy-mode.spec.ts b/tests/e2e/sleepy-mode.spec.ts new file mode 100644 index 00000000000..0521f195b82 --- /dev/null +++ b/tests/e2e/sleepy-mode.spec.ts @@ -0,0 +1,27 @@ +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 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() +}) From b6338f842518d7511058e01fe717e6a7b4fa102f Mon Sep 17 00:00:00 2001 From: danny <> Date: Sun, 20 Sep 2026 15:59:36 +0700 Subject: [PATCH 3/8] feat(sleepy-mode): give the scene an ambient background A flat fill read as a blank window rather than a resting screen. The scene now sits on a radial wash mixed from the existing tokens, so it gains depth without introducing a colour. The direction flips per theme: light mode can't go brighter than the canvas, so it vignettes the edges; dark mode lights a pale pool behind the clock. --- src/renderer/src/assets/main.css | 19 +++++++++++++++++++ .../sleepy-mode/SleepyModeOverlay.tsx | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 4edd4da2fde..77c3d5ad358 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -864,6 +864,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/sleepy-mode/SleepyModeOverlay.tsx b/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.tsx index a2a1c4a506b..5de23058318 100644 --- a/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.tsx +++ b/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.tsx @@ -134,7 +134,7 @@ export default function SleepyModeOverlay(): React.JSX.Element | null { role="dialog" aria-modal="true" aria-label={translate('auto.components.sleepy-mode.SleepyModeOverlay.label', 'Sleepy Mode')} - className="fixed inset-0 z-[95] flex flex-col items-center justify-center gap-3 bg-background" + className="sleepy-mode-scene fixed inset-0 z-[95] flex flex-col items-center justify-center gap-3" >

{timeFormat.format(now)} From 3acd26bdb09825bc3d52ad0d58cfde42e9ddfc33 Mon Sep 17 00:00:00 2001 From: danny <> Date: Sun, 20 Sep 2026 16:05:58 +0700 Subject: [PATCH 4/8] feat(sleepy-mode): put the pet on the resting screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Orca already ships Claudino and animates it off live agent state, but only as a draggable corner overlay. The resting screen now shows the same pet centred above the clock, so the scene reads the fleet at a glance: it runs while agents work, waits when one is blocked, and idles when the night is quiet. The rendering half of PetOverlay (sprite sheet stepping, auto-detected frames, the bob keyframes, the document-visibility gate) moves to PetSprite so both surfaces share one implementation instead of a copy. PetOverlay keeps all of its positioning, drag, and persistence behaviour. The scene's pet is not gated on the experimentalPet flag — that flag owns the draggable overlay, not the artwork — but an explicit "Hide pet" still hides it. --- .../src/components/pet/PetOverlay.tsx | 279 +---------------- src/renderer/src/components/pet/PetSprite.tsx | 280 ++++++++++++++++++ .../sleepy-mode/SleepyModeOverlay.test.tsx | 10 +- .../sleepy-mode/SleepyModeOverlay.tsx | 38 +++ 4 files changed, 341 insertions(+), 266 deletions(-) create mode 100644 src/renderer/src/components/pet/PetSprite.tsx diff --git a/src/renderer/src/components/pet/PetOverlay.tsx b/src/renderer/src/components/pet/PetOverlay.tsx index 590a8b196af..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,16 +87,10 @@ 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 sleepyModeActive = useAppStore((s) => s.sleepyModeActive) const [positionState, setPositionState] = useState<{ size: number @@ -386,11 +160,7 @@ export function PetOverlay(): React.JSX.Element { // content, so the grab/drag hit area hugs the pet, not the full square box.
- {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/sleepy-mode/SleepyModeOverlay.test.tsx b/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.test.tsx index 05481808a7e..37622e9a6d7 100644 --- a/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.test.tsx +++ b/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.test.tsx @@ -12,6 +12,10 @@ type SleepyStoreState = { settings: { sleepyModeIdleMinutes: number } agentStatusByPaneKey: Record agentStatusEpoch: number + retainedAgentsByPaneKey: Record + petVisible: boolean + petId: string + customPets: never[] } const storeMocks = vi.hoisted(() => ({ @@ -20,7 +24,11 @@ const storeMocks = vi.hoisted(() => ({ setSleepyModeActive: vi.fn(), settings: { sleepyModeIdleMinutes: 0 }, agentStatusByPaneKey: {}, - agentStatusEpoch: 0 + agentStatusEpoch: 0, + retainedAgentsByPaneKey: {}, + petVisible: true, + petId: 'claude-the-mage', + customPets: [] } })) diff --git a/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.tsx b/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.tsx index 5de23058318..babdfeedd41 100644 --- a/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.tsx +++ b/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.tsx @@ -7,6 +7,12 @@ 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' @@ -84,6 +90,37 @@ function FleetLine(): React.JSX.Element { ) } +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 @@ -136,6 +173,7 @@ export default function SleepyModeOverlay(): React.JSX.Element | null { aria-label={translate('auto.components.sleepy-mode.SleepyModeOverlay.label', 'Sleepy Mode')} className="sleepy-mode-scene fixed inset-0 z-[95] flex flex-col items-center justify-center gap-3" > +

{timeFormat.format(now)}

From a1e1f7add567ef971fcab7ca0aa735740f052d03 Mon Sep 17 00:00:00 2001 From: danny <> Date: Sun, 20 Sep 2026 16:31:03 +0700 Subject: [PATCH 5/8] chore(sleepy-mode): sync the runtime English catalog `pnpm lint` regenerates en-runtime-required.json from the keys the renderer reaches at boot; the new Sleepy Mode search keywords were missing from it. Also lets the E2E capture a before shot for the PR. --- src/renderer/src/i18n/en-runtime-required.json | 10 ++++++++++ tests/e2e/sleepy-mode.spec.ts | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index f28c32e0b8a..e1139d31d80 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -1766,6 +1766,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/tests/e2e/sleepy-mode.spec.ts b/tests/e2e/sleepy-mode.spec.ts index 0521f195b82..39a21bf232c 100644 --- a/tests/e2e/sleepy-mode.spec.ts +++ b/tests/e2e/sleepy-mode.spec.ts @@ -6,6 +6,11 @@ test('Sleepy Mode covers the window from the status bar and wakes on a keypress' }) => { 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() From 78364c392cd72ff8fd212110d52913616f84d845 Mon Sep 17 00:00:00 2001 From: danny <> Date: Sun, 20 Sep 2026 16:57:57 +0700 Subject: [PATCH 6/8] fix(sleepy-mode): trigger on the OS input clock, swallow the wake key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two interaction defects from review. The wake listener dismissed the scene but let the same event through, so the key that woke Orca also typed into the terminal or editor behind it. It is now consumed before the scene closes. The idle countdown listened for renderer events, which miss the two places work actually happens: xterm and Monaco stop propagation on what they handle, and browser panes are ``s in their own process that emit nothing in the host document. Typing in a terminal or driving a browser pane therefore did not reset the countdown and the scene could cover the window mid-use. It now samples `powerMonitor.getSystemIdleTime()` through a new `agentAwake:getSystemIdleSeconds` channel, which is the same clock the OS screensaver uses and covers every surface. An unknown idle time — a platform that cannot measure it, or a paired web client — never starts the scene. --- src/main/ipc/settings.ts | 2 + src/main/system-idle-seconds.test.ts | 21 ++++++ src/main/system-idle-seconds.ts | 21 ++++++ src/preload/api/agent-awake-bridge.ts | 2 + src/preload/api/agent-status-api.ts | 2 + .../sleepy-mode/SleepyModeOverlay.test.tsx | 69 +++++++++++++------ .../sleepy-mode/SleepyModeOverlay.tsx | 10 ++- .../use-sleepy-mode-idle-trigger.ts | 35 ++++++---- .../src/web/preload-api/web-settings-api.ts | 4 +- 9 files changed, 128 insertions(+), 38 deletions(-) create mode 100644 src/main/system-idle-seconds.test.ts create mode 100644 src/main/system-idle-seconds.ts 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 0cfce06803b..b4c55e9dd00 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/components/sleepy-mode/SleepyModeOverlay.test.tsx b/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.test.tsx index 37622e9a6d7..a3daeb9aa79 100644 --- a/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.test.tsx +++ b/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.test.tsx @@ -1,7 +1,6 @@ // @vitest-environment happy-dom import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { act } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import SleepyModeOverlay from './SleepyModeOverlay' @@ -44,6 +43,9 @@ 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: [] } } @@ -55,12 +57,22 @@ function setState(next: Partial): void { 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(() => { @@ -73,40 +85,43 @@ describe('SleepyModeOverlay', () => { expect(screen.queryByRole('dialog')).toBeNull() }) - it('starts after the configured idle delay and not before', () => { + it('starts once the OS reports the configured idle delay, and not before', async () => { setState({ settings: { sleepyModeIdleMinutes: 5 } }) render() - act(() => { - vi.advanceTimersByTime(299_000) - }) + systemIdleSeconds = 299 + await vi.advanceTimersByTimeAsync(30_000) expect(storeMocks.state.setSleepyModeActive).not.toHaveBeenCalled() - act(() => { - vi.advanceTimersByTime(1_000) - }) + systemIdleSeconds = 300 + await vi.advanceTimersByTimeAsync(15_000) expect(storeMocks.state.setSleepyModeActive).toHaveBeenCalledWith(true) }) - it('does not start when auto-start is off', () => { + it('does not start when auto-start is off', async () => { + systemIdleSeconds = 10_000 render() - act(() => { - vi.advanceTimersByTime(60 * 60_000) - }) + await vi.advanceTimersByTimeAsync(60 * 60_000) expect(storeMocks.state.setSleepyModeActive).not.toHaveBeenCalled() }) - it('restarts the countdown on input', () => { + it('never starts when the platform cannot report idle time', async () => { setState({ settings: { sleepyModeIdleMinutes: 5 } }) + systemIdleSeconds = null render() - act(() => { - vi.advanceTimersByTime(299_000) - }) - fireEvent.keyDown(window, { key: 'a' }) - act(() => { - vi.advanceTimersByTime(299_000) - }) + 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() }) @@ -123,4 +138,18 @@ describe('SleepyModeOverlay', () => { fireEvent.keyDown(window, { key: 'a' }) expect(storeMocks.state.setSleepyModeActive).toHaveBeenCalledWith(false) }) + + it('swallows the wake key so it never reaches the workspace underneath', () => { + setState({ sleepyModeActive: true }) + const reachedWorkspace = vi.fn() + document.addEventListener('keydown', reachedWorkspace) + render() + + const event = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true, bubbles: true }) + window.dispatchEvent(event) + + expect(event.defaultPrevented).toBe(true) + expect(reachedWorkspace).not.toHaveBeenCalled() + document.removeEventListener('keydown', reachedWorkspace) + }) }) diff --git a/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.tsx b/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.tsx index babdfeedd41..f10bf1f55fd 100644 --- a/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.tsx +++ b/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.tsx @@ -149,9 +149,15 @@ export default function SleepyModeOverlay(): React.JSX.Element | null { if (!active) { return } - const wake = (): void => setActive(false) + // 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 } as const + const options = { capture: true, passive: false } as const window.addEventListener('keydown', wake, options) window.addEventListener('pointerdown', wake, options) window.addEventListener('wheel', wake, options) 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 index ab5ccee96b2..df9994e9da9 100644 --- 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 @@ -1,12 +1,16 @@ import { useEffect } from 'react' -/** Input events that count as "the user is still here". Pointer moves are included so a - * mouse nudge behaves like every other screensaver. */ -const ACTIVITY_EVENTS = ['keydown', 'pointerdown', 'pointermove', 'wheel', 'touchstart'] as const +/** 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 after `delayMs` without renderer input. Disabled while `delayMs` is 0 + * 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, @@ -22,20 +26,21 @@ export function useSleepyModeIdleTrigger({ return } - // One controller owns every listener, so the cleanup can't miss one. - const controller = new AbortController() - let timer = window.setTimeout(onIdle, delayMs) - const restart = (): void => { - window.clearTimeout(timer) - timer = window.setTimeout(onIdle, delayMs) + 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() + } } - for (const event of ACTIVITY_EVENTS) { - window.addEventListener(event, restart, { passive: true, signal: controller.signal }) - } + const timer = window.setInterval(() => void sample(), Math.min(POLL_MS, delayMs)) return () => { - window.clearTimeout(timer) - controller.abort() + cancelled = true + window.clearInterval(timer) } }, [delayMs, suspended, onIdle]) } 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 } } } From 408bc83c5ed59d5b1edc7d66b25a0d441d9c6659 Mon Sep 17 00:00:00 2001 From: danny <> Date: Sun, 20 Sep 2026 17:09:57 +0700 Subject: [PATCH 7/8] test(sleepy-mode): dispatch the wake key at a real element Dispatching on `window` meant the event never travelled through the workspace, so the "never reached the workspace" listener could not have fired either way and that half of the test proved nothing. It now dispatches at an element in the document, and the test fails if the consumption is removed. --- .../sleepy-mode/SleepyModeOverlay.test.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.test.tsx b/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.test.tsx index a3daeb9aa79..1127502cc68 100644 --- a/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.test.tsx +++ b/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.test.tsx @@ -141,15 +141,20 @@ describe('SleepyModeOverlay', () => { it('swallows the wake key so it never reaches the workspace underneath', () => { setState({ sleepyModeActive: true }) - const reachedWorkspace = vi.fn() - document.addEventListener('keydown', reachedWorkspace) render() + // 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 workspace = document.createElement('input') + document.body.append(workspace) + const reachedWorkspace = vi.fn() + workspace.addEventListener('keydown', reachedWorkspace) + const event = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true, bubbles: true }) - window.dispatchEvent(event) + workspace.dispatchEvent(event) expect(event.defaultPrevented).toBe(true) expect(reachedWorkspace).not.toHaveBeenCalled() - document.removeEventListener('keydown', reachedWorkspace) + workspace.remove() }) }) From d871a3ef2803c7b4dd7c787136cb7ef91fb59788 Mon Sep 17 00:00:00 2001 From: danny <> Date: Thu, 24 Sep 2026 12:54:16 +0700 Subject: [PATCH 8/8] test(sleepy-mode): cover the pointer and scroll wake paths Only keydown was asserted, so a regression in either of the other two wake listeners passed. The case is now table-driven over keydown, pointerdown and wheel: each must dismiss the scene, be cancelled, and never reach a listener on the element it was dispatched at. Removing either listener fails its row. --- .../sleepy-mode/SleepyModeOverlay.test.tsx | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.test.tsx b/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.test.tsx index 1127502cc68..cba68379a63 100644 --- a/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.test.tsx +++ b/src/renderer/src/components/sleepy-mode/SleepyModeOverlay.test.tsx @@ -139,20 +139,33 @@ describe('SleepyModeOverlay', () => { expect(storeMocks.state.setSleepyModeActive).toHaveBeenCalledWith(false) }) - it('swallows the wake key so it never reaches the workspace underneath', () => { + // 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() - // 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 workspace = document.createElement('input') document.body.append(workspace) const reachedWorkspace = vi.fn() - workspace.addEventListener('keydown', reachedWorkspace) + workspace.addEventListener(name, reachedWorkspace) - const event = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true, bubbles: true }) + const event = build() workspace.dispatchEvent(event) + expect(storeMocks.state.setSleepyModeActive).toHaveBeenCalledWith(false) expect(event.defaultPrevented).toBe(true) expect(reachedWorkspace).not.toHaveBeenCalled() workspace.remove()