Files
Peter Steinberger a1615cdfcf feat(linux): suspend the local gateway across host sleep via logind (#122719)
* feat(linux): suspend the local gateway across host sleep via logind

Mirror the macOS sleep cycle in the Tauri companion: a logind
PrepareForSleep listener with a delay inhibitor best-effort prepares a
local gateway suspension before sleep and resumes it on wake with a
reconnect nudge and bounded retries; leases are route-bound, always
cleared on wake, and late prepare responses resume immediately.

* fix(linux): keep the sleep listener alive when inhibitor re-acquire fails

* fix(linux): activate the driver for sleep cycles and bound driver-side sleep RPCs

Sleep cycles temporarily unpark the gateway driver so prepare/resume work
without the Quick Chat window; suspend RPCs carry a 3s driver-side budget
that recycles a hung socket instead of starving wake retries; a mock-logind
integration test exercises the Linux listener end to end.

* fix(linux): count overlapping sleep cycles instead of a boolean flag

An older wake task ending late cleared the shared activation flag and
parked the driver mid-cycle; a saturating depth counter keeps the driver
active until the last cycle ends.

* fix(linux): gate sleep-cycle activation on loopback routes and never block wake on logind

Remote or unconfigured routes no longer activate the driver during
sleep, the paired end hook runs only for cycles that began, and wake
recovery is spawned before the inhibitor re-acquire so a slow logind
cannot delay reconnect/resume.
2026-08-12 11:53:51 -07:00

1070 lines
36 KiB
Rust

use crate::gateway_ws::{AgentsListResult, ChatSendResult, GatewayClient};
use crate::quickchat_widgets::QuickChatWidgetState;
use crate::{tray, DesktopState};
use serde::Serialize;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tauri::{
AppHandle, Emitter, LogicalSize, Manager, PhysicalPosition, State, Webview, WebviewUrl,
WebviewWindow, WebviewWindowBuilder, Window,
};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut};
use uuid::Uuid;
pub const QUICKCHAT_LABEL: &str = "quickchat";
// Alt+Space is GNOME's window-menu grab; a second X11 grab for it always fails.
pub const QUICKCHAT_SHORTCUT: &str = "CmdOrCtrl+Shift+Space";
const QUICKCHAT_SHORTCUT_FILE: &str = "quickchat-shortcut";
const QUICKCHAT_SHORTCUT_DISABLED_MARKER: &str = "quickchat-shortcut-disabled";
const QUICKCHAT_WIDTH: f64 = 640.0;
const QUICKCHAT_HEIGHT: f64 = 92.0;
const QUICKCHAT_EXPANDED_HEIGHT: f64 = 360.0;
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct QuickChatAgent {
id: String,
name: String,
emoji: Option<String>,
avatar_url: Option<String>,
is_default: bool,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct QuickChatShortcutStatus {
supported: bool,
enabled: bool,
accelerator: String,
}
#[derive(Clone)]
struct ActiveShortcut {
accelerator: String,
shortcut: Shortcut,
registered: bool,
}
#[derive(Clone)]
struct QuickChatRetryIdentity {
message: String,
agent_id: String,
scope: String,
main_key: String,
idempotency_key: String,
}
pub(crate) struct QuickChatShortcutPreference {
pub accelerator: String,
pub shortcut: Shortcut,
}
#[derive(Clone)]
pub struct QuickChatState {
selected_agent_id: Arc<Mutex<Option<String>>>,
active_shortcut: Arc<Mutex<ActiveShortcut>>,
shortcuts_supported: bool,
hide_requested: Arc<AtomicBool>,
retry_identity: Arc<Mutex<Option<QuickChatRetryIdentity>>>,
widget_state: QuickChatWidgetState,
}
impl QuickChatState {
pub fn new(shortcuts_supported: bool) -> Self {
let shortcut = parse_shortcut(QUICKCHAT_SHORTCUT)
.expect("the built-in Quick Chat shortcut must be valid");
Self {
selected_agent_id: Arc::new(Mutex::new(None)),
active_shortcut: Arc::new(Mutex::new(ActiveShortcut {
accelerator: QUICKCHAT_SHORTCUT.to_string(),
shortcut,
registered: false,
})),
shortcuts_supported,
hide_requested: Arc::new(AtomicBool::new(true)),
retry_identity: Arc::new(Mutex::new(None)),
widget_state: QuickChatWidgetState::default(),
}
}
pub(crate) fn widget_state(&self) -> &QuickChatWidgetState {
&self.widget_state
}
fn send_idempotency_key(
&self,
message: &str,
agent_id: &str,
scope: &str,
main_key: &str,
) -> Result<String, String> {
let mut retry = self
.retry_identity
.lock()
.map_err(|_| "Quick Chat retry state is unavailable.".to_string())?;
if let Some(current) = retry.as_ref() {
if current.message == message
&& current.agent_id == agent_id
&& current.scope == scope
&& current.main_key == main_key
{
return Ok(current.idempotency_key.clone());
}
}
let idempotency_key = Uuid::new_v4().to_string();
*retry = Some(QuickChatRetryIdentity {
message: message.to_string(),
agent_id: agent_id.to_string(),
scope: scope.to_string(),
main_key: main_key.to_string(),
idempotency_key: idempotency_key.clone(),
});
Ok(idempotency_key)
}
fn clear_send_retry(&self, idempotency_key: &str) {
if let Ok(mut retry) = self.retry_identity.lock() {
if retry
.as_ref()
.is_some_and(|current| current.idempotency_key == idempotency_key)
{
*retry = None;
}
}
}
async fn agent_catalog(
&self,
gateway: &GatewayClient,
) -> Result<(AgentsListResult, Vec<QuickChatAgent>), String> {
let catalog = gateway.agents_list().await?;
let agents = build_agents(&catalog)?;
{
let mut selection = self
.selected_agent_id
.lock()
.map_err(|_| "Quick Chat agent selection is unavailable.".to_string())?;
if selection
.as_ref()
.is_some_and(|selected| !agents.iter().any(|agent| agent.id == selected.as_str()))
{
*selection = None;
}
}
Ok((catalog, agents))
}
async fn agents(&self, gateway: &GatewayClient) -> Result<Vec<QuickChatAgent>, String> {
self.agent_catalog(gateway).await.map(|(_, agents)| agents)
}
async fn selected_agent(
&self,
gateway: &GatewayClient,
on_missing: MissingSelection,
) -> Result<(QuickChatAgent, AgentsListResult), String> {
// Snapshot the pin before agents() refreshes the cache: a refresh clears a
// stale pin, and the send path must see that the pin existed so it can fail
// instead of silently rerouting the message to the default agent.
let pinned = self
.selected_agent_id
.lock()
.map_err(|_| "Quick Chat agent selection is unavailable.".to_string())?
.clone();
let (catalog, agents) = self.agent_catalog(gateway).await?;
resolve_selected_agent(pinned.as_deref(), &agents, on_missing).map(|agent| (agent, catalog))
}
async fn select_agent(
&self,
gateway: &GatewayClient,
agent_id: &str,
) -> Result<QuickChatAgent, String> {
let agent_id = agent_id.trim();
let agents = self.agents(gateway).await?;
let selected = agents
.iter()
.find(|agent| agent.id == agent_id)
.cloned()
.ok_or_else(|| format!("Unknown Quick Chat agent \"{agent_id}\"."))?;
*self
.selected_agent_id
.lock()
.map_err(|_| "Quick Chat agent selection is unavailable.".to_string())? =
if selected.is_default {
None
} else {
Some(selected.id.clone())
};
Ok(selected)
}
fn shortcut_status(&self) -> Result<QuickChatShortcutStatus, String> {
let active = self
.active_shortcut
.lock()
.map_err(|_| "Quick Chat shortcut state is unavailable.".to_string())?;
Ok(QuickChatShortcutStatus {
supported: self.shortcuts_supported,
enabled: active.registered,
accelerator: active.accelerator.clone(),
})
}
fn active_shortcut(&self) -> Result<ActiveShortcut, String> {
self.active_shortcut
.lock()
.map_err(|_| "Quick Chat shortcut state is unavailable.".to_string())
.map(|active| active.clone())
}
pub(crate) fn set_active_shortcut(
&self,
accelerator: String,
shortcut: Shortcut,
registered: bool,
) {
if let Ok(mut active) = self.active_shortcut.lock() {
*active = ActiveShortcut {
accelerator,
shortcut,
registered,
};
}
}
pub(crate) fn set_shortcut_registered(&self, registered: bool) {
if let Ok(mut active) = self.active_shortcut.lock() {
active.registered = registered;
}
}
pub(crate) fn shortcut(&self) -> Option<Shortcut> {
self.active_shortcut
.lock()
.ok()
.map(|active| active.shortcut)
}
pub fn matches_shortcut(&self, shortcut: &Shortcut) -> bool {
self.active_shortcut
.lock()
.is_ok_and(|active| active.registered && active.shortcut == *shortcut)
}
}
#[derive(Clone, Copy, PartialEq)]
enum MissingSelection {
FallBackToDefault,
Fail,
}
fn resolve_selected_agent(
pinned: Option<&str>,
agents: &[QuickChatAgent],
on_missing: MissingSelection,
) -> Result<QuickChatAgent, String> {
if let Some(id) = pinned {
if let Some(agent) = agents.iter().find(|agent| agent.id == id) {
return Ok(agent.clone());
}
if on_missing == MissingSelection::Fail {
return Err("The selected agent is no longer available.".to_string());
}
}
agents
.iter()
.find(|agent| agent.is_default)
.cloned()
.ok_or_else(|| "OpenClaw did not report a default agent.".to_string())
}
fn non_empty(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn build_agents(catalog: &AgentsListResult) -> Result<Vec<QuickChatAgent>, String> {
let agents = catalog
.agents
.iter()
.filter(|summary| summary.kind.as_deref() != Some("system"))
.map(|summary| {
let id = summary.id.clone();
let identity = summary.identity.as_ref();
let name = non_empty(identity.and_then(|identity| identity.name.clone()))
.or_else(|| non_empty(summary.name.clone()))
.unwrap_or_else(|| id.clone());
QuickChatAgent {
id,
name,
emoji: non_empty(identity.and_then(|identity| identity.emoji.clone())),
avatar_url: non_empty(identity.and_then(|identity| identity.avatar_url.clone())),
is_default: summary.id == catalog.default_id,
}
})
.collect::<Vec<_>>();
if agents.iter().any(|agent| agent.is_default) {
Ok(agents)
} else {
Err("OpenClaw did not report a default agent.".to_string())
}
}
fn parse_shortcut(accelerator: &str) -> Result<Shortcut, String> {
accelerator
.parse::<Shortcut>()
.map_err(|error| format!("Invalid shortcut \"{accelerator}\": {error}"))
}
fn validate_quickchat_shortcut(accelerator: &str) -> Result<Shortcut, String> {
let shortcut = parse_shortcut(accelerator)?;
let dashboard_shortcut = parse_shortcut(tray::GLOBAL_SHORTCUT)
.expect("the built-in dashboard shortcut must be valid");
if shortcut == dashboard_shortcut {
return Err(format!(
"Shortcut \"{accelerator}\" is reserved for Open Dashboard."
));
}
Ok(shortcut)
}
fn shortcut_preference_from_path(path: &Path) -> QuickChatShortcutPreference {
let configured = fs::read_to_string(path)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
if let Some(accelerator) = configured {
if let Ok(shortcut) = validate_quickchat_shortcut(&accelerator) {
return QuickChatShortcutPreference {
accelerator,
shortcut,
};
}
}
default_shortcut_preference()
}
fn default_shortcut_preference() -> QuickChatShortcutPreference {
QuickChatShortcutPreference {
accelerator: QUICKCHAT_SHORTCUT.to_string(),
shortcut: parse_shortcut(QUICKCHAT_SHORTCUT)
.expect("the built-in Quick Chat shortcut must be valid"),
}
}
fn persist_shortcut_preference(path: &Path, accelerator: Option<&str>) -> std::io::Result<()> {
match accelerator {
Some(accelerator) => {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, accelerator.as_bytes())
}
None => match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
},
}
}
fn quickchat_config_file(
app: &impl Manager<tauri::Wry>,
filename: &str,
) -> Result<PathBuf, String> {
app.path()
.app_config_dir()
.map(|path| path.join(filename))
.map_err(|error| format!("Could not resolve Quick Chat preference path: {error}"))
}
pub(crate) fn load_shortcut_preference(
app: &impl Manager<tauri::Wry>,
) -> QuickChatShortcutPreference {
match quickchat_config_file(app, QUICKCHAT_SHORTCUT_FILE) {
Ok(path) => shortcut_preference_from_path(&path),
Err(error) => {
eprintln!("{error}");
default_shortcut_preference()
}
}
}
fn quickchat_shortcut_disabled_marker(app: &impl Manager<tauri::Wry>) -> Option<PathBuf> {
match app.path().app_config_dir() {
Ok(path) => Some(path.join(QUICKCHAT_SHORTCUT_DISABLED_MARKER)),
Err(error) => {
eprintln!("Could not resolve Quick Chat shortcut preference path: {error}");
None
}
}
}
pub(crate) fn quickchat_shortcut_marker_exists(path: &Path) -> bool {
match path.try_exists() {
Ok(exists) => exists,
Err(error) => {
eprintln!("Could not read Quick Chat shortcut preference: {error}");
false
}
}
}
pub(crate) fn quickchat_shortcut_enabled(app: &impl Manager<tauri::Wry>) -> bool {
!quickchat_shortcut_disabled_marker(app)
.as_deref()
.is_some_and(quickchat_shortcut_marker_exists)
}
pub(crate) fn persist_quickchat_shortcut_state(app: &AppHandle, registered: bool) {
let Some(marker) = quickchat_shortcut_disabled_marker(app) else {
return;
};
let result = if registered {
match fs::remove_file(&marker) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
} else {
marker
.parent()
.map(fs::create_dir_all)
.transpose()
.and_then(|_| fs::write(&marker, b""))
};
if let Err(error) = result {
eprintln!("Could not persist Quick Chat shortcut preference: {error}");
}
}
pub fn quickchat_position(
monitor_pos: (f64, f64),
monitor_size: (f64, f64),
window_size: (f64, f64),
) -> (f64, f64) {
let max_x = monitor_pos.0 + (monitor_size.0 - window_size.0).max(0.0);
let max_y = monitor_pos.1 + (monitor_size.1 - window_size.1).max(0.0);
let x = monitor_pos.0 + (monitor_size.0 - window_size.0).max(0.0) / 2.0;
let y = monitor_pos.1 + monitor_size.1 * 0.22;
(x.clamp(monitor_pos.0, max_x), y.clamp(monitor_pos.1, max_y))
}
fn ensure_quickchat_window(app: &AppHandle) -> Result<WebviewWindow, String> {
if let Some(window) = app.get_webview_window(QUICKCHAT_LABEL) {
app.state::<GatewayClient>().activate(app.clone());
return Ok(window);
}
let window = WebviewWindowBuilder::new(
app,
QUICKCHAT_LABEL,
WebviewUrl::App("quickchat.html".into()),
)
.title("Quick Chat")
.inner_size(QUICKCHAT_WIDTH, QUICKCHAT_HEIGHT)
.decorations(false)
.transparent(true)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.visible(false)
.build()
.map_err(|error| format!("Could not create Quick Chat window: {error}"))?;
app.state::<GatewayClient>().activate(app.clone());
Ok(window)
}
/// Re-express a window's physical size in a target monitor's physical pixels.
///
/// `inner_size()` reports physical pixels at the scale of the monitor the
/// window is on *now*, while `work_area()` is physical pixels of the monitor we
/// are about to move to. Comparing them directly misplaces Quick Chat across a
/// mixed-DPI boundary: a 640pt window on a 2x display reports 1280px, so
/// centring it on a 1x display uses double its real width. Scales that are
/// equal (the single-monitor case) give a ratio of 1 and change nothing.
pub(crate) fn quickchat_target_size(
window_physical: (f64, f64),
window_scale: f64,
monitor_scale: f64,
) -> (f64, f64) {
let usable = |scale: f64| scale.is_finite() && scale > 0.0;
if !usable(window_scale) || !usable(monitor_scale) {
return window_physical;
}
let ratio = monitor_scale / window_scale;
(window_physical.0 * ratio, window_physical.1 * ratio)
}
pub(crate) fn position_quickchat(app: &AppHandle, window: &Window) -> Result<(), String> {
let monitor = app
.cursor_position()
.ok()
.and_then(|cursor| app.monitor_from_point(cursor.x, cursor.y).ok().flatten())
.or_else(|| app.primary_monitor().ok().flatten())
.or_else(|| window.current_monitor().ok().flatten())
.ok_or_else(|| "Could not determine a monitor for Quick Chat.".to_string())?;
let work_area = monitor.work_area();
let window_size = window
.inner_size()
.map_err(|error| format!("Could not read Quick Chat size: {error}"))?;
let window_scale = window
.scale_factor()
.map_err(|error| format!("Could not read Quick Chat scale: {error}"))?;
let target_size = quickchat_target_size(
(window_size.width as f64, window_size.height as f64),
window_scale,
monitor.scale_factor(),
);
let (x, y) = quickchat_position(
(work_area.position.x as f64, work_area.position.y as f64),
(work_area.size.width as f64, work_area.size.height as f64),
target_size,
);
window
.set_position(PhysicalPosition::new(x.round() as i32, y.round() as i32))
.map_err(|error| format!("Could not position Quick Chat: {error}"))
}
pub fn request_hide(app: &AppHandle) {
app.state::<QuickChatState>()
.hide_requested
.store(true, Ordering::SeqCst);
let _ = app.emit_to(QUICKCHAT_LABEL, "quickchat:hide-requested", ());
}
pub fn toggle_quickchat(app: &AppHandle) {
if let Some(window) = app.get_webview_window(QUICKCHAT_LABEL) {
if window.is_visible().unwrap_or(false) {
request_hide(app);
return;
}
}
if let Err(error) = show_quickchat(app) {
eprintln!("Quick Chat unavailable: {error}");
}
}
fn show_quickchat(app: &AppHandle) -> Result<(), String> {
let window = ensure_quickchat_window(app)?;
app.state::<GatewayClient>().resume_paused_reconnect();
window
.set_size(LogicalSize::new(QUICKCHAT_WIDTH, QUICKCHAT_HEIGHT))
.map_err(|error| format!("Could not reset Quick Chat size: {error}"))?;
position_quickchat(app, &window.as_ref().window())?;
app.state::<QuickChatState>()
.hide_requested
.store(false, Ordering::SeqCst);
window
.show()
.map_err(|error| format!("Could not show Quick Chat: {error}"))?;
if let Err(error) = window.set_focus() {
// X11 focus-stealing prevention can reject the focus grab; retract the bar
// instead of leaving an unfocusable always-on-top window on screen. If even
// hide fails, destroy the window rather than strand it; the next toggle rebuilds.
app.state::<QuickChatState>()
.hide_requested
.store(true, Ordering::SeqCst);
if window.hide().is_err() {
let _ = window.destroy();
}
return Err(format!("Could not focus Quick Chat: {error}"));
}
window
.emit("quickchat:shown", ())
.map_err(|error| format!("Could not activate Quick Chat: {error}"))
}
// Commands take the calling WebView, not WebviewWindow: once widgets are attached the
// host becomes multi-WebView and Tauri intentionally rejects WebviewWindow command args.
fn require_quickchat_webview(webview: &Webview) -> Result<(), String> {
if webview.label() == QUICKCHAT_LABEL && webview.window().label() == QUICKCHAT_LABEL {
Ok(())
} else {
Err("Quick Chat command is available only to the Quick Chat webview.".to_string())
}
}
#[tauri::command]
pub async fn quickchat_agents(
webview: Webview,
gateway: State<'_, GatewayClient>,
state: State<'_, QuickChatState>,
) -> Result<Vec<QuickChatAgent>, String> {
require_quickchat_webview(&webview)?;
state.agents(gateway.inner()).await
}
#[tauri::command]
pub async fn quickchat_identity(
webview: Webview,
gateway: State<'_, GatewayClient>,
state: State<'_, QuickChatState>,
) -> Result<QuickChatAgent, String> {
require_quickchat_webview(&webview)?;
state
.selected_agent(gateway.inner(), MissingSelection::FallBackToDefault)
.await
.map(|(agent, _)| agent)
}
#[tauri::command]
pub async fn quickchat_select_agent(
webview: Webview,
gateway: State<'_, GatewayClient>,
state: State<'_, QuickChatState>,
agent_id: String,
) -> Result<QuickChatAgent, String> {
require_quickchat_webview(&webview)?;
state.select_agent(gateway.inner(), &agent_id).await
}
#[tauri::command]
pub async fn quickchat_send(
webview: Webview,
gateway: State<'_, GatewayClient>,
state: State<'_, QuickChatState>,
message: String,
) -> Result<ChatSendResult, String> {
require_quickchat_webview(&webview)?;
let message = message.trim().to_string();
if message.is_empty() {
return Err("Message cannot be empty.".to_string());
}
// Strict resolution: a vanished pin fails instead of silently rerouting to default.
let (agent, catalog) = state
.selected_agent(gateway.inner(), MissingSelection::Fail)
.await?;
let idempotency_key =
state.send_idempotency_key(&message, &agent.id, &catalog.scope, &catalog.main_key)?;
let result = gateway
.chat_send(
message,
&agent.id,
&catalog.scope,
&catalog.main_key,
&idempotency_key,
)
.await;
if result.is_ok() {
state.clear_send_retry(&idempotency_key);
}
result
}
#[tauri::command]
pub fn quickchat_shortcut(
webview: Webview,
state: State<'_, QuickChatState>,
) -> Result<QuickChatShortcutStatus, String> {
require_quickchat_webview(&webview)?;
state.shortcut_status()
}
#[tauri::command]
pub fn quickchat_set_shortcut(
webview: Webview,
app: AppHandle,
desktop: State<'_, DesktopState>,
state: State<'_, QuickChatState>,
accelerator: Option<String>,
) -> Result<QuickChatShortcutStatus, String> {
require_quickchat_webview(&webview)?;
if !state.shortcuts_supported {
return state.shortcut_status();
}
let configured = accelerator.and_then(|value| non_empty(Some(value)));
let candidate_accelerator = configured
.clone()
.unwrap_or_else(|| QUICKCHAT_SHORTCUT.to_string());
let candidate = validate_quickchat_shortcut(&candidate_accelerator)?;
let current = state.active_shortcut()?;
let preference_path = quickchat_config_file(&app, QUICKCHAT_SHORTCUT_FILE)?;
let manager = app.global_shortcut();
let current_registered = manager.is_registered(current.shortcut);
let should_register = quickchat_shortcut_enabled(&app);
let candidate_already_registered = current.shortcut == candidate && current_registered;
if !candidate_already_registered {
manager.register(candidate).map_err(|error| {
format!("Could not register shortcut \"{candidate_accelerator}\": {error}")
})?;
}
if current.shortcut != candidate && current_registered {
if let Err(error) = manager.unregister(current.shortcut) {
let _ = manager.unregister(candidate);
return Err(format!(
"Could not replace shortcut \"{}\": {error}",
current.accelerator
));
}
}
if !should_register {
if let Err(error) = manager.unregister(candidate) {
if current.shortcut != candidate && current_registered {
let _ = manager.register(current.shortcut);
}
return Err(format!(
"Could not finish validating shortcut \"{candidate_accelerator}\": {error}"
));
}
}
if let Err(error) = persist_shortcut_preference(&preference_path, configured.as_deref()) {
if manager.is_registered(candidate) {
let _ = manager.unregister(candidate);
}
if current_registered && !manager.is_registered(current.shortcut) {
let _ = manager.register(current.shortcut);
}
return Err(format!("Could not save the Quick Chat shortcut: {error}"));
}
let registered = should_register && manager.is_registered(candidate);
state.set_active_shortcut(candidate_accelerator, candidate, registered);
desktop.set_quickchat_shortcut_checked(registered);
state.shortcut_status()
}
#[tauri::command]
pub fn quickchat_set_expanded(webview: Webview, expanded: bool) -> Result<(), String> {
require_quickchat_webview(&webview)?;
let window = webview.window();
let height = if expanded {
QUICKCHAT_EXPANDED_HEIGHT
} else {
QUICKCHAT_HEIGHT
};
window
.set_size(LogicalSize::new(QUICKCHAT_WIDTH, height))
.map_err(|error| format!("Could not resize Quick Chat: {error}"))?;
position_quickchat(window.app_handle(), &window)
}
#[tauri::command]
pub async fn quickchat_activate(
webview: Webview,
state: State<'_, QuickChatState>,
session_id: String,
renderer_epoch: u64,
generation: u64,
) -> Result<bool, String> {
require_quickchat_webview(&webview)?;
state
.widget_state()
.activate(
&webview.window(),
&session_id,
renderer_epoch,
generation,
&state.hide_requested,
)
.await
}
#[tauri::command]
pub async fn quickchat_hide(
webview: Webview,
session_id: String,
renderer_epoch: u64,
generation: u64,
) -> Result<bool, String> {
require_quickchat_webview(&webview)?;
let window = webview.window();
let app = window.app_handle();
let state = app.state::<QuickChatState>();
state
.widget_state()
.hide(
app,
&window,
&session_id,
renderer_epoch,
generation,
&state.hide_requested,
)
.await
}
#[tauri::command]
pub async fn quickchat_ready(
webview: Webview,
gateway: State<'_, GatewayClient>,
state: State<'_, QuickChatState>,
session_id: String,
renderer_epoch: u64,
) -> Result<bool, String> {
require_quickchat_webview(&webview)?;
if !state
.widget_state()
.start_session(webview.app_handle(), &session_id, renderer_epoch)
.await?
{
return Ok(false);
}
gateway.emit_current_state(&webview)?;
Ok(!state.hide_requested.load(Ordering::SeqCst))
}
#[tauri::command]
pub fn quickchat_show_dashboard(webview: Webview, app: AppHandle) -> Result<(), String> {
require_quickchat_webview(&webview)?;
tray::open_dashboard(&app);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn assert_position(actual: (f64, f64), expected: (f64, f64)) {
assert!((actual.0 - expected.0).abs() < 1e-9);
assert!((actual.1 - expected.1).abs() < 1e-9);
}
fn test_agent(id: &str, is_default: bool) -> QuickChatAgent {
QuickChatAgent {
id: id.to_string(),
name: id.to_string(),
emoji: None,
avatar_url: None,
is_default,
}
}
fn test_directory(label: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock before Unix epoch")
.as_nanos();
std::env::temp_dir().join(format!(
"openclaw-quickchat-{label}-{}-{unique}",
std::process::id()
))
}
#[test]
fn unchanged_failed_draft_reuses_idempotency_key() {
let state = QuickChatState::new(true);
let first = state
.send_idempotency_key("hello", "main", "per-sender", "main")
.expect("first key");
let retry = state
.send_idempotency_key("hello", "main", "per-sender", "main")
.expect("retry key");
let edited = state
.send_idempotency_key("hello again", "main", "per-sender", "main")
.expect("edited key");
assert_eq!(first, retry);
assert_ne!(first, edited);
state.clear_send_retry(&edited);
assert_ne!(
state
.send_idempotency_key("hello again", "main", "per-sender", "main")
.expect("post-ack key"),
edited
);
}
#[test]
fn position_centers_window_at_twenty_two_percent_of_work_area() {
assert_position(
quickchat_position((0.0, 0.0), (1920.0, 1080.0), (640.0, 92.0)),
(640.0, 237.6),
);
assert_position(
quickchat_position((-1280.0, 40.0), (1280.0, 984.0), (640.0, 92.0)),
(-960.0, 256.48),
);
}
#[test]
fn position_stays_inside_small_work_area() {
assert_eq!(
quickchat_position((10.0, 20.0), (500.0, 80.0), (640.0, 92.0)),
(10.0, 20.0)
);
}
#[test]
fn agents_use_identity_precedence_and_render_fields() {
let catalog = AgentsListResult {
default_id: "main".to_string(),
main_key: "main".to_string(),
scope: "per-sender".to_string(),
agents: vec![
crate::gateway_ws::GatewayAgentSummary {
id: "main".to_string(),
kind: Some("agent".to_string()),
name: Some("Configured".to_string()),
identity: Some(crate::gateway_ws::GatewayAgentIdentity {
name: Some("Molty".to_string()),
emoji: Some("🦞".to_string()),
avatar_url: Some("data:image/png;base64,AA==".to_string()),
}),
},
crate::gateway_ws::GatewayAgentSummary {
id: "other".to_string(),
kind: None,
name: None,
identity: None,
},
crate::gateway_ws::GatewayAgentSummary {
id: "ordinary-looking-id".to_string(),
kind: Some("system".to_string()),
name: Some("System".to_string()),
identity: None,
},
],
};
let agents = build_agents(&catalog).expect("agent list");
assert_eq!(agents[0].name, "Molty");
assert_eq!(agents[0].emoji.as_deref(), Some("🦞"));
assert_eq!(
agents[0].avatar_url.as_deref(),
Some("data:image/png;base64,AA==")
);
assert_eq!(agents[1].name, "other");
assert_eq!(agents.len(), 2);
}
#[test]
fn shortcut_preference_round_trips_and_resets() {
let directory = test_directory("shortcut-roundtrip");
fs::create_dir_all(&directory).expect("create test directory");
let path = directory.join(QUICKCHAT_SHORTCUT_FILE);
persist_shortcut_preference(&path, Some("Ctrl+Alt+KeyK")).expect("write shortcut");
let loaded = shortcut_preference_from_path(&path);
assert_eq!(loaded.accelerator, "Ctrl+Alt+KeyK");
assert!(loaded
.shortcut
.matches(loaded.shortcut.mods, loaded.shortcut.key));
persist_shortcut_preference(&path, None).expect("reset shortcut");
assert!(!path.exists());
assert_eq!(
shortcut_preference_from_path(&path).accelerator,
QUICKCHAT_SHORTCUT
);
fs::remove_dir_all(directory).expect("remove test directory");
}
#[test]
fn invalid_shortcut_preference_falls_back_to_default() {
let directory = test_directory("shortcut-fallback");
fs::create_dir_all(&directory).expect("create test directory");
let path = directory.join(QUICKCHAT_SHORTCUT_FILE);
fs::write(&path, b"Ctrl+NotAKey").expect("write invalid shortcut");
let loaded = shortcut_preference_from_path(&path);
assert_eq!(loaded.accelerator, QUICKCHAT_SHORTCUT);
fs::remove_dir_all(directory).expect("remove test directory");
}
#[test]
fn dashboard_shortcut_preference_falls_back_to_default() {
let directory = test_directory("shortcut-reserved");
fs::create_dir_all(&directory).expect("create test directory");
let path = directory.join(QUICKCHAT_SHORTCUT_FILE);
fs::write(&path, tray::GLOBAL_SHORTCUT).expect("write reserved shortcut");
let loaded = shortcut_preference_from_path(&path);
assert_eq!(loaded.accelerator, QUICKCHAT_SHORTCUT);
fs::remove_dir_all(directory).expect("remove test directory");
}
#[test]
fn missing_pinned_agent_fails_sends_and_heals_identity() {
let agents = [test_agent("main", true), test_agent("work", false)];
let pinned = resolve_selected_agent(Some("work"), &agents, MissingSelection::Fail);
assert_eq!(pinned.expect("pinned agent").id, "work");
let gone = resolve_selected_agent(Some("gone"), &agents, MissingSelection::Fail);
assert!(gone.is_err());
let healed =
resolve_selected_agent(Some("gone"), &agents, MissingSelection::FallBackToDefault);
assert_eq!(healed.expect("default agent").id, "main");
}
#[test]
fn shortcut_dispatch_matches_custom_accelerator() {
let state = QuickChatState::new(true);
let custom = parse_shortcut("Ctrl+Alt+KeyK").expect("custom shortcut");
let default = parse_shortcut(QUICKCHAT_SHORTCUT).expect("default shortcut");
state.set_active_shortcut("Ctrl+Alt+KeyK".to_string(), custom, true);
assert!(state.matches_shortcut(&custom));
assert!(!state.matches_shortcut(&default));
state.set_shortcut_registered(false);
assert!(!state.matches_shortcut(&custom));
}
#[test]
fn target_size_is_identity_within_one_monitor() {
// The single-monitor case must not move by even a pixel.
assert_eq!(
quickchat_target_size((1280.0, 720.0), 2.0, 2.0),
(1280.0, 720.0)
);
assert_eq!(
quickchat_target_size((640.0, 360.0), 1.0, 1.0),
(640.0, 360.0)
);
assert_eq!(
quickchat_target_size((960.0, 540.0), 1.5, 1.5),
(960.0, 540.0)
);
}
#[test]
fn target_size_rescales_across_a_dpi_boundary() {
// A 640pt window on a 2x display reports 1280px; on a 1x display it is
// really 640px wide, and centring with 1280 would sit it far left.
assert_eq!(
quickchat_target_size((1280.0, 720.0), 2.0, 1.0),
(640.0, 360.0)
);
// And the reverse direction.
assert_eq!(
quickchat_target_size((640.0, 360.0), 1.0, 2.0),
(1280.0, 720.0)
);
// Fractional scales, as Windows and some Linux compositors report.
assert_eq!(
quickchat_target_size((1200.0, 600.0), 2.0, 1.5),
(900.0, 450.0)
);
}
#[test]
fn target_size_falls_back_on_unusable_scales() {
for scale in [0.0, -1.0, f64::NAN, f64::INFINITY] {
assert_eq!(
quickchat_target_size((800.0, 600.0), scale, 2.0),
(800.0, 600.0)
);
assert_eq!(
quickchat_target_size((800.0, 600.0), 2.0, scale),
(800.0, 600.0)
);
}
}
#[test]
fn rescaled_window_centers_on_the_target_monitor() {
// End to end: a 2x-scaled 640pt-wide window invoked on a 1x 1920px
// monitor. Without the rescale it centers using 1280 and lands at 320.
let target = quickchat_target_size((1280.0, 720.0), 2.0, 1.0);
let (x, _) = quickchat_position((0.0, 0.0), (1920.0, 1080.0), target);
assert_eq!(x, 640.0);
}
}