diff --git a/apps/desktop/src/electron/ElectronPowerSaveBlocker.test.ts b/apps/desktop/src/electron/ElectronPowerSaveBlocker.test.ts new file mode 100644 index 00000000000..89385a69c14 --- /dev/null +++ b/apps/desktop/src/electron/ElectronPowerSaveBlocker.test.ts @@ -0,0 +1,95 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import * as ElectronPowerSaveBlocker from "./ElectronPowerSaveBlocker.ts"; + +function makeStubApi() { + const calls: string[] = []; + const started = new Set(); + let nextId = 0; + const api: ElectronPowerSaveBlocker.PowerSaveBlockerApi = { + start: (type) => { + const id = nextId++; + started.add(id); + calls.push(`start:${type}:${id}`); + return id; + }, + stop: (id) => { + calls.push(`stop:${id}`); + return started.delete(id); + }, + isStarted: (id) => started.has(id), + }; + return { api, calls, started }; +} + +describe("ElectronPowerSaveBlocker", () => { + it.effect("starts a single prevent-app-suspension blocker and is idempotent", () => + Effect.gen(function* () { + const { api, calls, started } = makeStubApi(); + const service = ElectronPowerSaveBlocker.make(api); + + assert.isTrue(yield* service.setKeepAwake(true)); + assert.isTrue(yield* service.setKeepAwake(true)); + + assert.deepStrictEqual(calls, ["start:prevent-app-suspension:0"]); + assert.strictEqual(started.size, 1); + }), + ); + + it.effect("stops the held blocker and reports inactive", () => + Effect.gen(function* () { + const { api, calls, started } = makeStubApi(); + const service = ElectronPowerSaveBlocker.make(api); + + yield* service.setKeepAwake(true); + assert.isFalse(yield* service.setKeepAwake(false)); + + assert.deepStrictEqual(calls, ["start:prevent-app-suspension:0", "stop:0"]); + assert.strictEqual(started.size, 0); + }), + ); + + it.effect("treats release without a held blocker as a no-op", () => + Effect.gen(function* () { + const { api, calls } = makeStubApi(); + const service = ElectronPowerSaveBlocker.make(api); + + assert.isFalse(yield* service.setKeepAwake(false)); + assert.deepStrictEqual(calls, []); + }), + ); + + it.effect("re-acquires a fresh blocker after a release", () => + Effect.gen(function* () { + const { api, calls } = makeStubApi(); + const service = ElectronPowerSaveBlocker.make(api); + + yield* service.setKeepAwake(true); + yield* service.setKeepAwake(false); + yield* service.setKeepAwake(true); + + assert.deepStrictEqual(calls, [ + "start:prevent-app-suspension:0", + "stop:0", + "start:prevent-app-suspension:1", + ]); + }), + ); + + it.effect("restarts instead of trusting a blocker Electron no longer reports as started", () => + Effect.gen(function* () { + const { api, calls, started } = makeStubApi(); + const service = ElectronPowerSaveBlocker.make(api); + + yield* service.setKeepAwake(true); + started.clear(); + + assert.isTrue(yield* service.setKeepAwake(true)); + assert.deepStrictEqual(calls, [ + "start:prevent-app-suspension:0", + "start:prevent-app-suspension:1", + ]); + }), + ); +}); diff --git a/apps/desktop/src/electron/ElectronPowerSaveBlocker.ts b/apps/desktop/src/electron/ElectronPowerSaveBlocker.ts new file mode 100644 index 00000000000..c67a8652709 --- /dev/null +++ b/apps/desktop/src/electron/ElectronPowerSaveBlocker.ts @@ -0,0 +1,54 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as Electron from "electron"; + +/** + * The subset of `Electron.powerSaveBlocker` the service needs, injectable so + * tests can observe start/stop calls without an Electron runtime. + */ +export interface PowerSaveBlockerApi { + start(type: "prevent-app-suspension" | "prevent-display-sleep"): number; + stop(id: number): boolean; + isStarted(id: number): boolean; +} + +/** + * Holds at most one `prevent-app-suspension` power-save blocker for the whole + * app, toggled by the renderer while a locally hosted agent is working. The + * single slot is last-writer-wins across windows; the desktop runs one main + * window today. No app-quit cleanup is needed: the OS releases the assertion + * when the process exits. + */ +export class ElectronPowerSaveBlocker extends Context.Service< + ElectronPowerSaveBlocker, + { + readonly setKeepAwake: (keepAwake: boolean) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronPowerSaveBlocker") {} + +export const make = (api: PowerSaveBlockerApi): ElectronPowerSaveBlocker["Service"] => { + let blockerId: number | null = null; + + const isHeld = () => blockerId !== null && api.isStarted(blockerId); + + return ElectronPowerSaveBlocker.of({ + setKeepAwake: (keepAwake) => + Effect.sync(() => { + if (keepAwake) { + if (!isHeld()) { + blockerId = api.start("prevent-app-suspension"); + } + return true; + } + if (blockerId !== null && api.isStarted(blockerId)) { + api.stop(blockerId); + } + blockerId = null; + return false; + }), + }); +}; + +export const layer = Layer.sync(ElectronPowerSaveBlocker, () => make(Electron.powerSaveBlocker)); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index e478d0c6eff..5d5c57017c2 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -23,6 +23,7 @@ import { issueSshWebSocketTicket, resolveSshPasswordPrompt, } from "./methods/sshEnvironment.ts"; +import { setKeepAwake } from "./methods/power.ts"; import { checkForUpdate, downloadUpdate, @@ -81,6 +82,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(pickFolder); yield* ipc.handle(confirm); yield* ipc.handle(setTheme); + yield* ipc.handle(setKeepAwake); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); yield* ipc.handle(getUpdateState); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 5988b1e42f9..8ca4b371e80 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -1,6 +1,7 @@ export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; export const CONFIRM_CHANNEL = "desktop:confirm"; export const SET_THEME_CHANNEL = "desktop:set-theme"; +export const SET_KEEP_AWAKE_CHANNEL = "desktop:set-keep-awake"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; diff --git a/apps/desktop/src/ipc/methods/power.ts b/apps/desktop/src/ipc/methods/power.ts new file mode 100644 index 00000000000..9dd5c8e7d3c --- /dev/null +++ b/apps/desktop/src/ipc/methods/power.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as ElectronPowerSaveBlocker from "../../electron/ElectronPowerSaveBlocker.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +export const setKeepAwake = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SET_KEEP_AWAKE_CHANNEL, + payload: Schema.Boolean, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.power.setKeepAwake")(function* (keepAwake) { + const blocker = yield* ElectronPowerSaveBlocker.ElectronPowerSaveBlocker; + return yield* blocker.setKeepAwake(keepAwake); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0616184ec74..57a8a9cbea2 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -25,6 +25,7 @@ import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; import * as ElectronMenu from "./electron/ElectronMenu.ts"; import * as ElectronPowerMonitor from "./electron/ElectronPowerMonitor.ts"; +import * as ElectronPowerSaveBlocker from "./electron/ElectronPowerSaveBlocker.ts"; import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts"; import * as ElectronShell from "./electron/ElectronShell.ts"; @@ -118,6 +119,7 @@ const electronLayer = Layer.mergeAll( ElectronDialog.layer, ElectronMenu.layer, ElectronPowerMonitor.layer, + ElectronPowerSaveBlocker.layer, ElectronProtocol.layer, ElectronSafeStorage.layer, ElectronShell.layer, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 9f01baeed90..587cc824c2b 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -99,6 +99,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), + setKeepAwake: (keepAwake) => ipcRenderer.invoke(IpcChannels.SET_KEEP_AWAKE_CHANNEL, keepAwake), showContextMenu: (items, position) => ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, { items, diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 53ef74f2191..5a8f0f277c8 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -14,6 +14,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { autoOpenPlanSidebar: false, + caffeinateWhileAgentsRunning: false, confirmThreadArchive: true, confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 3aedd2ea6c0..9953909bf31 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -38,6 +38,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; +import * as ElectronPowerSaveBlocker from "../electron/ElectronPowerSaveBlocker.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; @@ -58,6 +59,16 @@ const environmentInput = { runningUnderArm64Translation: false, } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; +const electronPowerSaveBlockerLayer = Layer.sync( + ElectronPowerSaveBlocker.ElectronPowerSaveBlocker, + () => + ElectronPowerSaveBlocker.make({ + start: () => 1, + stop: () => true, + isStarted: () => true, + }), +); + function makeFakeBrowserWindow() { const windowListeners = new Map void>(); const webContentsListeners = new Map void>(); @@ -249,6 +260,7 @@ function makeTestLayer(input: { desktopServerExposureLayer, DesktopState.layer, electronMenuLayer, + electronPowerSaveBlockerLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: (url) => Effect.sync(() => { @@ -347,6 +359,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n DesktopAppSettings.layerTest(), desktopServerExposureLayer, electronMenuLayer, + electronPowerSaveBlockerLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: () => Effect.succeed(true), copyText: () => Effect.void, diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 3bf746a8e9b..f81a11217b6 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -12,6 +12,7 @@ import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; +import * as ElectronPowerSaveBlocker from "../electron/ElectronPowerSaveBlocker.ts"; import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; @@ -251,6 +252,7 @@ export const make = Effect.gen(function* () { const electronShell = yield* ElectronShell.ElectronShell; const electronTheme = yield* ElectronTheme.ElectronTheme; const electronWindow = yield* ElectronWindow.ElectronWindow; + const powerSaveBlocker = yield* ElectronPowerSaveBlocker.ElectronPowerSaveBlocker; const previewManager = yield* PreviewManager.PreviewManager; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; // Window-side latch for the primary backend's readiness. Set by @@ -650,6 +652,10 @@ export const make = Effect.gen(function* () { // that dies immediately on boot cannot reload-loop forever. runFork( Effect.gen(function* () { + // The gone renderer can no longer release the keep-awake assertion + // it may hold; drop it here so a dead window cannot pin the machine + // awake. A recovered renderer re-sends its computed state on mount. + yield* powerSaveBlocker.setKeepAwake(false); const now = yield* Clock.currentTimeMillis; rendererRecoveryTimestamps = rendererRecoveryTimestamps.filter( (timestamp) => now - timestamp < RENDERER_RECOVERY_WINDOW_MS, @@ -696,6 +702,7 @@ export const make = Effect.gen(function* () { window.on("closed", () => { clearDevelopmentLoadRetry(); clearBoundsPersist(); + void runPromise(powerSaveBlocker.setKeepAwake(false)); void runPromise(electronWindow.clearMain(Option.some(window))); }); diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.environments.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.environments.ts index 2fb9c16b654..617ee278a90 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.environments.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.environments.ts @@ -1,9 +1,8 @@ -import type { ConnectionCatalogEntry } from "@t3tools/client-runtime/connection"; import type { ServerConfig } from "@t3tools/contracts"; import { useMemo } from "react"; import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; -import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; +import { isDesktopHostedConnectionTarget } from "~/connection/desktopLocal"; import { buildLocalEnvironmentUpdateGroups, deriveEnvironmentDisplayLabel, @@ -12,16 +11,6 @@ import { type LocalEnvironmentUpdateGroup, } from "./ProviderUpdateLaunchNotification.logic"; -/** - * A local environment is either the same-origin primary backend or a - * desktop-local secondary (the parallel WSL backend), which connects over - * loopback with a bearer token and carries a `local:` - * connection id. SSH, relay, and other remote targets are excluded. - */ -function isLocalConnectionTarget(target: ConnectionCatalogEntry["target"]): boolean { - return target._tag === "PrimaryConnectionTarget" || isDesktopLocalConnectionTarget(target); -} - function normalizeConnectionState(phase: string | undefined): EnvironmentUpdateConnectionState { switch (phase) { case "connected": @@ -58,7 +47,7 @@ export function useLocalEnvironmentUpdateGroups(): { const inputs: LocalEnvironmentProvidersInput[] = []; for (const environment of environments) { - if (!isLocalConnectionTarget(environment.entry.target)) { + if (!isDesktopHostedConnectionTarget(environment.entry.target)) { continue; } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 9a5fe319568..add3118bd3c 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -634,6 +634,10 @@ export function useSettingsRestore(onRestored?: () => void) { ? ["Provider update checks"] : []), ...(isBackgroundActivityDirty ? ["Background activity"] : []), + ...(settings.caffeinateWhileAgentsRunning !== + DEFAULT_UNIFIED_SETTINGS.caffeinateWhileAgentsRunning + ? ["Caffeinate while agents are running"] + : []), ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? ["New thread mode"] : []), @@ -656,6 +660,7 @@ export function useSettingsRestore(onRestored?: () => void) { isTextGenerationModelDirty, isBackgroundActivityDirty, settings.autoOpenPlanSidebar, + settings.caffeinateWhileAgentsRunning, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, @@ -705,6 +710,7 @@ export function useSettingsRestore(onRestored?: () => void) { enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, + caffeinateWhileAgentsRunning: DEFAULT_UNIFIED_SETTINGS.caffeinateWhileAgentsRunning, backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile, automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, providerHealthRefreshInterval: DEFAULT_UNIFIED_SETTINGS.providerHealthRefreshInterval, @@ -1605,6 +1611,14 @@ export function GeneralSettingsPanel() { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false); + // Gate on the capability, not just the bridge: setKeepAwake is optional in + // the contract so a newer renderer degrades gracefully against an older + // desktop shell, and showing the toggle then would wire it to nothing. + const supportsKeepAwake = isElectron && typeof window.desktopBridge?.setKeepAwake === "function"; + const caffeinateDevice = + typeof navigator !== "undefined" && isMacPlatform(navigator.platform) + ? "your Mac" + : "your computer"; const lastEnabledProjectGroupingMode = useRef( readLastEnabledProjectGroupingMode(), ); @@ -1897,6 +1911,36 @@ export function GeneralSettingsPanel() { } /> + {supportsKeepAwake ? ( + + updateSettings({ + caffeinateWhileAgentsRunning: + DEFAULT_UNIFIED_SETTINGS.caffeinateWhileAgentsRunning, + }) + } + /> + ) : null + } + control={ + + updateSettings({ caffeinateWhileAgentsRunning: Boolean(checked) }) + } + aria-label="Caffeinate while agents are running" + /> + } + /> + ) : null} + + {isElectron ? : null} {primaryEnvironmentAuthenticated ? : null} @@ -144,6 +147,19 @@ function RootRouteView() { ); } +function DesktopCaffeinationSync() { + const enabled = useClientSettings((settings) => settings.caffeinateWhileAgentsRunning); + // Mount the driver only while the setting is on so the thread-shell scan and + // battery listeners cost nothing in the default (off) configuration; the + // driver's unmount cleanup releases any held assertion when it flips off. + return enabled ? : null; +} + +function DesktopCaffeinationDriver() { + useDesktopCaffeination(); + return null; +} + function GlassAppearanceSync() { const glassOpacity = useClientSettings((settings) => settings.glassOpacity); diff --git a/apps/web/src/state/desktopCaffeinate.test.ts b/apps/web/src/state/desktopCaffeinate.test.ts new file mode 100644 index 00000000000..bc4be479d68 --- /dev/null +++ b/apps/web/src/state/desktopCaffeinate.test.ts @@ -0,0 +1,125 @@ +import { + BearerConnectionTarget, + PrimaryConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + desktopLocalConnectionId, + isDesktopHostedConnectionTarget, +} from "../connection/desktopLocal"; +import { + computeKeepAwake, + isThreadShellWorking, + KEEP_AWAKE_BATTERY_MIN_LEVEL, +} from "./desktopCaffeinate"; + +function shellWithSession( + session: Partial> | null, +): Pick { + return { session: session as EnvironmentThreadShell["session"] }; +} + +describe("computeKeepAwake", () => { + const working = { enabled: true, anyLocalAgentWorking: true }; + + it("stays off while the setting is disabled or no local agent is working", () => { + expect(computeKeepAwake({ enabled: false, anyLocalAgentWorking: true, battery: null })).toBe( + false, + ); + expect(computeKeepAwake({ enabled: true, anyLocalAgentWorking: false, battery: null })).toBe( + false, + ); + }); + + it("releases when discharging below the battery cutoff", () => { + expect(computeKeepAwake({ ...working, battery: { charging: false, level: 0.09 } })).toBe(false); + }); + + it("holds at exactly the battery cutoff", () => { + expect( + computeKeepAwake({ + ...working, + battery: { charging: false, level: KEEP_AWAKE_BATTERY_MIN_LEVEL }, + }), + ).toBe(true); + }); + + it("holds while charging regardless of level", () => { + expect(computeKeepAwake({ ...working, battery: { charging: true, level: 0.05 } })).toBe(true); + }); + + it("treats unknown battery state as OK", () => { + expect(computeKeepAwake({ ...working, battery: null })).toBe(true); + }); +}); + +describe("isThreadShellWorking", () => { + it("is idle for settled sessions and running sessions without an active turn", () => { + expect(isThreadShellWorking(shellWithSession({ status: "running", activeTurnId: null }))).toBe( + false, + ); + expect(isThreadShellWorking(shellWithSession({ status: "ready" }))).toBe(false); + expect(isThreadShellWorking(shellWithSession({ status: "idle" }))).toBe(false); + expect(isThreadShellWorking(shellWithSession(null))).toBe(false); + }); + + it("is working for a running session with an active turn", () => { + expect( + isThreadShellWorking( + shellWithSession({ status: "running", activeTurnId: TurnId.make("turn-1") }), + ), + ).toBe(true); + }); + + it("is working while a session is starting, before its first turn begins", () => { + expect(isThreadShellWorking(shellWithSession({ status: "starting", activeTurnId: null }))).toBe( + true, + ); + }); +}); + +describe("isDesktopHostedConnectionTarget", () => { + it("classifies the primary local backend as desktop-hosted", () => { + const target = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-primary"), + httpBaseUrl: "http://127.0.0.1:3773", + label: "This device", + wsBaseUrl: "ws://127.0.0.1:3773", + }); + expect(isDesktopHostedConnectionTarget(target)).toBe(true); + }); + + it("classifies a desktop-local secondary backend as desktop-hosted", () => { + const target = new BearerConnectionTarget({ + connectionId: desktopLocalConnectionId("wsl:Ubuntu"), + environmentId: EnvironmentId.make("environment-wsl"), + label: "WSL (Ubuntu)", + }); + expect(isDesktopHostedConnectionTarget(target)).toBe(true); + }); + + it("excludes saved remote, SSH, and relay environments", () => { + const saved = new BearerConnectionTarget({ + connectionId: "saved-remote", + environmentId: EnvironmentId.make("environment-saved"), + label: "Home server", + }); + const ssh = new SshConnectionTarget({ + connectionId: "ssh:home", + environmentId: EnvironmentId.make("environment-ssh"), + label: "SSH (home)", + }); + const relay = new RelayConnectionTarget({ + environmentId: EnvironmentId.make("environment-relay"), + label: "Relay", + }); + expect(isDesktopHostedConnectionTarget(saved)).toBe(false); + expect(isDesktopHostedConnectionTarget(ssh)).toBe(false); + expect(isDesktopHostedConnectionTarget(relay)).toBe(false); + }); +}); diff --git a/apps/web/src/state/desktopCaffeinate.ts b/apps/web/src/state/desktopCaffeinate.ts new file mode 100644 index 00000000000..6e61d877c73 --- /dev/null +++ b/apps/web/src/state/desktopCaffeinate.ts @@ -0,0 +1,167 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { Atom } from "effect/unstable/reactivity"; +import { useEffect, useRef, useState } from "react"; + +import { environmentCatalog } from "../connection/catalog"; +import { isDesktopHostedConnectionTarget } from "../connection/desktopLocal"; +import { useClientSettings } from "../hooks/useSettings"; +import { environmentThreadShells } from "./threads"; + +/** + * Discharging below this level releases the keep-awake assertion so the + * feature cannot drain an already-low battery; charging or recovering above + * it re-acquires automatically. + */ +export const KEEP_AWAKE_BATTERY_MIN_LEVEL = 0.1; + +export interface BatteryState { + readonly charging: boolean; + /** 0..1, per the Battery Status API. */ + readonly level: number; +} + +/** + * A thread shell counts as working while its session is busy the way the + * server defines busy: "starting" (prompt submitted, worktree/provider still + * spinning up, and the gap between queued turns) or "running" with an active + * turn. Turns parked on approvals or user input stay "running" on purpose: + * the desktop hosts the server, so it must stay awake for a remote approval + * to be able to arrive at all. + */ +export function isThreadShellWorking(shell: Pick): boolean { + const session = shell.session; + if (session == null) { + return false; + } + return ( + session.status === "starting" || (session.status === "running" && session.activeTurnId != null) + ); +} + +export function computeKeepAwake(input: { + readonly enabled: boolean; + readonly anyLocalAgentWorking: boolean; + /** `null` means the Battery Status API is unavailable; treated as OK. */ + readonly battery: BatteryState | null; +}): boolean { + if (!input.enabled || !input.anyLocalAgentWorking) { + return false; + } + return ( + input.battery === null || + input.battery.charging || + input.battery.level >= KEEP_AWAKE_BATTERY_MIN_LEVEL + ); +} + +export const anyLocalAgentWorkingAtom = Atom.make((get) => { + const entries = get(environmentCatalog.catalogValueAtom).entries; + return get(environmentThreadShells.threadShellsAtom).some((shell) => { + const entry = entries.get(shell.environmentId); + return ( + entry !== undefined && + isDesktopHostedConnectionTarget(entry.target) && + isThreadShellWorking(shell) + ); + }); +}).pipe(Atom.withLabel("desktop:any-local-agent-working")); + +interface BatteryManagerLike extends EventTarget { + readonly charging: boolean; + readonly level: number; +} + +type NavigatorWithBattery = Navigator & { + getBattery?: () => Promise; +}; + +function useBatteryState(): BatteryState | null { + const [battery, setBattery] = useState(null); + + useEffect(() => { + const getBattery = (navigator as NavigatorWithBattery).getBattery; + if (typeof getBattery !== "function") { + return; + } + let cancelled = false; + let unsubscribe: (() => void) | undefined; + getBattery + .call(navigator) + .then((manager) => { + if (cancelled) { + return; + } + const update = () => { + setBattery((previous) => + previous !== null && + previous.charging === manager.charging && + previous.level === manager.level + ? previous + : { charging: manager.charging, level: manager.level }, + ); + }; + update(); + manager.addEventListener("chargingchange", update); + manager.addEventListener("levelchange", update); + unsubscribe = () => { + manager.removeEventListener("chargingchange", update); + manager.removeEventListener("levelchange", update); + }; + }) + .catch(() => { + // Battery state stays unknown, which computeKeepAwake treats as OK. + }); + return () => { + cancelled = true; + unsubscribe?.(); + }; + }, []); + + return battery; +} + +/** + * Drives the desktop shell's keep-awake assertion from the renderer, the only + * layer that sees every environment. Each state is sent over IPC at most once + * per flip; the initial send also resets main-process state after a renderer + * reload. The assertion is released on unmount and on `pagehide`, because + * React cleanups do not run when the renderer reloads. + */ +export function useDesktopCaffeination(): void { + const enabled = useClientSettings((settings) => settings.caffeinateWhileAgentsRunning); + const anyLocalAgentWorking = useAtomValue(anyLocalAgentWorkingAtom); + const battery = useBatteryState(); + const keepAwake = computeKeepAwake({ enabled, anyLocalAgentWorking, battery }); + const lastSentRef = useRef(null); + + useEffect(() => { + const setKeepAwake = window.desktopBridge?.setKeepAwake; + if (typeof setKeepAwake !== "function") { + return; + } + const send = (value: boolean) => { + if (lastSentRef.current === value) { + return; + } + lastSentRef.current = value; + void setKeepAwake(value).catch(() => { + // Forget the failed send so the next evaluation resends; the + // main-process handler is idempotent, so an extra send is harmless. + lastSentRef.current = null; + }); + }; + send(keepAwake); + if (!keepAwake) { + return; + } + const release = () => { + send(false); + }; + window.addEventListener("pagehide", release); + return () => { + window.removeEventListener("pagehide", release); + release(); + }; + }, [keepAwake]); +} diff --git a/docs/README.md b/docs/README.md index bc359826a04..afc185fbbb5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ - [Keeping app and server in sync](./user/updating.md) - [Source control integrations](./user/source-control.md) - [Background service (Linux)](./user/background-service.md) +- [Caffeinate while agents are running](./user/desktop-caffeinate.md) - Providers: [Codex](./user/providers-codex.md) ยท [Claude](./user/providers-claude.md) Mobile app: [apps/mobile/README.md](../apps/mobile/README.md) diff --git a/docs/user/desktop-caffeinate.md b/docs/user/desktop-caffeinate.md new file mode 100644 index 00000000000..d414322164d --- /dev/null +++ b/docs/user/desktop-caffeinate.md @@ -0,0 +1,24 @@ +# Caffeinate While Agents Are Running + +The desktop app can keep your machine awake while an agent works, so a long turn does not stop +when the machine goes to sleep. + +Turn it on in Settings > General > "Caffeinate while agents are running". The setting is off by +default and only appears in the desktop app. + +## How It Works + +While the toggle is on and at least one agent on this machine has a running turn, the desktop app +holds a system sleep-prevention assertion. The display can still turn off; only system sleep is +prevented. The assertion is released as soon as the last local agent settles. + +Keep the app window open while agents run; minimizing it is fine. On macOS, closing the window +releases the assertion even though agents keep running in the background, so the machine can go +back to sleeping on its normal schedule. + +To protect your battery, the assertion shuts off when the battery is discharging below 10%. It +comes back automatically when you plug in, when the battery charges above 10%, or when a new turn +starts under those conditions. + +Agents on remote environments (SSH, relay, or another machine) do not keep this machine awake. +They keep running on their own host even if this machine sleeps. diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 43167bbf0c3..813fb8b4ca2 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1021,6 +1021,13 @@ export interface DesktopBridge { pickFolder: (options?: PickFolderOptions) => Promise; confirm: (message: string) => Promise; setTheme: (theme: DesktopTheme) => Promise; + /** + * Hold or release the app's OS sleep-prevention assertion. Driven by the + * renderer while a locally hosted agent is working; resolves to the + * resulting active state. Optional so a newer renderer degrades gracefully + * against an older desktop shell. + */ + setKeepAwake?: (keepAwake: boolean) => Promise; showContextMenu: ( items: readonly ContextMenuItem[], position?: { x: number; y: number }, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 5bd22e95f20..e8fc699cc41 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -33,6 +33,19 @@ describe("ClientSettings word wrap", () => { }); }); +describe("ClientSettings caffeinate while agents are running", () => { + it("defaults off", () => { + expect(decodeClientSettings({}).caffeinateWhileAgentsRunning).toBe(false); + }); + + it("carries the toggle through the patch", () => { + expect( + decodeClientSettingsPatch({ caffeinateWhileAgentsRunning: true }) + .caffeinateWhileAgentsRunning, + ).toBe(true); + }); +}); + describe("ClientSettings glass opacity", () => { it("defaults to a readable translucent surface", () => { expect(decodeClientSettings({}).glassOpacity).toBe(80); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index cbb547b95fb..e6c81cd1d2a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -112,6 +112,11 @@ export type FontFamilyPreference = typeof FontFamilyPreference.Type; export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // Desktop-only: hold an OS sleep-prevention assertion while a locally + // hosted agent has a running turn. Ignored by web and mobile clients. + caffeinateWhileAgentsRunning: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( @@ -748,6 +753,7 @@ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean), + caffeinateWhileAgentsRunning: Schema.optionalKey(Schema.Boolean), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),