diff --git a/src/browser/features/Settings/Sections/GeneralSection.test.tsx b/src/browser/features/Settings/Sections/GeneralSection.test.tsx index 04e62003ebf..e8b4d870374 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.test.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.test.tsx @@ -29,6 +29,7 @@ interface MockConfig { worktreeArchiveBehavior: WorktreeArchiveBehavior; chatTranscriptFullWidth: boolean; llmDebugLogs: boolean; + keepScreenAwake: boolean; } type ExperimentOverrides = Partial>; @@ -46,6 +47,11 @@ interface MockAPIClient { }) => Promise; updateChatTranscriptFullWidth: (input: { enabled: boolean }) => Promise; updateLlmDebugLogs: (input: { enabled: boolean }) => Promise; + updateKeepScreenAwake: (input: { enabled: boolean }) => Promise; + onConfigChanged: ( + input: undefined, + options?: { signal?: AbortSignal } + ) => Promise>; }; server: { getSshHost: () => Promise; @@ -234,6 +240,7 @@ interface RenderGeneralSectionOptions { coderWorkspaceArchiveBehavior?: CoderWorkspaceArchiveBehavior; worktreeArchiveBehavior?: WorktreeArchiveBehavior; chatTranscriptFullWidth?: boolean; + keepScreenAwake?: boolean; localOverrides?: ExperimentOverrides; backendOverrides?: ExperimentOverrides; children?: React.ReactNode; @@ -256,6 +263,13 @@ interface MockAPISetup { updateChatTranscriptFullWidthMock: ReturnType< typeof mock<(input: { enabled: boolean }) => Promise> >; + updateKeepScreenAwakeMock: ReturnType< + typeof mock<(input: { enabled: boolean }) => Promise> + >; + /** Mutable backing config, so tests can simulate edits made outside this section. */ + config: MockConfig; + /** Notifies every live `config.onConfigChanged` subscriber, like the backend does. */ + emitConfigChanged: () => void; } function createMockAPI( @@ -275,6 +289,7 @@ function createMockAPI( worktreeArchiveBehavior: DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR, chatTranscriptFullWidth: false, llmDebugLogs: false, + keepScreenAwake: false, ...configOverrides, }; @@ -297,6 +312,49 @@ function createMockAPI( return Promise.resolve(); }); + const updateKeepScreenAwakeMock = mock(({ enabled }: { enabled: boolean }) => { + config.keepScreenAwake = enabled; + + return Promise.resolve(); + }); + + // Minimal stand-in for the backend's config-change event stream: each subscriber + // counts its own pending notifications. + const configChangeNotifiers = new Set<() => void>(); + const emitConfigChanged = () => { + for (const notify of Array.from(configChangeNotifiers)) notify(); + }; + const onConfigChanged = (_input: undefined, options?: { signal?: AbortSignal }) => { + let pending = 0; + let wake: (() => void) | null = null; + const notify = () => { + pending += 1; + wake?.(); + }; + configChangeNotifiers.add(notify); + const done = () => { + configChangeNotifiers.delete(notify); + wake?.(); + }; + options?.signal?.addEventListener("abort", done, { once: true }); + const iterator: AsyncIterator = { + next: async () => { + while (pending === 0 && !options?.signal?.aborted) { + await new Promise((resolve) => (wake = resolve)); + wake = null; + } + if (options?.signal?.aborted) return { done: true, value: undefined }; + pending -= 1; + return { done: false, value: undefined }; + }, + return: () => { + done(); + return Promise.resolve({ done: true, value: undefined }); + }, + }; + return Promise.resolve(iterator); + }; + return { api: { experiments: { getOverrides: getOverridesMock, setOverride: setOverrideMock }, @@ -309,6 +367,8 @@ function createMockAPI( return Promise.resolve(); }), + updateKeepScreenAwake: updateKeepScreenAwakeMock, + onConfigChanged, }, server: { getSshHost: mock(() => Promise.resolve(null)), @@ -325,6 +385,9 @@ function createMockAPI( getConfigMock, updateCoderPrefsMock, updateChatTranscriptFullWidthMock, + updateKeepScreenAwakeMock, + config, + emitConfigChanged, }; } @@ -350,6 +413,7 @@ describe("GeneralSection", () => { const setup = createMockAPI( { chatTranscriptFullWidth: options.chatTranscriptFullWidth, + keepScreenAwake: options.keepScreenAwake, coderWorkspaceArchiveBehavior: options.coderWorkspaceArchiveBehavior, worktreeArchiveBehavior: options.worktreeArchiveBehavior, }, @@ -624,6 +688,152 @@ describe("GeneralSection", () => { }); }); + test("loads and persists the keep screen awake toggle", async () => { + const { updateKeepScreenAwakeMock, view } = renderGeneralSection({ + keepScreenAwake: true, + }); + + const toggle = view.getByRole("switch", { + name: "Toggle keep screen awake while agents are working", + }); + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("true"); + }); + + fireEvent.click(toggle); + + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("false"); + expect(updateKeepScreenAwakeMock).toHaveBeenCalledWith({ enabled: false }); + }); + }); + + test.each([true, false])("reverts a rejected keep-awake save from %s", async (saved) => { + const { updateKeepScreenAwakeMock, view } = renderGeneralSection({ keepScreenAwake: saved }); + const toggle = view.getByRole("switch", { + name: "Toggle keep screen awake while agents are working", + }); + await act(() => Promise.resolve()); + expect(toggle.getAttribute("aria-checked")).toBe(String(saved)); + updateKeepScreenAwakeMock.mockRejectedValueOnce(new Error("config write failed")); + + fireEvent.click(toggle); + expect(toggle.getAttribute("aria-checked")).toBe(String(!saved)); + await waitFor(() => { + expect(updateKeepScreenAwakeMock).toHaveBeenCalledWith({ enabled: !saved }); + expect(toggle.getAttribute("aria-checked")).toBe(String(saved)); + }); + }); + + test.each([true, false])( + "rolls rapid keep-awake toggles back to the confirmed value (first save succeeds: %s)", + async (firstSucceeds) => { + const { updateKeepScreenAwakeMock, view } = renderGeneralSection({ keepScreenAwake: true }); + const toggle = view.getByRole("switch", { + name: "Toggle keep screen awake while agents are working", + }); + await waitFor(() => expect(toggle.getAttribute("aria-checked")).toBe("true")); + const writes: Array<{ resolve: () => void; reject: (error: Error) => void }> = []; + updateKeepScreenAwakeMock.mockImplementation( + () => new Promise((resolve, reject) => writes.push({ resolve, reject })) + ); + + fireEvent.click(toggle); + await waitFor(() => expect(writes).toHaveLength(1)); + fireEvent.click(toggle); + fireEvent.click(toggle); + expect(toggle.getAttribute("aria-checked")).toBe("false"); + expect(writes).toHaveLength(1); + + act(() => { + if (firstSucceeds) writes[0].resolve(); + else writes[0].reject(new Error("first save failed")); + }); + await waitFor(() => expect(writes).toHaveLength(2)); + // A stale failure must not replace the newer selection, even before its write starts. + expect(toggle.getAttribute("aria-checked")).toBe("false"); + act(() => writes[1].reject(new Error("second save failed"))); + await waitFor(() => expect(writes).toHaveLength(3)); + expect(toggle.getAttribute("aria-checked")).toBe("false"); + act(() => writes[2].reject(new Error("last save failed"))); + await waitFor(() => expect(toggle.getAttribute("aria-checked")).toBe(String(!firstSucceeds))); + } + ); + + test("follows keep-awake changes made outside the mounted section", async () => { + // e.g. the "Toggle Keep Screen Awake" palette command runs while Settings is open. + const { config, emitConfigChanged, view } = renderGeneralSection({ keepScreenAwake: false }); + const toggle = view.getByRole("switch", { + name: "Toggle keep screen awake while agents are working", + }); + await waitFor(() => expect(toggle.getAttribute("aria-checked")).toBe("false")); + + config.keepScreenAwake = true; + act(() => emitConfigChanged()); + await waitFor(() => expect(toggle.getAttribute("aria-checked")).toBe("true")); + + config.keepScreenAwake = false; + act(() => emitConfigChanged()); + await waitFor(() => expect(toggle.getAttribute("aria-checked")).toBe("false")); + }); + + test("an external config change does not override an in-flight keep-awake save", async () => { + const { config, emitConfigChanged, updateKeepScreenAwakeMock, view } = renderGeneralSection({ + keepScreenAwake: false, + }); + const toggle = view.getByRole("switch", { + name: "Toggle keep screen awake while agents are working", + }); + await waitFor(() => expect(toggle.getAttribute("aria-checked")).toBe("false")); + let finishWrite: (() => void) | null = null; + updateKeepScreenAwakeMock.mockImplementation(({ enabled }) => { + return new Promise((resolve) => { + finishWrite = () => { + config.keepScreenAwake = enabled; + resolve(); + }; + }); + }); + + fireEvent.click(toggle); + await waitFor(() => expect(finishWrite).not.toBeNull()); + // An unrelated config edit lands while our write is still pending (disk still says false). + act(() => emitConfigChanged()); + await act(() => new Promise((resolve) => setTimeout(resolve, 0))); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + + act(() => finishWrite?.()); + act(() => emitConfigChanged()); + await act(() => new Promise((resolve) => setTimeout(resolve, 0))); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + }); + + test("replays an external keep-awake change that landed during a local save", async () => { + const { config, emitConfigChanged, updateKeepScreenAwakeMock, view } = renderGeneralSection({ + keepScreenAwake: false, + }); + const toggle = view.getByRole("switch", { + name: "Toggle keep screen awake while agents are working", + }); + await waitFor(() => expect(toggle.getAttribute("aria-checked")).toBe("false")); + let resolveWrite: (() => void) | null = null; + updateKeepScreenAwakeMock.mockImplementation(({ enabled }) => { + // The backend applies our write right away, but its response is still in flight. + config.keepScreenAwake = enabled; + return new Promise((resolve) => (resolveWrite = resolve)); + }); + + fireEvent.click(toggle); + await waitFor(() => expect(resolveWrite).not.toBeNull()); + // The palette command then turns it back off before our response arrives. + config.keepScreenAwake = false; + act(() => emitConfigChanged()); + await act(() => new Promise((resolve) => setTimeout(resolve, 0))); + + act(() => resolveWrite?.()); + await waitFor(() => expect(toggle.getAttribute("aria-checked")).toBe("false")); + }); + test("renders the worktree archive behavior copy and loads the saved value", async () => { const { view } = renderGeneralSection({ coderWorkspaceArchiveBehavior: "delete", diff --git a/src/browser/features/Settings/Sections/GeneralSection.tsx b/src/browser/features/Settings/Sections/GeneralSection.tsx index 6adb90a7f7b..a7d0b4d6254 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -301,6 +301,7 @@ export function GeneralSection() { const [archiveSettingsLoaded, setArchiveSettingsLoaded] = useState(false); const [chatTranscriptFullWidth, setChatTranscriptFullWidth] = useState(false); const [llmDebugLogs, setLlmDebugLogs] = useState(false); + const [keepScreenAwake, setKeepScreenAwake] = useState(false); const archiveBehaviorLoadNonceRef = useRef(0); const archiveBehaviorRef = useRef(DEFAULT_CODER_ARCHIVE_BEHAVIOR); const worktreeArchiveBehaviorRef = useRef( @@ -309,12 +310,21 @@ export function GeneralSection() { const chatTranscriptFullWidthLoadNonceRef = useRef(0); const llmDebugLogsLoadNonceRef = useRef(0); + const keepScreenAwakeLoadNonceRef = useRef(0); + const keepScreenAwakeSavedRef = useRef(false); + // Local saves still in flight; an external refresh must not replace the user's newest choice. + const keepScreenAwakePendingWritesRef = useRef(0); + // An external config change arrived while local saves were pending; replay it once they settle + // so a later value accepted by the backend (e.g. from the palette command) is not lost. + const keepScreenAwakeRefreshDeferredRef = useRef(false); + const keepScreenAwakeRefreshRef = useRef<(() => Promise) | null>(null); // updateCoderPrefs writes config.json on the backend. Serialize (and coalesce) updates so rapid // selections can't race and persist a stale value via out-of-order writes. const archiveBehaviorUpdateChainRef = useRef>(Promise.resolve()); const chatTranscriptFullWidthUpdateChainRef = useRef>(Promise.resolve()); const llmDebugLogsUpdateChainRef = useRef>(Promise.resolve()); + const keepScreenAwakeUpdateChainRef = useRef>(Promise.resolve()); const archiveBehaviorPendingUpdateRef = useRef( undefined ); @@ -331,6 +341,7 @@ export function GeneralSection() { const archiveBehaviorNonce = ++archiveBehaviorLoadNonceRef.current; const chatTranscriptFullWidthNonce = ++chatTranscriptFullWidthLoadNonceRef.current; const llmDebugLogsNonce = ++llmDebugLogsLoadNonceRef.current; + const keepScreenAwakeNonce = ++keepScreenAwakeLoadNonceRef.current; void api.config .getConfig() @@ -366,6 +377,11 @@ export function GeneralSection() { if (llmDebugLogsNonce === llmDebugLogsLoadNonceRef.current) { setLlmDebugLogs(cfg.llmDebugLogs === true); } + + if (keepScreenAwakeNonce === keepScreenAwakeLoadNonceRef.current) { + keepScreenAwakeSavedRef.current = cfg.keepScreenAwake === true; + setKeepScreenAwake(keepScreenAwakeSavedRef.current); + } }) .catch(() => { if (archiveBehaviorNonce === archiveBehaviorLoadNonceRef.current) { @@ -500,6 +516,113 @@ export function GeneralSection() { }); }; + const handleKeepScreenAwakeChange = (checked: boolean) => { + if (!api) { + return; + } + // Invalidate any in-flight config load so it does not overwrite the user's selection. + const nonce = ++keepScreenAwakeLoadNonceRef.current; + setKeepScreenAwake(checked); + + // Serialize writes, but only roll back the latest selection when persistence fails. + keepScreenAwakePendingWritesRef.current++; + keepScreenAwakeUpdateChainRef.current = keepScreenAwakeUpdateChainRef.current + .then(async () => { + await api.config.updateKeepScreenAwake({ enabled: checked }); + keepScreenAwakeSavedRef.current = checked; + }) + .catch(() => { + if (nonce === keepScreenAwakeLoadNonceRef.current) { + setKeepScreenAwake(keepScreenAwakeSavedRef.current); + } + }) + .finally(() => { + keepScreenAwakePendingWritesRef.current--; + assert( + keepScreenAwakePendingWritesRef.current >= 0, + "keep-awake pending write count went negative" + ); + if ( + keepScreenAwakePendingWritesRef.current === 0 && + keepScreenAwakeRefreshDeferredRef.current + ) { + keepScreenAwakeRefreshDeferredRef.current = false; + return keepScreenAwakeRefreshRef.current?.().catch(() => { + // Best-effort: keep the last known value and wait for the next change. + }); + } + }); + }; + + // The setting can also change outside this section (the "Toggle Keep Screen Awake" palette + // command runs while Settings is open), so follow config changes instead of showing a stale + // switch whose next click would rewrite the current value rather than toggle it. + useEffect(() => { + const onConfigChanged = api?.config?.onConfigChanged; + if (!api || onConfigChanged == null) { + return; + } + const abortController = new AbortController(); + const signal = abortController.signal; + + const refresh = async () => { + // Defer while our own saves are in flight: disk may still hold an older value. The last + // save to settle replays this refresh, so a newer external value still wins. + if (keepScreenAwakePendingWritesRef.current > 0) { + keepScreenAwakeRefreshDeferredRef.current = true; + return; + } + const nonce = keepScreenAwakeLoadNonceRef.current; + const cfg = await api.config.getConfig(); + if (signal.aborted) { + return; + } + if ( + nonce !== keepScreenAwakeLoadNonceRef.current || + keepScreenAwakePendingWritesRef.current > 0 + ) { + // A local click started a save during the read; re-read once it settles. + keepScreenAwakeRefreshDeferredRef.current = true; + return; + } + keepScreenAwakeSavedRef.current = cfg.keepScreenAwake === true; + setKeepScreenAwake(keepScreenAwakeSavedRef.current); + }; + keepScreenAwakeRefreshRef.current = refresh; + + let iterator: AsyncIterator | null = null; + const listen = async () => { + try { + iterator = await onConfigChanged(undefined, { signal }); + if (signal.aborted) { + await iterator.return?.(); + return; + } + for (;;) { + const event = await iterator.next(); + if (event.done || signal.aborted) { + return; + } + await refresh().catch(() => { + // Best-effort: keep the last known value and wait for the next change. + }); + } + } catch { + // Subscription cancellation is expected during unmount/API reconnects. + } + }; + // Settles on its own once aborted; errors are handled inside listen(). + void listen(); + + return () => { + abortController.abort(); + void iterator?.return?.(); + if (keepScreenAwakeRefreshRef.current === refresh) { + keepScreenAwakeRefreshRef.current = null; + } + }; + }, [api]); + // Load SSH host from server on mount (browser mode only) useEffect(() => { if (isBrowserMode && api) { @@ -987,6 +1110,31 @@ export function GeneralSection() { +
+

System

+
+
+
+
+ Keep screen awake while agents are working +
+
+ {/* Electron's display-sleep blocker does not guarantee the OS lock policy is + suppressed, so only promise display/system sleep. */} + Prevents display and system sleep while any chat is streaming or waiting on + background bash or workflow activity. Released as soon as all agents are idle. + Desktop app only. +
+
+ +
+
+
+

Archiving

diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index 5016cac5b48..9ac9468477a 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -170,6 +170,8 @@ export interface MockORPCClientOptions { worktreeArchiveBehavior?: WorktreeArchiveBehavior; /** Initial full-width transcript toggle for config.getConfig */ chatTranscriptFullWidth?: boolean; + /** Initial keep-screen-awake toggle for config.getConfig */ + keepScreenAwake?: boolean; /** Initial runtime enablement for config.getConfig */ runtimeEnablement?: Record; /** Initial default runtime for config.getConfig (global) */ @@ -424,6 +426,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl coderWorkspaceArchiveBehavior: initialCoderWorkspaceArchiveBehavior = "stop", worktreeArchiveBehavior: initialWorktreeArchiveBehavior = "keep", chatTranscriptFullWidth: initialChatTranscriptFullWidth = false, + keepScreenAwake: initialKeepScreenAwake = false, runtimeEnablement: initialRuntimeEnablement, defaultRuntime: initialDefaultRuntime, heartbeatDefaultPrompt: initialHeartbeatDefaultPrompt, @@ -576,6 +579,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl let coderWorkspaceArchiveBehavior = initialCoderWorkspaceArchiveBehavior; let worktreeArchiveBehavior = initialWorktreeArchiveBehavior; let chatTranscriptFullWidth = initialChatTranscriptFullWidth; + let keepScreenAwake = initialKeepScreenAwake; let runtimeEnablement: Record = initialRuntimeEnablement ?? { local: true, worktree: true, @@ -824,6 +828,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl chatTranscriptFullWidth, muxGovernorEnrolled, llmDebugLogs: false, + keepScreenAwake, }), saveConfig: (input: { taskSettings?: unknown; @@ -926,6 +931,11 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl notifyConfigChanged(); return Promise.resolve(undefined); }, + updateKeepScreenAwake: (input: { enabled: boolean }) => { + keepScreenAwake = input.enabled; + notifyConfigChanged(); + return Promise.resolve(undefined); + }, updateCoderPrefs: (input: { coderWorkspaceArchiveBehavior: CoderWorkspaceArchiveBehavior; worktreeArchiveBehavior: WorktreeArchiveBehavior; diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts index 520d1626d9e..b07f9d874cd 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -96,6 +96,7 @@ export const CommandIds = { // Settings commands settingsOpen: () => "settings:open" as const, settingsOpenSection: (section: string) => `settings:open:${section}` as const, + settingsToggleKeepScreenAwake: () => "settings:toggle-keep-screen-awake" as const, coderDisconnect: () => "providers:coder:disconnect" as const, coderRefreshModels: () => "providers:coder:refresh-models" as const, diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts index f5e1664cd23..17c944f1fb2 100644 --- a/src/browser/utils/commands/sources.test.ts +++ b/src/browser/utils/commands/sources.test.ts @@ -1216,6 +1216,32 @@ test("buildCoreSources includes rebuild analytics database action with discovera expect(rebuildAction?.keywords).toContain("stats"); }); +test("toggle keep screen awake command inverts the persisted config flag", async () => { + let keepScreenAwake = false; + const updateKeepScreenAwake = mock((input: { enabled: boolean }) => { + keepScreenAwake = input.enabled; + return Promise.resolve(); + }); + const actions = getActions({ + api: { + config: { + getConfig: () => Promise.resolve({ keepScreenAwake }), + updateKeepScreenAwake, + }, + } as unknown as APIClient, + }); + const toggleAction = actions.find((a) => a.id === "settings:toggle-keep-screen-awake"); + + expect(toggleAction).toBeDefined(); + await toggleAction!.run(); + expect(updateKeepScreenAwake).toHaveBeenLastCalledWith({ enabled: true }); + + // Reads the current backend value each time instead of tracking local state. + await toggleAction!.run(); + expect(updateKeepScreenAwake).toHaveBeenLastCalledWith({ enabled: false }); + expect(keepScreenAwake).toBe(false); +}); + test("analytics rebuild command calls route and dispatches toast feedback", async () => { const rebuildDatabase = mock(() => Promise.resolve({ success: true, workspacesIngested: 4 })); diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 4f5360dc47d..e4d7ca461ea 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -1727,6 +1727,23 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi }, ]); + // Keyboard route for the Settings → General → System switch. + actions.push(() => [ + { + id: CommandIds.settingsToggleKeepScreenAwake(), + title: "Toggle Keep Screen Awake", + subtitle: "Prevent display sleep while agents are working (desktop app only)", + section: section.settings, + keywords: ["awake", "sleep", "screen", "display", "lock", "power", "caffeinate"], + run: async () => { + if (!p.api) return; + // The flag lives in config.json (not localStorage), so read the current value first. + const cfg = await p.api.config.getConfig(); + await p.api.config.updateKeepScreenAwake({ enabled: !cfg.keepScreenAwake }); + }, + }, + ]); + // Settings if (p.onOpenSettings) { const openSettings = p.onOpenSettings; diff --git a/src/common/config/schemas/appConfigOnDisk.ts b/src/common/config/schemas/appConfigOnDisk.ts index 721e3c6a221..b17e1b4c55f 100644 --- a/src/common/config/schemas/appConfigOnDisk.ts +++ b/src/common/config/schemas/appConfigOnDisk.ts @@ -154,6 +154,8 @@ export const AppConfigOnDiskSchema = z chatTranscriptFullWidth: z.boolean().optional(), muxGatewayEnabled: z.boolean().optional(), llmDebugLogs: z.boolean().optional(), + /** Desktop only: hold a display-sleep blocker while any local agent is working. */ + keepScreenAwake: z.boolean().optional(), heartbeatDefaultPrompt: z.string().optional(), heartbeatDefaultIntervalMs: z .number() diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index b20186914d0..887b7d8de54 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -2640,6 +2640,7 @@ export const config = { muxGovernorEnrolled: z.boolean(), chatTranscriptFullWidth: z.boolean(), llmDebugLogs: z.boolean(), + keepScreenAwake: z.boolean(), heartbeatDefaultPrompt: z.string().optional(), heartbeatDefaultIntervalMs: z.number().optional(), goalDefaults: GoalDefaultsConfigSchema, @@ -2774,6 +2775,7 @@ export const config = { }, updateChatTranscriptFullWidth: booleanToggleRoute, updateLlmDebugLogs: booleanToggleRoute, + updateKeepScreenAwake: booleanToggleRoute, updateHeartbeatDefaultPrompt: { input: z .object({ diff --git a/src/common/types/project.ts b/src/common/types/project.ts index e7c50ca4139..43b62dbc8f0 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -93,6 +93,11 @@ export interface ProjectsConfig { muxGatewayEnabled?: boolean; /** Enable recording AI SDK devtools logs to ~/.xum/sessions//devtools.jsonl */ llmDebugLogs?: boolean; + /** + * Desktop only: keep the display (and system) awake while any local workspace is + * streaming or waiting on background bash / workflow activity. Absent = off. + */ + keepScreenAwake?: boolean; /** Default heartbeat prompt used when a workspace heartbeat does not set its own message. */ heartbeatDefaultPrompt?: string; /** Default heartbeat interval used when a workspace heartbeat does not set its own cadence. */ diff --git a/src/common/utils/workspaceActivity.ts b/src/common/utils/workspaceActivity.ts new file mode 100644 index 00000000000..8e6964a8f1c --- /dev/null +++ b/src/common/utils/workspaceActivity.ts @@ -0,0 +1,24 @@ +import type { WorkspaceActivitySnapshot } from "@/common/types/workspace"; + +/** + * Whether a workspace is "working": an agent stream is active, or the workspace is + * waiting to be woken by an armed background bash monitor or an unfinished workflow + * run. Backend consumers that gate side effects on agent activity (heartbeats, the + * desktop keep-awake blocker) share it so they agree with each other. Unlike the + * sidebar's "working" badge, a stream paused on `ask_user_question` still counts as + * busy, because the workspace activity stream does not carry that pause. + * + * `null`/`undefined` (workspace removed or never seen) is idle. + */ +export function isWorkspaceActivityBusy( + activity: WorkspaceActivitySnapshot | null | undefined +): boolean { + if (!activity) { + return false; + } + return ( + activity.streaming || + (activity.activeBashMonitorCount ?? 0) > 0 || + (activity.activeWorkflowRunCount ?? 0) > 0 + ); +} diff --git a/src/desktop/keepAwake.test.ts b/src/desktop/keepAwake.test.ts new file mode 100644 index 00000000000..d952ded174d --- /dev/null +++ b/src/desktop/keepAwake.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import type { WorkspaceActivitySnapshot } from "@/common/types/workspace"; +import { AssertionError } from "@/common/utils/assert"; +import { KeepAwakeController, type PowerSaveBlockerLike } from "./keepAwake"; + +class FakeBlocker implements PowerSaveBlockerLike { + private nextId = 1; + private readonly running = new Set(); + readonly startCalls: string[] = []; + readonly startedIds: number[] = []; + readonly stopCalls: number[] = []; + /** When false, `isStarted` lies about freshly started ids (defensive-path test). */ + reportsStarted = true; + + start(type: "prevent-display-sleep"): number { + const id = this.nextId++; + this.running.add(id); + this.startCalls.push(type); + this.startedIds.push(id); + return id; + } + + stop(id: number): boolean { + this.stopCalls.push(id); + return this.running.delete(id); + } + + isStarted(id: number): boolean { + return this.reportsStarted && this.running.has(id); + } + + get runningCount(): number { + return this.running.size; + } +} + +function snapshot(overrides: Partial = {}): WorkspaceActivitySnapshot { + return { + recency: 0, + streaming: false, + lastModel: null, + lastThinkingLevel: null, + ...overrides, + }; +} + +interface Harness { + blocker: FakeBlocker; + controller: KeepAwakeController; + emit: (workspaceId: string, activity: WorkspaceActivitySnapshot | null) => boolean; + setEnabled: (enabled: boolean) => void; + activityListenerCount: () => number; +} + +function createHarness(options: { + enabled?: boolean; + activityList?: () => Promise | null>; +}): Harness { + const blocker = new FakeBlocker(); + const activityEmitter = new EventEmitter(); + const configEmitter = new EventEmitter(); + let enabled = options.enabled ?? false; + + const controller = new KeepAwakeController({ + blocker, + isEnabled: () => enabled, + onEnabledChanged: (callback) => { + configEmitter.on("changed", callback); + return () => { + configEmitter.off("changed", callback); + }; + }, + activity: { + on: (event, listener) => activityEmitter.on(event, listener), + off: (event, listener) => activityEmitter.off(event, listener), + getActivityList: options.activityList ?? (() => Promise.resolve({})), + }, + }); + + return { + blocker, + controller, + emit: (workspaceId, activity) => activityEmitter.emit("activity", { workspaceId, activity }), + setEnabled: (next) => { + enabled = next; + configEmitter.emit("changed"); + }, + activityListenerCount: () => activityEmitter.listenerCount("activity"), + }; +} + +describe("KeepAwakeController", () => { + test("never starts a blocker while the setting is disabled", async () => { + const h = createHarness({ enabled: false }); + await h.controller.start(); + + h.emit("a", snapshot({ streaming: true })); + h.emit("b", snapshot({ activeBashMonitorCount: 1 })); + + expect(h.blocker.startCalls).toEqual([]); + expect(h.controller.isHoldingBlocker).toBe(false); + }); + + test("holds exactly one blocker across overlapping busy workspaces", async () => { + const h = createHarness({ enabled: true }); + await h.controller.start(); + + h.emit("a", snapshot({ streaming: true })); + expect(h.blocker.startCalls).toEqual(["prevent-display-sleep"]); + expect(h.controller.isHoldingBlocker).toBe(true); + + // A second busy workspace (armed bash monitor) must not start a second blocker. + h.emit("b", snapshot({ activeBashMonitorCount: 1 })); + expect(h.blocker.startCalls).toHaveLength(1); + + // First workspace goes idle: still held for the second. + h.emit("a", snapshot({ streaming: false })); + expect(h.blocker.stopCalls).toEqual([]); + expect(h.controller.isHoldingBlocker).toBe(true); + + // Last busy workspace goes idle: released exactly once. + h.emit("b", snapshot({ activeBashMonitorCount: 0 })); + expect(h.blocker.stopCalls).toEqual([h.blocker.startedIds[0]]); + expect(h.blocker.runningCount).toBe(0); + expect(h.controller.isHoldingBlocker).toBe(false); + }); + + test("workflow runs count as busy and a null activity clears the workspace", async () => { + const h = createHarness({ enabled: true }); + await h.controller.start(); + + h.emit("a", snapshot({ activeWorkflowRunCount: 2 })); + expect(h.controller.isHoldingBlocker).toBe(true); + + // Workspace removed while its workflow run was still counted. + h.emit("a", null); + expect(h.controller.isHoldingBlocker).toBe(false); + expect(h.blocker.stopCalls).toHaveLength(1); + }); + + test("ignores goal-only activity pushes", async () => { + const h = createHarness({ enabled: true }); + await h.controller.start(); + + // A goal overlay on a stale baseline must neither acquire... + h.emit("a", snapshot({ streaming: true, transientGoalOnly: true })); + expect(h.blocker.startCalls).toEqual([]); + + h.emit("a", snapshot({ streaming: true })); + expect(h.controller.isHoldingBlocker).toBe(true); + + // ...nor release while the workspace is otherwise still busy. + h.emit("a", snapshot({ streaming: false, transientGoalOnly: true })); + expect(h.controller.isHoldingBlocker).toBe(true); + expect(h.blocker.stopCalls).toEqual([]); + }); + + test("reacts to the setting toggling while workspaces are busy", async () => { + const h = createHarness({ enabled: false }); + await h.controller.start(); + + h.emit("a", snapshot({ streaming: true })); + expect(h.blocker.startCalls).toEqual([]); + + h.setEnabled(true); + expect(h.blocker.startCalls).toHaveLength(1); + expect(h.controller.isHoldingBlocker).toBe(true); + + h.setEnabled(false); + expect(h.blocker.stopCalls).toEqual([h.blocker.startedIds[0]]); + expect(h.controller.isHoldingBlocker).toBe(false); + + // Unrelated config edits while already in the wanted state are idempotent. + h.setEnabled(false); + expect(h.blocker.startCalls).toHaveLength(1); + expect(h.blocker.stopCalls).toHaveLength(1); + + h.setEnabled(true); + expect(h.blocker.startCalls).toHaveLength(2); + expect(h.blocker.startedIds[1]).not.toBe(h.blocker.startedIds[0]); + expect(h.controller.isHoldingBlocker).toBe(true); + }); + + test("seeds from the activity list and acquires for an already-streaming workspace", async () => { + const h = createHarness({ + enabled: true, + activityList: () => + Promise.resolve({ + idle: snapshot(), + streaming: snapshot({ streaming: true }), + }), + }); + await h.controller.start(); + + expect(h.blocker.startCalls).toHaveLength(1); + expect(h.controller.isHoldingBlocker).toBe(true); + + h.emit("streaming", snapshot({ streaming: false })); + expect(h.controller.isHoldingBlocker).toBe(false); + }); + + test("a live event during the seed read wins over the older snapshot", async () => { + let resolveList: (value: Record) => void = () => { + throw new Error("activity list resolver not initialised"); + }; + const h = createHarness({ + enabled: true, + activityList: () => + new Promise((resolve) => { + resolveList = resolve; + }), + }); + + const started = h.controller.start(); + // The stream the snapshot will still report as active has already ended... + h.emit("ended", snapshot({ streaming: false })); + // ...and a stream the snapshot predates has started. + h.emit("fresh", snapshot({ activeBashMonitorCount: 1 })); + resolveList({ ended: snapshot({ streaming: true }), fresh: snapshot() }); + await started; + + expect(h.controller.isHoldingBlocker).toBe(true); + // Only "fresh" is busy: releasing it must drop the blocker even though the snapshot + // claimed "ended" was streaming. + h.emit("fresh", snapshot({ activeBashMonitorCount: 0 })); + expect(h.controller.isHoldingBlocker).toBe(false); + }); + + // Desktop startup does not wait for the seed read (it can be slow on large workspace + // stores), so live events and quit can both arrive while it is still in flight. + test("live events acquire and release before the seed read settles", async () => { + let resolveList: (value: Record) => void = () => { + throw new Error("activity list resolver not initialised"); + }; + const h = createHarness({ + enabled: true, + activityList: () => + new Promise((resolve) => { + resolveList = resolve; + }), + }); + + const started = h.controller.start(); + h.emit("a", snapshot({ streaming: true })); + expect(h.controller.isHoldingBlocker).toBe(true); + h.emit("a", snapshot({ streaming: false })); + expect(h.controller.isHoldingBlocker).toBe(false); + + resolveList({}); + await started; + expect(h.blocker.startCalls).toHaveLength(1); + expect(h.controller.isHoldingBlocker).toBe(false); + }); + + test("dispose during the seed read releases and ignores the late snapshot", async () => { + let resolveList: (value: Record) => void = () => { + throw new Error("activity list resolver not initialised"); + }; + const h = createHarness({ + enabled: true, + activityList: () => + new Promise((resolve) => { + resolveList = resolve; + }), + }); + + const started = h.controller.start(); + h.emit("a", snapshot({ streaming: true })); + expect(h.controller.isHoldingBlocker).toBe(true); + + h.controller.dispose(); + expect(h.blocker.runningCount).toBe(0); + expect(h.activityListenerCount()).toBe(0); + + resolveList({ b: snapshot({ streaming: true }) }); + await started; + expect(h.blocker.startCalls).toHaveLength(1); + expect(h.controller.isHoldingBlocker).toBe(false); + }); + + test("dispose releases the blocker and stops reacting to later events", async () => { + const h = createHarness({ enabled: true }); + await h.controller.start(); + + h.emit("a", snapshot({ streaming: true })); + expect(h.controller.isHoldingBlocker).toBe(true); + + h.controller.dispose(); + expect(h.blocker.stopCalls).toEqual([h.blocker.startedIds[0]]); + expect(h.blocker.runningCount).toBe(0); + expect(h.activityListenerCount()).toBe(0); + + h.emit("b", snapshot({ streaming: true })); + h.setEnabled(true); + expect(h.blocker.startCalls).toHaveLength(1); + expect(h.controller.isHoldingBlocker).toBe(false); + + // Idempotent. + h.controller.dispose(); + expect(h.blocker.stopCalls).toHaveLength(1); + }); + + test("asserts when the blocker reports a freshly started id as not running", async () => { + const h = createHarness({ + enabled: true, + activityList: () => Promise.resolve({ a: snapshot({ streaming: true }) }), + }); + h.blocker.reportsStarted = false; + + const startOutcome = await h.controller.start().then( + () => "resolved", + (error: unknown) => error + ); + expect(startOutcome).toBeInstanceOf(AssertionError); + expect((startOutcome as AssertionError).message).toMatch(/not running/); + expect(h.controller.isHoldingBlocker).toBe(false); + + // Listener failures stay inside the controller: the emitter (WorkspaceService) must + // never see the assertion thrown from an activity event. + expect(() => h.emit("b", snapshot({ streaming: true }))).not.toThrow(); + expect(h.controller.isHoldingBlocker).toBe(false); + }); +}); diff --git a/src/desktop/keepAwake.ts b/src/desktop/keepAwake.ts new file mode 100644 index 00000000000..2502f1a5cbd --- /dev/null +++ b/src/desktop/keepAwake.ts @@ -0,0 +1,177 @@ +import type { WorkspaceActivitySnapshot } from "@/common/types/workspace"; +import { isWorkspaceActivityBusy } from "@/common/utils/workspaceActivity"; +import assert from "@/common/utils/assert"; +import { log } from "@/node/services/log"; + +/** + * Structural subset of Electron's `powerSaveBlocker` so the controller can be unit-tested + * without Electron. `prevent-display-sleep` implies `prevent-app-suspension`, so a single + * blocker type covers both the display and system idle sleep. + */ +export interface PowerSaveBlockerLike { + start(type: "prevent-display-sleep"): number; + stop(id: number): boolean; + isStarted(id: number): boolean; +} + +export interface WorkspaceActivityEvent { + workspaceId: string; + activity: WorkspaceActivitySnapshot | null; +} + +/** Structural subset of WorkspaceService the controller depends on. */ +export interface WorkspaceActivitySource { + on(event: "activity", listener: (event: WorkspaceActivityEvent) => void): unknown; + off(event: "activity", listener: (event: WorkspaceActivityEvent) => void): unknown; + getActivityList(): Promise | null>; +} + +export interface KeepAwakeControllerDeps { + blocker: PowerSaveBlockerLike; + /** Current value of the persisted opt-in (`config.keepScreenAwake`). */ + isEnabled: () => boolean; + /** Subscribe to config edits made by this process; returns the unsubscribe function. */ + onEnabledChanged: (callback: () => void) => () => void; + activity: WorkspaceActivitySource; +} + +/** + * Holds exactly one Electron display-sleep blocker while the opt-in setting is enabled AND + * at least one local workspace is working (see `isWorkspaceActivityBusy`). The blocker is + * released the moment every workspace is idle, the setting is turned off, or the app quits. + * + * Purely event-driven: no timers or grace periods, because the workspace activity stream and + * config-change notifications are deterministic signals for both edges. + */ +export class KeepAwakeController { + private readonly deps: KeepAwakeControllerDeps; + private readonly busy = new Set(); + private blockerId: number | null = null; + private subscribed = false; + private disposed = false; + private unsubscribeConfig: (() => void) | null = null; + /** + * Non-null only while `start()` awaits the activity snapshot: ids that received a live + * event during that window are newer than the snapshot and must not be overwritten by it. + */ + private liveTouchedDuringSeed: Set | null = null; + + constructor(deps: KeepAwakeControllerDeps) { + this.deps = deps; + } + + /** Test/debug visibility only. */ + get isHoldingBlocker(): boolean { + return this.blockerId !== null; + } + + async start(): Promise { + assert(!this.disposed, "KeepAwakeController.start() called after dispose()"); + assert(!this.subscribed, "KeepAwakeController.start() called twice"); + this.subscribed = true; + + // Subscribe BEFORE reading the snapshot so a stream that starts or ends while the read + // is in flight is never lost or resurrected by the (older) snapshot. + this.deps.activity.on("activity", this.onActivity); + this.unsubscribeConfig = this.deps.onEnabledChanged(this.onConfigChanged); + + this.liveTouchedDuringSeed = new Set(); + let snapshot: Record | null = null; + try { + snapshot = await this.deps.activity.getActivityList(); + } catch (error) { + // The live subscription is already active; a failed seed only delays the first + // acquire until the next activity event. + log.error("keep-awake: failed to read the initial activity list", { error }); + } + if (this.disposed) { + return; + } + const liveTouched = this.liveTouchedDuringSeed; + this.liveTouchedDuringSeed = null; + for (const [workspaceId, activity] of Object.entries(snapshot ?? {})) { + if (liveTouched.has(workspaceId)) { + continue; + } + if (isWorkspaceActivityBusy(activity)) { + this.busy.add(workspaceId); + } + } + this.reconcile(); + } + + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + if (this.subscribed) { + this.deps.activity.off("activity", this.onActivity); + } + this.unsubscribeConfig?.(); + this.unsubscribeConfig = null; + this.busy.clear(); + this.release(); + } + + // Listener bodies are isolated: a failure here must never propagate into the + // WorkspaceService emit path (which would disrupt the stream that produced the event). + private readonly onActivity = (event: WorkspaceActivityEvent): void => { + try { + this.handleActivity(event); + } catch (error) { + log.error("keep-awake: failed to handle activity event", { error }); + } + }; + + private readonly onConfigChanged = (): void => { + try { + this.reconcile(); + } catch (error) { + log.error("keep-awake: failed to reconcile after config change", { error }); + } + }; + + private handleActivity(event: WorkspaceActivityEvent): void { + // Goal-only pushes overlay a goal onto a possibly stale baseline snapshot; the renderer + // merges only the goal from them, so they carry no authoritative busy signal either. + if (event.activity?.transientGoalOnly === true) { + return; + } + this.liveTouchedDuringSeed?.add(event.workspaceId); + if (isWorkspaceActivityBusy(event.activity)) { + this.busy.add(event.workspaceId); + } else { + this.busy.delete(event.workspaceId); + } + this.reconcile(); + } + + private reconcile(): void { + if (this.disposed) { + return; + } + const want = this.deps.isEnabled() && this.busy.size > 0; + if (want && this.blockerId === null) { + const id = this.deps.blocker.start("prevent-display-sleep"); + assert( + this.deps.blocker.isStarted(id), + "powerSaveBlocker.start returned an id that is not running" + ); + this.blockerId = id; + log.debug("keep-awake: acquired display-sleep blocker", { id, busy: this.busy.size }); + } else if (!want) { + this.release(); + } + } + + private release(): void { + if (this.blockerId === null) { + return; + } + const id = this.blockerId; + this.blockerId = null; + const stopped = this.deps.blocker.stop(id); + log.debug("keep-awake: released display-sleep blocker", { id, stopped }); + } +} diff --git a/src/desktop/main.ts b/src/desktop/main.ts index 2ad382d3591..0622be93c47 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -43,6 +43,7 @@ if (process.platform === "darwin") { } import { DesktopWindowManager } from "./desktopWindowManager"; +import { KeepAwakeController } from "./keepAwake"; import { RemoteConnectionManager } from "./remoteConnectionManager"; import { REMOTE_CONNECTION_CHANNELS, @@ -75,6 +76,7 @@ import { dialog, nativeImage, nativeTheme, + powerSaveBlocker, screen, shell, } from "electron"; @@ -216,6 +218,8 @@ import { log } from "@/node/services/log"; let config: Config | null = null; let services: ServiceContainer | null = null; let remoteConnectionManager: RemoteConnectionManager | null = null; +// Holds the display-sleep blocker while local agents work (opt-in via config.keepScreenAwake). +let keepAwake: KeepAwakeController | null = null; const localIpcWindows = new Map(); function isTrustedLocalUrl(target: string, expected: URL): boolean { @@ -772,6 +776,23 @@ async function loadServices(): Promise { latestUpdateStatus = status; }); + // Backend services run in-process here, so main can observe workspace activity directly. + // Only local activity is tracked: remote-backend windows run their agents elsewhere. + keepAwake = new KeepAwakeController({ + blocker: powerSaveBlocker, + isEnabled: () => stores.config.getKeepScreenAwakeEnabled(), + onEnabledChanged: (callback) => stores.config.onConfigChanged(callback), + activity: services.workspaceService, + }); + // Do not await: the initial activity seed enumerates config and probes workspace state on + // disk, which can be slow on large stores and must not keep the window behind the splash + // screen. start() subscribes synchronously, so live events already reconcile while the + // seed is in flight, and dispose() on quit is safe mid-seed. + keepAwake.start().catch((error: unknown) => { + // Startup must never fail over an optional convenience; live events still reconcile. + log.error("keep-awake: failed to start controller", { error }); + }); + // Generate auth token (use env var or random per-session) const authToken = getXumEnv("SERVER_AUTH_TOKEN") ?? randomBytes(32).toString("hex"); @@ -1404,6 +1425,10 @@ async function startDesktopAfterStorage(): Promise { // IMPORTANT: must be set before any early returns. isQuitting = true; remoteConnectionManager?.dispose(); + // Release the display-sleep blocker before services tear down; Electron would drop it on + // exit anyway, but a slow dispose must not keep the screen awake meanwhile. + keepAwake?.dispose(); + keepAwake = null; if (isUpdateInstallInProgress()) { // Don't block updater-driven quitAndInstall() — let Electron quit immediately // so the platform installer can take over. Best-effort cleanup only. diff --git a/src/node/config/index.ts b/src/node/config/index.ts index 8f2ceb5b368..7134a9bd757 100644 --- a/src/node/config/index.ts +++ b/src/node/config/index.ts @@ -2043,6 +2043,7 @@ export class Config { chatTranscriptFullWidth: parseOptionalBoolean(parsed.chatTranscriptFullWidth), muxGatewayEnabled, llmDebugLogs: parseOptionalBoolean(parsed.llmDebugLogs), + keepScreenAwake: parseOptionalBoolean(parsed.keepScreenAwake), heartbeatDefaultPrompt: parseOptionalNonEmptyString(parsed.heartbeatDefaultPrompt), heartbeatDefaultIntervalMs: parseOptionalHeartbeatIntervalMs( parsed.heartbeatDefaultIntervalMs @@ -2152,6 +2153,11 @@ export class Config { data.llmDebugLogs = llmDebugLogs; } + // Opt-in flag: only the enabled state is written so "off" leaves no key behind. + if (parseOptionalBoolean(config.keepScreenAwake) === true) { + data.keepScreenAwake = true; + } + const heartbeatDefaultPrompt = parseOptionalNonEmptyString(config.heartbeatDefaultPrompt); if (heartbeatDefaultPrompt) { data.heartbeatDefaultPrompt = heartbeatDefaultPrompt; @@ -2570,6 +2576,7 @@ export class Config { muxGovernorEnrolled: Boolean(config.muxGovernorUrl && config.muxGovernorToken), chatTranscriptFullWidth: config.chatTranscriptFullWidth === true, llmDebugLogs: config.llmDebugLogs === true, + keepScreenAwake: config.keepScreenAwake === true, heartbeatDefaultPrompt: config.heartbeatDefaultPrompt ?? undefined, heartbeatDefaultIntervalMs: config.heartbeatDefaultIntervalMs ?? undefined, goalDefaults: normalizeGoalDefaults(config.goalDefaults ?? DEFAULT_GOAL_DEFAULTS), @@ -2606,6 +2613,14 @@ export class Config { await this.editConfig((config) => ({ ...config, llmDebugLogs: enabled })); } + async updateKeepScreenAwake(enabled: boolean): Promise { + await this.editConfig((config) => { + if (enabled) config.keepScreenAwake = true; + else delete config.keepScreenAwake; + return config; + }); + } + async updateHeartbeatDefaultPrompt(defaultPrompt: string | null | undefined): Promise { await this.editConfig((config) => { const trimmed = defaultPrompt?.trim(); @@ -3013,6 +3028,10 @@ export class Config { return this.loadConfigOrDefault().llmDebugLogs === true; } + getKeepScreenAwakeEnabled(): boolean { + return this.loadConfigOrDefault().keepScreenAwake === true; + } + async setUpdateChannel(channel: UpdateChannel): Promise { await this.editConfig((config) => { config.updateChannel = channel; diff --git a/src/node/orpc/router.test.ts b/src/node/orpc/router.test.ts index e9a0876c671..88321e52ad3 100644 --- a/src/node/orpc/router.test.ts +++ b/src/node/orpc/router.test.ts @@ -346,6 +346,23 @@ describe("router config transcript mutation", () => { expect(config.loadConfigOrDefault().chatTranscriptFullWidth).toBeUndefined(); }); + test("persists the keep-screen-awake config flag", async () => { + const client = createRouterClient(router(), { context: createContext() }); + + expect((await client.config.getConfig()).keepScreenAwake).toBe(false); + expect(config.getKeepScreenAwakeEnabled()).toBe(false); + await client.config.updateKeepScreenAwake({ enabled: true }); + expect((await client.config.getConfig()).keepScreenAwake).toBe(true); + expect(config.loadConfigOrDefault().keepScreenAwake).toBe(true); + expect(config.getKeepScreenAwakeEnabled()).toBe(true); + + // Off state removes the key entirely (absent = off) instead of persisting `false`. + await client.config.updateKeepScreenAwake({ enabled: false }); + expect((await client.config.getConfig()).keepScreenAwake).toBe(false); + expect(config.loadConfigOrDefault().keepScreenAwake).toBeUndefined(); + expect(config.getKeepScreenAwakeEnabled()).toBe(false); + }); + test("refuses procedure calls once the server has begun shutting down", async () => { let shuttingDown = false; const context = { diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 9447e837e88..a0526eca764 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -472,6 +472,14 @@ export const router = (authToken?: string) => { yield* atomicPromise(async () => context.config.updateLlmDebugLogs(input.enabled)); }) ), + updateKeepScreenAwake: t + .input(schemas.config.updateKeepScreenAwake.input) + .output(schemas.config.updateKeepScreenAwake.output) + .handler( + handlerGen(function* ({ context }, input) { + yield* atomicPromise(async () => context.config.updateKeepScreenAwake(input.enabled)); + }) + ), updateHeartbeatDefaultPrompt: t .input(schemas.config.updateHeartbeatDefaultPrompt.input) .output(schemas.config.updateHeartbeatDefaultPrompt.output)