From 9399839dd4d2c171de829af95d189491a38c9bf4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 11:50:22 -0500 Subject: [PATCH 1/5] feat(desktop): keep-computer-awake toggle + System settings section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "keep computer awake" toggle (Claude-style) for long/overnight runs: the renderer owns the device-local pref and mirrors it to the Electron main process, which holds a single `powerSaveBlocker('prevent-app-suspension')` — the same authority split as translucency. Surfaced as a statusbar quick-toggle and a Settings row. Introduce a dedicated System settings section (device-local machine prefs) and de-crowd Appearance by moving Window Translucency + UI Scale into it (both are main-process/window-owned, not visual theme). Give Haptic Feedback its first Settings home there too (the titlebar quick-toggle stays). Relocated i18n copy into `settings.system` across en/zh/zh-hant/ja; wired the `system` route into the SettingsView union + allowlist + nav. --- apps/desktop/electron/main.ts | 9 ++ apps/desktop/electron/power-save.test.ts | 58 +++++++++ apps/desktop/electron/power-save.ts | 50 ++++++++ apps/desktop/electron/preload.ts | 1 + .../src/app/settings/appearance-settings.tsx | 61 ---------- apps/desktop/src/app/settings/index.tsx | 12 ++ .../src/app/settings/system-settings.tsx | 110 ++++++++++++++++++ apps/desktop/src/app/settings/types.ts | 1 + .../app/shell/hooks/use-statusbar-items.tsx | 13 ++- apps/desktop/src/global.d.ts | 1 + apps/desktop/src/i18n/en.ts | 23 +++- apps/desktop/src/i18n/ja.ts | 22 +++- apps/desktop/src/i18n/types.ts | 19 ++- apps/desktop/src/i18n/zh-hant.ts | 22 +++- apps/desktop/src/i18n/zh.ts | 22 +++- apps/desktop/src/store/keep-awake.test.ts | 40 +++++++ apps/desktop/src/store/keep-awake.ts | 32 +++++ 17 files changed, 406 insertions(+), 90 deletions(-) create mode 100644 apps/desktop/electron/power-save.test.ts create mode 100644 apps/desktop/electron/power-save.ts create mode 100644 apps/desktop/src/app/settings/system-settings.tsx create mode 100644 apps/desktop/src/store/keep-awake.test.ts create mode 100644 apps/desktop/src/store/keep-awake.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 646eefc6eb2d9..cac3031dee6a4 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -20,6 +20,7 @@ import { nativeTheme, Notification, powerMonitor, + powerSaveBlocker, protocol, safeStorage, screen, @@ -104,6 +105,7 @@ import { } from './hardening' import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window' import { ensureMainWindow } from './main-window-lifecycle' +import { createKeepAwake } from './power-save' import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' import { @@ -8554,6 +8556,13 @@ ipcMain.on('hermes:translucency', (_event, payload) => { } }) +// Keep-awake: the renderer owns the preference; main holds the one blocker. +const keepAwake = createKeepAwake(powerSaveBlocker) + +ipcMain.on('hermes:keep-awake', (_event, on) => { + keepAwake.set(Boolean(on)) +}) + ipcMain.handle('hermes:openExternal', (_event, url) => { if (!openExternalUrl(url)) { throw new Error('Invalid external URL') diff --git a/apps/desktop/electron/power-save.test.ts b/apps/desktop/electron/power-save.test.ts new file mode 100644 index 0000000000000..97725e0ed019e --- /dev/null +++ b/apps/desktop/electron/power-save.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createKeepAwake, type PowerSaveBlockerLike } from './power-save' + +function fakeBlocker() { + let next = 1 + const started = new Set() + + const blocker: PowerSaveBlockerLike = { + isStarted: id => started.has(id), + start: vi.fn(() => { + const id = next++ + started.add(id) + + return id + }), + stop: vi.fn(id => void started.delete(id)) + } + + return { blocker, started } +} + +describe('createKeepAwake', () => { + it('starts once, is idempotent, and stops', () => { + const { blocker } = fakeBlocker() + const keepAwake = createKeepAwake(blocker) + + expect(keepAwake.isActive()).toBe(false) + expect(keepAwake.set(true)).toBe(true) + keepAwake.set(true) // idempotent — no second blocker + expect(blocker.start).toHaveBeenCalledTimes(1) + expect(blocker.start).toHaveBeenCalledWith('prevent-app-suspension') + + expect(keepAwake.set(false)).toBe(false) + keepAwake.set(false) + expect(blocker.stop).toHaveBeenCalledTimes(1) + }) + + it('re-arms after the OS dropped the blocker', () => { + const { blocker, started } = fakeBlocker() + const keepAwake = createKeepAwake(blocker) + + keepAwake.set(true) + started.clear() // system released it out from under us + expect(keepAwake.isActive()).toBe(false) + + keepAwake.set(true) + expect(blocker.start).toHaveBeenCalledTimes(2) + expect(keepAwake.isActive()).toBe(true) + }) + + it('honors a custom blocker type', () => { + const { blocker } = fakeBlocker() + createKeepAwake(blocker, 'prevent-display-sleep').set(true) + + expect(blocker.start).toHaveBeenCalledWith('prevent-display-sleep') + }) +}) diff --git a/apps/desktop/electron/power-save.ts b/apps/desktop/electron/power-save.ts new file mode 100644 index 0000000000000..0939de99d80cc --- /dev/null +++ b/apps/desktop/electron/power-save.ts @@ -0,0 +1,50 @@ +/** + * Keep-awake — hold a single machine-global power-save blocker. + * + * `prevent-app-suspension` stops the system from sleeping (long overnight + * agent runs keep going) while still letting the display dim. The renderer + * owns the preference (persisted in localStorage) and mirrors it here over + * IPC; the main process owns the one native blocker, same authority split as + * translucency/zoom. Electron auto-releases the blocker on quit. + */ + +export type KeepAwakeType = 'prevent-app-suspension' | 'prevent-display-sleep' + +/** The slice of Electron's `powerSaveBlocker` we use (injected for testing). */ +export interface PowerSaveBlockerLike { + start(type: KeepAwakeType): number + stop(id: number): void + isStarted(id: number): boolean +} + +export interface KeepAwake { + /** Turn the blocker on/off (idempotent). Returns the resulting state. */ + set(on: boolean): boolean + isActive(): boolean +} + +export function createKeepAwake( + blocker: PowerSaveBlockerLike, + type: KeepAwakeType = 'prevent-app-suspension' +): KeepAwake { + let id: null | number = null + + const isActive = () => id !== null && blocker.isStarted(id) + + return { + isActive, + set(on) { + if (on && !isActive()) { + id = blocker.start(type) + } else if (!on && id !== null) { + if (blocker.isStarted(id)) { + blocker.stop(id) + } + + id = null + } + + return isActive() + } + } +} diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 952fdfdc8dad0..732d13a53668b 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -79,6 +79,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload), setNativeTheme: mode => ipcRenderer.send('hermes:native-theme', mode), setTranslucency: payload => ipcRenderer.send('hermes:translucency', payload), + setKeepAwake: on => ipcRenderer.send('hermes:keep-awake', on), setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)), openExternal: url => ipcRenderer.invoke('hermes:openExternal', url), openPreviewInBrowser: url => ipcRenderer.invoke('hermes:openPreviewInBrowser', url), diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index 32deb34d2d478..7b37f6844dd50 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -16,8 +16,6 @@ import { $backdrop, setBackdrop } from '@/store/backdrop' import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent' import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile' import { $toolViewMode, setToolViewMode } from '@/store/tool-view' -import { $translucency, setTranslucency } from '@/store/translucency' -import { $zoomPercent, setZoomPercent } from '@/store/zoom' import { getBaseColors, useTheme } from '@/themes/context' import { installVscodeThemeFromMarketplace } from '@/themes/install' import type { DesktopTheme } from '@/themes/types' @@ -64,18 +62,6 @@ function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' }) ) } -// UI scale presets, as zoom percentages. 100 is the browser-default size; -// the ids double as the percent values sent to the main process. A Cmd/Ctrl -// +/- step landing between presets highlights nothing, and the row -// description keeps showing the exact current percent. -const UI_SCALE_PRESETS = ['90', '100', '110', '125', '150', '175'] as const - -type UiScalePreset = (typeof UI_SCALE_PRESETS)[number] - -function matchUiScalePreset(percent: number): UiScalePreset | null { - return UI_SCALE_PRESETS.find(preset => Number(preset) === percent) ?? null -} - function useDebounced(value: T, delayMs: number): T { const [debounced, setDebounced] = useState(value) @@ -245,10 +231,8 @@ export function AppearanceSettings() { const { t, isSavingLocale } = useI18n() const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme() const toolViewMode = useStore($toolViewMode) - const zoomPercent = useStore($zoomPercent) const embedMode = useStore($embedMode) const embedAllowed = useStore($embedAllowed) - const translucency = useStore($translucency) const backdrop = useStore($backdrop) const installs = useStore($marketplaceInstalls) const profiles = useStore($profiles) @@ -293,10 +277,6 @@ export function AppearanceSettings() { { id: 'off', label: a.embedsOff } ] as const satisfies readonly { id: EmbedMode; label: string }[] - const uiScaleOptions = UI_SCALE_PRESETS.map(preset => ({ id: preset, label: `${preset}%` })) - - const matchedScalePreset = matchUiScalePreset(zoomPercent) - return (
@@ -412,47 +392,6 @@ export function AppearanceSettings() { wide /> - { - triggerHaptic('selection') - setZoomPercent(Number(id)) - }} - options={uiScaleOptions} - value={matchedScalePreset ?? ('' as UiScalePreset)} - /> - } - description={a.uiScaleDesc(zoomPercent)} - title={a.uiScaleTitle} - /> - - - { - triggerHaptic('selection') - setTranslucency(Number(event.target.value)) - }} - step={5} - style={{ accentColor: 'var(--dt-primary)' }} - type="range" - value={translucency} - /> - - {translucency}% - -
- } - description={a.translucencyDesc} - title={a.translucencyTitle} - /> - setActiveView('notifications') }, + { + active: activeView === 'system', + icon: Cpu, + id: 'system', + label: t.settings.nav.system, + onSelect: () => setActiveView('system') + }, { active: activeView === 'billing', icon: BarChart3, @@ -316,6 +326,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set ) : activeView === 'notifications' ? ( + ) : activeView === 'system' ? ( + ) : activeView === 'billing' ? ( ) : activeView === 'plugins' ? ( diff --git a/apps/desktop/src/app/settings/system-settings.tsx b/apps/desktop/src/app/settings/system-settings.tsx new file mode 100644 index 0000000000000..87510e952f55b --- /dev/null +++ b/apps/desktop/src/app/settings/system-settings.tsx @@ -0,0 +1,110 @@ +import { useStore } from '@nanostores/react' + +import { SegmentedControl } from '@/components/ui/segmented-control' +import { Switch } from '@/components/ui/switch' +import { useI18n } from '@/i18n' +import { triggerHaptic } from '@/lib/haptics' +import { Cpu } from '@/lib/icons' +import { $hapticsMuted, setHapticsMuted } from '@/store/haptics' +import { $keepAwake, setKeepAwake } from '@/store/keep-awake' +import { $translucency, setTranslucency } from '@/store/translucency' +import { $zoomPercent, setZoomPercent } from '@/store/zoom' + +import { ListRow, SectionHeading, SettingsContent } from './primitives' + +// UI scale presets as zoom percentages (100 = browser default); the ids double +// as the percent sent to main. A Cmd/Ctrl +/- step between presets highlights +// nothing, and the row description keeps showing the exact current percent. +const UI_SCALE_PRESETS = ['90', '100', '110', '125', '150', '175'] as const + +type UiScalePreset = (typeof UI_SCALE_PRESETS)[number] + +function ToggleRow(props: { checked: boolean; description: string; label: string; onChange: (on: boolean) => void }) { + return ( + { + triggerHaptic('selection') + props.onChange(on) + }} + /> + } + description={props.description} + title={props.label} + /> + ) +} + +export function SystemSettings() { + const { t } = useI18n() + const s = t.settings.system + const keepAwake = useStore($keepAwake) + const translucency = useStore($translucency) + const zoomPercent = useStore($zoomPercent) + const hapticsMuted = useStore($hapticsMuted) + + const uiScaleOptions = UI_SCALE_PRESETS.map(preset => ({ id: preset, label: `${preset}%` })) + const matchedScale = UI_SCALE_PRESETS.find(preset => Number(preset) === zoomPercent) ?? ('' as UiScalePreset) + + return ( + + +

+ {s.intro} +

+ + + + { + triggerHaptic('selection') + setZoomPercent(Number(id)) + }} + options={uiScaleOptions} + value={matchedScale} + /> + } + description={s.uiScaleDesc(zoomPercent)} + title={s.uiScaleTitle} + /> + + + { + triggerHaptic('selection') + setTranslucency(Number(event.target.value)) + }} + step={5} + style={{ accentColor: 'var(--dt-primary)' }} + type="range" + value={translucency} + /> + + {translucency}% + + + } + description={s.translucencyDesc} + title={s.translucencyTitle} + /> + + setHapticsMuted(!on)} + /> +
+ ) +} diff --git a/apps/desktop/src/app/settings/types.ts b/apps/desktop/src/app/settings/types.ts index 2828609ef63fb..bc726d221bb32 100644 --- a/apps/desktop/src/app/settings/types.ts +++ b/apps/desktop/src/app/settings/types.ts @@ -14,6 +14,7 @@ export type SettingsView = | 'plugins' | 'providers' | 'sessions' + | 'system' | `config:${string}` export type EnvPatch = Partial> diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 7d73180217a71..94485b4fef452 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -9,11 +9,12 @@ import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel' import { Codicon } from '@/components/ui/codicon' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { useI18n } from '@/i18n' -import { Activity, AlertCircle, Clock, Command, FolderOpen, Hash, Loader2, Terminal } from '@/lib/icons' +import { Activity, AlertCircle, Clock, Command, FolderOpen, Hash, Loader2, Sun, Terminal } from '@/lib/icons' import type { RuntimeReadinessResult } from '@/lib/runtime-readiness' import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar' import { cn } from '@/lib/utils' import { copyFilePath, revealFile } from '@/store/file-actions' +import { $keepAwake, toggleKeepAwake } from '@/store/keep-awake' import { revealFileInTree } from '@/store/layout' import { $activeGatewayProfile } from '@/store/profile' import { $projectTree, projectNameForCwd } from '@/store/projects' @@ -90,6 +91,7 @@ export function useStatusbarItems({ const primaryActiveSessionId = useStore($activeSessionId) const activeGatewayProfile = useStore($activeGatewayProfile) const terminalTakeover = useStore($terminalTakeover) + const keepAwake = useStore($keepAwake) const primaryBusy = useStore($busy) const currentCwd = useStore($currentCwd) // Derive the workspace's project name from the already-cached project tree @@ -451,6 +453,14 @@ export function useStatusbarItems({ title: terminalTakeover ? copy.hideTerminal : copy.showTerminal, variant: 'action' }, + { + className: `w-7 justify-center px-0${keepAwake ? ' bg-accent/55 text-foreground' : ''}`, + icon: , + id: 'keep-awake', + onSelect: () => toggleKeepAwake(), + title: keepAwake ? copy.keepAwakeOn : copy.keepAwakeOff, + variant: 'action' + }, clientVersionItem, ...(backendVersionItem ? [backendVersionItem] : []) ], @@ -465,6 +475,7 @@ export function useStatusbarItems({ contextUsage, copy, currentUsage, + keepAwake, requestGateway, sessionStartedAt, gatewayState, diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index a37091ceeb49c..9202c28e7fc62 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -88,6 +88,7 @@ declare global { setTitleBarTheme?: (payload: HermesTitleBarTheme) => void setNativeTheme?: (mode: 'dark' | 'light' | 'system') => void setTranslucency?: (payload: { intensity: number }) => void + setKeepAwake?: (on: boolean) => void setPreviewShortcutActive?: (active: boolean) => void openExternal: (url: string) => Promise openPreviewInBrowser?: (url: string) => Promise diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index a0e79037f8752..94a5a71da82f0 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -325,7 +325,8 @@ export const en: Translations = { about: 'About', billing: 'Billing', notifications: 'Notifications', - plugins: 'Plugins' + plugins: 'Plugins', + system: 'System' }, plugins: { title: 'Desktop plugins', @@ -379,6 +380,19 @@ export const en: Translations = { completionSoundDesc: 'Plays when an agent turn finishes. Pick a preset and preview it here.', completionSoundPreview: 'Preview' }, + system: { + title: 'System', + intro: 'How Hermes behaves on this machine. These are device-local — each computer keeps its own settings.', + keepAwakeTitle: 'Keep computer awake', + keepAwakeDesc: 'Stop this machine from sleeping so long or overnight runs keep going. The display can still dim.', + translucencyTitle: 'Window Translucency', + translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.', + uiScaleTitle: 'UI Scale', + uiScaleDesc: (percent: number) => + `Scales text and controls across the whole app. Cmd/Ctrl with +, - and 0 also works. Current: ${percent}%.`, + hapticsTitle: 'Haptic Feedback', + hapticsDesc: 'Trackpad taps on actions and toggles. Supported hardware only (macOS).' + }, sections: { model: 'Model', chat: 'Chat', @@ -410,11 +424,6 @@ export const en: Translations = { colorModeDesc: 'Pick a fixed mode or let Hermes follow your system setting.', toolViewTitle: 'Tool Call Display', toolViewDesc: 'Product hides raw tool payloads; Technical shows full input/output.', - uiScaleTitle: 'UI Scale', - uiScaleDesc: (percent: number) => - `Scales text and controls across the whole app. Cmd/Ctrl with +, - and 0 also works. Current: ${percent}%.`, - translucencyTitle: 'Window Translucency', - translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.', backdropTitle: 'Chat Backdrop', backdropDesc: 'The faint statue image behind the conversation.', embedsTitle: 'Inline Embeds', @@ -2174,6 +2183,8 @@ export const en: Translations = { openCommandCenter: 'Open Command Center', showTerminal: 'Show terminal', hideTerminal: 'Hide terminal', + keepAwakeOn: 'Keeping awake — click to allow sleep', + keepAwakeOff: 'Keep computer awake', gateway: 'Gateway', gatewayReady: 'ready', gatewayNeedsSetup: 'needs setup', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index ed902c8ee8b7a..d8f7473fb4fa4 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -225,7 +225,8 @@ export const ja = defineLocale({ archivedChats: 'アーカイブ済みチャット', about: '情報', billing: '請求', - notifications: '通知' + notifications: '通知', + system: 'システム' }, notifications: { title: '通知', @@ -265,6 +266,18 @@ export const ja = defineLocale({ completionSoundDesc: 'エージェントのターン終了時に再生されます。プリセットを選んでここで試聴できます。', completionSoundPreview: '試聴' }, + system: { + title: 'システム', + intro: 'この端末での Hermes の動作。設定は端末ごとに保存されます。', + keepAwakeTitle: 'コンピューターをスリープさせない', + keepAwakeDesc: '本体のスリープを防ぎ、長時間や夜通しの実行を継続します。画面は暗転できます。', + translucencyTitle: 'ウィンドウの透過', + translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。', + uiScaleTitle: 'UI スケール', + uiScaleDesc: (percent: number) => `アプリ全体の文字と UI を拡大縮小します。Cmd/Ctrl と +、-、0 でも変更できます。現在: ${percent}%`, + hapticsTitle: '触覚フィードバック', + hapticsDesc: '操作やトグル時にトラックパッドの触覚を返します。対応ハードウェアのみ(macOS)。' + }, sections: { model: 'モデル', chat: 'チャット', @@ -296,11 +309,6 @@ export const ja = defineLocale({ colorModeDesc: '固定モードを選ぶか、Hermes をシステム設定に合わせます。', toolViewTitle: 'ツール呼び出しの表示', toolViewDesc: 'プロダクト表示は生のツールペイロードを隠し、テクニカル表示は入出力をすべて表示します。', - uiScaleTitle: 'UI スケール', - uiScaleDesc: (percent: number) => - `アプリ全体の文字と UI を拡大縮小します。Cmd/Ctrl と +、-、0 でも変更できます。現在: ${percent}%`, - translucencyTitle: 'ウィンドウの透過', - translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。', backdropTitle: 'チャット背景', backdropDesc: '会話の背後に表示される淡い彫像の画像。', embedsTitle: 'インライン埋め込み', @@ -2100,6 +2108,8 @@ export const ja = defineLocale({ openCommandCenter: 'コマンドセンターを開く', showTerminal: 'ターミナルを表示', hideTerminal: 'ターミナルを非表示', + keepAwakeOn: 'スリープ抑止中 — クリックで解除', + keepAwakeOff: 'コンピューターをスリープさせない', gateway: 'ゲートウェイ', gatewayReady: '準備完了', gatewayNeedsSetup: '設定が必要', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index ae2ec69bcdac1..646901b4f818b 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -284,6 +284,7 @@ export interface Translations { billing: string notifications: string plugins: string + system: string } plugins: { title: string @@ -317,6 +318,18 @@ export interface Translations { completionSoundDesc: string completionSoundPreview: string } + system: { + title: string + intro: string + keepAwakeTitle: string + keepAwakeDesc: string + translucencyTitle: string + translucencyDesc: string + uiScaleTitle: string + uiScaleDesc: (percent: number) => string + hapticsTitle: string + hapticsDesc: string + } sections: Record searchPlaceholder: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions', string> modeOptions: Record<'light' | 'dark' | 'system', ModeOptionCopy> @@ -327,10 +340,6 @@ export interface Translations { colorModeDesc: string toolViewTitle: string toolViewDesc: string - uiScaleTitle: string - uiScaleDesc: (percent: number) => string - translucencyTitle: string - translucencyDesc: string backdropTitle: string backdropDesc: string embedsTitle: string @@ -1802,6 +1811,8 @@ export interface Translations { openCommandCenter: string showTerminal: string hideTerminal: string + keepAwakeOn: string + keepAwakeOff: string gateway: string gatewayReady: string gatewayNeedsSetup: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index c30818309a426..345a7935ac7ae 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -219,7 +219,8 @@ export const zhHant = defineLocale({ archivedChats: '已封存聊天', about: '關於', billing: '帳單', - notifications: '通知' + notifications: '通知', + system: '系統' }, notifications: { title: '通知', @@ -258,6 +259,18 @@ export const zhHant = defineLocale({ completionSoundDesc: '代理回合結束時播放。可在此選擇預設並預覽。', completionSoundPreview: '預覽' }, + system: { + title: '系統', + intro: 'Hermes 在這台電腦上的行為。設定會依裝置保存,每台電腦各自獨立。', + keepAwakeTitle: '保持電腦喚醒', + keepAwakeDesc: '阻止本機睡眠,讓長時間或整夜執行持續進行。螢幕仍可變暗。', + translucencyTitle: '視窗透明', + translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。', + uiScaleTitle: '介面縮放', + uiScaleDesc: (percent: number) => `縮放整個應用程式的文字與介面。也可使用 Cmd/Ctrl 加 +、- 或 0 調整。目前:${percent}%`, + hapticsTitle: '觸覺回饋', + hapticsDesc: '在操作與開關時觸發觸控板輕觸。僅支援對應硬體(macOS)。' + }, sections: { model: '模型', chat: '聊天', @@ -288,11 +301,6 @@ export const zhHant = defineLocale({ colorModeDesc: '選擇固定模式,或讓 Hermes 跟隨系統設定。', toolViewTitle: '工具呼叫顯示', toolViewDesc: '產品模式會隱藏原始工具 payload;技術模式會顯示完整輸入/輸出。', - uiScaleTitle: '介面縮放', - uiScaleDesc: (percent: number) => - `縮放整個應用程式的文字與介面。也可使用 Cmd/Ctrl 加 +、- 或 0 調整。目前:${percent}%`, - translucencyTitle: '視窗透明', - translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。', backdropTitle: '聊天背景', backdropDesc: '對話後方那張淡淡的雕像圖片。', embedsTitle: '內嵌預覽', @@ -2033,6 +2041,8 @@ export const zhHant = defineLocale({ openCommandCenter: '開啟命令中心', showTerminal: '顯示終端機', hideTerminal: '隱藏終端機', + keepAwakeOn: '保持喚醒中 — 點擊以允許睡眠', + keepAwakeOff: '保持電腦喚醒', gateway: '閘道', gatewayReady: '就緒', gatewayNeedsSetup: '需要設定', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 253e01326c617..a019bf3992dcf 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -316,7 +316,8 @@ export const zh: Translations = { about: '关于', billing: '账单', notifications: '通知', - plugins: '插件' + plugins: '插件', + system: '系统' }, plugins: { title: '桌面插件', @@ -369,6 +370,18 @@ export const zh: Translations = { completionSoundDesc: '智能体回合结束时播放。可在此选择预设并预览。', completionSoundPreview: '预览' }, + system: { + title: '系统', + intro: 'Hermes 在这台电脑上的行为。设置按设备保存,每台电脑各自独立。', + keepAwakeTitle: '保持电脑唤醒', + keepAwakeDesc: '阻止本机休眠,让长时间或通宵运行继续进行。屏幕仍可变暗。', + translucencyTitle: '窗口透明', + translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。', + uiScaleTitle: '界面缩放', + uiScaleDesc: (percent: number) => `缩放整个应用的文字和界面。也可使用 Cmd/Ctrl 加 +、- 或 0 调整。当前:${percent}%`, + hapticsTitle: '触感反馈', + hapticsDesc: '在操作和开关时触发触控板轻触。仅支持相应硬件(macOS)。' + }, sections: { model: '模型', chat: '对话', @@ -399,11 +412,6 @@ export const zh: Translations = { colorModeDesc: '选择固定模式,或让 Hermes 跟随系统设置。', toolViewTitle: '工具调用显示', toolViewDesc: '产品模式隐藏原始工具数据;技术模式显示完整输入/输出。', - uiScaleTitle: '界面缩放', - uiScaleDesc: (percent: number) => - `缩放整个应用的文字和界面。也可使用 Cmd/Ctrl 加 +、- 或 0 调整。当前:${percent}%`, - translucencyTitle: '窗口透明', - translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。', backdropTitle: '聊天背景', backdropDesc: '对话后方那张淡淡的雕像图片。', embedsTitle: '内嵌预览', @@ -2344,6 +2352,8 @@ export const zh: Translations = { openCommandCenter: '打开命令中心', showTerminal: '显示终端', hideTerminal: '隐藏终端', + keepAwakeOn: '保持唤醒中 — 点击以允许休眠', + keepAwakeOff: '保持电脑唤醒', gateway: '网关', gatewayReady: '就绪', gatewayNeedsSetup: '需要设置', diff --git a/apps/desktop/src/store/keep-awake.test.ts b/apps/desktop/src/store/keep-awake.test.ts new file mode 100644 index 0000000000000..e6acffa9f93d3 --- /dev/null +++ b/apps/desktop/src/store/keep-awake.test.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { storedBoolean } from '@/lib/storage' + +import { $keepAwake, setKeepAwake, toggleKeepAwake } from './keep-awake' + +const KEY = 'hermes.desktop.keepAwake.v1' +const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] } +const initialHermesDesktop = desktopWindow.hermesDesktop +const setKeepAwakeBridge = vi.fn() + +beforeEach(() => { + desktopWindow.hermesDesktop = { setKeepAwake: setKeepAwakeBridge } as unknown as Window['hermesDesktop'] + setKeepAwake(false) + setKeepAwakeBridge.mockClear() +}) + +afterEach(() => { + desktopWindow.hermesDesktop = initialHermesDesktop +}) + +describe('keep-awake store', () => { + it('persists the pref and mirrors it to the main process', () => { + setKeepAwake(true) + expect($keepAwake.get()).toBe(true) + expect(storedBoolean(KEY, false)).toBe(true) + expect(setKeepAwakeBridge).toHaveBeenLastCalledWith(true) + + setKeepAwake(false) + expect(storedBoolean(KEY, true)).toBe(false) + expect(setKeepAwakeBridge).toHaveBeenLastCalledWith(false) + }) + + it('toggles the current value', () => { + toggleKeepAwake() + expect($keepAwake.get()).toBe(true) + toggleKeepAwake() + expect($keepAwake.get()).toBe(false) + }) +}) diff --git a/apps/desktop/src/store/keep-awake.ts b/apps/desktop/src/store/keep-awake.ts new file mode 100644 index 0000000000000..b0db1cbfdb556 --- /dev/null +++ b/apps/desktop/src/store/keep-awake.ts @@ -0,0 +1,32 @@ +/** + * Keep-awake — stop the machine sleeping during long, unattended runs. + * + * A device-local preference (each computer keeps its own), off by default. The + * renderer owns the value and persists it; the main process holds the actual + * power-save blocker (see electron/power-save.ts) and re-reads this on every + * window load via the subscribe below. Linux/web builds without the bridge just + * no-op. + */ + +import { atom } from 'nanostores' + +import { persistBoolean, storedBoolean } from '@/lib/storage' + +const KEY = 'hermes.desktop.keepAwake.v1' + +export const $keepAwake = atom(typeof window === 'undefined' ? false : storedBoolean(KEY, false)) + +export function setKeepAwake(on: boolean): void { + $keepAwake.set(on) +} + +export function toggleKeepAwake(): void { + $keepAwake.set(!$keepAwake.get()) +} + +if (typeof window !== 'undefined') { + $keepAwake.subscribe(on => { + persistBoolean(KEY, on) + window.hermesDesktop?.setKeepAwake?.(on) + }) +} From 9b513a3b8d9752432c69dadce998ff1ce2a70c9c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 12:31:06 -0500 Subject: [PATCH 2/5] refactor(desktop): hoist shared ToggleRow into settings primitives System + Notifications each had an identical local ToggleRow; lift one haptic-baked version into primitives and reuse it. Net -12 lines. --- .../app/settings/notifications-settings.tsx | 29 +-------------- apps/desktop/src/app/settings/primitives.tsx | 35 +++++++++++++++++++ .../src/app/settings/system-settings.tsx | 22 +----------- 3 files changed, 37 insertions(+), 49 deletions(-) diff --git a/apps/desktop/src/app/settings/notifications-settings.tsx b/apps/desktop/src/app/settings/notifications-settings.tsx index efb0bf662f6e9..b2810f691fed9 100644 --- a/apps/desktop/src/app/settings/notifications-settings.tsx +++ b/apps/desktop/src/app/settings/notifications-settings.tsx @@ -3,7 +3,6 @@ import type { ReactNode } from 'react' import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Switch } from '@/components/ui/switch' import { useI18n } from '@/i18n' import { COMPLETION_SOUND_VARIANTS, previewCompletionSound } from '@/lib/completion-sound' import { triggerHaptic } from '@/lib/haptics' @@ -20,7 +19,7 @@ import { import { notify } from '@/store/notifications' import { CONTROL_TEXT } from './constants' -import { ListRow, SectionHeading, SettingsContent } from './primitives' +import { ListRow, SectionHeading, SettingsContent, ToggleRow } from './primitives' const CAPTION = 'text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)' @@ -28,32 +27,6 @@ function Caption({ children, className }: { children: ReactNode; className?: str return

{children}

} -function ToggleRow(props: { - checked: boolean - description: string - disabled?: boolean - label: string - onChange: (on: boolean) => void -}) { - return ( - { - triggerHaptic('selection') - props.onChange(on) - }} - /> - } - description={props.description} - title={props.label} - /> - ) -} - export function NotificationsSettings() { const { t } = useI18n() const prefs = useStore($nativeNotifyPrefs) diff --git a/apps/desktop/src/app/settings/primitives.tsx b/apps/desktop/src/app/settings/primitives.tsx index 2d9d1906ef5ff..fd53b2d32b6f0 100644 --- a/apps/desktop/src/app/settings/primitives.tsx +++ b/apps/desktop/src/app/settings/primitives.tsx @@ -3,6 +3,8 @@ import type { ReactNode } from 'react' import { PageLoader } from '@/components/page-loader' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { Switch } from '@/components/ui/switch' +import { triggerHaptic } from '@/lib/haptics' import type { IconComponent } from '@/lib/icons' import { cn } from '@/lib/utils' @@ -108,6 +110,39 @@ export function ListRow({ ) } +// A labelled on/off row — the canonical device-pref switch (haptic baked in). +export function ToggleRow({ + checked, + description, + disabled, + label, + onChange +}: { + checked: boolean + description?: string + disabled?: boolean + label: string + onChange: (on: boolean) => void +}) { + return ( + { + triggerHaptic('selection') + onChange(on) + }} + /> + } + description={description} + title={label} + /> + ) +} + export function LoadingState({ label }: { label: string }) { return } diff --git a/apps/desktop/src/app/settings/system-settings.tsx b/apps/desktop/src/app/settings/system-settings.tsx index 87510e952f55b..d338f5962a60a 100644 --- a/apps/desktop/src/app/settings/system-settings.tsx +++ b/apps/desktop/src/app/settings/system-settings.tsx @@ -1,7 +1,6 @@ import { useStore } from '@nanostores/react' import { SegmentedControl } from '@/components/ui/segmented-control' -import { Switch } from '@/components/ui/switch' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { Cpu } from '@/lib/icons' @@ -10,7 +9,7 @@ import { $keepAwake, setKeepAwake } from '@/store/keep-awake' import { $translucency, setTranslucency } from '@/store/translucency' import { $zoomPercent, setZoomPercent } from '@/store/zoom' -import { ListRow, SectionHeading, SettingsContent } from './primitives' +import { ListRow, SectionHeading, SettingsContent, ToggleRow } from './primitives' // UI scale presets as zoom percentages (100 = browser default); the ids double // as the percent sent to main. A Cmd/Ctrl +/- step between presets highlights @@ -19,25 +18,6 @@ const UI_SCALE_PRESETS = ['90', '100', '110', '125', '150', '175'] as const type UiScalePreset = (typeof UI_SCALE_PRESETS)[number] -function ToggleRow(props: { checked: boolean; description: string; label: string; onChange: (on: boolean) => void }) { - return ( - { - triggerHaptic('selection') - props.onChange(on) - }} - /> - } - description={props.description} - title={props.label} - /> - ) -} - export function SystemSettings() { const { t } = useI18n() const s = t.settings.system From ac9a1014a69cb55e142f53c9ce51caa370c0d6e1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 13:40:16 -0500 Subject: [PATCH 3/5] =?UTF-8?q?refactor(desktop):=20drop=20System=20settin?= =?UTF-8?q?gs=20section;=20keep-awake=20=E2=86=92=20Advanced?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the dedicated System section: Window Translucency + UI Scale move back to Appearance, and Haptics returns to its titlebar-only home. Keep computer awake now lives as a device-local toggle at the top of Advanced (a ConfigSettings section-specific extra, like the Model block), keeping the statusbar quick-toggle. Relocated i18n back to settings.appearance / settings.config across all four locales. --- .../src/app/settings/appearance-settings.tsx | 61 +++++++++++++ .../src/app/settings/config-settings.tsx | 10 ++- apps/desktop/src/app/settings/index.tsx | 12 --- .../src/app/settings/system-settings.tsx | 90 ------------------- apps/desktop/src/app/settings/types.ts | 1 - apps/desktop/src/i18n/en.ts | 25 ++---- apps/desktop/src/i18n/ja.ts | 24 ++--- apps/desktop/src/i18n/types.ts | 19 ++-- apps/desktop/src/i18n/zh-hant.ts | 24 ++--- apps/desktop/src/i18n/zh.ts | 24 ++--- 10 files changed, 112 insertions(+), 178 deletions(-) delete mode 100644 apps/desktop/src/app/settings/system-settings.tsx diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index 7b37f6844dd50..32deb34d2d478 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -16,6 +16,8 @@ import { $backdrop, setBackdrop } from '@/store/backdrop' import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent' import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile' import { $toolViewMode, setToolViewMode } from '@/store/tool-view' +import { $translucency, setTranslucency } from '@/store/translucency' +import { $zoomPercent, setZoomPercent } from '@/store/zoom' import { getBaseColors, useTheme } from '@/themes/context' import { installVscodeThemeFromMarketplace } from '@/themes/install' import type { DesktopTheme } from '@/themes/types' @@ -62,6 +64,18 @@ function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' }) ) } +// UI scale presets, as zoom percentages. 100 is the browser-default size; +// the ids double as the percent values sent to the main process. A Cmd/Ctrl +// +/- step landing between presets highlights nothing, and the row +// description keeps showing the exact current percent. +const UI_SCALE_PRESETS = ['90', '100', '110', '125', '150', '175'] as const + +type UiScalePreset = (typeof UI_SCALE_PRESETS)[number] + +function matchUiScalePreset(percent: number): UiScalePreset | null { + return UI_SCALE_PRESETS.find(preset => Number(preset) === percent) ?? null +} + function useDebounced(value: T, delayMs: number): T { const [debounced, setDebounced] = useState(value) @@ -231,8 +245,10 @@ export function AppearanceSettings() { const { t, isSavingLocale } = useI18n() const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme() const toolViewMode = useStore($toolViewMode) + const zoomPercent = useStore($zoomPercent) const embedMode = useStore($embedMode) const embedAllowed = useStore($embedAllowed) + const translucency = useStore($translucency) const backdrop = useStore($backdrop) const installs = useStore($marketplaceInstalls) const profiles = useStore($profiles) @@ -277,6 +293,10 @@ export function AppearanceSettings() { { id: 'off', label: a.embedsOff } ] as const satisfies readonly { id: EmbedMode; label: string }[] + const uiScaleOptions = UI_SCALE_PRESETS.map(preset => ({ id: preset, label: `${preset}%` })) + + const matchedScalePreset = matchUiScalePreset(zoomPercent) + return (
@@ -392,6 +412,47 @@ export function AppearanceSettings() { wide /> + { + triggerHaptic('selection') + setZoomPercent(Number(id)) + }} + options={uiScaleOptions} + value={matchedScalePreset ?? ('' as UiScalePreset)} + /> + } + description={a.uiScaleDesc(zoomPercent)} + title={a.uiScaleTitle} + /> + + + { + triggerHaptic('selection') + setTranslucency(Number(event.target.value)) + }} + step={5} + style={{ accentColor: 'var(--dt-primary)' }} + type="range" + value={translucency} + /> + + {translucency}% + +
+ } + description={a.translucencyDesc} + title={a.translucencyTitle} + /> + )} + {/* Device-local desktop pref (not config.yaml) — lives here since keeping + the machine awake is a power-user knob. */} + {activeSectionId === 'advanced' && ( + + )} {visibleFields.length === 0 ? ( ) : ( diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index 0637eda06fcf5..f51cca30f949a 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -10,7 +10,6 @@ import { Archive, BarChart3, Bell, - Cpu, Download, Globe, Info, @@ -43,7 +42,6 @@ import { NotificationsSettings } from './notifications-settings' import { PluginsSettings } from './plugins-settings' import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings' import { SessionsSettings } from './sessions-settings' -import { SystemSettings } from './system-settings' import type { SettingsPageProps, SettingsView as SettingsViewId } from './types' const SETTINGS_VIEWS: readonly SettingsViewId[] = [ @@ -56,7 +54,6 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [ 'billing', 'plugins', 'sessions', - 'system', 'about' ] @@ -156,13 +153,6 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set label: t.settings.nav.notifications, onSelect: () => setActiveView('notifications') }, - { - active: activeView === 'system', - icon: Cpu, - id: 'system', - label: t.settings.nav.system, - onSelect: () => setActiveView('system') - }, { active: activeView === 'billing', icon: BarChart3, @@ -326,8 +316,6 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set ) : activeView === 'notifications' ? ( - ) : activeView === 'system' ? ( - ) : activeView === 'billing' ? ( ) : activeView === 'plugins' ? ( diff --git a/apps/desktop/src/app/settings/system-settings.tsx b/apps/desktop/src/app/settings/system-settings.tsx deleted file mode 100644 index d338f5962a60a..0000000000000 --- a/apps/desktop/src/app/settings/system-settings.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { useStore } from '@nanostores/react' - -import { SegmentedControl } from '@/components/ui/segmented-control' -import { useI18n } from '@/i18n' -import { triggerHaptic } from '@/lib/haptics' -import { Cpu } from '@/lib/icons' -import { $hapticsMuted, setHapticsMuted } from '@/store/haptics' -import { $keepAwake, setKeepAwake } from '@/store/keep-awake' -import { $translucency, setTranslucency } from '@/store/translucency' -import { $zoomPercent, setZoomPercent } from '@/store/zoom' - -import { ListRow, SectionHeading, SettingsContent, ToggleRow } from './primitives' - -// UI scale presets as zoom percentages (100 = browser default); the ids double -// as the percent sent to main. A Cmd/Ctrl +/- step between presets highlights -// nothing, and the row description keeps showing the exact current percent. -const UI_SCALE_PRESETS = ['90', '100', '110', '125', '150', '175'] as const - -type UiScalePreset = (typeof UI_SCALE_PRESETS)[number] - -export function SystemSettings() { - const { t } = useI18n() - const s = t.settings.system - const keepAwake = useStore($keepAwake) - const translucency = useStore($translucency) - const zoomPercent = useStore($zoomPercent) - const hapticsMuted = useStore($hapticsMuted) - - const uiScaleOptions = UI_SCALE_PRESETS.map(preset => ({ id: preset, label: `${preset}%` })) - const matchedScale = UI_SCALE_PRESETS.find(preset => Number(preset) === zoomPercent) ?? ('' as UiScalePreset) - - return ( - - -

- {s.intro} -

- - - - { - triggerHaptic('selection') - setZoomPercent(Number(id)) - }} - options={uiScaleOptions} - value={matchedScale} - /> - } - description={s.uiScaleDesc(zoomPercent)} - title={s.uiScaleTitle} - /> - - - { - triggerHaptic('selection') - setTranslucency(Number(event.target.value)) - }} - step={5} - style={{ accentColor: 'var(--dt-primary)' }} - type="range" - value={translucency} - /> - - {translucency}% - - - } - description={s.translucencyDesc} - title={s.translucencyTitle} - /> - - setHapticsMuted(!on)} - /> -
- ) -} diff --git a/apps/desktop/src/app/settings/types.ts b/apps/desktop/src/app/settings/types.ts index bc726d221bb32..2828609ef63fb 100644 --- a/apps/desktop/src/app/settings/types.ts +++ b/apps/desktop/src/app/settings/types.ts @@ -14,7 +14,6 @@ export type SettingsView = | 'plugins' | 'providers' | 'sessions' - | 'system' | `config:${string}` export type EnvPatch = Partial> diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 94a5a71da82f0..3202e977934be 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -325,8 +325,7 @@ export const en: Translations = { about: 'About', billing: 'Billing', notifications: 'Notifications', - plugins: 'Plugins', - system: 'System' + plugins: 'Plugins' }, plugins: { title: 'Desktop plugins', @@ -380,19 +379,6 @@ export const en: Translations = { completionSoundDesc: 'Plays when an agent turn finishes. Pick a preset and preview it here.', completionSoundPreview: 'Preview' }, - system: { - title: 'System', - intro: 'How Hermes behaves on this machine. These are device-local — each computer keeps its own settings.', - keepAwakeTitle: 'Keep computer awake', - keepAwakeDesc: 'Stop this machine from sleeping so long or overnight runs keep going. The display can still dim.', - translucencyTitle: 'Window Translucency', - translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.', - uiScaleTitle: 'UI Scale', - uiScaleDesc: (percent: number) => - `Scales text and controls across the whole app. Cmd/Ctrl with +, - and 0 also works. Current: ${percent}%.`, - hapticsTitle: 'Haptic Feedback', - hapticsDesc: 'Trackpad taps on actions and toggles. Supported hardware only (macOS).' - }, sections: { model: 'Model', chat: 'Chat', @@ -424,6 +410,11 @@ export const en: Translations = { colorModeDesc: 'Pick a fixed mode or let Hermes follow your system setting.', toolViewTitle: 'Tool Call Display', toolViewDesc: 'Product hides raw tool payloads; Technical shows full input/output.', + uiScaleTitle: 'UI Scale', + uiScaleDesc: (percent: number) => + `Scales text and controls across the whole app. Cmd/Ctrl with +, - and 0 also works. Current: ${percent}%.`, + translucencyTitle: 'Window Translucency', + translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.', backdropTitle: 'Chat Backdrop', backdropDesc: 'The faint statue image behind the conversation.', embedsTitle: 'Inline Embeds', @@ -532,7 +523,9 @@ export const en: Translations = { failedLoad: 'Settings failed to load', autosaveFailed: 'Autosave failed', imported: 'Config imported', - invalidJson: 'Invalid config JSON' + invalidJson: 'Invalid config JSON', + keepAwakeTitle: 'Keep computer awake', + keepAwakeDesc: 'Stop this machine from sleeping so long or overnight runs keep going. The display can still dim.' }, credentials: { pasteKey: 'Paste key', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index d8f7473fb4fa4..fabc1bc7b70de 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -225,8 +225,7 @@ export const ja = defineLocale({ archivedChats: 'アーカイブ済みチャット', about: '情報', billing: '請求', - notifications: '通知', - system: 'システム' + notifications: '通知' }, notifications: { title: '通知', @@ -266,18 +265,6 @@ export const ja = defineLocale({ completionSoundDesc: 'エージェントのターン終了時に再生されます。プリセットを選んでここで試聴できます。', completionSoundPreview: '試聴' }, - system: { - title: 'システム', - intro: 'この端末での Hermes の動作。設定は端末ごとに保存されます。', - keepAwakeTitle: 'コンピューターをスリープさせない', - keepAwakeDesc: '本体のスリープを防ぎ、長時間や夜通しの実行を継続します。画面は暗転できます。', - translucencyTitle: 'ウィンドウの透過', - translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。', - uiScaleTitle: 'UI スケール', - uiScaleDesc: (percent: number) => `アプリ全体の文字と UI を拡大縮小します。Cmd/Ctrl と +、-、0 でも変更できます。現在: ${percent}%`, - hapticsTitle: '触覚フィードバック', - hapticsDesc: '操作やトグル時にトラックパッドの触覚を返します。対応ハードウェアのみ(macOS)。' - }, sections: { model: 'モデル', chat: 'チャット', @@ -309,6 +296,11 @@ export const ja = defineLocale({ colorModeDesc: '固定モードを選ぶか、Hermes をシステム設定に合わせます。', toolViewTitle: 'ツール呼び出しの表示', toolViewDesc: 'プロダクト表示は生のツールペイロードを隠し、テクニカル表示は入出力をすべて表示します。', + uiScaleTitle: 'UI スケール', + uiScaleDesc: (percent: number) => + `アプリ全体の文字と UI を拡大縮小します。Cmd/Ctrl と +、-、0 でも変更できます。現在: ${percent}%`, + translucencyTitle: 'ウィンドウの透過', + translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。', backdropTitle: 'チャット背景', backdropDesc: '会話の背後に表示される淡い彫像の画像。', embedsTitle: 'インライン埋め込み', @@ -629,7 +621,9 @@ export const ja = defineLocale({ failedLoad: '設定の読み込みに失敗しました', autosaveFailed: '自動保存に失敗しました', imported: '設定をインポートしました', - invalidJson: '設定 JSON が無効です' + invalidJson: '設定 JSON が無効です', + keepAwakeTitle: 'コンピューターをスリープさせない', + keepAwakeDesc: '本体のスリープを防ぎ、長時間や夜通しの実行を継続します。画面は暗転できます。' }, credentials: { pasteKey: 'キーを貼り付け', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 646901b4f818b..c479706e29d81 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -284,7 +284,6 @@ export interface Translations { billing: string notifications: string plugins: string - system: string } plugins: { title: string @@ -318,18 +317,6 @@ export interface Translations { completionSoundDesc: string completionSoundPreview: string } - system: { - title: string - intro: string - keepAwakeTitle: string - keepAwakeDesc: string - translucencyTitle: string - translucencyDesc: string - uiScaleTitle: string - uiScaleDesc: (percent: number) => string - hapticsTitle: string - hapticsDesc: string - } sections: Record searchPlaceholder: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions', string> modeOptions: Record<'light' | 'dark' | 'system', ModeOptionCopy> @@ -340,6 +327,10 @@ export interface Translations { colorModeDesc: string toolViewTitle: string toolViewDesc: string + uiScaleTitle: string + uiScaleDesc: (percent: number) => string + translucencyTitle: string + translucencyDesc: string backdropTitle: string backdropDesc: string embedsTitle: string @@ -444,6 +435,8 @@ export interface Translations { autosaveFailed: string imported: string invalidJson: string + keepAwakeTitle: string + keepAwakeDesc: string } credentials: { pasteKey: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 345a7935ac7ae..986bf37923515 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -219,8 +219,7 @@ export const zhHant = defineLocale({ archivedChats: '已封存聊天', about: '關於', billing: '帳單', - notifications: '通知', - system: '系統' + notifications: '通知' }, notifications: { title: '通知', @@ -259,18 +258,6 @@ export const zhHant = defineLocale({ completionSoundDesc: '代理回合結束時播放。可在此選擇預設並預覽。', completionSoundPreview: '預覽' }, - system: { - title: '系統', - intro: 'Hermes 在這台電腦上的行為。設定會依裝置保存,每台電腦各自獨立。', - keepAwakeTitle: '保持電腦喚醒', - keepAwakeDesc: '阻止本機睡眠,讓長時間或整夜執行持續進行。螢幕仍可變暗。', - translucencyTitle: '視窗透明', - translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。', - uiScaleTitle: '介面縮放', - uiScaleDesc: (percent: number) => `縮放整個應用程式的文字與介面。也可使用 Cmd/Ctrl 加 +、- 或 0 調整。目前:${percent}%`, - hapticsTitle: '觸覺回饋', - hapticsDesc: '在操作與開關時觸發觸控板輕觸。僅支援對應硬體(macOS)。' - }, sections: { model: '模型', chat: '聊天', @@ -301,6 +288,11 @@ export const zhHant = defineLocale({ colorModeDesc: '選擇固定模式,或讓 Hermes 跟隨系統設定。', toolViewTitle: '工具呼叫顯示', toolViewDesc: '產品模式會隱藏原始工具 payload;技術模式會顯示完整輸入/輸出。', + uiScaleTitle: '介面縮放', + uiScaleDesc: (percent: number) => + `縮放整個應用程式的文字與介面。也可使用 Cmd/Ctrl 加 +、- 或 0 調整。目前:${percent}%`, + translucencyTitle: '視窗透明', + translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。', backdropTitle: '聊天背景', backdropDesc: '對話後方那張淡淡的雕像圖片。', embedsTitle: '內嵌預覽', @@ -617,7 +609,9 @@ export const zhHant = defineLocale({ failedLoad: '設定載入失敗', autosaveFailed: '自動儲存失敗', imported: '設定已匯入', - invalidJson: '設定 JSON 無效' + invalidJson: '設定 JSON 無效', + keepAwakeTitle: '保持電腦喚醒', + keepAwakeDesc: '阻止本機睡眠,讓長時間或整夜執行持續進行。螢幕仍可變暗。' }, credentials: { pasteKey: '貼上金鑰', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index a019bf3992dcf..deb750478509e 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -316,8 +316,7 @@ export const zh: Translations = { about: '关于', billing: '账单', notifications: '通知', - plugins: '插件', - system: '系统' + plugins: '插件' }, plugins: { title: '桌面插件', @@ -370,18 +369,6 @@ export const zh: Translations = { completionSoundDesc: '智能体回合结束时播放。可在此选择预设并预览。', completionSoundPreview: '预览' }, - system: { - title: '系统', - intro: 'Hermes 在这台电脑上的行为。设置按设备保存,每台电脑各自独立。', - keepAwakeTitle: '保持电脑唤醒', - keepAwakeDesc: '阻止本机休眠,让长时间或通宵运行继续进行。屏幕仍可变暗。', - translucencyTitle: '窗口透明', - translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。', - uiScaleTitle: '界面缩放', - uiScaleDesc: (percent: number) => `缩放整个应用的文字和界面。也可使用 Cmd/Ctrl 加 +、- 或 0 调整。当前:${percent}%`, - hapticsTitle: '触感反馈', - hapticsDesc: '在操作和开关时触发触控板轻触。仅支持相应硬件(macOS)。' - }, sections: { model: '模型', chat: '对话', @@ -412,6 +399,11 @@ export const zh: Translations = { colorModeDesc: '选择固定模式,或让 Hermes 跟随系统设置。', toolViewTitle: '工具调用显示', toolViewDesc: '产品模式隐藏原始工具数据;技术模式显示完整输入/输出。', + uiScaleTitle: '界面缩放', + uiScaleDesc: (percent: number) => + `缩放整个应用的文字和界面。也可使用 Cmd/Ctrl 加 +、- 或 0 调整。当前:${percent}%`, + translucencyTitle: '窗口透明', + translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。', backdropTitle: '聊天背景', backdropDesc: '对话后方那张淡淡的雕像图片。', embedsTitle: '内嵌预览', @@ -728,7 +720,9 @@ export const zh: Translations = { failedLoad: '设置加载失败', autosaveFailed: '自动保存失败', imported: '配置已导入', - invalidJson: '配置 JSON 无效' + invalidJson: '配置 JSON 无效', + keepAwakeTitle: '保持电脑唤醒', + keepAwakeDesc: '阻止本机休眠,让长时间或通宵运行继续进行。屏幕仍可变暗。' }, credentials: { pasteKey: '粘贴密钥', From fc8e96b200ec38e95b31192ffa0fb9fae1f4c94e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 13:49:27 -0500 Subject: [PATCH 4/5] fix(desktop): vertically center settings panel loader The settings OverlayMain has a titlebar-height top pad (no bottom pad), so the full-panel LoadingState centered in the band beneath it and read low. Cancel the top pad on the loader so it centers in the whole card; the one inline (mid-panel) memory loader switches to a plain min-height PageLoader so it's unaffected. --- .../src/app/settings/memory/provider-config-panel.tsx | 5 +++-- apps/desktop/src/app/settings/primitives.tsx | 11 ++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/settings/memory/provider-config-panel.tsx b/apps/desktop/src/app/settings/memory/provider-config-panel.tsx index ab7b29da9884f..aa0c5ffd86616 100644 --- a/apps/desktop/src/app/settings/memory/provider-config-panel.tsx +++ b/apps/desktop/src/app/settings/memory/provider-config-panel.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react' +import { PageLoader } from '@/components/page-loader' import { Button } from '@/components/ui/button' import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes' @@ -7,7 +8,7 @@ import { SlidersHorizontal } from '@/lib/icons' import { notifyError } from '@/store/notifications' import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes' -import { ListRow, LoadingState, Pill } from '../primitives' +import { ListRow, Pill } from '../primitives' import { FieldControl, FieldTitle } from './field-control' import { ProviderConfigModal } from './provider-config-modal' @@ -95,7 +96,7 @@ export function ProviderConfigPanel({ provider }: { provider: string }) { ) } - return + return } const inlineFields = config.fields.filter(field => field.inline) diff --git a/apps/desktop/src/app/settings/primitives.tsx b/apps/desktop/src/app/settings/primitives.tsx index fd53b2d32b6f0..4e55a2f9f4b97 100644 --- a/apps/desktop/src/app/settings/primitives.tsx +++ b/apps/desktop/src/app/settings/primitives.tsx @@ -143,8 +143,17 @@ export function ToggleRow({ ) } +// The settings panels render this as the sole child of the top-padded +// OverlayMain (pt = titlebar + 1rem, no bottom pad — see settings/index.tsx). +// Cancel that top pad so the loader centers in the whole card, not just the +// band beneath it. Inline loaders (mid-panel) should use directly. export function LoadingState({ label }: { label: string }) { - return + return ( + + ) } // Canonical implementation lives in components/ui; re-exported so the many From 3ef5249558a47752e46c3c6a8f74c1280221af5e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 13:57:41 -0500 Subject: [PATCH 5/5] refactor(desktop): drop keep-awake statusbar toggle; persist in main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep-awake lives only in Settings → Advanced now. Remove the statusbar quick-toggle (+ its Sun icon, store toggle helper, and keepAwakeOn/Off strings across locales). Since the statusbar was what eagerly loaded the store at boot, move persistence to the main process (keep-awake.json, re-applied on app ready — same pattern as translucency), so a cold launch restores the blocker without the renderer opening Settings. --- apps/desktop/electron/main.ts | 27 ++++++++++++++++--- .../app/shell/hooks/use-statusbar-items.tsx | 13 +-------- apps/desktop/src/i18n/en.ts | 2 -- apps/desktop/src/i18n/ja.ts | 2 -- apps/desktop/src/i18n/types.ts | 2 -- apps/desktop/src/i18n/zh-hant.ts | 2 -- apps/desktop/src/i18n/zh.ts | 2 -- apps/desktop/src/store/keep-awake.test.ts | 9 +------ apps/desktop/src/store/keep-awake.ts | 15 +++++------ 9 files changed, 32 insertions(+), 42 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index cac3031dee6a4..7f09220e148a6 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -105,8 +105,8 @@ import { } from './hardening' import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window' import { ensureMainWindow } from './main-window-lifecycle' -import { createKeepAwake } from './power-save' import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' +import { createKeepAwake } from './power-save' import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' import { buildSessionWindowUrl, @@ -8556,11 +8556,31 @@ ipcMain.on('hermes:translucency', (_event, payload) => { } }) -// Keep-awake: the renderer owns the preference; main holds the one blocker. +// Keep-awake: hold the machine awake for long/overnight runs. Main owns the one +// blocker and its persisted state so a cold launch restores it (applied on +// ready — powerSaveBlocker needs the app ready). The renderer toggles it from +// Settings → Advanced over IPC. See store/keep-awake. +const KEEP_AWAKE_CONFIG_PATH = path.join(app.getPath('userData'), 'keep-awake.json') const keepAwake = createKeepAwake(powerSaveBlocker) +function readPersistedKeepAwake() { + try { + return JSON.parse(fs.readFileSync(KEEP_AWAKE_CONFIG_PATH, 'utf8')).on === true + } catch { + return false + } +} + ipcMain.on('hermes:keep-awake', (_event, on) => { - keepAwake.set(Boolean(on)) + const enabled = Boolean(on) + keepAwake.set(enabled) + + try { + fs.mkdirSync(path.dirname(KEEP_AWAKE_CONFIG_PATH), { recursive: true }) + fs.writeFileSync(KEEP_AWAKE_CONFIG_PATH, JSON.stringify({ on: enabled }, null, 2), 'utf8') + } catch (error) { + rememberLog(`[keep-awake] write failed: ${error.message}`) + } }) ipcMain.handle('hermes:openExternal', (_event, url) => { @@ -9563,6 +9583,7 @@ app.whenReady().then(() => { ensureWslWindowsFonts() configureSpellChecker() registerPowerResumeListeners() + keepAwake.set(readPersistedKeepAwake()) createWindow() // Win/Linux cold start: the launching hermes:// URL is in our own argv. diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 94485b4fef452..7d73180217a71 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -9,12 +9,11 @@ import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel' import { Codicon } from '@/components/ui/codicon' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { useI18n } from '@/i18n' -import { Activity, AlertCircle, Clock, Command, FolderOpen, Hash, Loader2, Sun, Terminal } from '@/lib/icons' +import { Activity, AlertCircle, Clock, Command, FolderOpen, Hash, Loader2, Terminal } from '@/lib/icons' import type { RuntimeReadinessResult } from '@/lib/runtime-readiness' import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar' import { cn } from '@/lib/utils' import { copyFilePath, revealFile } from '@/store/file-actions' -import { $keepAwake, toggleKeepAwake } from '@/store/keep-awake' import { revealFileInTree } from '@/store/layout' import { $activeGatewayProfile } from '@/store/profile' import { $projectTree, projectNameForCwd } from '@/store/projects' @@ -91,7 +90,6 @@ export function useStatusbarItems({ const primaryActiveSessionId = useStore($activeSessionId) const activeGatewayProfile = useStore($activeGatewayProfile) const terminalTakeover = useStore($terminalTakeover) - const keepAwake = useStore($keepAwake) const primaryBusy = useStore($busy) const currentCwd = useStore($currentCwd) // Derive the workspace's project name from the already-cached project tree @@ -453,14 +451,6 @@ export function useStatusbarItems({ title: terminalTakeover ? copy.hideTerminal : copy.showTerminal, variant: 'action' }, - { - className: `w-7 justify-center px-0${keepAwake ? ' bg-accent/55 text-foreground' : ''}`, - icon: , - id: 'keep-awake', - onSelect: () => toggleKeepAwake(), - title: keepAwake ? copy.keepAwakeOn : copy.keepAwakeOff, - variant: 'action' - }, clientVersionItem, ...(backendVersionItem ? [backendVersionItem] : []) ], @@ -475,7 +465,6 @@ export function useStatusbarItems({ contextUsage, copy, currentUsage, - keepAwake, requestGateway, sessionStartedAt, gatewayState, diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 3202e977934be..a17ecbb3bd60b 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2176,8 +2176,6 @@ export const en: Translations = { openCommandCenter: 'Open Command Center', showTerminal: 'Show terminal', hideTerminal: 'Hide terminal', - keepAwakeOn: 'Keeping awake — click to allow sleep', - keepAwakeOff: 'Keep computer awake', gateway: 'Gateway', gatewayReady: 'ready', gatewayNeedsSetup: 'needs setup', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index fabc1bc7b70de..3ade882c6ab87 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -2102,8 +2102,6 @@ export const ja = defineLocale({ openCommandCenter: 'コマンドセンターを開く', showTerminal: 'ターミナルを表示', hideTerminal: 'ターミナルを非表示', - keepAwakeOn: 'スリープ抑止中 — クリックで解除', - keepAwakeOff: 'コンピューターをスリープさせない', gateway: 'ゲートウェイ', gatewayReady: '準備完了', gatewayNeedsSetup: '設定が必要', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index c479706e29d81..25a9f67e21980 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1804,8 +1804,6 @@ export interface Translations { openCommandCenter: string showTerminal: string hideTerminal: string - keepAwakeOn: string - keepAwakeOff: string gateway: string gatewayReady: string gatewayNeedsSetup: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 986bf37923515..d061a6d57a796 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -2035,8 +2035,6 @@ export const zhHant = defineLocale({ openCommandCenter: '開啟命令中心', showTerminal: '顯示終端機', hideTerminal: '隱藏終端機', - keepAwakeOn: '保持喚醒中 — 點擊以允許睡眠', - keepAwakeOff: '保持電腦喚醒', gateway: '閘道', gatewayReady: '就緒', gatewayNeedsSetup: '需要設定', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index deb750478509e..61d6ff602aab3 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2346,8 +2346,6 @@ export const zh: Translations = { openCommandCenter: '打开命令中心', showTerminal: '显示终端', hideTerminal: '隐藏终端', - keepAwakeOn: '保持唤醒中 — 点击以允许休眠', - keepAwakeOff: '保持电脑唤醒', gateway: '网关', gatewayReady: '就绪', gatewayNeedsSetup: '需要设置', diff --git a/apps/desktop/src/store/keep-awake.test.ts b/apps/desktop/src/store/keep-awake.test.ts index e6acffa9f93d3..4807e436ae4c9 100644 --- a/apps/desktop/src/store/keep-awake.test.ts +++ b/apps/desktop/src/store/keep-awake.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { storedBoolean } from '@/lib/storage' -import { $keepAwake, setKeepAwake, toggleKeepAwake } from './keep-awake' +import { $keepAwake, setKeepAwake } from './keep-awake' const KEY = 'hermes.desktop.keepAwake.v1' const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] } @@ -30,11 +30,4 @@ describe('keep-awake store', () => { expect(storedBoolean(KEY, true)).toBe(false) expect(setKeepAwakeBridge).toHaveBeenLastCalledWith(false) }) - - it('toggles the current value', () => { - toggleKeepAwake() - expect($keepAwake.get()).toBe(true) - toggleKeepAwake() - expect($keepAwake.get()).toBe(false) - }) }) diff --git a/apps/desktop/src/store/keep-awake.ts b/apps/desktop/src/store/keep-awake.ts index b0db1cbfdb556..f2d2022391f37 100644 --- a/apps/desktop/src/store/keep-awake.ts +++ b/apps/desktop/src/store/keep-awake.ts @@ -1,11 +1,12 @@ /** * Keep-awake — stop the machine sleeping during long, unattended runs. * - * A device-local preference (each computer keeps its own), off by default. The - * renderer owns the value and persists it; the main process holds the actual - * power-save blocker (see electron/power-save.ts) and re-reads this on every - * window load via the subscribe below. Linux/web builds without the bridge just - * no-op. + * A device-local preference (each computer keeps its own), off by default. This + * atom backs the Settings → Advanced toggle and mirrors changes to the main + * process, which owns the real power-save blocker AND its own persisted copy — + * so a cold launch restores the blocker without the renderer visiting Settings + * (see electron/main.ts + electron/power-save.ts). Linux/web without the bridge + * just no-op. */ import { atom } from 'nanostores' @@ -20,10 +21,6 @@ export function setKeepAwake(on: boolean): void { $keepAwake.set(on) } -export function toggleKeepAwake(): void { - $keepAwake.set(!$keepAwake.get()) -} - if (typeof window !== 'undefined') { $keepAwake.subscribe(on => { persistBoolean(KEY, on)