diff --git a/docs/features/wake-lock/SPECIFICATION.md b/docs/features/wake-lock/SPECIFICATION.md new file mode 100644 index 00000000..73252843 --- /dev/null +++ b/docs/features/wake-lock/SPECIFICATION.md @@ -0,0 +1,17 @@ +# Wake Lock Behavior + +## Product Rule + +CodeNomad only requests a wake lock for qualifying active work that is already running and can continue without continuous foreground interaction. The goal is to prevent idle system sleep where the platform supports that behavior without intentionally keeping the display awake. + +Wake lock must not be held when work is idle, paused, completed, cancelled, failed, or waiting for new user input or permission before it can continue. + +## Platform Behavior + +- **Electron:** request system-sleep-only behavior with `prevent-app-suspension`. +- **Tauri:** request the native keep-awake mode with `display: false`, `idle: true`, and `sleep: false`. +- **Web:** do not fall back to `navigator.wakeLock.request("screen")`; if a true system-sleep-only primitive is unavailable, CodeNomad degrades to no wake lock. + +## Release Expectations + +Wake lock should be released promptly when qualifying active work ends or when the app cleans up the active session lifecycle. diff --git a/packages/electron-app/electron/main/ipc.ts b/packages/electron-app/electron/main/ipc.ts index 5189bad3..d6536945 100644 --- a/packages/electron-app/electron/main/ipc.ts +++ b/packages/electron-app/electron/main/ipc.ts @@ -92,7 +92,7 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan return { enabled: true } } try { - wakeLockId = powerSaveBlocker.start("prevent-display-sleep") + wakeLockId = powerSaveBlocker.start("prevent-app-suspension") } catch { wakeLockId = null return { enabled: false } diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index 9dd5d33b..6935d7fc 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -145,8 +145,8 @@ fn wake_lock_start( config: Option, ) -> Result<(), String> { let config = config.unwrap_or(WakeLockConfig { - display: true, - idle: false, + display: false, + idle: true, sleep: false, }); diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index ed1ae942..a93c5e6c 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -50,7 +50,7 @@ import { updateSessionModel, } from "./stores/sessions" -import { getInstanceSessionIndicatorStatus } from "./stores/session-status" +import { hasWakeLockEligibleWork } from "./stores/session-status" import { openSettings } from "./stores/settings-screen" import { closeSidecarTab, @@ -204,8 +204,7 @@ const App: Component = () => { const shouldHoldWakeLock = createMemo(() => { const map = instances() for (const id of map.keys()) { - const status = getInstanceSessionIndicatorStatus(id) - if (status !== "idle") { + if (hasWakeLockEligibleWork(id)) { return true } } diff --git a/packages/ui/src/components/tool-call/diagnostics.ts b/packages/ui/src/components/tool-call/diagnostics.ts index d48b3975..8651df69 100644 --- a/packages/ui/src/components/tool-call/diagnostics.ts +++ b/packages/ui/src/components/tool-call/diagnostics.ts @@ -58,7 +58,9 @@ export function extractDiagnostics(state: ToolState | undefined): DiagnosticEntr const diagnosticsMap = metadata?.diagnostics as DiagnosticsMap | undefined if (!diagnosticsMap) return [] - return buildDiagnosticEntries(diagnosticsMap, [input.filePath, metadata.filePath, metadata.filepath, input.path]) + return buildDiagnosticEntries(diagnosticsMap, [input.filePath, metadata.filePath, metadata.filepath, input.path].map((value) => + typeof value === "string" ? value : undefined, + )) } export function resolveDiagnosticsKey(diagnostics: DiagnosticsMap, preferredPaths: Array): string | undefined { diff --git a/packages/ui/src/lib/native/wake-lock.ts b/packages/ui/src/lib/native/wake-lock.ts index ce77c925..065cd041 100644 --- a/packages/ui/src/lib/native/wake-lock.ts +++ b/packages/ui/src/lib/native/wake-lock.ts @@ -9,51 +9,6 @@ let inFlight: Promise | null = null let applied = false -let webWakeLock: any = null - -async function setWebWakeLock(enabled: boolean): Promise { - if (typeof navigator === "undefined") return false - - const api = (navigator as any).wakeLock - if (!api?.request) { - return false - } - - try { - if (enabled) { - if (webWakeLock) { - return true - } - webWakeLock = await api.request("screen") - try { - webWakeLock.addEventListener?.("release", () => { - // If the lock is released by the UA (e.g., tab hidden), clear local state. - webWakeLock = null - if (desired) { - // Re-acquire best-effort. - queueMicrotask(() => { - void setWakeLockDesired(true) - }) - } - }) - } catch { - // optional - } - return true - } - - if (webWakeLock) { - await webWakeLock.release?.() - } - webWakeLock = null - return false - } catch (error) { - log.log("[wake-lock] web wake lock failed", error) - webWakeLock = null - return false - } -} - function hasAnyWakeLockSupport(): boolean { if (typeof window === "undefined") return false if (isElectronHost()) { @@ -63,7 +18,7 @@ function hasAnyWakeLockSupport(): boolean { if (isTauriHost()) { return typeof window.__TAURI__?.core?.invoke === "function" } - return Boolean((navigator as any)?.wakeLock?.request) + return false } async function setElectronWakeLock(enabled: boolean): Promise { @@ -89,9 +44,7 @@ async function setTauriWakeLock(enabled: boolean): Promise { } if (enabled) { - // Match Electron's prevent-display-sleep behavior by keeping the display - // awake without blocking explicit system sleep requests. - await invoke("wake_lock_start", { config: { display: true, idle: false, sleep: false } }) + await invoke("wake_lock_start", { config: { display: false, idle: true, sleep: false } }) return true } @@ -108,17 +61,15 @@ async function applyWakeLock(enabled: boolean): Promise { if (isElectronHost()) { const ok = await setElectronWakeLock(enabled) - if (ok || !enabled) return ok - // fallback to web API if electron preload didn't expose it + return ok } if (isTauriHost()) { const ok = await setTauriWakeLock(enabled) - if (ok || !enabled) return ok - // fallback to web API if tauri command isn't available + return ok } - return await setWebWakeLock(enabled) + return false } export function setWakeLockDesired(nextDesired: boolean): Promise { diff --git a/packages/ui/src/stores/session-status.test.ts b/packages/ui/src/stores/session-status.test.ts new file mode 100644 index 00000000..2413743d --- /dev/null +++ b/packages/ui/src/stores/session-status.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { shouldSessionHoldWakeLock } from "./wake-lock-eligibility.ts" + +describe("shouldSessionHoldWakeLock", () => { + it("holds wake lock only for qualifying active work", () => { + assert.equal(shouldSessionHoldWakeLock({ status: "working", pendingPermission: false, pendingQuestion: false }), true) + assert.equal( + shouldSessionHoldWakeLock({ status: "compacting", pendingPermission: false, pendingQuestion: false }), + true, + ) + assert.equal(shouldSessionHoldWakeLock({ status: "idle", pendingPermission: false, pendingQuestion: false }), false) + }) + + it("does not hold wake lock while waiting for permission or input", () => { + assert.equal(shouldSessionHoldWakeLock({ status: "working", pendingPermission: true, pendingQuestion: false }), false) + assert.equal(shouldSessionHoldWakeLock({ status: "working", pendingPermission: false, pendingQuestion: true }), false) + }) +}) diff --git a/packages/ui/src/stores/session-status.ts b/packages/ui/src/stores/session-status.ts index 865f1c8a..7ce17fac 100644 --- a/packages/ui/src/stores/session-status.ts +++ b/packages/ui/src/stores/session-status.ts @@ -1,11 +1,27 @@ import type { Session, SessionRetryState, SessionStatus } from "../types/session" import { getInstanceSessionIndicatorStatusCached, sessions } from "./session-state" +import { shouldSessionHoldWakeLock } from "./wake-lock-eligibility" function getSession(instanceId: string, sessionId: string): Session | null { const instanceSessions = sessions().get(instanceId) return instanceSessions?.get(sessionId) ?? null } +export function hasWakeLockEligibleWork(instanceId: string): boolean { + const instanceSessions = sessions().get(instanceId) + if (!instanceSessions) { + return false + } + + for (const session of instanceSessions.values()) { + if (shouldSessionHoldWakeLock(session)) { + return true + } + } + + return false +} + export function getSessionStatus(instanceId: string, sessionId: string): SessionStatus { const session = getSession(instanceId, sessionId) if (!session) { diff --git a/packages/ui/src/stores/wake-lock-eligibility.ts b/packages/ui/src/stores/wake-lock-eligibility.ts new file mode 100644 index 00000000..27a31929 --- /dev/null +++ b/packages/ui/src/stores/wake-lock-eligibility.ts @@ -0,0 +1,11 @@ +import type { Session } from "../types/session" + +export function shouldSessionHoldWakeLock( + session: Pick, +): boolean { + if (session.pendingPermission || session.pendingQuestion) { + return false + } + + return session.status === "working" || session.status === "compacting" +}