mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
perf(desktop): stop beachballs on agents menu and thread open (#1402)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
use crate::managed_agents::{discover_provider_candidates, invoke_provider, BackendProviderInfo};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn discover_backend_providers() -> Vec<BackendProviderInfo> {
|
||||
discover_provider_candidates()
|
||||
.into_iter()
|
||||
.map(|(id, path)| BackendProviderInfo {
|
||||
id,
|
||||
binary_path: path.display().to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn probe_backend_provider(binary_path: String) -> Result<serde_json::Value, String> {
|
||||
// Validate that the requested path is actually a discovered buzz-backend-* binary.
|
||||
// This prevents arbitrary binary execution via a compromised frontend or IPC.
|
||||
let candidates = discover_provider_candidates();
|
||||
let path = std::path::PathBuf::from(&binary_path);
|
||||
let canonical = path
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("binary not found: {binary_path}: {e}"))?;
|
||||
let is_known = candidates
|
||||
.iter()
|
||||
.any(|(_, p)| p.canonicalize().ok().as_ref() == Some(&canonical));
|
||||
if !is_known {
|
||||
return Err(format!(
|
||||
"binary '{binary_path}' is not a discovered buzz-backend-* provider"
|
||||
));
|
||||
}
|
||||
// request_id is for provider-side logging — not validated in the response
|
||||
// (stdin→stdout is 1:1 per process invocation).
|
||||
let request = serde_json::json!({
|
||||
"op": "info",
|
||||
"request_id": uuid::Uuid::new_v4().to_string(),
|
||||
});
|
||||
tokio::task::spawn_blocking(move || {
|
||||
invoke_provider(&canonical, &request, std::time::Duration::from_secs(10))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
}
|
||||
@@ -5,14 +5,14 @@ use crate::{
|
||||
app_state::AppState,
|
||||
managed_agents::{
|
||||
build_managed_agent_summary, current_instance_id, discover_provider_candidates,
|
||||
ensure_persona_is_active, find_managed_agent_mut, invoke_provider, load_managed_agents,
|
||||
load_personas, managed_agent_avatar_url, managed_agent_log_path, managed_agents_base_dir,
|
||||
ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas,
|
||||
managed_agent_avatar_url, managed_agent_log_path, managed_agents_base_dir,
|
||||
normalize_agent_args, provider_deploy, read_log_tail, resolve_provider_binary,
|
||||
save_managed_agents, start_managed_agent_process, stop_managed_agent_process,
|
||||
sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind,
|
||||
BackendProviderInfo, CreateManagedAgentRequest, CreateManagedAgentResponse,
|
||||
ManagedAgentLogResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig,
|
||||
DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
|
||||
CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentLogResponse,
|
||||
ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND,
|
||||
DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
|
||||
},
|
||||
relay::{relay_ws_url_with_override, sync_managed_agent_profile},
|
||||
util::now_iso,
|
||||
@@ -452,35 +452,44 @@ async fn deploy_to_provider(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Async so the blocking body (disk reads of agent/persona records, per-agent
|
||||
// process-liveness syscalls, and a possible save) runs on Tauri's worker pool
|
||||
// via spawn_blocking instead of the main UI thread — it was a beachball on the
|
||||
// agents menu mount and after every start/stop/edit refetch. State is re-derived
|
||||
// from the owned AppHandle inside the closure because `State<'_, _>` is borrowed
|
||||
// and `std::sync::MutexGuard` is not `Send`.
|
||||
#[tauri::command]
|
||||
pub fn list_managed_agents(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<ManagedAgentSummary>, String> {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut records = load_managed_agents(&app)?;
|
||||
let mut runtimes = state
|
||||
.managed_agent_processes
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
pub async fn list_managed_agents(app: AppHandle) -> Result<Vec<ManagedAgentSummary>, String> {
|
||||
use tauri::Manager;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut records = load_managed_agents(&app)?;
|
||||
let mut runtimes = state
|
||||
.managed_agent_processes
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
let (sync_changed, exited_pubkeys) =
|
||||
sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app));
|
||||
if sync_changed {
|
||||
save_managed_agents(&app, &records)?;
|
||||
}
|
||||
for pubkey in &exited_pubkeys {
|
||||
state.clear_session_cache(pubkey);
|
||||
}
|
||||
let (sync_changed, exited_pubkeys) =
|
||||
sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app));
|
||||
if sync_changed {
|
||||
save_managed_agents(&app, &records)?;
|
||||
}
|
||||
for pubkey in &exited_pubkeys {
|
||||
state.clear_session_cache(pubkey);
|
||||
}
|
||||
|
||||
let personas = load_personas(&app).unwrap_or_default();
|
||||
records
|
||||
.iter()
|
||||
.map(|record| build_managed_agent_summary(&app, record, &runtimes, &personas))
|
||||
.collect()
|
||||
let personas = load_personas(&app).unwrap_or_default();
|
||||
records
|
||||
.iter()
|
||||
.map(|record| build_managed_agent_summary(&app, record, &runtimes, &personas))
|
||||
.collect()
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1243,60 +1252,17 @@ fn profile_needs_sync(
|
||||
}
|
||||
}
|
||||
|
||||
// Async so the blocking body (disk reads/writes + process termination) runs off
|
||||
// the main UI thread via spawn_blocking. State is re-derived from the owned
|
||||
// AppHandle inside the closure (`State<'_, _>` is borrowed, MutexGuard is !Send).
|
||||
#[tauri::command]
|
||||
pub fn stop_managed_agent(
|
||||
pub async fn stop_managed_agent(
|
||||
pubkey: String,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ManagedAgentSummary, String> {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut records = load_managed_agents(&app)?;
|
||||
let mut runtimes = state
|
||||
.managed_agent_processes
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
let (sync_changed, exited_pubkeys) =
|
||||
sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app));
|
||||
if sync_changed {
|
||||
save_managed_agents(&app, &records)?;
|
||||
}
|
||||
for pubkey in &exited_pubkeys {
|
||||
state.clear_session_cache(pubkey);
|
||||
}
|
||||
|
||||
{
|
||||
let record = find_managed_agent_mut(&mut records, &pubkey)?;
|
||||
// Remote agents are stopped via !shutdown @mention from the frontend,
|
||||
// not via this backend command. Reject the call.
|
||||
if record.backend != BackendKind::Local {
|
||||
return Err(
|
||||
"remote agents are stopped via !shutdown message, not this command".to_string(),
|
||||
);
|
||||
}
|
||||
stop_managed_agent_process(&app, record, &mut runtimes)?;
|
||||
}
|
||||
state.clear_session_cache(&pubkey);
|
||||
save_managed_agents(&app, &records)?;
|
||||
let record = records
|
||||
.iter()
|
||||
.find(|record| record.pubkey == pubkey)
|
||||
.ok_or_else(|| format!("agent {pubkey} not found"))?;
|
||||
let personas = load_personas(&app).unwrap_or_default();
|
||||
build_managed_agent_summary(&app, record, &runtimes, &personas)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_managed_agent(
|
||||
pubkey: String,
|
||||
force_remote_delete: Option<bool>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
{
|
||||
use tauri::Manager;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
@@ -1316,43 +1282,104 @@ pub fn delete_managed_agent(
|
||||
state.clear_session_cache(pubkey);
|
||||
}
|
||||
|
||||
// Guard: reject deletion of deployed remote agents unless explicitly forced.
|
||||
// This turns "don't orphan remote infra" from a UI convention into a backend
|
||||
// invariant — a buggy or compromised IPC caller cannot silently orphan a live
|
||||
// remote deployment. The frontend sends force_remote_delete: true only after
|
||||
// the user confirms the orphan warning.
|
||||
if let Some(record) = records.iter().find(|r| r.pubkey == pubkey) {
|
||||
if record.backend != BackendKind::Local
|
||||
&& record.backend_agent_id.is_some()
|
||||
&& !force_remote_delete.unwrap_or(false)
|
||||
{
|
||||
{
|
||||
let record = find_managed_agent_mut(&mut records, &pubkey)?;
|
||||
// Remote agents are stopped via !shutdown @mention from the frontend,
|
||||
// not via this backend command. Reject the call.
|
||||
if record.backend != BackendKind::Local {
|
||||
return Err(
|
||||
"cannot delete a deployed remote agent without force_remote_delete: true"
|
||||
.to_string(),
|
||||
"remote agents are stopped via !shutdown message, not this command".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) {
|
||||
stop_managed_agent_process(&app, record, &mut runtimes)?;
|
||||
}
|
||||
state.clear_session_cache(&pubkey);
|
||||
let initial_len = records.len();
|
||||
records.retain(|record| record.pubkey != pubkey);
|
||||
if records.len() == initial_len {
|
||||
return Err(format!("agent {pubkey} not found"));
|
||||
}
|
||||
save_managed_agents(&app, &records)?;
|
||||
// Remove the agent's nsec from the keyring after the record is gone.
|
||||
crate::managed_agents::delete_agent_key(&pubkey);
|
||||
// Tombstone-after-validation: only reached past the deployed-remote
|
||||
// guard above and a confirmed removal — never orphan a live remote
|
||||
// deployment's relay record. Inside the lock, before the block closes
|
||||
// (no .await here). Every agent published, so every delete tombstones.
|
||||
tombstone_managed_agent_pending(&app, &state, &pubkey);
|
||||
}
|
||||
try_regenerate_nest(&app);
|
||||
Ok(())
|
||||
let record = records
|
||||
.iter()
|
||||
.find(|record| record.pubkey == pubkey)
|
||||
.ok_or_else(|| format!("agent {pubkey} not found"))?;
|
||||
let personas = load_personas(&app).unwrap_or_default();
|
||||
build_managed_agent_summary(&app, record, &runtimes, &personas)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
}
|
||||
|
||||
// Async so the blocking body (disk reads/writes, process termination, keyring
|
||||
// delete, nest regeneration) runs off the main UI thread via spawn_blocking.
|
||||
#[tauri::command]
|
||||
pub async fn delete_managed_agent(
|
||||
pubkey: String,
|
||||
force_remote_delete: Option<bool>,
|
||||
app: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
use tauri::Manager;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
{
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut records = load_managed_agents(&app)?;
|
||||
let mut runtimes = state
|
||||
.managed_agent_processes
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
let (sync_changed, exited_pubkeys) = sync_managed_agent_processes(
|
||||
&mut records,
|
||||
&mut runtimes,
|
||||
¤t_instance_id(&app),
|
||||
);
|
||||
if sync_changed {
|
||||
save_managed_agents(&app, &records)?;
|
||||
}
|
||||
for pubkey in &exited_pubkeys {
|
||||
state.clear_session_cache(pubkey);
|
||||
}
|
||||
|
||||
// Guard: reject deletion of deployed remote agents unless explicitly forced.
|
||||
// This turns "don't orphan remote infra" from a UI convention into a backend
|
||||
// invariant — a buggy or compromised IPC caller cannot silently orphan a live
|
||||
// remote deployment. The frontend sends force_remote_delete: true only after
|
||||
// the user confirms the orphan warning.
|
||||
if let Some(record) = records.iter().find(|r| r.pubkey == pubkey) {
|
||||
if record.backend != BackendKind::Local
|
||||
&& record.backend_agent_id.is_some()
|
||||
&& !force_remote_delete.unwrap_or(false)
|
||||
{
|
||||
return Err(
|
||||
"cannot delete a deployed remote agent without force_remote_delete: true"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) {
|
||||
stop_managed_agent_process(&app, record, &mut runtimes)?;
|
||||
}
|
||||
state.clear_session_cache(&pubkey);
|
||||
let initial_len = records.len();
|
||||
records.retain(|record| record.pubkey != pubkey);
|
||||
if records.len() == initial_len {
|
||||
return Err(format!("agent {pubkey} not found"));
|
||||
}
|
||||
save_managed_agents(&app, &records)?;
|
||||
// Remove the agent's nsec from the keyring after the record is gone.
|
||||
crate::managed_agents::delete_agent_key(&pubkey);
|
||||
// Tombstone-after-validation: only reached past the deployed-remote
|
||||
// guard above and a confirmed removal — never orphan a live remote
|
||||
// deployment's relay record. Inside the lock, before the block closes
|
||||
// (no .await here). Every agent published, so every delete tombstones.
|
||||
tombstone_managed_agent_pending(&app, &state, &pubkey);
|
||||
}
|
||||
try_regenerate_nest(&app);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1382,49 +1409,6 @@ pub fn get_managed_agent_log(
|
||||
})
|
||||
}
|
||||
|
||||
// ── New backend-provider commands ────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn discover_backend_providers() -> Vec<BackendProviderInfo> {
|
||||
discover_provider_candidates()
|
||||
.into_iter()
|
||||
.map(|(id, path)| BackendProviderInfo {
|
||||
id,
|
||||
binary_path: path.display().to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn probe_backend_provider(binary_path: String) -> Result<serde_json::Value, String> {
|
||||
// Validate that the requested path is actually a discovered buzz-backend-* binary.
|
||||
// This prevents arbitrary binary execution via a compromised frontend or IPC.
|
||||
let candidates = discover_provider_candidates();
|
||||
let path = std::path::PathBuf::from(&binary_path);
|
||||
let canonical = path
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("binary not found: {binary_path}: {e}"))?;
|
||||
let is_known = candidates
|
||||
.iter()
|
||||
.any(|(_, p)| p.canonicalize().ok().as_ref() == Some(&canonical));
|
||||
if !is_known {
|
||||
return Err(format!(
|
||||
"binary '{binary_path}' is not a discovered buzz-backend-* provider"
|
||||
));
|
||||
}
|
||||
// request_id is for provider-side logging — not validated in the response
|
||||
// (stdin→stdout is 1:1 per process invocation).
|
||||
let request = serde_json::json!({
|
||||
"op": "info",
|
||||
"request_id": uuid::Uuid::new_v4().to_string(),
|
||||
});
|
||||
tokio::task::spawn_blocking(move || {
|
||||
invoke_provider(&canonical, &request, std::time::Duration::from_secs(10))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
}
|
||||
|
||||
// Remote agent shutdown is handled entirely by the frontend:
|
||||
// 1. Frontend sends "!shutdown" @mention via WebSocket (signed by user's key)
|
||||
// 2. Harness sees it, exits gracefully, sets presence to "offline"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod agent_config;
|
||||
mod agent_discovery;
|
||||
mod agent_models;
|
||||
mod agent_providers;
|
||||
mod agent_settings;
|
||||
mod agents;
|
||||
mod canvas;
|
||||
@@ -34,6 +35,7 @@ mod workspace;
|
||||
pub use agent_config::*;
|
||||
pub use agent_discovery::*;
|
||||
pub use agent_models::*;
|
||||
pub use agent_providers::*;
|
||||
pub use agent_settings::*;
|
||||
pub use agents::*;
|
||||
pub use canvas::*;
|
||||
|
||||
@@ -150,129 +150,142 @@ pub(super) fn tombstone_persona_pending(app: &AppHandle, state: &AppState, d_tag
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_personas(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<PersonaRecord>, String> {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
load_personas(&app)
|
||||
pub async fn list_personas(app: AppHandle) -> Result<Vec<PersonaRecord>, String> {
|
||||
use tauri::Manager;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
load_personas(&app)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_persona(
|
||||
pub async fn create_persona(
|
||||
input: CreatePersonaRequest,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<PersonaRecord, String> {
|
||||
let display_name = trim_required(&input.display_name, "Display name")?;
|
||||
// System prompt optional: core memory is auto-injected. Empty is valid.
|
||||
let system_prompt = input.system_prompt.trim().to_string();
|
||||
let avatar_url = trim_optional(input.avatar_url);
|
||||
let runtime = trim_optional(input.runtime);
|
||||
let model = trim_optional(input.model);
|
||||
let provider = trim_optional(input.provider);
|
||||
let now = now_iso();
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut personas = load_personas(&app)?;
|
||||
let name_pool: Vec<String> = input
|
||||
.name_pool
|
||||
.into_iter()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
crate::managed_agents::validate_user_env_keys(&input.env_vars)?;
|
||||
let persona = PersonaRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
display_name,
|
||||
avatar_url,
|
||||
system_prompt,
|
||||
runtime,
|
||||
model,
|
||||
provider,
|
||||
name_pool,
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
env_vars: input.env_vars,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
personas.push(persona.clone());
|
||||
save_personas(&app, &personas)?;
|
||||
retain_persona_pending(&app, &state, &persona);
|
||||
try_regenerate_nest(&app);
|
||||
Ok(persona)
|
||||
use tauri::Manager;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
let display_name = trim_required(&input.display_name, "Display name")?;
|
||||
// System prompt optional: core memory is auto-injected. Empty is valid.
|
||||
let system_prompt = input.system_prompt.trim().to_string();
|
||||
let avatar_url = trim_optional(input.avatar_url);
|
||||
let runtime = trim_optional(input.runtime);
|
||||
let model = trim_optional(input.model);
|
||||
let provider = trim_optional(input.provider);
|
||||
let now = now_iso();
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut personas = load_personas(&app)?;
|
||||
let name_pool: Vec<String> = input
|
||||
.name_pool
|
||||
.into_iter()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
crate::managed_agents::validate_user_env_keys(&input.env_vars)?;
|
||||
let persona = PersonaRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
display_name,
|
||||
avatar_url,
|
||||
system_prompt,
|
||||
runtime,
|
||||
model,
|
||||
provider,
|
||||
name_pool,
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
env_vars: input.env_vars,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
personas.push(persona.clone());
|
||||
save_personas(&app, &personas)?;
|
||||
retain_persona_pending(&app, &state, &persona);
|
||||
try_regenerate_nest(&app);
|
||||
Ok(persona)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_persona(
|
||||
pub async fn update_persona(
|
||||
input: UpdatePersonaRequest,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<PersonaRecord, String> {
|
||||
let display_name = trim_required(&input.display_name, "Display name")?;
|
||||
// Do not trim system_prompt: `compose_prompt` appends pack_instructions
|
||||
// verbatim (including any trailing newline), and write_back_persona_md
|
||||
// decomposes by suffix-stripping. Trimming would break that exact-suffix
|
||||
// match for the common case where instructions.md has a trailing newline.
|
||||
let system_prompt = input.system_prompt.clone();
|
||||
let avatar_url = trim_optional(input.avatar_url);
|
||||
let runtime = trim_optional(input.runtime);
|
||||
let model = trim_optional(input.model);
|
||||
let provider = trim_optional(input.provider);
|
||||
use tauri::Manager;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
let display_name = trim_required(&input.display_name, "Display name")?;
|
||||
// Do not trim system_prompt: `compose_prompt` appends pack_instructions
|
||||
// verbatim (including any trailing newline), and write_back_persona_md
|
||||
// decomposes by suffix-stripping. Trimming would break that exact-suffix
|
||||
// match for the common case where instructions.md has a trailing newline.
|
||||
let system_prompt = input.system_prompt.clone();
|
||||
let avatar_url = trim_optional(input.avatar_url);
|
||||
let runtime = trim_optional(input.runtime);
|
||||
let model = trim_optional(input.model);
|
||||
let provider = trim_optional(input.provider);
|
||||
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut personas = load_personas(&app)?;
|
||||
let persona = personas
|
||||
.iter_mut()
|
||||
.find(|record| record.id == input.id)
|
||||
.ok_or_else(|| format!("persona {} not found", input.id))?;
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut personas = load_personas(&app)?;
|
||||
let persona = personas
|
||||
.iter_mut()
|
||||
.find(|record| record.id == input.id)
|
||||
.ok_or_else(|| format!("persona {} not found", input.id))?;
|
||||
|
||||
if persona.is_builtin {
|
||||
return Err("Built-in personas cannot be edited.".to_string());
|
||||
}
|
||||
persona.display_name = display_name;
|
||||
persona.avatar_url = avatar_url;
|
||||
persona.system_prompt = system_prompt;
|
||||
persona.runtime = runtime;
|
||||
persona.model = model;
|
||||
persona.provider = provider;
|
||||
persona.name_pool = input
|
||||
.name_pool
|
||||
.into_iter()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if let Some(env_vars) = input.env_vars {
|
||||
crate::managed_agents::validate_user_env_keys(&env_vars)?;
|
||||
persona.env_vars = env_vars;
|
||||
}
|
||||
persona.updated_at = now_iso();
|
||||
if persona.is_builtin {
|
||||
return Err("Built-in personas cannot be edited.".to_string());
|
||||
}
|
||||
persona.display_name = display_name;
|
||||
persona.avatar_url = avatar_url;
|
||||
persona.system_prompt = system_prompt;
|
||||
persona.runtime = runtime;
|
||||
persona.model = model;
|
||||
persona.provider = provider;
|
||||
persona.name_pool = input
|
||||
.name_pool
|
||||
.into_iter()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if let Some(env_vars) = input.env_vars {
|
||||
crate::managed_agents::validate_user_env_keys(&env_vars)?;
|
||||
persona.env_vars = env_vars;
|
||||
}
|
||||
persona.updated_at = now_iso();
|
||||
|
||||
save_personas(&app, &personas)?;
|
||||
let result = personas
|
||||
.into_iter()
|
||||
.find(|record| record.id == input.id)
|
||||
.ok_or_else(|| format!("persona {} disappeared unexpectedly", input.id))?;
|
||||
save_personas(&app, &personas)?;
|
||||
let result = personas
|
||||
.into_iter()
|
||||
.find(|record| record.id == input.id)
|
||||
.ok_or_else(|| format!("persona {} disappeared unexpectedly", input.id))?;
|
||||
|
||||
// For pack-backed personas, also write the edit back to the source
|
||||
// `.persona.md` so that launch sync (which reads the file) becomes a
|
||||
// no-op rather than overwriting the record we just saved.
|
||||
write_back_persona_md(&app, &result);
|
||||
// For pack-backed personas, also write the edit back to the source
|
||||
// `.persona.md` so that launch sync (which reads the file) becomes a
|
||||
// no-op rather than overwriting the record we just saved.
|
||||
write_back_persona_md(&app, &result);
|
||||
|
||||
retain_persona_pending(&app, &state, &result);
|
||||
try_regenerate_nest(&app);
|
||||
Ok(result)
|
||||
retain_persona_pending(&app, &state, &result);
|
||||
try_regenerate_nest(&app);
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
}
|
||||
|
||||
mod writeback;
|
||||
@@ -282,55 +295,57 @@ use writeback::write_back_persona_md;
|
||||
mod inbound_tests;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_persona(
|
||||
id: String,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut personas = load_personas(&app)?;
|
||||
let persona = personas
|
||||
.iter()
|
||||
.find(|record| record.id == id)
|
||||
.ok_or_else(|| format!("persona {id} not found"))?;
|
||||
let referenced_by_team = load_teams(&app)?.iter().any(|team| {
|
||||
team.persona_ids
|
||||
pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> {
|
||||
use tauri::Manager;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut personas = load_personas(&app)?;
|
||||
let persona = personas
|
||||
.iter()
|
||||
.any(|persona_id| persona_id == id.as_str())
|
||||
});
|
||||
validate_persona_deletion(persona, referenced_by_team)?;
|
||||
// Capture the coordinate before the record leaves the list. Only reached
|
||||
// for non-builtin, non-team personas (validate_persona_deletion rejects
|
||||
// both), so every deleted persona here is one this owner published.
|
||||
let d_tag = crate::managed_agents::persona_events::persona_d_tag(persona);
|
||||
.find(|record| record.id == id)
|
||||
.ok_or_else(|| format!("persona {id} not found"))?;
|
||||
let referenced_by_team = load_teams(&app)?.iter().any(|team| {
|
||||
team.persona_ids
|
||||
.iter()
|
||||
.any(|persona_id| persona_id == id.as_str())
|
||||
});
|
||||
validate_persona_deletion(persona, referenced_by_team)?;
|
||||
// Capture the coordinate before the record leaves the list. Only reached
|
||||
// for non-builtin, non-team personas (validate_persona_deletion rejects
|
||||
// both), so every deleted persona here is one this owner published.
|
||||
let d_tag = crate::managed_agents::persona_events::persona_d_tag(persona);
|
||||
|
||||
let original_len = personas.len();
|
||||
personas.retain(|record| record.id != id);
|
||||
if personas.len() == original_len {
|
||||
return Err(format!("persona {id} not found"));
|
||||
}
|
||||
save_personas(&app, &personas)?;
|
||||
tombstone_persona_pending(&app, &state, &d_tag);
|
||||
|
||||
let mut agents = load_managed_agents(&app)?;
|
||||
let mut changed_agents = false;
|
||||
let now = now_iso();
|
||||
for agent in &mut agents {
|
||||
if agent.persona_id.as_deref() == Some(id.as_str()) {
|
||||
agent.persona_id = None;
|
||||
agent.updated_at = now.clone();
|
||||
changed_agents = true;
|
||||
let original_len = personas.len();
|
||||
personas.retain(|record| record.id != id);
|
||||
if personas.len() == original_len {
|
||||
return Err(format!("persona {id} not found"));
|
||||
}
|
||||
}
|
||||
if changed_agents {
|
||||
save_managed_agents(&app, &agents)?;
|
||||
}
|
||||
try_regenerate_nest(&app);
|
||||
save_personas(&app, &personas)?;
|
||||
tombstone_persona_pending(&app, &state, &d_tag);
|
||||
|
||||
Ok(())
|
||||
let mut agents = load_managed_agents(&app)?;
|
||||
let mut changed_agents = false;
|
||||
let now = now_iso();
|
||||
for agent in &mut agents {
|
||||
if agent.persona_id.as_deref() == Some(id.as_str()) {
|
||||
agent.persona_id = None;
|
||||
agent.updated_at = now.clone();
|
||||
changed_agents = true;
|
||||
}
|
||||
}
|
||||
if changed_agents {
|
||||
save_managed_agents(&app, &agents)?;
|
||||
}
|
||||
try_regenerate_nest(&app);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
}
|
||||
|
||||
/// Apply an inbound kind:30175 persona event from the relay onto the local
|
||||
@@ -679,51 +694,56 @@ fn apply_inbound_team(teams: &mut Vec<TeamRecord>, d_tag: String, inbound: TeamE
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_persona_active(
|
||||
pub async fn set_persona_active(
|
||||
id: String,
|
||||
active: bool,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<PersonaRecord, String> {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut personas = load_personas(&app)?;
|
||||
let persona = personas
|
||||
.iter_mut()
|
||||
.find(|record| record.id == id)
|
||||
.ok_or_else(|| format!("persona {id} not found"))?;
|
||||
use tauri::Manager;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut personas = load_personas(&app)?;
|
||||
let persona = personas
|
||||
.iter_mut()
|
||||
.find(|record| record.id == id)
|
||||
.ok_or_else(|| format!("persona {id} not found"))?;
|
||||
|
||||
let referenced_by_managed_agent = !active
|
||||
&& load_managed_agents(&app)?
|
||||
.iter()
|
||||
.any(|agent| agent.persona_id.as_deref() == Some(id.as_str()));
|
||||
let referenced_by_team = !active
|
||||
&& load_teams(&app)?.iter().any(|team| {
|
||||
team.persona_ids
|
||||
let referenced_by_managed_agent = !active
|
||||
&& load_managed_agents(&app)?
|
||||
.iter()
|
||||
.any(|persona_id| persona_id == id.as_str())
|
||||
});
|
||||
.any(|agent| agent.persona_id.as_deref() == Some(id.as_str()));
|
||||
let referenced_by_team = !active
|
||||
&& load_teams(&app)?.iter().any(|team| {
|
||||
team.persona_ids
|
||||
.iter()
|
||||
.any(|persona_id| persona_id == id.as_str())
|
||||
});
|
||||
|
||||
validate_persona_activation_change(
|
||||
persona,
|
||||
active,
|
||||
referenced_by_managed_agent,
|
||||
referenced_by_team,
|
||||
)?;
|
||||
validate_persona_activation_change(
|
||||
persona,
|
||||
active,
|
||||
referenced_by_managed_agent,
|
||||
referenced_by_team,
|
||||
)?;
|
||||
|
||||
if persona.is_active == active {
|
||||
return Ok(persona.clone());
|
||||
}
|
||||
if persona.is_active == active {
|
||||
return Ok(persona.clone());
|
||||
}
|
||||
|
||||
persona.is_active = active;
|
||||
persona.updated_at = now_iso();
|
||||
persona.is_active = active;
|
||||
persona.updated_at = now_iso();
|
||||
|
||||
let updated = persona.clone();
|
||||
save_personas(&app, &personas)?;
|
||||
try_regenerate_nest(&app);
|
||||
Ok(updated)
|
||||
let updated = persona.clone();
|
||||
save_personas(&app, &personas)?;
|
||||
try_regenerate_nest(&app);
|
||||
Ok(updated)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
}
|
||||
|
||||
const MAX_PNG_BYTES: usize = 10 * 1024 * 1024;
|
||||
|
||||
@@ -137,139 +137,162 @@ fn tombstone_team_pending(app: &AppHandle, state: &AppState, d_tag: &str) {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_teams(app: AppHandle, state: State<'_, AppState>) -> Result<Vec<TeamRecord>, String> {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
load_teams(&app)
|
||||
pub async fn list_teams(app: AppHandle) -> Result<Vec<TeamRecord>, String> {
|
||||
use tauri::Manager;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
load_teams(&app)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_team(
|
||||
input: CreateTeamRequest,
|
||||
pub async fn create_team(input: CreateTeamRequest, app: AppHandle) -> Result<TeamRecord, String> {
|
||||
use tauri::Manager;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
let name = trim_required(&input.name, "Team name")?;
|
||||
let description = trim_optional(input.description);
|
||||
let now = now_iso();
|
||||
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let personas = load_personas(&app)?;
|
||||
ensure_persona_ids_are_active(&personas, &input.persona_ids)?;
|
||||
let mut teams = load_teams(&app)?;
|
||||
let team = TeamRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name,
|
||||
description,
|
||||
persona_ids: input.persona_ids,
|
||||
is_builtin: false,
|
||||
source_dir: None,
|
||||
is_symlink: false,
|
||||
symlink_target: None,
|
||||
version: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
teams.push(team.clone());
|
||||
save_teams(&app, &teams)?;
|
||||
// Created teams are always non-builtin; publish to the relay.
|
||||
retain_team_pending(&app, &state, &team);
|
||||
Ok(team)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_team(input: UpdateTeamRequest, app: AppHandle) -> Result<TeamRecord, String> {
|
||||
use tauri::Manager;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
let name = trim_required(&input.name, "Team name")?;
|
||||
let description = trim_optional(input.description);
|
||||
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let personas = load_personas(&app)?;
|
||||
ensure_persona_ids_are_active(&personas, &input.persona_ids)?;
|
||||
let mut teams = load_teams(&app)?;
|
||||
let team = teams
|
||||
.iter_mut()
|
||||
.find(|record| record.id == input.id)
|
||||
.ok_or_else(|| format!("team {} not found", input.id))?;
|
||||
|
||||
team.name = name;
|
||||
team.description = description;
|
||||
team.persona_ids = input.persona_ids;
|
||||
team.updated_at = now_iso();
|
||||
|
||||
let updated = team.clone();
|
||||
save_teams(&app, &teams)?;
|
||||
// Built-in teams are not owner-authored — never publish them.
|
||||
if !updated.is_builtin {
|
||||
retain_team_pending(&app, &state, &updated);
|
||||
}
|
||||
Ok(updated)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_team(id: String, app: AppHandle) -> Result<(), String> {
|
||||
use tauri::Manager;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let cascaded_persona_d_tags = delete_team_with_cascade(&app, &id)?;
|
||||
// delete_team_with_cascade rejects built-in teams via validate_team_deletion,
|
||||
// so reaching here means this team was owner-published — tombstone it. The
|
||||
// d_tag is the team id, captured before the record left the store.
|
||||
tombstone_team_pending(&app, &state, &id);
|
||||
// Tombstone the cascaded personas too, so their orphaned kind:30175 heads
|
||||
// don't linger on the relay (F4). Each d-tag was captured pre-removal.
|
||||
for persona_d_tag in &cascaded_persona_d_tags {
|
||||
super::personas::tombstone_persona_pending(&app, &state, persona_d_tag);
|
||||
}
|
||||
try_regenerate_nest(&app);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_team_from_directory(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<TeamRecord, String> {
|
||||
let name = trim_required(&input.name, "Team name")?;
|
||||
let description = trim_optional(input.description);
|
||||
let now = now_iso();
|
||||
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let personas = load_personas(&app)?;
|
||||
ensure_persona_ids_are_active(&personas, &input.persona_ids)?;
|
||||
let mut teams = load_teams(&app)?;
|
||||
let team = TeamRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name,
|
||||
description,
|
||||
persona_ids: input.persona_ids,
|
||||
is_builtin: false,
|
||||
source_dir: None,
|
||||
is_symlink: false,
|
||||
symlink_target: None,
|
||||
version: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
teams.push(team.clone());
|
||||
save_teams(&app, &teams)?;
|
||||
// Created teams are always non-builtin; publish to the relay.
|
||||
retain_team_pending(&app, &state, &team);
|
||||
Ok(team)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_team(
|
||||
input: UpdateTeamRequest,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<TeamRecord, String> {
|
||||
let name = trim_required(&input.name, "Team name")?;
|
||||
let description = trim_optional(input.description);
|
||||
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let personas = load_personas(&app)?;
|
||||
ensure_persona_ids_are_active(&personas, &input.persona_ids)?;
|
||||
let mut teams = load_teams(&app)?;
|
||||
let team = teams
|
||||
.iter_mut()
|
||||
.find(|record| record.id == input.id)
|
||||
.ok_or_else(|| format!("team {} not found", input.id))?;
|
||||
|
||||
team.name = name;
|
||||
team.description = description;
|
||||
team.persona_ids = input.persona_ids;
|
||||
team.updated_at = now_iso();
|
||||
|
||||
let updated = team.clone();
|
||||
save_teams(&app, &teams)?;
|
||||
// Built-in teams are not owner-authored — never publish them.
|
||||
if !updated.is_builtin {
|
||||
retain_team_pending(&app, &state, &updated);
|
||||
}
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_team(id: String, app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let cascaded_persona_d_tags = delete_team_with_cascade(&app, &id)?;
|
||||
// delete_team_with_cascade rejects built-in teams via validate_team_deletion,
|
||||
// so reaching here means this team was owner-published — tombstone it. The
|
||||
// d_tag is the team id, captured before the record left the store.
|
||||
tombstone_team_pending(&app, &state, &id);
|
||||
// Tombstone the cascaded personas too, so their orphaned kind:30175 heads
|
||||
// don't linger on the relay (F4). Each d-tag was captured pre-removal.
|
||||
for persona_d_tag in &cascaded_persona_d_tags {
|
||||
super::personas::tombstone_persona_pending(&app, &state, persona_d_tag);
|
||||
}
|
||||
try_regenerate_nest(&app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn install_team_from_directory(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
path: String,
|
||||
symlink: Option<bool>,
|
||||
) -> Result<TeamRecord, String> {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let source = std::path::PathBuf::from(&path);
|
||||
if !source.is_dir() {
|
||||
return Err(format!("team path is not a directory: {path}"));
|
||||
}
|
||||
let result = do_import_team(&app, &source, symlink.unwrap_or(false))?;
|
||||
try_regenerate_nest(&app);
|
||||
Ok(result)
|
||||
use tauri::Manager;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let source = std::path::PathBuf::from(&path);
|
||||
if !source.is_dir() {
|
||||
return Err(format!("team path is not a directory: {path}"));
|
||||
}
|
||||
let result = do_import_team(&app, &source, symlink.unwrap_or(false))?;
|
||||
try_regenerate_nest(&app);
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sync_team_directory(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
team_id: String,
|
||||
) -> Result<SyncResult, String> {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let result = do_sync_team(&app, &team_id)?;
|
||||
try_regenerate_nest(&app);
|
||||
Ok(result)
|
||||
pub async fn sync_team_directory(app: AppHandle, team_id: String) -> Result<SyncResult, String> {
|
||||
use tauri::Manager;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let result = do_sync_team(&app, &team_id)?;
|
||||
try_regenerate_nest(&app);
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
|
||||
import type {
|
||||
useDeleteMessageMutation,
|
||||
useEditMessageMutation,
|
||||
@@ -81,19 +79,26 @@ export function useChannelPaneHandlers({
|
||||
const toggleMutateRef = React.useRef(toggleReactionMutation.mutateAsync);
|
||||
toggleMutateRef.current = toggleReactionMutation.mutateAsync;
|
||||
|
||||
const deferPanelState = React.useCallback((update: () => void) => {
|
||||
window.setTimeout(() => {
|
||||
React.startTransition(update);
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
const handleCancelThreadReply = React.useCallback(() => {
|
||||
setThreadReplyTargetId(openThreadHeadIdRef.current);
|
||||
}, [setThreadReplyTargetId]);
|
||||
|
||||
const handleCloseThread = React.useCallback(() => {
|
||||
flushSync(() => {
|
||||
deferPanelState(() => {
|
||||
onOptimisticOpenThreadHeadIdChange(null);
|
||||
setOpenThreadHeadId(null);
|
||||
setThreadReplyTargetId(null);
|
||||
setThreadScrollTargetId(null);
|
||||
setExpandedThreadReplyIds(new Set());
|
||||
});
|
||||
setOpenThreadHeadId(null);
|
||||
setThreadReplyTargetId(null);
|
||||
setThreadScrollTargetId(null);
|
||||
setExpandedThreadReplyIds(new Set());
|
||||
}, [
|
||||
deferPanelState,
|
||||
onOptimisticOpenThreadHeadIdChange,
|
||||
setExpandedThreadReplyIds,
|
||||
setOpenThreadHeadId,
|
||||
@@ -135,27 +140,28 @@ export function useChannelPaneHandlers({
|
||||
const handleOpenThread = React.useCallback(
|
||||
(message: { id: string }) => {
|
||||
if (openThreadHeadIdRef.current === message.id) {
|
||||
flushSync(() => {
|
||||
deferPanelState(() => {
|
||||
onOptimisticOpenThreadHeadIdChange(null);
|
||||
setOpenThreadHeadId(null);
|
||||
setThreadReplyTargetId(null);
|
||||
setThreadScrollTargetId(null);
|
||||
setExpandedThreadReplyIds(new Set());
|
||||
});
|
||||
setOpenThreadHeadId(null);
|
||||
setThreadReplyTargetId(null);
|
||||
setThreadScrollTargetId(null);
|
||||
setExpandedThreadReplyIds(new Set());
|
||||
setEditTargetId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
flushSync(() => {
|
||||
deferPanelState(() => {
|
||||
onOptimisticOpenThreadHeadIdChange(message.id);
|
||||
setOpenThreadHeadId(message.id);
|
||||
setThreadReplyTargetId(message.id);
|
||||
setThreadScrollTargetId(null);
|
||||
setExpandedThreadReplyIds(new Set());
|
||||
});
|
||||
setOpenThreadHeadId(message.id);
|
||||
setThreadReplyTargetId(message.id);
|
||||
setThreadScrollTargetId(null);
|
||||
setExpandedThreadReplyIds(new Set());
|
||||
setEditTargetId(null);
|
||||
},
|
||||
[
|
||||
deferPanelState,
|
||||
onOptimisticOpenThreadHeadIdChange,
|
||||
setEditTargetId,
|
||||
setExpandedThreadReplyIds,
|
||||
|
||||
Reference in New Issue
Block a user