Files
openclaw/apps/linux/src-tauri/src/quickchat.rs
Peter Steinberger bc8d0da092 feat(linux): Quick Chat streamed replies and precise pairing states (#110632)
* feat(linux): stream Quick Chat gateway events

* feat(linux): render streamed Quick Chat replies

* fix(linux): correlate Quick Chat streams by run

* style(linux): rustfmt

* test(linux): move Quick Chat stream helper tests into the tooling vitest project

* test: type the Linux quickchat stream harness for strict checks

* test: explicit harness shape for the root test typecheck
2026-07-18 13:22:48 +01:00

926 lines
31 KiB
Rust

use crate::gateway_ws::{AgentsListResult, ChatSendResult, GatewayClient};
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, WebviewUrl, WebviewWindow,
WebviewWindowBuilder,
};
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>>>,
}
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)),
}
}
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()
.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)
}
fn position_quickchat(app: &AppHandle, window: &WebviewWindow) -> 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 (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),
(window_size.width as f64, window_size.height as f64),
);
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_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)?;
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}"))
}
fn require_quickchat_window(window: &WebviewWindow) -> Result<(), String> {
if window.label() == QUICKCHAT_LABEL {
Ok(())
} else {
Err("Quick Chat command is available only to the Quick Chat window.".to_string())
}
}
#[tauri::command]
pub async fn quickchat_agents(
window: WebviewWindow,
gateway: State<'_, GatewayClient>,
state: State<'_, QuickChatState>,
) -> Result<Vec<QuickChatAgent>, String> {
require_quickchat_window(&window)?;
state.agents(gateway.inner()).await
}
#[tauri::command]
pub async fn quickchat_identity(
window: WebviewWindow,
gateway: State<'_, GatewayClient>,
state: State<'_, QuickChatState>,
) -> Result<QuickChatAgent, String> {
require_quickchat_window(&window)?;
state
.selected_agent(gateway.inner(), MissingSelection::FallBackToDefault)
.await
.map(|(agent, _)| agent)
}
#[tauri::command]
pub async fn quickchat_select_agent(
window: WebviewWindow,
gateway: State<'_, GatewayClient>,
state: State<'_, QuickChatState>,
agent_id: String,
) -> Result<QuickChatAgent, String> {
require_quickchat_window(&window)?;
state.select_agent(gateway.inner(), &agent_id).await
}
#[tauri::command]
pub async fn quickchat_send(
window: WebviewWindow,
gateway: State<'_, GatewayClient>,
state: State<'_, QuickChatState>,
message: String,
) -> Result<ChatSendResult, String> {
require_quickchat_window(&window)?;
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(
window: WebviewWindow,
state: State<'_, QuickChatState>,
) -> Result<QuickChatShortcutStatus, String> {
require_quickchat_window(&window)?;
state.shortcut_status()
}
#[tauri::command]
pub fn quickchat_set_shortcut(
window: WebviewWindow,
app: AppHandle,
desktop: State<'_, DesktopState>,
state: State<'_, QuickChatState>,
accelerator: Option<String>,
) -> Result<QuickChatShortcutStatus, String> {
require_quickchat_window(&window)?;
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(window: WebviewWindow, expanded: bool) -> Result<(), String> {
require_quickchat_window(&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 fn quickchat_hide(window: WebviewWindow) -> Result<(), String> {
require_quickchat_window(&window)?;
window
.app_handle()
.state::<QuickChatState>()
.hide_requested
.store(true, Ordering::SeqCst);
window
.hide()
.map_err(|error| format!("Could not hide Quick Chat: {error}"))?;
let _ = window.set_size(LogicalSize::new(QUICKCHAT_WIDTH, QUICKCHAT_HEIGHT));
Ok(())
}
#[tauri::command]
pub fn quickchat_ready(
window: WebviewWindow,
gateway: State<'_, GatewayClient>,
state: State<'_, QuickChatState>,
) -> Result<bool, String> {
require_quickchat_window(&window)?;
gateway.emit_current_state(&window)?;
Ok(!state.hide_requested.load(Ordering::SeqCst))
}
#[tauri::command]
pub fn quickchat_show_dashboard(
window: WebviewWindow,
app: AppHandle,
desktop: State<'_, DesktopState>,
) -> Result<(), String> {
require_quickchat_window(&window)?;
tray::open_dashboard(&app, desktop.inner());
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(),
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(),
name: None,
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");
}
#[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));
}
}