feat(desktop): add keep awake while agents are active setting (#484)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-05-05 13:14:00 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 6216f64e17
commit ec489c5b1a
11 changed files with 574 additions and 205 deletions
+1 -1
View File
@@ -71,7 +71,7 @@ const overrides = new Map([
["src-tauri/src/relay.rs", 510], // +4 lines for NIP-OA auth tag injection in profile sync (build_profile_event) + verification test
["src-tauri/src/commands/pairing.rs", 600], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + NIP-43 probe + event parsing helpers
["src-tauri/src/lib.rs", 715], // +4 lines for PairingHandle managed state + 3 pairing command registrations
["src/shared/api/tauri.ts", 1210], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role)
["src/shared/api/tauri.ts", 1212], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role) + prevent sleep
]);
async function walkFiles(directory) {
+6 -1
View File
@@ -1,7 +1,7 @@
use std::{
collections::HashMap,
io::Write,
sync::{atomic::AtomicU16, Mutex},
sync::{atomic::AtomicU16, Arc, Mutex},
};
use nostr::{Keys, ToBech32};
@@ -30,6 +30,8 @@ pub struct AppState {
pub audio_output_device: Mutex<Option<String>>,
/// Port of the localhost media streaming proxy (set during setup).
pub media_proxy_port: AtomicU16,
/// IOKit power assertion state — prevents idle sleep while agents run.
pub prevent_sleep: Arc<Mutex<crate::prevent_sleep::PreventSleepState>>,
}
pub fn build_app_state() -> AppState {
@@ -71,6 +73,9 @@ pub fn build_app_state() -> AppState {
app_handle: Mutex::new(None),
audio_output_device: Mutex::new(None),
media_proxy_port: AtomicU16::new(0),
prevent_sleep: Arc::new(Mutex::new(
crate::prevent_sleep::PreventSleepState::default(),
)),
}
}
+2
View File
@@ -11,6 +11,7 @@ mod media;
mod messages;
pub mod pairing;
mod personas;
mod prevent_sleep;
mod profile;
mod relay_members;
mod social;
@@ -30,6 +31,7 @@ pub use media::*;
pub use messages::*;
pub use pairing::*;
pub use personas::*;
pub use prevent_sleep::*;
pub use profile::*;
pub use relay_members::*;
pub use social::*;
@@ -0,0 +1,15 @@
use crate::app_state::AppState;
#[tauri::command]
pub fn set_prevent_sleep_active(
active: bool,
state: tauri::State<'_, AppState>,
app_handle: tauri::AppHandle,
) -> Result<(), String> {
if active {
crate::prevent_sleep::acquire(&state.prevent_sleep, &app_handle)
} else {
crate::prevent_sleep::release(&state.prevent_sleep);
Ok(())
}
}
+3
View File
@@ -7,6 +7,7 @@ mod media_proxy;
mod migration;
mod models;
pub mod nostr_convert;
mod prevent_sleep;
mod relay;
mod util;
@@ -565,6 +566,7 @@ pub fn run() {
cancel_pairing,
apply_workspace,
get_active_workspace,
set_prevent_sleep_active,
])
.build(tauri::generate_context!())
.expect("error while building tauri application");
@@ -574,6 +576,7 @@ pub fn run() {
RunEvent::ExitRequested { .. } | RunEvent::Exit => {
shutdown_started.store(true, Ordering::SeqCst);
if !shutdown_done.swap(true, Ordering::SeqCst) {
prevent_sleep::release(&app_handle.state::<AppState>().prevent_sleep);
if let Err(error) = shutdown_managed_agents(app_handle) {
eprintln!("sprout-desktop: failed to stop managed agents: {error}");
}
+156
View File
@@ -0,0 +1,156 @@
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Emitter};
/// Tracks the macOS IOKit power assertion that prevents idle sleep
/// while local managed agents are running.
#[derive(Default)]
pub struct PreventSleepState {
assertion_id: Option<u32>,
timer_abort: Option<tokio::task::AbortHandle>,
}
// ── macOS implementation ────────────────────────────────────────────────────
#[cfg(target_os = "macos")]
mod macos {
#[link(name = "IOKit", kind = "framework")]
extern "C" {
pub fn IOPMAssertionCreateWithName(
assertion_type: *const std::ffi::c_void, // CFStringRef
level: u32, // IOPMAssertionLevel
name: *const std::ffi::c_void, // CFStringRef
assertion_id: *mut u32, // IOPMAssertionID
) -> i32; // IOReturn
pub fn IOPMAssertionRelease(assertion_id: u32) -> i32;
}
#[link(name = "CoreFoundation", kind = "framework")]
extern "C" {
pub fn CFStringCreateWithCString(
alloc: *const std::ffi::c_void,
c_str: *const std::ffi::c_char,
encoding: u32,
) -> *const std::ffi::c_void;
pub fn CFRelease(cf: *const std::ffi::c_void);
}
}
#[cfg(target_os = "macos")]
const K_IOPM_ASSERTION_LEVEL_ON: u32 = 255;
#[cfg(target_os = "macos")]
const K_CF_STRING_ENCODING_UTF8: u32 = 0x0800_0100;
/// 4-hour cap in seconds.
const CAP_SECONDS: u64 = 4 * 3600;
/// Create a `PreventUserIdleSystemSleep` assertion if not already held.
/// Starts a 4-hour timer that auto-releases and emits `prevent-sleep-expired`.
pub fn acquire(
state: &Arc<Mutex<PreventSleepState>>,
app_handle: &AppHandle,
) -> Result<(), String> {
let mut guard = state.lock().map_err(|e| e.to_string())?;
// Idempotent — already held.
if guard.assertion_id.is_some() {
return Ok(());
}
#[cfg(target_os = "macos")]
{
let assertion_type = b"PreventUserIdleSystemSleep\0".as_ptr() as *const std::ffi::c_char;
let reason = b"Sprout \xe2\x80\x94 agents are active\0".as_ptr() as *const std::ffi::c_char;
unsafe {
let cf_type = macos::CFStringCreateWithCString(
std::ptr::null(),
assertion_type,
K_CF_STRING_ENCODING_UTF8,
);
let cf_reason = macos::CFStringCreateWithCString(
std::ptr::null(),
reason,
K_CF_STRING_ENCODING_UTF8,
);
if cf_type.is_null() || cf_reason.is_null() {
if !cf_type.is_null() {
macos::CFRelease(cf_type);
}
if !cf_reason.is_null() {
macos::CFRelease(cf_reason);
}
return Err("Failed to create CFString for IOKit assertion".into());
}
let mut assertion_id: u32 = 0;
let ret = macos::IOPMAssertionCreateWithName(
cf_type,
K_IOPM_ASSERTION_LEVEL_ON,
cf_reason,
&mut assertion_id,
);
macos::CFRelease(cf_type);
macos::CFRelease(cf_reason);
if ret != 0 {
return Err(format!(
"IOPMAssertionCreateWithName failed with IOReturn {ret}"
));
}
guard.assertion_id = Some(assertion_id);
}
}
// Start the 4-hour cap timer only if an assertion was actually created.
if guard.assertion_id.is_some() {
let handle = app_handle.clone();
let timer_state = Arc::clone(state);
let timer_task = tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(CAP_SECONDS)).await;
release(&timer_state);
let _ = handle.emit("prevent-sleep-expired", ());
});
guard.timer_abort = Some(timer_task.abort_handle());
}
Ok(())
}
/// Release the power assertion if held. Cancel the cap timer.
pub fn release(state: &Arc<Mutex<PreventSleepState>>) {
let mut guard = match state.lock() {
Ok(g) => g,
Err(_) => return,
};
if let Some(abort) = guard.timer_abort.take() {
abort.abort();
}
#[cfg(target_os = "macos")]
if let Some(id) = guard.assertion_id.take() {
unsafe {
macos::IOPMAssertionRelease(id);
}
}
#[cfg(not(target_os = "macos"))]
{
guard.assertion_id = None;
}
}
/// Returns `true` if a power assertion is currently held.
#[allow(dead_code)]
pub fn is_held(state: &Arc<Mutex<PreventSleepState>>) -> bool {
state
.lock()
.map(|g| g.assertion_id.is_some())
.unwrap_or(false)
}
+208 -203
View File
@@ -27,6 +27,7 @@ import {
setDesktopAppBadgeCount,
type DesktopNotificationTarget,
} from "@/features/notifications/lib/desktop";
import { PreventSleepProvider } from "@/features/agents/usePreventSleep";
import {
usePresenceSession,
usePresenceSubscription,
@@ -462,217 +463,221 @@ export function AppShell() {
}, [handleCloseSettings, handleOpenSettings, settingsOpen]);
return (
<ChannelNavigationProvider channels={channels}>
<AppShellProvider
value={{
markChannelRead,
openChannelManagement: () => {
setIsChannelManagementOpen(true);
},
}}
>
<HuddleProvider>
<div className="flex h-dvh flex-col overflow-hidden overscroll-none">
<SidebarProvider className="min-h-0 flex-1 overflow-hidden">
<div className="fixed left-[80px] top-[8px] z-50 flex items-center gap-1.5">
<SidebarTrigger className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
<Button
aria-label="Go back"
className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
data-testid="global-back"
disabled={!canGoBack}
onClick={goBack}
size="icon"
variant="ghost"
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<Button
aria-label="Go forward"
className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
data-testid="global-forward"
disabled={!canGoForward}
onClick={goForward}
size="icon"
variant="ghost"
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
<div className="fixed right-[16px] top-[8px] z-50">
<UpdateIndicator
onOpenUpdates={() => handleOpenSettings("updates")}
/>
</div>
<AppSidebar
activeWorkspace={workspacesHook.activeWorkspace}
channels={sidebarChannels}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
channelsQuery.error instanceof Error
? channelsQuery.error.message
: undefined
}
fallbackDisplayName={identityQuery.data?.displayName}
homeBadgeCount={homeBadgeCount}
isAddWorkspaceOpen={isAddWorkspaceOpen}
isCreatingChannel={createChannelMutation.isPending}
isCreatingForum={createForumMutation.isPending}
isLoading={channelsQuery.isLoading}
isOpeningDm={openDmMutation.isPending}
isNewDmOpen={isNewDmOpen}
isPresencePending={presenceSession.isPending}
onAddWorkspace={(workspace) => {
const id = workspacesHook.addWorkspace(workspace);
workspacesHook.switchWorkspace(id);
}}
onAddWorkspaceOpenChange={setIsAddWorkspaceOpen}
onNewDmOpenChange={setIsNewDmOpen}
onOpenAddWorkspace={() => setIsAddWorkspaceOpen(true)}
onUpdateWorkspace={workspacesHook.updateWorkspace}
onRemoveWorkspace={workspacesHook.removeWorkspace}
onSwitchWorkspace={workspacesHook.switchWorkspace}
selfPresenceStatus={presenceSession.currentStatus}
workspaces={workspacesHook.workspaces}
onCreateChannel={async ({
description,
name,
visibility,
ttlSeconds,
}) => {
const createdChannel =
await createChannelMutation.mutateAsync({
<PreventSleepProvider>
<ChannelNavigationProvider channels={channels}>
<AppShellProvider
value={{
markChannelRead,
openChannelManagement: () => {
setIsChannelManagementOpen(true);
},
}}
>
<HuddleProvider>
<div className="flex h-dvh flex-col overflow-hidden overscroll-none">
<SidebarProvider className="min-h-0 flex-1 overflow-hidden">
<div className="fixed left-[80px] top-[8px] z-50 flex items-center gap-1.5">
<SidebarTrigger className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
<Button
aria-label="Go back"
className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
data-testid="global-back"
disabled={!canGoBack}
onClick={goBack}
size="icon"
variant="ghost"
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<Button
aria-label="Go forward"
className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
data-testid="global-forward"
disabled={!canGoForward}
onClick={goForward}
size="icon"
variant="ghost"
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
<div className="fixed right-[16px] top-[8px] z-50">
<UpdateIndicator
onOpenUpdates={() => handleOpenSettings("updates")}
/>
</div>
<AppSidebar
activeWorkspace={workspacesHook.activeWorkspace}
channels={sidebarChannels}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
channelsQuery.error instanceof Error
? channelsQuery.error.message
: undefined
}
fallbackDisplayName={identityQuery.data?.displayName}
homeBadgeCount={homeBadgeCount}
isAddWorkspaceOpen={isAddWorkspaceOpen}
isCreatingChannel={createChannelMutation.isPending}
isCreatingForum={createForumMutation.isPending}
isLoading={channelsQuery.isLoading}
isOpeningDm={openDmMutation.isPending}
isNewDmOpen={isNewDmOpen}
isPresencePending={presenceSession.isPending}
onAddWorkspace={(workspace) => {
const id = workspacesHook.addWorkspace(workspace);
workspacesHook.switchWorkspace(id);
}}
onAddWorkspaceOpenChange={setIsAddWorkspaceOpen}
onNewDmOpenChange={setIsNewDmOpen}
onOpenAddWorkspace={() => setIsAddWorkspaceOpen(true)}
onUpdateWorkspace={workspacesHook.updateWorkspace}
onRemoveWorkspace={workspacesHook.removeWorkspace}
onSwitchWorkspace={workspacesHook.switchWorkspace}
selfPresenceStatus={presenceSession.currentStatus}
workspaces={workspacesHook.workspaces}
onCreateChannel={async ({
description,
name,
visibility,
ttlSeconds,
}) => {
const createdChannel =
await createChannelMutation.mutateAsync({
name,
description,
channelType: "stream",
visibility,
ttlSeconds,
});
await goChannel(createdChannel.id);
}}
onCreateForum={async ({
description,
name,
visibility,
ttlSeconds,
}) => {
const createdForum = await createForumMutation.mutateAsync({
name,
description,
channelType: "stream",
channelType: "forum",
visibility,
ttlSeconds,
});
await goChannel(createdChannel.id);
}}
onCreateForum={async ({
description,
name,
visibility,
ttlSeconds,
}) => {
const createdForum = await createForumMutation.mutateAsync({
name,
description,
channelType: "forum",
visibility,
ttlSeconds,
});
await goChannel(createdForum.id);
}}
onHideDm={handleHideDm}
onOpenBrowseChannels={handleOpenBrowseChannels}
onOpenBrowseForums={handleOpenBrowseForums}
onOpenDm={async ({ pubkeys }) => {
const directMessage = await openDmMutation.mutateAsync({
pubkeys,
});
await goChannel(directMessage.id);
}}
onOpenSearch={handleOpenSearch}
onSelectAgents={() => {
void goAgents();
}}
onSelectChannel={(channelId) => {
void goChannel(channelId);
}}
onSelectHome={() => {
void goHome();
}}
onSelectPulse={() => {
void goPulse();
}}
onSelectSettings={handleOpenSettings}
onSelectWorkflows={() => {
void goWorkflows();
}}
onSetPresenceStatus={(status) =>
presenceSession.setStatus(status)
}
onSetUserStatus={(text, emoji) =>
setUserStatusMutation.mutate({ text, emoji })
}
onClearUserStatus={() =>
setUserStatusMutation.mutate({ text: "", emoji: "" })
}
profile={profileQuery.data}
selfUserStatus={
deferredPubkey
? (selfStatusQuery.data?.[deferredPubkey.toLowerCase()] ??
undefined)
: undefined
}
selectedChannelId={selectedChannelId}
selectedView={selectedView}
unreadChannelIds={unreadChannelIds}
/>
await goChannel(createdForum.id);
}}
onHideDm={handleHideDm}
onOpenBrowseChannels={handleOpenBrowseChannels}
onOpenBrowseForums={handleOpenBrowseForums}
onOpenDm={async ({ pubkeys }) => {
const directMessage = await openDmMutation.mutateAsync({
pubkeys,
});
await goChannel(directMessage.id);
}}
onOpenSearch={handleOpenSearch}
onSelectAgents={() => {
void goAgents();
}}
onSelectChannel={(channelId) => {
void goChannel(channelId);
}}
onSelectHome={() => {
void goHome();
}}
onSelectPulse={() => {
void goPulse();
}}
onSelectSettings={handleOpenSettings}
onSelectWorkflows={() => {
void goWorkflows();
}}
onSetPresenceStatus={(status) =>
presenceSession.setStatus(status)
}
onSetUserStatus={(text, emoji) =>
setUserStatusMutation.mutate({ text, emoji })
}
onClearUserStatus={() =>
setUserStatusMutation.mutate({ text: "", emoji: "" })
}
profile={profileQuery.data}
selfUserStatus={
deferredPubkey
? (selfStatusQuery.data?.[deferredPubkey.toLowerCase()] ??
undefined)
: undefined
}
selectedChannelId={selectedChannelId}
selectedView={selectedView}
unreadChannelIds={unreadChannelIds}
/>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
<Outlet />
</SidebarInset>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
<Outlet />
</SidebarInset>
<AppShellOverlays
activeChannel={activeChannel}
browseDialogType={browseDialogType}
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
isChannelManagementOpen={isChannelManagementOpen}
isSearchOpen={isSearchOpen}
onBrowseChannelJoin={handleBrowseChannelJoin}
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
onChannelManagementOpenChange={setIsChannelManagementOpen}
onDeleteActiveChannel={() => {
setIsChannelManagementOpen(false);
void goHome({ replace: true });
}}
onOpenSearchResult={handleOpenSearchResult}
onSearchOpenChange={setIsSearchOpen}
onSelectChannel={(channelId) => {
void goChannel(channelId);
}}
/>
<AppShellOverlays
activeChannel={activeChannel}
browseDialogType={browseDialogType}
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
isChannelManagementOpen={isChannelManagementOpen}
isSearchOpen={isSearchOpen}
onBrowseChannelJoin={handleBrowseChannelJoin}
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
onChannelManagementOpenChange={setIsChannelManagementOpen}
onDeleteActiveChannel={() => {
setIsChannelManagementOpen(false);
void goHome({ replace: true });
}}
onOpenSearchResult={handleOpenSearchResult}
onSearchOpenChange={setIsSearchOpen}
onSelectChannel={(channelId) => {
void goChannel(channelId);
}}
/>
{settingsOpen ? (
<React.Suspense fallback={null}>
<LazySettingsScreen
currentPubkey={identityQuery.data?.pubkey}
fallbackDisplayName={identityQuery.data?.displayName}
isUpdatingDesktopNotifications={
notificationSettings.isUpdatingDesktopEnabled
}
notificationErrorMessage={notificationSettings.errorMessage}
notificationPermission={notificationSettings.permission}
notificationSettings={notificationSettings.settings}
onClose={handleCloseSettings}
onSectionChange={setSettingsSection}
onSetDesktopNotificationsEnabled={
notificationSettings.setDesktopEnabled
}
onSetHomeBadgeEnabled={
notificationSettings.setHomeBadgeEnabled
}
onSetMentionNotificationsEnabled={
notificationSettings.setMentionsEnabled
}
onSetNeedsActionNotificationsEnabled={
notificationSettings.setNeedsActionEnabled
}
section={settingsSection}
/>
</React.Suspense>
) : null}
</SidebarProvider>
<HuddleBar />
</div>
</HuddleProvider>
</AppShellProvider>
</ChannelNavigationProvider>
{settingsOpen ? (
<React.Suspense fallback={null}>
<LazySettingsScreen
currentPubkey={identityQuery.data?.pubkey}
fallbackDisplayName={identityQuery.data?.displayName}
isUpdatingDesktopNotifications={
notificationSettings.isUpdatingDesktopEnabled
}
notificationErrorMessage={
notificationSettings.errorMessage
}
notificationPermission={notificationSettings.permission}
notificationSettings={notificationSettings.settings}
onClose={handleCloseSettings}
onSectionChange={setSettingsSection}
onSetDesktopNotificationsEnabled={
notificationSettings.setDesktopEnabled
}
onSetHomeBadgeEnabled={
notificationSettings.setHomeBadgeEnabled
}
onSetMentionNotificationsEnabled={
notificationSettings.setMentionsEnabled
}
onSetNeedsActionNotificationsEnabled={
notificationSettings.setNeedsActionEnabled
}
section={settingsSection}
/>
</React.Suspense>
) : null}
</SidebarProvider>
<HuddleBar />
</div>
</HuddleProvider>
</AppShellProvider>
</ChannelNavigationProvider>
</PreventSleepProvider>
);
}
@@ -0,0 +1,90 @@
import * as React from "react";
import { useManagedAgentsQuery } from "@/features/agents/hooks";
import { setPreventSleepActive } from "@/shared/api/tauri";
import { listen } from "@tauri-apps/api/event";
// Intentionally not scoped per-pubkey — multi-user desktop is rare and the
// setting applies to the machine's sleep behavior regardless of account.
const STORAGE_KEY = "sprout-prevent-sleep";
function readPreference(): boolean {
return window.localStorage.getItem(STORAGE_KEY) === "true";
}
function writePreference(enabled: boolean) {
window.localStorage.setItem(STORAGE_KEY, String(enabled));
}
interface PreventSleepValue {
enabled: boolean;
setEnabled: (value: boolean) => void;
active: boolean;
hasRunningAgents: boolean;
expired: boolean;
clearExpired: () => void;
}
const PreventSleepContext = React.createContext<PreventSleepValue | null>(null);
export function PreventSleepProvider({
children,
}: {
children: React.ReactNode;
}) {
const value = usePreventSleepInternal();
return React.createElement(PreventSleepContext.Provider, { value }, children);
}
export function usePreventSleepContext(): PreventSleepValue {
const ctx = React.useContext(PreventSleepContext);
if (!ctx) {
throw new Error(
"usePreventSleepContext must be used within a PreventSleepProvider",
);
}
return ctx;
}
function usePreventSleepInternal() {
const [enabled, setEnabledState] = React.useState(readPreference);
const { data: agents } = useManagedAgentsQuery();
// Only local "running" agents need sleep prevention. Remote "deployed"
// agents run on provider infrastructure and are unaffected by local sleep.
const hasRunningAgents = React.useMemo(
() => agents?.some((agent) => agent.status === "running") ?? false,
[agents],
);
const active = enabled && hasRunningAgents;
const setEnabled = React.useCallback((value: boolean) => {
writePreference(value);
setEnabledState(value);
}, []);
// Sync active state to backend
React.useEffect(() => {
void setPreventSleepActive(active);
}, [active]);
// Listen for 4-hour expiry
const [expired, setExpired] = React.useState(false);
React.useEffect(() => {
const unlisten = listen("prevent-sleep-expired", () => {
setExpired(true);
});
return () => {
void unlisten.then((fn) => fn());
};
}, []);
return {
enabled,
setEnabled,
active,
hasRunningAgents,
expired,
clearExpired: () => setExpired(false),
};
}
@@ -0,0 +1,80 @@
import { Shield } from "lucide-react";
import { usePreventSleepContext } from "@/features/agents/usePreventSleep";
import { cn } from "@/shared/lib/cn";
export function PreventSleepSettingsCard() {
const {
enabled,
setEnabled,
active,
hasRunningAgents,
expired,
clearExpired,
} = usePreventSleepContext();
return (
<section className="min-w-0" data-testid="settings-agents">
<div className="mb-3 min-w-0">
<h2 className="text-sm font-semibold tracking-tight">Agents</h2>
<p className="text-sm text-muted-foreground">
Settings that affect how local managed agents run on this machine.
</p>
</div>
<button
aria-pressed={enabled}
className={cn(
"flex w-full flex-col items-start gap-2 rounded-xl border px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
enabled
? "border-primary bg-primary/10 text-foreground"
: "border-border/80 bg-background/60 text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
data-testid="prevent-sleep-toggle"
onClick={() => {
if (expired) {
clearExpired();
}
setEnabled(!enabled);
}}
type="button"
>
<div className="flex items-center gap-2">
<Shield className="h-4 w-4" />
<span className="font-medium text-foreground">
Keep awake while agents are active
</span>
</div>
<p className="text-sm text-muted-foreground">
Prevents your computer from sleeping while local agents are running.
Automatically releases when all agents stop or after 4 hours.
</p>
</button>
<div className="mt-3 flex items-center gap-2 text-sm">
<span
className={cn(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold uppercase tracking-[0.18em]",
active
? "border-primary/30 bg-primary/10 text-primary"
: "border-border/80 bg-muted text-muted-foreground",
)}
data-testid="prevent-sleep-status"
>
{active ? "Active" : "Inactive"}
</span>
{enabled && !hasRunningAgents && (
<span className="text-muted-foreground">
Waiting for agents to start
</span>
)}
</div>
{expired && (
<p className="mt-3 rounded-xl border border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-sm text-yellow-700 dark:text-yellow-400">
Sleep prevention expired after 4 hours. Toggle off and on to
re-enable.
</p>
)}
</section>
);
}
@@ -1,6 +1,7 @@
import { useState, useMemo, useRef } from "react";
import {
BellRing,
Bot,
Check,
Download,
Keyboard,
@@ -28,12 +29,14 @@ import { DoctorSettingsPanel } from "./DoctorSettingsPanel";
import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard";
import { MobilePairingCard } from "./MobilePairingCard";
import { NotificationSettingsCard } from "./NotificationSettingsCard";
import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard";
import { ProfileSettingsCard } from "./ProfileSettingsCard";
import { UpdateChecker } from "../UpdateChecker";
export type SettingsSection =
| "profile"
| "notifications"
| "agents"
| "appearance"
| "shortcuts"
| "tokens"
@@ -74,6 +77,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [
label: "Notifications",
icon: BellRing,
},
{
value: "agents",
label: "Agents",
icon: Bot,
},
{
value: "appearance",
label: "Appearance",
@@ -256,6 +264,8 @@ export function renderSettingsSection(
}
/>
);
case "agents":
return <PreventSleepSettingsCard />;
case "appearance":
return <ThemeSettingsCard />;
case "shortcuts":
+3
View File
@@ -1206,3 +1206,6 @@ export async function applyWorkspace(
token: token ?? null,
});
}
export const setPreventSleepActive = (active: boolean) =>
invokeTauri("set_prevent_sleep_active", { active });