mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -1206,3 +1206,6 @@ export async function applyWorkspace(
|
||||
token: token ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export const setPreventSleepActive = (active: boolean) =>
|
||||
invokeTauri("set_prevent_sleep_active", { active });
|
||||
|
||||
Reference in New Issue
Block a user