mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Refactor desktop modules and fix channel loading (#60)
This commit is contained in:
@@ -8,8 +8,9 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check:file-sizes": "node ./scripts/check-file-sizes.mjs",
|
||||
"lint": "biome lint .",
|
||||
"check": "biome check .",
|
||||
"check": "biome check . && pnpm check:file-sizes",
|
||||
"format": "biome format --write .",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
|
||||
115
desktop/scripts/check-file-sizes.mjs
Normal file
115
desktop/scripts/check-file-sizes.mjs
Normal file
@@ -0,0 +1,115 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
|
||||
const rules = [
|
||||
{
|
||||
root: "src-tauri/src",
|
||||
extensions: new Set([".rs"]),
|
||||
maxLines: 500,
|
||||
},
|
||||
{
|
||||
root: "src/app",
|
||||
extensions: new Set([".ts", ".tsx"]),
|
||||
maxLines: 500,
|
||||
},
|
||||
{
|
||||
root: "src/features",
|
||||
extensions: new Set([".ts", ".tsx"]),
|
||||
maxLines: 500,
|
||||
},
|
||||
{
|
||||
root: "src/shared/api",
|
||||
extensions: new Set([".ts", ".tsx"]),
|
||||
maxLines: 500,
|
||||
},
|
||||
];
|
||||
|
||||
// Exceptions should stay rare and temporary. Prefer splitting files instead.
|
||||
const overrides = new Map([
|
||||
["src/app/AppShell.tsx", 700],
|
||||
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
|
||||
["src/features/settings/ui/SettingsView.tsx", 600],
|
||||
["src/features/sidebar/ui/AppSidebar.tsx", 650],
|
||||
["src/features/tokens/ui/TokenSettingsCard.tsx", 800],
|
||||
["src/shared/api/tauri.ts", 900],
|
||||
]);
|
||||
|
||||
async function walkFiles(directory) {
|
||||
const entries = await fs.readdir(directory, { withFileTypes: true });
|
||||
const files = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return walkFiles(fullPath);
|
||||
}
|
||||
|
||||
return [fullPath];
|
||||
}),
|
||||
);
|
||||
|
||||
return files.flat();
|
||||
}
|
||||
|
||||
function findRule(relativePath) {
|
||||
return rules.find((rule) => {
|
||||
const normalizedRoot = `${rule.root}${path.sep}`;
|
||||
return relativePath.startsWith(normalizedRoot);
|
||||
});
|
||||
}
|
||||
|
||||
function countLines(content) {
|
||||
if (content.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return content.split(/\r?\n/).length;
|
||||
}
|
||||
|
||||
const candidateFiles = (
|
||||
await Promise.all(
|
||||
rules.map((rule) => walkFiles(path.join(projectRoot, rule.root))),
|
||||
)
|
||||
).flat();
|
||||
|
||||
const violations = [];
|
||||
|
||||
for (const filePath of candidateFiles) {
|
||||
const relativePath = path.relative(projectRoot, filePath);
|
||||
const rule = findRule(relativePath);
|
||||
if (!rule) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const extension = path.extname(relativePath);
|
||||
if (!rule.extensions.has(extension)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const limit = overrides.get(relativePath) ?? rule.maxLines;
|
||||
const content = await fs.readFile(filePath, "utf8");
|
||||
const lineCount = countLines(content);
|
||||
if (lineCount > limit) {
|
||||
violations.push({
|
||||
limit,
|
||||
lineCount,
|
||||
relativePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error("Desktop file size check failed:");
|
||||
for (const violation of violations) {
|
||||
console.error(
|
||||
`- ${violation.relativePath}: ${violation.lineCount} lines (limit ${violation.limit})`,
|
||||
);
|
||||
}
|
||||
console.error(
|
||||
"Split the file or add a narrowly scoped exception in `desktop/scripts/check-file-sizes.mjs`.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
55
desktop/src-tauri/src/app_state.rs
Normal file
55
desktop/src-tauri/src/app_state.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use std::{collections::HashMap, sync::Mutex};
|
||||
|
||||
use nostr::Keys;
|
||||
|
||||
use crate::managed_agents::ManagedAgentProcess;
|
||||
|
||||
pub struct AppState {
|
||||
pub keys: Mutex<Keys>,
|
||||
pub http_client: reqwest::Client,
|
||||
pub configured_api_token: Option<String>,
|
||||
pub session_token: Mutex<Option<String>>,
|
||||
pub managed_agents_store_lock: Mutex<()>,
|
||||
pub managed_agent_processes: Mutex<HashMap<String, ManagedAgentProcess>>,
|
||||
}
|
||||
|
||||
pub fn build_app_state() -> AppState {
|
||||
// GUI app: warn on bad key but don't crash, and fall back to ephemeral.
|
||||
let (keys, source) = match std::env::var("SPROUT_PRIVATE_KEY") {
|
||||
Ok(nsec) => match Keys::parse(nsec.trim()) {
|
||||
Ok(keys) => (keys, "configured"),
|
||||
Err(error) => {
|
||||
eprintln!("sprout-desktop: invalid SPROUT_PRIVATE_KEY: {error}");
|
||||
(Keys::generate(), "ephemeral")
|
||||
}
|
||||
},
|
||||
Err(std::env::VarError::NotUnicode(_)) => {
|
||||
eprintln!("sprout-desktop: SPROUT_PRIVATE_KEY contains invalid UTF-8");
|
||||
(Keys::generate(), "ephemeral")
|
||||
}
|
||||
Err(std::env::VarError::NotPresent) => (Keys::generate(), "ephemeral"),
|
||||
};
|
||||
|
||||
eprintln!(
|
||||
"sprout-desktop: {source} identity pubkey {}",
|
||||
keys.public_key().to_hex()
|
||||
);
|
||||
|
||||
let api_token = match std::env::var("SPROUT_API_TOKEN") {
|
||||
Ok(token) if !token.trim().is_empty() => Some(token),
|
||||
Ok(_) | Err(std::env::VarError::NotPresent) => None,
|
||||
Err(std::env::VarError::NotUnicode(_)) => {
|
||||
eprintln!("sprout-desktop: SPROUT_API_TOKEN contains invalid UTF-8");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
AppState {
|
||||
keys: Mutex::new(keys),
|
||||
http_client: reqwest::Client::new(),
|
||||
configured_api_token: api_token,
|
||||
session_token: Mutex::new(None),
|
||||
managed_agents_store_lock: Mutex::new(()),
|
||||
managed_agent_processes: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
426
desktop/src-tauri/src/commands/agents.rs
Normal file
426
desktop/src-tauri/src/commands/agents.rs
Normal file
@@ -0,0 +1,426 @@
|
||||
use nostr::{Keys, ToBech32};
|
||||
use reqwest::Method;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
managed_agents::{
|
||||
admin_command, build_managed_agent_summary, command_availability, default_token_scopes,
|
||||
discover_local_acp_providers, find_managed_agent_mut, load_managed_agents,
|
||||
managed_agent_log_path, read_log_tail, run_sprout_admin_mint_token, save_managed_agents,
|
||||
start_managed_agent_process, stop_managed_agent_process, sync_managed_agent_processes,
|
||||
AcpProviderInfo, CreateManagedAgentRequest, CreateManagedAgentResponse,
|
||||
DiscoverManagedAgentPrereqsRequest, ManagedAgentLogResponse, ManagedAgentPrereqsInfo,
|
||||
ManagedAgentSummary, MintManagedAgentTokenRequest, MintManagedAgentTokenResponse,
|
||||
RelayAgentInfo, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_ARG, DEFAULT_AGENT_COMMAND,
|
||||
DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, DEFAULT_MCP_COMMAND,
|
||||
},
|
||||
relay::{build_authed_request, managed_agent_owner_pubkey, relay_ws_url, send_json_request},
|
||||
util::now_iso,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn discover_acp_providers() -> Vec<AcpProviderInfo> {
|
||||
discover_local_acp_providers()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn discover_managed_agent_prereqs(
|
||||
input: DiscoverManagedAgentPrereqsRequest,
|
||||
app: AppHandle,
|
||||
) -> ManagedAgentPrereqsInfo {
|
||||
let acp_command = input
|
||||
.acp_command
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_ACP_COMMAND);
|
||||
let mcp_command = input
|
||||
.mcp_command
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_MCP_COMMAND);
|
||||
let admin_command = admin_command();
|
||||
|
||||
ManagedAgentPrereqsInfo {
|
||||
admin: command_availability(&admin_command, Some(&app)),
|
||||
acp: command_availability(acp_command, Some(&app)),
|
||||
mcp: command_availability(mcp_command, Some(&app)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_relay_agents(state: State<'_, AppState>) -> Result<Vec<RelayAgentInfo>, String> {
|
||||
let request = build_authed_request(&state.http_client, Method::GET, "/api/agents", &state)?;
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[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())?;
|
||||
|
||||
if sync_managed_agent_processes(&mut records, &mut runtimes) {
|
||||
save_managed_agents(&app, &records)?;
|
||||
}
|
||||
|
||||
records
|
||||
.iter()
|
||||
.map(|record| build_managed_agent_summary(&app, record, &runtimes))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_managed_agent(
|
||||
input: CreateManagedAgentRequest,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<CreateManagedAgentResponse, String> {
|
||||
let name = input.name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("agent name is required".to_string());
|
||||
}
|
||||
|
||||
let owner_pubkey = if input.mint_token {
|
||||
Some(managed_agent_owner_pubkey(&state).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
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())?;
|
||||
|
||||
if sync_managed_agent_processes(&mut records, &mut runtimes) {
|
||||
save_managed_agents(&app, &records)?;
|
||||
}
|
||||
|
||||
let keys = Keys::generate();
|
||||
let pubkey = keys.public_key().to_hex();
|
||||
if records.iter().any(|record| record.pubkey == pubkey) {
|
||||
return Err(format!("agent {pubkey} already exists"));
|
||||
}
|
||||
|
||||
let private_key_nsec = keys
|
||||
.secret_key()
|
||||
.to_bech32()
|
||||
.map_err(|error| format!("failed to encode private key: {error}"))?;
|
||||
let token_scopes = if input.mint_token {
|
||||
let requested = input
|
||||
.token_scopes
|
||||
.into_iter()
|
||||
.map(|scope| scope.trim().to_string())
|
||||
.filter(|scope| !scope.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if requested.is_empty() {
|
||||
default_token_scopes()
|
||||
} else {
|
||||
requested
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let minted = if input.mint_token {
|
||||
let token_name = input
|
||||
.token_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(name);
|
||||
Some(run_sprout_admin_mint_token(
|
||||
&app,
|
||||
&pubkey,
|
||||
owner_pubkey
|
||||
.as_deref()
|
||||
.ok_or_else(|| "managed agent owner pubkey was not resolved".to_string())?,
|
||||
token_name,
|
||||
&token_scopes,
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let resolved_relay_url = input
|
||||
.relay_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(relay_ws_url);
|
||||
|
||||
let mut record = crate::managed_agents::ManagedAgentRecord {
|
||||
pubkey: pubkey.clone(),
|
||||
name: name.to_string(),
|
||||
private_key_nsec: private_key_nsec.clone(),
|
||||
api_token: minted.as_ref().map(|output| output.api_token.clone()),
|
||||
relay_url: resolved_relay_url,
|
||||
acp_command: input
|
||||
.acp_command
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_ACP_COMMAND)
|
||||
.to_string(),
|
||||
agent_command: input
|
||||
.agent_command
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_AGENT_COMMAND)
|
||||
.to_string(),
|
||||
agent_args: input
|
||||
.agent_args
|
||||
.into_iter()
|
||||
.map(|arg| arg.trim().to_string())
|
||||
.filter(|arg| !arg.is_empty())
|
||||
.collect::<Vec<_>>(),
|
||||
mcp_command: input
|
||||
.mcp_command
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_MCP_COMMAND)
|
||||
.to_string(),
|
||||
turn_timeout_seconds: input
|
||||
.turn_timeout_seconds
|
||||
.filter(|seconds| *seconds > 0)
|
||||
.unwrap_or(DEFAULT_AGENT_TURN_TIMEOUT_SECONDS),
|
||||
created_at: now_iso(),
|
||||
updated_at: now_iso(),
|
||||
last_started_at: None,
|
||||
last_stopped_at: None,
|
||||
last_exit_code: None,
|
||||
last_error: None,
|
||||
};
|
||||
|
||||
if record.agent_args.is_empty() {
|
||||
record.agent_args.push(DEFAULT_AGENT_ARG.to_string());
|
||||
}
|
||||
|
||||
records.push(record);
|
||||
|
||||
let mut spawn_error = None;
|
||||
if input.spawn_after_create {
|
||||
let record = find_managed_agent_mut(&mut records, &pubkey)?;
|
||||
if let Err(error) = start_managed_agent_process(&app, record, &mut runtimes) {
|
||||
record.updated_at = now_iso();
|
||||
record.last_error = Some(error.clone());
|
||||
spawn_error = Some(error);
|
||||
}
|
||||
}
|
||||
|
||||
save_managed_agents(&app, &records)?;
|
||||
|
||||
let record = records
|
||||
.iter()
|
||||
.find(|record| record.pubkey == pubkey)
|
||||
.ok_or_else(|| "created agent disappeared unexpectedly".to_string())?;
|
||||
let agent = build_managed_agent_summary(&app, record, &runtimes)?;
|
||||
|
||||
Ok(CreateManagedAgentResponse {
|
||||
agent,
|
||||
private_key_nsec,
|
||||
api_token: minted.map(|output| output.api_token),
|
||||
spawn_error,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn start_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())?;
|
||||
|
||||
if sync_managed_agent_processes(&mut records, &mut runtimes) {
|
||||
save_managed_agents(&app, &records)?;
|
||||
}
|
||||
|
||||
{
|
||||
let record = find_managed_agent_mut(&mut records, &pubkey)?;
|
||||
start_managed_agent_process(&app, record, &mut runtimes)?;
|
||||
}
|
||||
|
||||
save_managed_agents(&app, &records)?;
|
||||
let record = records
|
||||
.iter()
|
||||
.find(|record| record.pubkey == pubkey)
|
||||
.ok_or_else(|| format!("agent {pubkey} not found"))?;
|
||||
build_managed_agent_summary(&app, record, &runtimes)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub 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())?;
|
||||
|
||||
if sync_managed_agent_processes(&mut records, &mut runtimes) {
|
||||
save_managed_agents(&app, &records)?;
|
||||
}
|
||||
|
||||
{
|
||||
let record = find_managed_agent_mut(&mut records, &pubkey)?;
|
||||
stop_managed_agent_process(record, &mut runtimes)?;
|
||||
}
|
||||
|
||||
save_managed_agents(&app, &records)?;
|
||||
let record = records
|
||||
.iter()
|
||||
.find(|record| record.pubkey == pubkey)
|
||||
.ok_or_else(|| format!("agent {pubkey} not found"))?;
|
||||
build_managed_agent_summary(&app, record, &runtimes)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_managed_agent(
|
||||
pubkey: 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 records = load_managed_agents(&app)?;
|
||||
let mut runtimes = state
|
||||
.managed_agent_processes
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
if sync_managed_agent_processes(&mut records, &mut runtimes) {
|
||||
save_managed_agents(&app, &records)?;
|
||||
}
|
||||
|
||||
if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) {
|
||||
stop_managed_agent_process(record, &mut runtimes)?;
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mint_managed_agent_token(
|
||||
input: MintManagedAgentTokenRequest,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<MintManagedAgentTokenResponse, String> {
|
||||
let owner_pubkey = managed_agent_owner_pubkey(&state).await?;
|
||||
|
||||
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())?;
|
||||
|
||||
if sync_managed_agent_processes(&mut records, &mut runtimes) {
|
||||
save_managed_agents(&app, &records)?;
|
||||
}
|
||||
|
||||
let record = find_managed_agent_mut(&mut records, &input.pubkey)?;
|
||||
let scopes = input
|
||||
.scopes
|
||||
.into_iter()
|
||||
.map(|scope| scope.trim().to_string())
|
||||
.filter(|scope| !scope.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
let scopes = if scopes.is_empty() {
|
||||
default_token_scopes()
|
||||
} else {
|
||||
scopes
|
||||
};
|
||||
let token_name = input
|
||||
.token_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("{}-token", record.name));
|
||||
let minted =
|
||||
run_sprout_admin_mint_token(&app, &record.pubkey, &owner_pubkey, &token_name, &scopes)?;
|
||||
|
||||
record.api_token = Some(minted.api_token.clone());
|
||||
record.updated_at = now_iso();
|
||||
record.last_error = None;
|
||||
let pubkey = record.pubkey.clone();
|
||||
|
||||
save_managed_agents(&app, &records)?;
|
||||
let record = records
|
||||
.iter()
|
||||
.find(|record| record.pubkey == pubkey)
|
||||
.ok_or_else(|| format!("agent {pubkey} not found"))?;
|
||||
let agent = build_managed_agent_summary(&app, record, &runtimes)?;
|
||||
|
||||
Ok(MintManagedAgentTokenResponse {
|
||||
agent,
|
||||
token: minted.api_token,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_managed_agent_log(
|
||||
pubkey: String,
|
||||
line_count: Option<u32>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ManagedAgentLogResponse, String> {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let records = load_managed_agents(&app)?;
|
||||
if !records.iter().any(|record| record.pubkey == pubkey) {
|
||||
return Err(format!("agent {pubkey} not found"));
|
||||
}
|
||||
|
||||
let log_path = managed_agent_log_path(&app, &pubkey)?;
|
||||
Ok(ManagedAgentLogResponse {
|
||||
content: read_log_tail(&log_path, line_count.unwrap_or(120) as usize)?,
|
||||
log_path: log_path.display().to_string(),
|
||||
})
|
||||
}
|
||||
165
desktop/src-tauri/src/commands/channels.rs
Normal file
165
desktop/src-tauri/src/commands/channels.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
use reqwest::Method;
|
||||
use tauri::State;
|
||||
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
models::{
|
||||
AddMembersBody, AddMembersResponse, ChannelDetailInfo, ChannelInfo, ChannelMembersResponse,
|
||||
CreateChannelBody, SetPurposeBody, SetTopicBody, UpdateChannelBody,
|
||||
},
|
||||
relay::{build_authed_request, send_empty_request, send_json_request},
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_channels(state: State<'_, AppState>) -> Result<Vec<ChannelInfo>, String> {
|
||||
let request = build_authed_request(&state.http_client, Method::GET, "/api/channels", &state)?;
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_channel(
|
||||
name: String,
|
||||
channel_type: String,
|
||||
visibility: String,
|
||||
description: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ChannelInfo, String> {
|
||||
let request = build_authed_request(&state.http_client, Method::POST, "/api/channels", &state)?
|
||||
.json(&CreateChannelBody {
|
||||
name: &name,
|
||||
channel_type: &channel_type,
|
||||
visibility: &visibility,
|
||||
description: description.as_deref(),
|
||||
});
|
||||
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_channel_details(
|
||||
channel_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ChannelDetailInfo, String> {
|
||||
let path = format!("/api/channels/{channel_id}");
|
||||
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_channel_members(
|
||||
channel_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ChannelMembersResponse, String> {
|
||||
let path = format!("/api/channels/{channel_id}/members");
|
||||
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_channel(
|
||||
channel_id: String,
|
||||
name: Option<String>,
|
||||
description: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ChannelDetailInfo, String> {
|
||||
let path = format!("/api/channels/{channel_id}");
|
||||
let request = build_authed_request(&state.http_client, Method::PUT, &path, &state)?.json(
|
||||
&UpdateChannelBody {
|
||||
name: name.as_deref(),
|
||||
description: description.as_deref(),
|
||||
},
|
||||
);
|
||||
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_channel_topic(
|
||||
channel_id: String,
|
||||
topic: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let path = format!("/api/channels/{channel_id}/topic");
|
||||
let request = build_authed_request(&state.http_client, Method::PUT, &path, &state)?
|
||||
.json(&SetTopicBody { topic: &topic });
|
||||
send_empty_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_channel_purpose(
|
||||
channel_id: String,
|
||||
purpose: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let path = format!("/api/channels/{channel_id}/purpose");
|
||||
let request = build_authed_request(&state.http_client, Method::PUT, &path, &state)?
|
||||
.json(&SetPurposeBody { purpose: &purpose });
|
||||
send_empty_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn archive_channel(channel_id: String, state: State<'_, AppState>) -> Result<(), String> {
|
||||
let path = format!("/api/channels/{channel_id}/archive");
|
||||
let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?;
|
||||
send_empty_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn unarchive_channel(
|
||||
channel_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let path = format!("/api/channels/{channel_id}/unarchive");
|
||||
let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?;
|
||||
send_empty_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_channel(channel_id: String, state: State<'_, AppState>) -> Result<(), String> {
|
||||
let path = format!("/api/channels/{channel_id}");
|
||||
let request = build_authed_request(&state.http_client, Method::DELETE, &path, &state)?;
|
||||
send_empty_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_channel_members(
|
||||
channel_id: String,
|
||||
pubkeys: Vec<String>,
|
||||
role: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AddMembersResponse, String> {
|
||||
let path = format!("/api/channels/{channel_id}/members");
|
||||
let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?.json(
|
||||
&AddMembersBody {
|
||||
pubkeys: &pubkeys,
|
||||
role: role.as_deref(),
|
||||
},
|
||||
);
|
||||
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_channel_member(
|
||||
channel_id: String,
|
||||
pubkey: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let path = format!("/api/channels/{channel_id}/members/{pubkey}");
|
||||
let request = build_authed_request(&state.http_client, Method::DELETE, &path, &state)?;
|
||||
send_empty_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn join_channel(channel_id: String, state: State<'_, AppState>) -> Result<(), String> {
|
||||
let path = format!("/api/channels/{channel_id}/join");
|
||||
let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?;
|
||||
send_empty_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn leave_channel(channel_id: String, state: State<'_, AppState>) -> Result<(), String> {
|
||||
let path = format!("/api/channels/{channel_id}/leave");
|
||||
let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?;
|
||||
send_empty_request(request).await
|
||||
}
|
||||
81
desktop/src-tauri/src/commands/identity.rs
Normal file
81
desktop/src-tauri/src/commands/identity.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use nostr::{EventBuilder, JsonUtil, Kind, Tag, ToBech32};
|
||||
use tauri::State;
|
||||
|
||||
use crate::{app_state::AppState, models::IdentityInfo, relay::relay_ws_url};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_identity(state: State<'_, AppState>) -> Result<IdentityInfo, String> {
|
||||
let keys = state.keys.lock().map_err(|error| error.to_string())?;
|
||||
let pubkey = keys.public_key();
|
||||
let pubkey_hex = pubkey.to_hex();
|
||||
let bech32 = pubkey
|
||||
.to_bech32()
|
||||
.map_err(|error| format!("bech32 encode failed: {error}"))?;
|
||||
let display_name = if bech32.len() > 16 {
|
||||
format!("{}…{}", &bech32[..10], &bech32[bech32.len() - 4..])
|
||||
} else {
|
||||
bech32
|
||||
};
|
||||
|
||||
Ok(IdentityInfo {
|
||||
pubkey: pubkey_hex,
|
||||
display_name,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_relay_ws_url() -> String {
|
||||
relay_ws_url()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sign_event(
|
||||
kind: u16,
|
||||
content: String,
|
||||
tags: Vec<Vec<String>>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
let keys = state.keys.lock().map_err(|error| error.to_string())?;
|
||||
|
||||
let nostr_tags = tags
|
||||
.into_iter()
|
||||
.map(|tag| Tag::parse(tag).map_err(|error| format!("invalid tag: {error}")))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let event = EventBuilder::new(Kind::Custom(kind), content)
|
||||
.tags(nostr_tags)
|
||||
.sign_with_keys(&keys)
|
||||
.map_err(|error| format!("sign failed: {error}"))?;
|
||||
|
||||
Ok(event.as_json())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_auth_event(
|
||||
challenge: String,
|
||||
relay_url: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
let keys = state.keys.lock().map_err(|error| error.to_string())?;
|
||||
|
||||
let mut tags = vec![
|
||||
Tag::parse(vec!["relay", &relay_url])
|
||||
.map_err(|error| format!("relay tag failed: {error}"))?,
|
||||
Tag::parse(vec!["challenge", &challenge])
|
||||
.map_err(|error| format!("challenge tag failed: {error}"))?,
|
||||
];
|
||||
|
||||
if let Some(token) = state.configured_api_token.as_deref() {
|
||||
tags.push(
|
||||
Tag::parse(vec!["auth_token", token])
|
||||
.map_err(|error| format!("auth token tag failed: {error}"))?,
|
||||
);
|
||||
}
|
||||
|
||||
let event = EventBuilder::new(Kind::Custom(22242), "")
|
||||
.tags(tags)
|
||||
.sign_with_keys(&keys)
|
||||
.map_err(|error| format!("sign failed: {error}"))?;
|
||||
|
||||
Ok(event.as_json())
|
||||
}
|
||||
114
desktop/src-tauri/src/commands/messages.rs
Normal file
114
desktop/src-tauri/src/commands/messages.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
use reqwest::Method;
|
||||
use tauri::State;
|
||||
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
models::{
|
||||
AddReactionBody, FeedResponse, GetFeedQuery, SearchQueryParams, SearchResponse,
|
||||
SendChannelMessageBody, SendChannelMessageResponse,
|
||||
},
|
||||
relay::{build_authed_request, relay_error_message, send_empty_request, send_json_request},
|
||||
util::percent_encode,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_feed(
|
||||
since: Option<i64>,
|
||||
limit: Option<u32>,
|
||||
types: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<FeedResponse, String> {
|
||||
let request = build_authed_request(&state.http_client, Method::GET, "/api/feed", &state)?
|
||||
.query(&GetFeedQuery {
|
||||
since,
|
||||
limit,
|
||||
types: types.as_deref(),
|
||||
});
|
||||
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn search_messages(
|
||||
q: String,
|
||||
limit: Option<u32>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SearchResponse, String> {
|
||||
let request = build_authed_request(&state.http_client, Method::GET, "/api/search", &state)?
|
||||
.query(&SearchQueryParams { q: q.trim(), limit });
|
||||
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_channel_message(
|
||||
channel_id: String,
|
||||
content: String,
|
||||
parent_event_id: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SendChannelMessageResponse, String> {
|
||||
let path = format!("/api/channels/{channel_id}/messages");
|
||||
let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?.json(
|
||||
&SendChannelMessageBody {
|
||||
content: content.trim(),
|
||||
parent_event_id: parent_event_id.as_deref(),
|
||||
broadcast_to_channel: false,
|
||||
},
|
||||
);
|
||||
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_reaction(
|
||||
event_id: String,
|
||||
emoji: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let path = format!("/api/messages/{event_id}/reactions");
|
||||
let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?.json(
|
||||
&AddReactionBody {
|
||||
emoji: emoji.trim(),
|
||||
},
|
||||
);
|
||||
|
||||
send_empty_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_reaction(
|
||||
event_id: String,
|
||||
emoji: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let path = format!(
|
||||
"/api/messages/{event_id}/reactions/{}",
|
||||
percent_encode(emoji.trim())
|
||||
);
|
||||
let request = build_authed_request(&state.http_client, Method::DELETE, &path, &state)?;
|
||||
|
||||
send_empty_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_event(event_id: String, state: State<'_, AppState>) -> Result<String, String> {
|
||||
let request = build_authed_request(
|
||||
&state.http_client,
|
||||
Method::GET,
|
||||
&format!("/api/events/{event_id}"),
|
||||
&state,
|
||||
)?;
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("request failed: {error}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(relay_error_message(response).await);
|
||||
}
|
||||
|
||||
response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| format!("parse failed: {error}"))
|
||||
}
|
||||
13
desktop/src-tauri/src/commands/mod.rs
Normal file
13
desktop/src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
mod agents;
|
||||
mod channels;
|
||||
mod identity;
|
||||
mod messages;
|
||||
mod profile;
|
||||
mod tokens;
|
||||
|
||||
pub use agents::*;
|
||||
pub use channels::*;
|
||||
pub use identity::*;
|
||||
pub use messages::*;
|
||||
pub use profile::*;
|
||||
pub use tokens::*;
|
||||
107
desktop/src-tauri/src/commands/profile.rs
Normal file
107
desktop/src-tauri/src/commands/profile.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use reqwest::Method;
|
||||
use sprout_core::PresenceStatus;
|
||||
use tauri::State;
|
||||
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
models::{
|
||||
GetUsersBatchBody, ProfileInfo, SetPresenceBody, SetPresenceResponse, UpdateProfileBody,
|
||||
UsersBatchResponse,
|
||||
},
|
||||
relay::{build_authed_request, send_empty_request, send_json_request},
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_profile(state: State<'_, AppState>) -> Result<ProfileInfo, String> {
|
||||
let request = build_authed_request(
|
||||
&state.http_client,
|
||||
Method::GET,
|
||||
"/api/users/me/profile",
|
||||
&state,
|
||||
)?;
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_profile(
|
||||
display_name: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
about: Option<String>,
|
||||
nip05_handle: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ProfileInfo, String> {
|
||||
let request = build_authed_request(
|
||||
&state.http_client,
|
||||
Method::PUT,
|
||||
"/api/users/me/profile",
|
||||
&state,
|
||||
)?
|
||||
.json(&UpdateProfileBody {
|
||||
display_name: display_name.as_deref(),
|
||||
avatar_url: avatar_url.as_deref(),
|
||||
about: about.as_deref(),
|
||||
nip05_handle: nip05_handle.as_deref(),
|
||||
});
|
||||
send_empty_request(request).await?;
|
||||
|
||||
let request = build_authed_request(
|
||||
&state.http_client,
|
||||
Method::GET,
|
||||
"/api/users/me/profile",
|
||||
&state,
|
||||
)?;
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_user_profile(
|
||||
pubkey: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ProfileInfo, String> {
|
||||
let path = match pubkey {
|
||||
Some(pubkey) => format!("/api/users/{pubkey}/profile"),
|
||||
None => "/api/users/me/profile".to_string(),
|
||||
};
|
||||
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_users_batch(
|
||||
pubkeys: Vec<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<UsersBatchResponse, String> {
|
||||
let request =
|
||||
build_authed_request(&state.http_client, Method::POST, "/api/users/batch", &state)?.json(
|
||||
&GetUsersBatchBody {
|
||||
pubkeys: pubkeys.as_slice(),
|
||||
},
|
||||
);
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_presence(
|
||||
pubkeys: Vec<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<HashMap<String, PresenceStatus>, String> {
|
||||
if pubkeys.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let request = build_authed_request(&state.http_client, Method::GET, "/api/presence", &state)?
|
||||
.query(&[("pubkeys", pubkeys.join(","))]);
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_presence(
|
||||
status: PresenceStatus,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SetPresenceResponse, String> {
|
||||
let request = build_authed_request(&state.http_client, Method::PUT, "/api/presence", &state)?
|
||||
.json(&SetPresenceBody { status });
|
||||
send_json_request(request).await
|
||||
}
|
||||
83
desktop/src-tauri/src/commands/tokens.rs
Normal file
83
desktop/src-tauri/src/commands/tokens.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use reqwest::Method;
|
||||
use tauri::State;
|
||||
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
models::{ListTokensResponse, MintTokenBody, MintTokenResponse, RevokeAllTokensResponse},
|
||||
relay::{
|
||||
build_authed_request, build_nip98_auth_header, build_token_management_request,
|
||||
relay_api_base_url, send_empty_request, send_json_request,
|
||||
},
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_tokens(state: State<'_, AppState>) -> Result<ListTokensResponse, String> {
|
||||
let request =
|
||||
build_token_management_request(&state.http_client, Method::GET, "/api/tokens", &state)?;
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mint_token(
|
||||
name: String,
|
||||
scopes: Vec<String>,
|
||||
channel_ids: Option<Vec<String>>,
|
||||
expires_in_days: Option<u32>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<MintTokenResponse, String> {
|
||||
let body = MintTokenBody {
|
||||
name: &name,
|
||||
scopes: &scopes,
|
||||
channel_ids: channel_ids.as_deref(),
|
||||
expires_in_days,
|
||||
};
|
||||
let request = if state.configured_api_token.is_some() {
|
||||
build_authed_request(&state.http_client, Method::POST, "/api/tokens", &state)?.json(&body)
|
||||
} else {
|
||||
let url = format!("{}{}", relay_api_base_url(), "/api/tokens");
|
||||
let body_bytes =
|
||||
serde_json::to_vec(&body).map_err(|error| format!("serialize failed: {error}"))?;
|
||||
let auth_header = build_nip98_auth_header(&Method::POST, &url, &body_bytes, &state)?;
|
||||
let forwarded_proto = if url.starts_with("http://") {
|
||||
"http"
|
||||
} else {
|
||||
"https"
|
||||
};
|
||||
|
||||
state
|
||||
.http_client
|
||||
.request(Method::POST, url)
|
||||
.header("Authorization", auth_header)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Forwarded-Proto", forwarded_proto)
|
||||
.body(body_bytes)
|
||||
};
|
||||
let response: MintTokenResponse = send_json_request(request).await?;
|
||||
|
||||
if state.configured_api_token.is_none() {
|
||||
let mut token = state
|
||||
.session_token
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
*token = Some(response.token.clone());
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn revoke_token(token_id: String, state: State<'_, AppState>) -> Result<(), String> {
|
||||
let path = format!("/api/tokens/{token_id}");
|
||||
let request =
|
||||
build_token_management_request(&state.http_client, Method::DELETE, &path, &state)?;
|
||||
send_empty_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn revoke_all_tokens(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<RevokeAllTokensResponse, String> {
|
||||
let request =
|
||||
build_token_management_request(&state.http_client, Method::DELETE, "/api/tokens", &state)?;
|
||||
send_json_request(request).await
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
386
desktop/src-tauri/src/managed_agents/discovery.rs
Normal file
386
desktop/src-tauri/src/managed_agents/discovery.rs
Normal file
@@ -0,0 +1,386 @@
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Output},
|
||||
};
|
||||
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::managed_agents::{
|
||||
AcpProviderInfo, CommandAvailabilityInfo, SproutAdminMintTokenJsonOutput, DEFAULT_AGENT_ARG,
|
||||
};
|
||||
|
||||
struct KnownAcpProvider {
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
command: &'static str,
|
||||
default_args: &'static [&'static str],
|
||||
}
|
||||
|
||||
const COMMON_BINARY_PATHS: &[&str] = &[
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
"/home/linuxbrew/.linuxbrew/bin",
|
||||
];
|
||||
|
||||
const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[
|
||||
KnownAcpProvider {
|
||||
id: "goose",
|
||||
label: "Goose",
|
||||
command: "goose",
|
||||
default_args: &[DEFAULT_AGENT_ARG],
|
||||
},
|
||||
KnownAcpProvider {
|
||||
id: "claude",
|
||||
label: "Claude Code",
|
||||
command: "claude-agent-acp",
|
||||
default_args: &[],
|
||||
},
|
||||
KnownAcpProvider {
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
command: "codex-acp",
|
||||
default_args: &[],
|
||||
},
|
||||
];
|
||||
|
||||
fn workspace_root_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
|
||||
}
|
||||
|
||||
fn command_looks_like_path(command: &str) -> bool {
|
||||
let path = Path::new(command);
|
||||
path.is_absolute() || path.components().count() > 1
|
||||
}
|
||||
|
||||
fn executable_basename(command: &str) -> String {
|
||||
let suffix = std::env::consts::EXE_SUFFIX;
|
||||
if suffix.is_empty() || command.ends_with(suffix) {
|
||||
command.to_string()
|
||||
} else {
|
||||
format!("{command}{suffix}")
|
||||
}
|
||||
}
|
||||
|
||||
fn command_search_dirs(app: Option<&AppHandle>) -> Vec<PathBuf> {
|
||||
let mut dirs = vec![
|
||||
workspace_root_dir().join("target/release"),
|
||||
workspace_root_dir().join("target/debug"),
|
||||
];
|
||||
|
||||
if let Ok(current_dir) = std::env::current_dir() {
|
||||
dirs.push(current_dir.join("target/release"));
|
||||
dirs.push(current_dir.join("target/debug"));
|
||||
}
|
||||
|
||||
if app.is_some() {
|
||||
if let Ok(exe_path) = std::env::current_exe() {
|
||||
if let Some(parent) = exe_path.parent() {
|
||||
dirs.push(parent.to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut unique = Vec::new();
|
||||
for dir in dirs {
|
||||
if unique.iter().any(|candidate: &PathBuf| candidate == &dir) {
|
||||
continue;
|
||||
}
|
||||
unique.push(dir);
|
||||
}
|
||||
|
||||
unique
|
||||
}
|
||||
|
||||
fn resolve_workspace_command(command: &str, app: Option<&AppHandle>) -> Option<PathBuf> {
|
||||
if command_looks_like_path(command) {
|
||||
let path = PathBuf::from(command);
|
||||
return path.exists().then_some(path);
|
||||
}
|
||||
|
||||
let file_name = executable_basename(command);
|
||||
command_search_dirs(app)
|
||||
.into_iter()
|
||||
.map(|dir| dir.join(&file_name))
|
||||
.find(|candidate| candidate.exists())
|
||||
}
|
||||
|
||||
pub fn resolve_command(command: &str, app: Option<&AppHandle>) -> Option<PathBuf> {
|
||||
if let Some(path) = resolve_workspace_command(command, app) {
|
||||
return Some(path);
|
||||
}
|
||||
|
||||
if command_looks_like_path(command) {
|
||||
let path = PathBuf::from(command);
|
||||
return path.exists().then_some(path);
|
||||
}
|
||||
|
||||
for candidate in path_candidates_from_env(command) {
|
||||
if candidate.exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(path) = find_via_login_shell(command) {
|
||||
return Some(path);
|
||||
}
|
||||
|
||||
for dir in COMMON_BINARY_PATHS {
|
||||
let candidate = PathBuf::from(dir).join(executable_basename(command));
|
||||
if candidate.exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn path_candidates_from_env(command: &str) -> Vec<PathBuf> {
|
||||
std::env::var_os("PATH")
|
||||
.map(|paths| {
|
||||
std::env::split_paths(&paths)
|
||||
.map(|dir| dir.join(executable_basename(command)))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn find_via_login_shell(command: &str) -> Option<PathBuf> {
|
||||
let which_cmd = format!("command -v {command}");
|
||||
|
||||
for shell in ["/bin/zsh", "/bin/bash"] {
|
||||
let Ok(output) = Command::new(shell).args(["-l", "-c", &which_cmd]).output() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let Some(resolved) = stdout.lines().rfind(|line| !line.trim().is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
let path = PathBuf::from(resolved.trim());
|
||||
if path.is_absolute() && path.exists() {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn find_command(command: &str) -> Option<PathBuf> {
|
||||
resolve_command(command, None)
|
||||
}
|
||||
|
||||
pub fn command_availability(command: &str, app: Option<&AppHandle>) -> CommandAvailabilityInfo {
|
||||
let resolved_path = resolve_command(command, app).map(|path| path.display().to_string());
|
||||
CommandAvailabilityInfo {
|
||||
command: command.to_string(),
|
||||
available: resolved_path.is_some(),
|
||||
resolved_path,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn missing_command_message(command: &str, role: &str) -> String {
|
||||
if command_looks_like_path(command) {
|
||||
return format!("{role} `{command}` does not exist.");
|
||||
}
|
||||
|
||||
format!(
|
||||
"{role} `{command}` was not found. Build the workspace binaries (`cargo build --release --workspace`) or add `target/release` to PATH as described in TESTING.md."
|
||||
)
|
||||
}
|
||||
|
||||
pub fn discover_local_acp_providers() -> Vec<AcpProviderInfo> {
|
||||
KNOWN_ACP_PROVIDERS
|
||||
.iter()
|
||||
.filter_map(|provider| {
|
||||
find_command(provider.command).map(|binary_path| AcpProviderInfo {
|
||||
id: provider.id.to_string(),
|
||||
label: provider.label.to_string(),
|
||||
command: provider.command.to_string(),
|
||||
binary_path: binary_path.display().to_string(),
|
||||
default_args: provider
|
||||
.default_args
|
||||
.iter()
|
||||
.map(|arg| (*arg).to_string())
|
||||
.collect(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn admin_command() -> String {
|
||||
std::env::var("SPROUT_ADMIN_COMMAND").unwrap_or_else(|_| "sprout-admin".to_string())
|
||||
}
|
||||
|
||||
pub fn default_token_scopes() -> Vec<String> {
|
||||
vec![
|
||||
"messages:read".to_string(),
|
||||
"messages:write".to_string(),
|
||||
"channels:read".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn run_sprout_admin_command(
|
||||
program: &Path,
|
||||
pubkey: &str,
|
||||
owner_pubkey: &str,
|
||||
name: &str,
|
||||
scope_arg: &str,
|
||||
json: bool,
|
||||
) -> Result<Output, String> {
|
||||
let mut command = Command::new(program);
|
||||
command.arg("mint-token");
|
||||
if json {
|
||||
command.arg("--json");
|
||||
}
|
||||
command.args([
|
||||
"--name",
|
||||
name,
|
||||
"--scopes",
|
||||
scope_arg,
|
||||
"--pubkey",
|
||||
pubkey,
|
||||
"--owner-pubkey",
|
||||
owner_pubkey,
|
||||
]);
|
||||
|
||||
command.output().map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
format!(
|
||||
"failed to run `{}`: command not found. Build the workspace binaries from TESTING.md or set SPROUT_ADMIN_COMMAND explicitly.",
|
||||
program.display()
|
||||
)
|
||||
} else {
|
||||
format!("failed to run `{}`: {error}", program.display())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn output_error_detail(program: &Path, output: &Output) -> String {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let detail = if !stderr.is_empty() { stderr } else { stdout };
|
||||
format!(
|
||||
"`{}` exited with {}: {detail}",
|
||||
program.display(),
|
||||
output.status
|
||||
)
|
||||
}
|
||||
|
||||
fn boxed_line_content(line: &str) -> String {
|
||||
line.trim_matches(|ch: char| ch == '║' || ch.is_whitespace())
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn parse_legacy_sprout_admin_mint_output(
|
||||
stdout: &str,
|
||||
pubkey: &str,
|
||||
name: &str,
|
||||
scopes: &[String],
|
||||
) -> Result<SproutAdminMintTokenJsonOutput, String> {
|
||||
let contents: Vec<String> = stdout
|
||||
.lines()
|
||||
.map(boxed_line_content)
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect();
|
||||
|
||||
let token_id = contents
|
||||
.iter()
|
||||
.find_map(|line| {
|
||||
line.strip_prefix("Token ID:")
|
||||
.map(|value| value.trim().to_string())
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
"failed to parse legacy sprout-admin output: missing token id".to_string()
|
||||
})?;
|
||||
|
||||
let api_token_index = contents
|
||||
.iter()
|
||||
.position(|line| line == "API Token:")
|
||||
.ok_or_else(|| {
|
||||
"failed to parse legacy sprout-admin output: missing API token label".to_string()
|
||||
})?;
|
||||
let api_token = contents
|
||||
.iter()
|
||||
.skip(api_token_index + 1)
|
||||
.find(|line| !line.ends_with(':'))
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
"failed to parse legacy sprout-admin output: missing API token value".to_string()
|
||||
})?;
|
||||
|
||||
Ok(SproutAdminMintTokenJsonOutput {
|
||||
token_id,
|
||||
name: name.to_string(),
|
||||
scopes: scopes.to_vec(),
|
||||
pubkey: pubkey.to_string(),
|
||||
private_key_nsec: None,
|
||||
api_token,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run_sprout_admin_mint_token(
|
||||
app: &AppHandle,
|
||||
pubkey: &str,
|
||||
owner_pubkey: &str,
|
||||
name: &str,
|
||||
scopes: &[String],
|
||||
) -> Result<SproutAdminMintTokenJsonOutput, String> {
|
||||
let configured_program = admin_command();
|
||||
let program = resolve_command(&configured_program, Some(app))
|
||||
.ok_or_else(|| missing_command_message(&configured_program, "sprout-admin command"))?;
|
||||
let scope_arg = scopes.join(",");
|
||||
let output = run_sprout_admin_command(&program, pubkey, owner_pubkey, name, &scope_arg, true)?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.contains("unexpected argument '--json'") {
|
||||
let legacy_output =
|
||||
run_sprout_admin_command(&program, pubkey, owner_pubkey, name, &scope_arg, false)?;
|
||||
if !legacy_output.status.success() {
|
||||
return Err(output_error_detail(&program, &legacy_output));
|
||||
}
|
||||
|
||||
return parse_legacy_sprout_admin_mint_output(
|
||||
&String::from_utf8_lossy(&legacy_output.stdout),
|
||||
pubkey,
|
||||
name,
|
||||
scopes,
|
||||
);
|
||||
}
|
||||
|
||||
return Err(output_error_detail(&program, &output));
|
||||
}
|
||||
|
||||
let parsed: SproutAdminMintTokenJsonOutput = serde_json::from_slice(&output.stdout)
|
||||
.map_err(|error| format!("failed to parse sprout-admin JSON output: {error}"))?;
|
||||
|
||||
if parsed.pubkey.to_lowercase() != pubkey.to_lowercase() {
|
||||
return Err("sprout-admin returned a token for the wrong pubkey".to_string());
|
||||
}
|
||||
|
||||
if parsed.token_id.trim().is_empty() {
|
||||
return Err("sprout-admin returned an empty token id".to_string());
|
||||
}
|
||||
|
||||
if parsed.name.trim() != name.trim() {
|
||||
return Err("sprout-admin returned a token with the wrong name".to_string());
|
||||
}
|
||||
|
||||
if parsed.scopes != scopes {
|
||||
return Err("sprout-admin returned a token with the wrong scopes".to_string());
|
||||
}
|
||||
|
||||
if parsed.private_key_nsec.is_some() {
|
||||
return Err(
|
||||
"sprout-admin unexpectedly returned a private key for an existing pubkey".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(parsed)
|
||||
}
|
||||
9
desktop/src-tauri/src/managed_agents/mod.rs
Normal file
9
desktop/src-tauri/src/managed_agents/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
mod discovery;
|
||||
mod runtime;
|
||||
mod storage;
|
||||
mod types;
|
||||
|
||||
pub use discovery::*;
|
||||
pub use runtime::*;
|
||||
pub use storage::*;
|
||||
pub use types::*;
|
||||
235
desktop/src-tauri/src/managed_agents/runtime.rs
Normal file
235
desktop/src-tauri/src/managed_agents/runtime.rs
Normal file
@@ -0,0 +1,235 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::{
|
||||
managed_agents::{
|
||||
append_log_marker, managed_agent_log_path, missing_command_message, open_log_file,
|
||||
resolve_command, ManagedAgentProcess, ManagedAgentRecord, ManagedAgentSummary,
|
||||
DEFAULT_AGENT_ARG,
|
||||
},
|
||||
util::now_iso,
|
||||
};
|
||||
|
||||
pub fn sync_managed_agent_processes(
|
||||
records: &mut [ManagedAgentRecord],
|
||||
runtimes: &mut HashMap<String, ManagedAgentProcess>,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
let mut exited = Vec::new();
|
||||
|
||||
for (pubkey, runtime) in runtimes.iter_mut() {
|
||||
let status = match runtime.child.try_wait() {
|
||||
Ok(status) => status,
|
||||
Err(error) => {
|
||||
if let Some(record) = records.iter_mut().find(|record| record.pubkey == *pubkey) {
|
||||
record.updated_at = now_iso();
|
||||
record.last_error = Some(format!("failed to inspect process state: {error}"));
|
||||
}
|
||||
changed = true;
|
||||
exited.push(pubkey.clone());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(status) = status else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some(record) = records.iter_mut().find(|record| record.pubkey == *pubkey) {
|
||||
record.updated_at = now_iso();
|
||||
record.last_stopped_at = Some(now_iso());
|
||||
record.last_exit_code = status.code();
|
||||
record.last_error = if status.success() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("harness exited with status {status}"))
|
||||
};
|
||||
}
|
||||
|
||||
changed = true;
|
||||
exited.push(pubkey.clone());
|
||||
}
|
||||
|
||||
for pubkey in exited {
|
||||
runtimes.remove(&pubkey);
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
pub fn build_managed_agent_summary(
|
||||
app: &AppHandle,
|
||||
record: &ManagedAgentRecord,
|
||||
runtimes: &HashMap<String, ManagedAgentProcess>,
|
||||
) -> Result<ManagedAgentSummary, String> {
|
||||
let (status, pid, log_path) = if let Some(runtime) = runtimes.get(&record.pubkey) {
|
||||
(
|
||||
"running".to_string(),
|
||||
Some(runtime.child.id()),
|
||||
runtime.log_path.display().to_string(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"stopped".to_string(),
|
||||
None,
|
||||
managed_agent_log_path(app, &record.pubkey)?
|
||||
.display()
|
||||
.to_string(),
|
||||
)
|
||||
};
|
||||
|
||||
Ok(ManagedAgentSummary {
|
||||
pubkey: record.pubkey.clone(),
|
||||
name: record.name.clone(),
|
||||
relay_url: record.relay_url.clone(),
|
||||
acp_command: record.acp_command.clone(),
|
||||
agent_command: record.agent_command.clone(),
|
||||
agent_args: record.agent_args.clone(),
|
||||
mcp_command: record.mcp_command.clone(),
|
||||
turn_timeout_seconds: record.turn_timeout_seconds,
|
||||
has_api_token: record.api_token.is_some(),
|
||||
status,
|
||||
pid,
|
||||
created_at: record.created_at.clone(),
|
||||
updated_at: record.updated_at.clone(),
|
||||
last_started_at: record.last_started_at.clone(),
|
||||
last_stopped_at: record.last_stopped_at.clone(),
|
||||
last_exit_code: record.last_exit_code,
|
||||
last_error: record.last_error.clone(),
|
||||
log_path,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn find_managed_agent_mut<'a>(
|
||||
records: &'a mut [ManagedAgentRecord],
|
||||
pubkey: &str,
|
||||
) -> Result<&'a mut ManagedAgentRecord, String> {
|
||||
records
|
||||
.iter_mut()
|
||||
.find(|record| record.pubkey == pubkey)
|
||||
.ok_or_else(|| format!("agent {pubkey} not found"))
|
||||
}
|
||||
|
||||
pub fn start_managed_agent_process(
|
||||
app: &AppHandle,
|
||||
record: &mut ManagedAgentRecord,
|
||||
runtimes: &mut HashMap<String, ManagedAgentProcess>,
|
||||
) -> Result<(), String> {
|
||||
if let Some(runtime) = runtimes.get_mut(&record.pubkey) {
|
||||
if runtime
|
||||
.child
|
||||
.try_wait()
|
||||
.map_err(|error| format!("failed to inspect running process: {error}"))?
|
||||
.is_none()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
runtimes.remove(&record.pubkey);
|
||||
}
|
||||
|
||||
let log_path = managed_agent_log_path(app, &record.pubkey)?;
|
||||
append_log_marker(
|
||||
&log_path,
|
||||
&format!(
|
||||
"\n=== starting {} ({}) at {} ===",
|
||||
record.name,
|
||||
record.pubkey,
|
||||
now_iso()
|
||||
),
|
||||
)?;
|
||||
|
||||
let stdout = open_log_file(&log_path)?;
|
||||
let stderr = stdout
|
||||
.try_clone()
|
||||
.map_err(|error| format!("failed to clone log handle: {error}"))?;
|
||||
let agent_args = if record.agent_args.is_empty() {
|
||||
vec![DEFAULT_AGENT_ARG.to_string()]
|
||||
} else {
|
||||
record.agent_args.clone()
|
||||
};
|
||||
let resolved_acp_command = resolve_command(&record.acp_command, Some(app))
|
||||
.ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?;
|
||||
let resolved_mcp_command = resolve_command(&record.mcp_command, Some(app))
|
||||
.ok_or_else(|| missing_command_message(&record.mcp_command, "MCP server command"))?;
|
||||
|
||||
let mut command = std::process::Command::new(&resolved_acp_command);
|
||||
command.stdin(std::process::Stdio::null());
|
||||
command.stdout(std::process::Stdio::from(stdout));
|
||||
command.stderr(std::process::Stdio::from(stderr));
|
||||
command.env("SPROUT_PRIVATE_KEY", &record.private_key_nsec);
|
||||
command.env("SPROUT_RELAY_URL", &record.relay_url);
|
||||
command.env("SPROUT_ACP_AGENT_COMMAND", &record.agent_command);
|
||||
command.env("SPROUT_ACP_AGENT_ARGS", agent_args.join(","));
|
||||
command.env("SPROUT_ACP_MCP_COMMAND", &resolved_mcp_command);
|
||||
command.env(
|
||||
"SPROUT_ACP_TURN_TIMEOUT",
|
||||
record.turn_timeout_seconds.to_string(),
|
||||
);
|
||||
command.env(
|
||||
"GOOSE_MODE",
|
||||
std::env::var("GOOSE_MODE").unwrap_or_else(|_| "auto".to_string()),
|
||||
);
|
||||
command.env_remove("SPROUT_ACP_PRIVATE_KEY");
|
||||
command.env_remove("SPROUT_ACP_API_TOKEN");
|
||||
|
||||
if let Some(token) = &record.api_token {
|
||||
command.env("SPROUT_API_TOKEN", token);
|
||||
} else {
|
||||
command.env_remove("SPROUT_API_TOKEN");
|
||||
}
|
||||
|
||||
let child = command.spawn().map_err(|error| {
|
||||
format!(
|
||||
"failed to spawn `{}` for agent {}: {error}",
|
||||
resolved_acp_command.display(),
|
||||
record.name
|
||||
)
|
||||
})?;
|
||||
|
||||
let now = now_iso();
|
||||
record.updated_at = now.clone();
|
||||
record.last_started_at = Some(now);
|
||||
record.last_stopped_at = None;
|
||||
record.last_exit_code = None;
|
||||
record.last_error = None;
|
||||
|
||||
runtimes.insert(
|
||||
record.pubkey.clone(),
|
||||
ManagedAgentProcess { child, log_path },
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop_managed_agent_process(
|
||||
record: &mut ManagedAgentRecord,
|
||||
runtimes: &mut HashMap<String, ManagedAgentProcess>,
|
||||
) -> Result<(), String> {
|
||||
let Some(mut runtime) = runtimes.remove(&record.pubkey) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let _ = runtime.child.kill();
|
||||
let status = runtime
|
||||
.child
|
||||
.wait()
|
||||
.map_err(|error| format!("failed to wait for agent shutdown: {error}"))?;
|
||||
let now = now_iso();
|
||||
record.updated_at = now.clone();
|
||||
record.last_stopped_at = Some(now);
|
||||
record.last_exit_code = status.code();
|
||||
record.last_error = None;
|
||||
|
||||
append_log_marker(
|
||||
&runtime.log_path,
|
||||
&format!(
|
||||
"=== stopped {} ({}) at {} ===",
|
||||
record.name,
|
||||
record.pubkey,
|
||||
now_iso()
|
||||
),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
88
desktop/src-tauri/src/managed_agents/storage.rs
Normal file
88
desktop/src-tauri/src/managed_agents/storage.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
use std::{
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{BufRead, BufReader, Write},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::managed_agents::ManagedAgentRecord;
|
||||
|
||||
pub fn managed_agents_base_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("failed to resolve app data dir: {error}"))?
|
||||
.join("agents");
|
||||
fs::create_dir_all(&dir).map_err(|error| format!("failed to create agents dir: {error}"))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn managed_agents_store_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(managed_agents_base_dir(app)?.join("managed-agents.json"))
|
||||
}
|
||||
|
||||
fn managed_agents_logs_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = managed_agents_base_dir(app)?.join("logs");
|
||||
fs::create_dir_all(&dir).map_err(|error| format!("failed to create logs dir: {error}"))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
pub fn managed_agent_log_path(app: &AppHandle, pubkey: &str) -> Result<PathBuf, String> {
|
||||
Ok(managed_agents_logs_dir(app)?.join(format!("{pubkey}.log")))
|
||||
}
|
||||
|
||||
pub fn load_managed_agents(app: &AppHandle) -> Result<Vec<ManagedAgentRecord>, String> {
|
||||
let path = managed_agents_store_path(app)?;
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|error| format!("failed to read agent store: {error}"))?;
|
||||
serde_json::from_str(&content).map_err(|error| format!("failed to parse agent store: {error}"))
|
||||
}
|
||||
|
||||
pub fn save_managed_agents(app: &AppHandle, records: &[ManagedAgentRecord]) -> Result<(), String> {
|
||||
let mut sorted = records.to_vec();
|
||||
sorted.sort_by(|left, right| {
|
||||
left.name
|
||||
.to_lowercase()
|
||||
.cmp(&right.name.to_lowercase())
|
||||
.then_with(|| left.pubkey.cmp(&right.pubkey))
|
||||
});
|
||||
|
||||
let path = managed_agents_store_path(app)?;
|
||||
let payload = serde_json::to_vec_pretty(&sorted)
|
||||
.map_err(|error| format!("failed to serialize agent store: {error}"))?;
|
||||
fs::write(&path, payload).map_err(|error| format!("failed to write agent store: {error}"))
|
||||
}
|
||||
|
||||
pub(crate) fn open_log_file(path: &Path) -> Result<File, String> {
|
||||
OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.map_err(|error| format!("failed to open log file {}: {error}", path.display()))
|
||||
}
|
||||
|
||||
pub(crate) fn append_log_marker(path: &Path, message: &str) -> Result<(), String> {
|
||||
let mut file = open_log_file(path)?;
|
||||
writeln!(file, "{message}").map_err(|error| format!("failed to write log marker: {error}"))
|
||||
}
|
||||
|
||||
pub fn read_log_tail(path: &Path, max_lines: usize) -> Result<String, String> {
|
||||
if !path.exists() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let file = File::open(path)
|
||||
.map_err(|error| format!("failed to read log file {}: {error}", path.display()))?;
|
||||
let reader = BufReader::new(file);
|
||||
let lines = reader
|
||||
.lines()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| format!("failed to read log lines: {error}"))?;
|
||||
let start = lines.len().saturating_sub(max_lines);
|
||||
Ok(lines[start..].join("\n"))
|
||||
}
|
||||
156
desktop/src-tauri/src/managed_agents/types.rs
Normal file
156
desktop/src-tauri/src/managed_agents/types.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
use std::{path::PathBuf, process::Child};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RelayAgentInfo {
|
||||
pub pubkey: String,
|
||||
pub name: String,
|
||||
pub agent_type: String,
|
||||
pub channels: Vec<String>,
|
||||
pub capabilities: Vec<String>,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ManagedAgentRecord {
|
||||
pub pubkey: String,
|
||||
pub name: String,
|
||||
pub private_key_nsec: String,
|
||||
pub api_token: Option<String>,
|
||||
pub relay_url: String,
|
||||
pub acp_command: String,
|
||||
pub agent_command: String,
|
||||
pub agent_args: Vec<String>,
|
||||
pub mcp_command: String,
|
||||
pub turn_timeout_seconds: u64,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub last_started_at: Option<String>,
|
||||
pub last_stopped_at: Option<String>,
|
||||
pub last_exit_code: Option<i32>,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ManagedAgentProcess {
|
||||
pub child: Child,
|
||||
pub log_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ManagedAgentSummary {
|
||||
pub pubkey: String,
|
||||
pub name: String,
|
||||
pub relay_url: String,
|
||||
pub acp_command: String,
|
||||
pub agent_command: String,
|
||||
pub agent_args: Vec<String>,
|
||||
pub mcp_command: String,
|
||||
pub turn_timeout_seconds: u64,
|
||||
pub has_api_token: bool,
|
||||
pub status: String,
|
||||
pub pid: Option<u32>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub last_started_at: Option<String>,
|
||||
pub last_stopped_at: Option<String>,
|
||||
pub last_exit_code: Option<i32>,
|
||||
pub last_error: Option<String>,
|
||||
pub log_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateManagedAgentRequest {
|
||||
pub name: String,
|
||||
pub relay_url: Option<String>,
|
||||
pub acp_command: Option<String>,
|
||||
pub agent_command: Option<String>,
|
||||
#[serde(default)]
|
||||
pub agent_args: Vec<String>,
|
||||
pub mcp_command: Option<String>,
|
||||
pub turn_timeout_seconds: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub mint_token: bool,
|
||||
#[serde(default)]
|
||||
pub token_scopes: Vec<String>,
|
||||
pub token_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub spawn_after_create: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateManagedAgentResponse {
|
||||
pub agent: ManagedAgentSummary,
|
||||
pub private_key_nsec: String,
|
||||
pub api_token: Option<String>,
|
||||
pub spawn_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MintManagedAgentTokenRequest {
|
||||
pub pubkey: String,
|
||||
pub token_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub scopes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MintManagedAgentTokenResponse {
|
||||
pub agent: ManagedAgentSummary,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ManagedAgentLogResponse {
|
||||
pub content: String,
|
||||
pub log_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AcpProviderInfo {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub command: String,
|
||||
pub binary_path: String,
|
||||
pub default_args: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CommandAvailabilityInfo {
|
||||
pub command: String,
|
||||
pub resolved_path: Option<String>,
|
||||
pub available: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DiscoverManagedAgentPrereqsRequest {
|
||||
pub acp_command: Option<String>,
|
||||
pub mcp_command: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ManagedAgentPrereqsInfo {
|
||||
pub admin: CommandAvailabilityInfo,
|
||||
pub acp: CommandAvailabilityInfo,
|
||||
pub mcp: CommandAvailabilityInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SproutAdminMintTokenJsonOutput {
|
||||
pub token_id: String,
|
||||
pub name: String,
|
||||
pub scopes: Vec<String>,
|
||||
pub pubkey: String,
|
||||
pub private_key_nsec: Option<String>,
|
||||
pub api_token: String,
|
||||
}
|
||||
|
||||
pub const DEFAULT_ACP_COMMAND: &str = "sprout-acp";
|
||||
pub const DEFAULT_AGENT_COMMAND: &str = "goose";
|
||||
pub const DEFAULT_MCP_COMMAND: &str = "sprout-mcp-server";
|
||||
pub const DEFAULT_AGENT_ARG: &str = "acp";
|
||||
pub const DEFAULT_AGENT_TURN_TIMEOUT_SECONDS: u64 = 300;
|
||||
302
desktop/src-tauri/src/models.rs
Normal file
302
desktop/src-tauri/src/models.rs
Normal file
@@ -0,0 +1,302 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use sprout_core::PresenceStatus;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct IdentityInfo {
|
||||
pub pubkey: String,
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ProfileInfo {
|
||||
pub pubkey: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub about: Option<String>,
|
||||
pub nip05_handle: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct UserProfileSummaryInfo {
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub nip05_handle: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct UsersBatchResponse {
|
||||
pub profiles: HashMap<String, UserProfileSummaryInfo>,
|
||||
pub missing: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct SetPresenceResponse {
|
||||
pub status: PresenceStatus,
|
||||
pub ttl_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ChannelInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub channel_type: String,
|
||||
pub visibility: String,
|
||||
#[serde(deserialize_with = "deserialize_null_string_as_empty")]
|
||||
pub description: String,
|
||||
pub topic: Option<String>,
|
||||
pub purpose: Option<String>,
|
||||
pub member_count: i64,
|
||||
pub last_message_at: Option<String>,
|
||||
pub archived_at: Option<String>,
|
||||
pub participants: Vec<String>,
|
||||
pub participant_pubkeys: Vec<String>,
|
||||
#[serde(default = "default_true")]
|
||||
pub is_member: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ChannelDetailInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub channel_type: String,
|
||||
pub visibility: String,
|
||||
#[serde(deserialize_with = "deserialize_null_string_as_empty")]
|
||||
pub description: String,
|
||||
pub topic: Option<String>,
|
||||
pub topic_set_by: Option<String>,
|
||||
pub topic_set_at: Option<String>,
|
||||
pub purpose: Option<String>,
|
||||
pub purpose_set_by: Option<String>,
|
||||
pub purpose_set_at: Option<String>,
|
||||
pub created_by: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub archived_at: Option<String>,
|
||||
pub member_count: i64,
|
||||
pub topic_required: bool,
|
||||
pub max_members: Option<i32>,
|
||||
pub nip29_group_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ChannelMemberInfo {
|
||||
pub pubkey: String,
|
||||
pub role: String,
|
||||
pub joined_at: String,
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ChannelMembersResponse {
|
||||
pub members: Vec<ChannelMemberInfo>,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct AddMembersResponse {
|
||||
pub added: Vec<String>,
|
||||
pub errors: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CreateChannelBody<'a> {
|
||||
pub name: &'a str,
|
||||
pub channel_type: &'a str,
|
||||
pub visibility: &'a str,
|
||||
pub description: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UpdateChannelBody<'a> {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SetTopicBody<'a> {
|
||||
pub topic: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SetPurposeBody<'a> {
|
||||
pub purpose: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AddMembersBody<'a> {
|
||||
pub pubkeys: &'a [String],
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub role: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UpdateProfileBody<'a> {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub avatar_url: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub about: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub nip05_handle: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SetPresenceBody {
|
||||
pub status: PresenceStatus,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetFeedQuery<'a> {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub since: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub types: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SearchQueryParams<'a> {
|
||||
pub q: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SendChannelMessageBody<'a> {
|
||||
pub content: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_event_id: Option<&'a str>,
|
||||
pub broadcast_to_channel: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AddReactionBody<'a> {
|
||||
pub emoji: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct MintTokenBody<'a> {
|
||||
pub name: &'a str,
|
||||
pub scopes: &'a [String],
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub channel_ids: Option<&'a [String]>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_in_days: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct MintTokenResponse {
|
||||
pub id: String,
|
||||
pub token: String,
|
||||
pub name: String,
|
||||
pub scopes: Vec<String>,
|
||||
pub channel_ids: Vec<String>,
|
||||
pub created_at: String,
|
||||
pub expires_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TokenInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub scopes: Vec<String>,
|
||||
pub channel_ids: Vec<String>,
|
||||
pub created_at: String,
|
||||
pub expires_at: Option<String>,
|
||||
pub last_used_at: Option<String>,
|
||||
pub revoked_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ListTokensResponse {
|
||||
pub tokens: Vec<TokenInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct RevokeAllTokensResponse {
|
||||
pub revoked_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct FeedItemInfo {
|
||||
pub id: String,
|
||||
pub kind: u32,
|
||||
pub pubkey: String,
|
||||
pub content: String,
|
||||
pub created_at: u64,
|
||||
pub channel_id: Option<String>,
|
||||
pub channel_name: String,
|
||||
pub tags: Vec<Vec<String>>,
|
||||
pub category: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct FeedSections {
|
||||
pub mentions: Vec<FeedItemInfo>,
|
||||
pub needs_action: Vec<FeedItemInfo>,
|
||||
pub activity: Vec<FeedItemInfo>,
|
||||
pub agent_activity: Vec<FeedItemInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct FeedMeta {
|
||||
pub since: i64,
|
||||
pub total: u64,
|
||||
pub generated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct FeedResponse {
|
||||
pub feed: FeedSections,
|
||||
pub meta: FeedMeta,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct SearchHitInfo {
|
||||
pub event_id: String,
|
||||
pub content: String,
|
||||
pub kind: u32,
|
||||
pub pubkey: String,
|
||||
pub channel_id: String,
|
||||
pub channel_name: String,
|
||||
pub created_at: u64,
|
||||
pub score: f64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct SearchResponse {
|
||||
pub hits: Vec<SearchHitInfo>,
|
||||
pub found: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct SendChannelMessageResponse {
|
||||
pub event_id: String,
|
||||
pub parent_event_id: Option<String>,
|
||||
pub root_event_id: Option<String>,
|
||||
pub depth: u32,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetUsersBatchBody<'a> {
|
||||
pub pubkeys: &'a [String],
|
||||
}
|
||||
|
||||
fn deserialize_null_string_as_empty<'de, D>(deserializer: D) -> Result<String, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
168
desktop/src-tauri/src/relay.rs
Normal file
168
desktop/src-tauri/src/relay.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use nostr::{EventBuilder, JsonUtil, Kind, Tag};
|
||||
use reqwest::Method;
|
||||
use serde::de::DeserializeOwned;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{app_state::AppState, models::ProfileInfo};
|
||||
|
||||
pub fn relay_ws_url() -> String {
|
||||
std::env::var("SPROUT_RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
|
||||
}
|
||||
|
||||
pub fn relay_api_base_url() -> String {
|
||||
if let Ok(base) = std::env::var("SPROUT_RELAY_HTTP") {
|
||||
return base;
|
||||
}
|
||||
|
||||
relay_ws_url()
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://")
|
||||
}
|
||||
|
||||
pub fn build_authed_request(
|
||||
client: &reqwest::Client,
|
||||
method: Method,
|
||||
path: &str,
|
||||
state: &AppState,
|
||||
) -> Result<reqwest::RequestBuilder, String> {
|
||||
let url = format!("{}{}", relay_api_base_url(), path);
|
||||
let request = client.request(method, url);
|
||||
|
||||
if let Some(token) = state.configured_api_token.as_deref() {
|
||||
return Ok(request.header("Authorization", format!("Bearer {token}")));
|
||||
}
|
||||
|
||||
let pubkey_hex = auth_pubkey_header(state)?;
|
||||
Ok(request.header("X-Pubkey", pubkey_hex))
|
||||
}
|
||||
|
||||
pub fn auth_pubkey_header(state: &AppState) -> Result<String, String> {
|
||||
let keys = state.keys.lock().map_err(|error| error.to_string())?;
|
||||
Ok(keys.public_key().to_hex())
|
||||
}
|
||||
|
||||
pub async fn managed_agent_owner_pubkey(state: &AppState) -> Result<String, String> {
|
||||
if state.configured_api_token.is_none() {
|
||||
return auth_pubkey_header(state);
|
||||
}
|
||||
|
||||
let request = build_authed_request(
|
||||
&state.http_client,
|
||||
Method::GET,
|
||||
"/api/users/me/profile",
|
||||
state,
|
||||
)?;
|
||||
let profile: ProfileInfo = send_json_request(request).await.map_err(|error| {
|
||||
format!(
|
||||
"failed to resolve the authenticated token owner: {error}. Managed-agent minting with SPROUT_API_TOKEN requires a token for the desktop identity with `users:read`."
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(profile.pubkey)
|
||||
}
|
||||
|
||||
fn session_api_token(state: &AppState) -> Result<Option<String>, String> {
|
||||
let token = state
|
||||
.session_token
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(token.clone())
|
||||
}
|
||||
|
||||
pub fn build_token_management_request(
|
||||
client: &reqwest::Client,
|
||||
method: Method,
|
||||
path: &str,
|
||||
state: &AppState,
|
||||
) -> Result<reqwest::RequestBuilder, String> {
|
||||
let url = format!("{}{}", relay_api_base_url(), path);
|
||||
let request = client.request(method, url);
|
||||
|
||||
if let Some(token) = state.configured_api_token.as_deref() {
|
||||
return Ok(request.header("Authorization", format!("Bearer {token}")));
|
||||
}
|
||||
|
||||
if let Some(token) = session_api_token(state)? {
|
||||
return Ok(request.header("Authorization", format!("Bearer {token}")));
|
||||
}
|
||||
|
||||
let pubkey_hex = auth_pubkey_header(state)?;
|
||||
Ok(request.header("X-Pubkey", pubkey_hex))
|
||||
}
|
||||
|
||||
pub fn build_nip98_auth_header(
|
||||
method: &Method,
|
||||
url: &str,
|
||||
body: &[u8],
|
||||
state: &AppState,
|
||||
) -> Result<String, String> {
|
||||
let keys = state.keys.lock().map_err(|error| error.to_string())?;
|
||||
let payload_hash = format!("{:x}", Sha256::digest(body));
|
||||
let tags = vec![
|
||||
Tag::parse(vec!["u", url]).map_err(|error| format!("url tag failed: {error}"))?,
|
||||
Tag::parse(vec!["method", method.as_str()])
|
||||
.map_err(|error| format!("method tag failed: {error}"))?,
|
||||
Tag::parse(vec!["payload", &payload_hash])
|
||||
.map_err(|error| format!("payload tag failed: {error}"))?,
|
||||
];
|
||||
|
||||
let event = EventBuilder::new(Kind::HttpAuth, "")
|
||||
.tags(tags)
|
||||
.sign_with_keys(&keys)
|
||||
.map_err(|error| format!("sign failed: {error}"))?;
|
||||
|
||||
Ok(format!(
|
||||
"Nostr {}",
|
||||
BASE64.encode(event.as_json().as_bytes())
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn relay_error_message(response: reqwest::Response) -> String {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&body) {
|
||||
if let Some(message) = value.get("message").and_then(serde_json::Value::as_str) {
|
||||
return format!("relay returned {status}: {message}");
|
||||
}
|
||||
|
||||
if let Some(error) = value.get("error").and_then(serde_json::Value::as_str) {
|
||||
return format!("relay returned {status}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
format!("relay returned {status}: {body}")
|
||||
}
|
||||
|
||||
pub async fn send_json_request<T>(request: reqwest::RequestBuilder) -> Result<T, String>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("request failed: {error}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(relay_error_message(response).await);
|
||||
}
|
||||
|
||||
response
|
||||
.json::<T>()
|
||||
.await
|
||||
.map_err(|error| format!("parse failed: {error}"))
|
||||
}
|
||||
|
||||
pub async fn send_empty_request(request: reqwest::RequestBuilder) -> Result<(), String> {
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("request failed: {error}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(relay_error_message(response).await);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
30
desktop/src-tauri/src/util.rs
Normal file
30
desktop/src-tauri/src/util.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use chrono::Utc;
|
||||
|
||||
pub fn now_iso() -> String {
|
||||
Utc::now().to_rfc3339()
|
||||
}
|
||||
|
||||
pub fn percent_encode(input: &str) -> String {
|
||||
let mut encoded = String::with_capacity(input.len());
|
||||
|
||||
for byte in input.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
encoded.push(byte as char);
|
||||
}
|
||||
_ => {
|
||||
let high = char::from_digit((byte >> 4) as u32, 16)
|
||||
.expect("nibble 0-15 is always a valid hex digit")
|
||||
.to_ascii_uppercase();
|
||||
let low = char::from_digit((byte & 0x0f) as u32, 16)
|
||||
.expect("nibble 0-15 is always a valid hex digit")
|
||||
.to_ascii_uppercase();
|
||||
encoded.push('%');
|
||||
encoded.push(high);
|
||||
encoded.push(low);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
encoded
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import { ChannelBrowserDialog } from "@/features/channels/ui/ChannelBrowserDialo
|
||||
import { SearchDialog } from "@/features/search/ui/SearchDialog";
|
||||
import { SettingsView } from "@/features/settings/ui/SettingsView";
|
||||
import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { getEventById, joinChannel } from "@/shared/api/tauri";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import type { Channel, RelayEvent, SearchHit } from "@/shared/api/types";
|
||||
@@ -206,8 +207,12 @@ export function AppShell() {
|
||||
: selectedView === "settings"
|
||||
? "settings"
|
||||
: `channel:${activeChannel?.id ?? "none"}`;
|
||||
const shouldLoadTimeline =
|
||||
activeChannel !== null && activeChannel.channelType !== "forum";
|
||||
const isTimelineLoading =
|
||||
messagesQuery.isLoading && resolvedMessages.length === 0;
|
||||
shouldLoadTimeline &&
|
||||
(messagesQuery.isPending ||
|
||||
(messagesQuery.isFetching && resolvedMessages.length === 0));
|
||||
|
||||
const requestedAncestorIdsRef = React.useRef<Set<string>>(new Set());
|
||||
const previousActiveChannelIdRef = React.useRef<string | null>(
|
||||
@@ -300,6 +305,20 @@ export function AppShell() {
|
||||
[handleOpenChannel],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
void relayClient.preconnect().catch((error) => {
|
||||
if (!isCancelled) {
|
||||
console.error("Failed to preconnect to relay", error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (previousActiveChannelIdRef.current === activeChannelId) {
|
||||
return;
|
||||
@@ -541,7 +560,7 @@ export function AppShell() {
|
||||
presenceStatus={presenceSession.currentStatus}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<React.Fragment key={activeChannel?.id ?? "no-channel"}>
|
||||
<MessageTimeline
|
||||
activeReplyTargetId={replyTargetId}
|
||||
emptyDescription={
|
||||
@@ -557,7 +576,6 @@ export function AppShell() {
|
||||
: "No channel selected"
|
||||
}
|
||||
isLoading={isTimelineLoading}
|
||||
key={activeChannel?.id ?? "no-channel"}
|
||||
messages={timelineMessages}
|
||||
onReply={(message) => {
|
||||
setReplyTargetId((current) =>
|
||||
@@ -599,7 +617,6 @@ export function AppShell() {
|
||||
sendMessageMutation.isPending
|
||||
}
|
||||
isSending={sendMessageMutation.isPending}
|
||||
key={activeChannel?.id ?? "no-channel"}
|
||||
onCancelReply={() => {
|
||||
setReplyTargetId(null);
|
||||
}}
|
||||
@@ -632,7 +649,7 @@ export function AppShell() {
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
210
desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx
Normal file
210
desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
useAddChannelMembersMutation,
|
||||
useChannelsQuery,
|
||||
} from "@/features/channels/hooks";
|
||||
import type { Channel, ChannelRole, ManagedAgent } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
|
||||
export function AddAgentToChannelDialog({
|
||||
agent,
|
||||
open,
|
||||
onAdded,
|
||||
onOpenChange,
|
||||
}: {
|
||||
agent: ManagedAgent | null;
|
||||
open: boolean;
|
||||
onAdded: (channel: Channel, agent: ManagedAgent) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const [channelId, setChannelId] = React.useState("");
|
||||
const [role, setRole] = React.useState<Exclude<ChannelRole, "owner">>("bot");
|
||||
const addMembersMutation = useAddChannelMembersMutation(channelId || null);
|
||||
const channels = React.useMemo(
|
||||
() =>
|
||||
(channelsQuery.data ?? []).filter(
|
||||
(channel) => channel.channelType !== "dm" && !channel.archivedAt,
|
||||
),
|
||||
[channelsQuery.data],
|
||||
);
|
||||
|
||||
function reset() {
|
||||
setChannelId("");
|
||||
setRole("bot");
|
||||
addMembersMutation.reset();
|
||||
}
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
if (!next) {
|
||||
reset();
|
||||
}
|
||||
|
||||
onOpenChange(next);
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!channelId && channels.length > 0) {
|
||||
setChannelId(channels[0].id);
|
||||
}
|
||||
}, [channelId, channels, open]);
|
||||
|
||||
const selectedChannel =
|
||||
channels.find((channel) => channel.id === channelId) ?? null;
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!agent || !selectedChannel) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await addMembersMutation.mutateAsync({
|
||||
pubkeys: [agent.pubkey],
|
||||
role,
|
||||
});
|
||||
const membershipError = result.errors.find(
|
||||
(error) => error.pubkey === agent.pubkey,
|
||||
);
|
||||
|
||||
if (membershipError) {
|
||||
throw new Error(membershipError.error);
|
||||
}
|
||||
|
||||
onAdded(selectedChannel, agent);
|
||||
handleOpenChange(false);
|
||||
} catch {
|
||||
// React Query stores the error; keep the dialog open and render it inline.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogContent className="max-w-xl overflow-hidden p-0">
|
||||
<div className="flex max-h-[85vh] flex-col">
|
||||
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
|
||||
<DialogTitle>Add agent to channel</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add {agent?.name ?? "this agent"} to a channel so desktop chat can
|
||||
`@mention` it. If the harness is already running, restart it after
|
||||
adding membership so it subscribes to the new channel.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 px-6 py-5">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="agent-channel-id">
|
||||
Channel
|
||||
</label>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm"
|
||||
disabled={channels.length === 0 || addMembersMutation.isPending}
|
||||
id="agent-channel-id"
|
||||
onChange={(event) => setChannelId(event.target.value)}
|
||||
value={channelId}
|
||||
>
|
||||
{channels.length === 0 ? (
|
||||
<option value="">No channels available</option>
|
||||
) : null}
|
||||
{channels.map((channel) => (
|
||||
<option key={channel.id} value={channel.id}>
|
||||
{channel.name} · {channel.visibility}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only channels accessible to the current desktop user are shown
|
||||
here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="agent-channel-role"
|
||||
>
|
||||
Role
|
||||
</label>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm"
|
||||
disabled={addMembersMutation.isPending}
|
||||
id="agent-channel-role"
|
||||
onChange={(event) =>
|
||||
setRole(event.target.value as Exclude<ChannelRole, "owner">)
|
||||
}
|
||||
value={role}
|
||||
>
|
||||
<option value="bot">bot</option>
|
||||
<option value="member">member</option>
|
||||
<option value="guest">guest</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4">
|
||||
<p className="text-sm font-semibold tracking-tight">
|
||||
Agent pubkey
|
||||
</p>
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
<code className="min-w-0 flex-1 break-all rounded-xl border border-border/70 bg-background/80 px-3 py-2 text-xs">
|
||||
{agent?.pubkey ?? "No agent selected"}
|
||||
</code>
|
||||
{agent ? (
|
||||
<CopyButton label="Copy pubkey" value={agent.pubkey} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{channelsQuery.error instanceof Error ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{channelsQuery.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{addMembersMutation.error instanceof Error ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{addMembersMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-border/60 px-6 py-4">
|
||||
<Button
|
||||
onClick={() => handleOpenChange(false)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
!agent ||
|
||||
!selectedChannel ||
|
||||
channelsQuery.isLoading ||
|
||||
addMembersMutation.isPending
|
||||
}
|
||||
onClick={() => void handleSubmit()}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
{addMembersMutation.isPending ? "Adding..." : "Add to channel"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
34
desktop/src/features/agents/ui/CopyButton.tsx
Normal file
34
desktop/src/features/agents/ui/CopyButton.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
export function CopyButton({
|
||||
value,
|
||||
label,
|
||||
}: {
|
||||
value: string;
|
||||
label?: string;
|
||||
}) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1_500);
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{copied ? "Copied" : (label ?? "Copy")}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
343
desktop/src/features/agents/ui/CreateAgentDialog.tsx
Normal file
343
desktop/src/features/agents/ui/CreateAgentDialog.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
useAcpProvidersQuery,
|
||||
useCreateManagedAgentMutation,
|
||||
useManagedAgentPrereqsQuery,
|
||||
} from "@/features/agents/hooks";
|
||||
import type {
|
||||
CreateManagedAgentInput,
|
||||
CreateManagedAgentResponse,
|
||||
TokenScope,
|
||||
} from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import {
|
||||
CreateAgentBasicsFields,
|
||||
CreateAgentOptionToggles,
|
||||
CreateAgentPrerequisitesCard,
|
||||
CreateAgentRuntimeFields,
|
||||
CreateAgentTokenSection,
|
||||
} from "./CreateAgentDialogSections";
|
||||
|
||||
export function CreateAgentDialog({
|
||||
open,
|
||||
onCreated,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onCreated: (result: CreateManagedAgentResponse) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const createMutation = useCreateManagedAgentMutation();
|
||||
const providersQuery = useAcpProvidersQuery();
|
||||
const [acpCommand, setAcpCommand] = React.useState("sprout-acp");
|
||||
const [agentCommand, setAgentCommand] = React.useState("goose");
|
||||
const [agentArgs, setAgentArgs] = React.useState("acp");
|
||||
const [mcpCommand, setMcpCommand] = React.useState("sprout-mcp-server");
|
||||
const prereqsQuery = useManagedAgentPrereqsQuery(acpCommand, mcpCommand);
|
||||
const [name, setName] = React.useState("");
|
||||
const [relayUrl, setRelayUrl] = React.useState("");
|
||||
const [mintToken, setMintToken] = React.useState(true);
|
||||
const [spawnAfterCreate, setSpawnAfterCreate] = React.useState(true);
|
||||
const [tokenName, setTokenName] = React.useState("");
|
||||
const [selectedScopes, setSelectedScopes] = React.useState<Set<TokenScope>>(
|
||||
() =>
|
||||
new Set<TokenScope>(["messages:read", "messages:write", "channels:read"]),
|
||||
);
|
||||
const [turnTimeoutSeconds, setTurnTimeoutSeconds] = React.useState("300");
|
||||
const [selectedProviderId, setSelectedProviderId] =
|
||||
React.useState<string>("custom");
|
||||
const [hasSyncedProviderSelection, setHasSyncedProviderSelection] =
|
||||
React.useState(false);
|
||||
const providers = providersQuery.data ?? [];
|
||||
const prereqs = prereqsQuery.data ?? null;
|
||||
const selectedProvider = React.useMemo(
|
||||
() =>
|
||||
providers.find((provider) => provider.id === selectedProviderId) ?? null,
|
||||
[providers, selectedProviderId],
|
||||
);
|
||||
const isMintSupported = prereqs?.admin.available ?? false;
|
||||
const isSpawnSupported =
|
||||
prereqs?.acp.available === true && prereqs?.mcp.available === true;
|
||||
const mintToggleDisabled =
|
||||
prereqsQuery.isLoading || (prereqs !== null && !isMintSupported);
|
||||
const spawnToggleDisabled =
|
||||
prereqsQuery.isLoading || (prereqs !== null && !isSpawnSupported);
|
||||
const isDiscoveryPending = providersQuery.isLoading || prereqsQuery.isLoading;
|
||||
const prerequisiteCards = [
|
||||
{
|
||||
id: "admin",
|
||||
label: "Token minting",
|
||||
info: prereqs?.admin ?? null,
|
||||
command: prereqs?.admin.command ?? "sprout-admin",
|
||||
},
|
||||
{
|
||||
id: "acp",
|
||||
label: "ACP harness",
|
||||
info: prereqs?.acp ?? null,
|
||||
command: prereqs?.acp.command ?? (acpCommand.trim() || "sprout-acp"),
|
||||
},
|
||||
{
|
||||
id: "mcp",
|
||||
label: "MCP server",
|
||||
info: prereqs?.mcp ?? null,
|
||||
command:
|
||||
prereqs?.mcp.command ?? (mcpCommand.trim() || "sprout-mcp-server"),
|
||||
},
|
||||
];
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasSyncedProviderSelection || providersQuery.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matchingProvider =
|
||||
providers.find((provider) => provider.command === agentCommand) ?? null;
|
||||
if (matchingProvider) {
|
||||
setSelectedProviderId(matchingProvider.id);
|
||||
}
|
||||
setHasSyncedProviderSelection(true);
|
||||
}, [
|
||||
agentCommand,
|
||||
hasSyncedProviderSelection,
|
||||
providers,
|
||||
providersQuery.isLoading,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!prereqs || prereqs.admin.available || !mintToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMintToken(false);
|
||||
}, [mintToken, prereqs]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!prereqs ||
|
||||
(prereqs.acp.available && prereqs.mcp.available) ||
|
||||
!spawnAfterCreate
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSpawnAfterCreate(false);
|
||||
}, [prereqs, spawnAfterCreate]);
|
||||
|
||||
function reset() {
|
||||
setName("");
|
||||
setRelayUrl("");
|
||||
setMintToken(true);
|
||||
setSpawnAfterCreate(true);
|
||||
setTokenName("");
|
||||
setSelectedScopes(
|
||||
new Set<TokenScope>(["messages:read", "messages:write", "channels:read"]),
|
||||
);
|
||||
setAcpCommand("sprout-acp");
|
||||
setAgentCommand("goose");
|
||||
setAgentArgs("acp");
|
||||
setMcpCommand("sprout-mcp-server");
|
||||
setTurnTimeoutSeconds("300");
|
||||
setSelectedProviderId("custom");
|
||||
setHasSyncedProviderSelection(false);
|
||||
createMutation.reset();
|
||||
}
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
if (!next) {
|
||||
reset();
|
||||
}
|
||||
|
||||
onOpenChange(next);
|
||||
}
|
||||
|
||||
function toggleScope(scope: TokenScope) {
|
||||
setSelectedScopes((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(scope)) {
|
||||
next.delete(scope);
|
||||
} else {
|
||||
next.add(scope);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleProviderChange(nextProviderId: string) {
|
||||
setSelectedProviderId(nextProviderId);
|
||||
|
||||
if (nextProviderId === "custom") {
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = providers.find(
|
||||
(candidate) => candidate.id === nextProviderId,
|
||||
);
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAgentCommand(provider.command);
|
||||
setAgentArgs(provider.defaultArgs.join(","));
|
||||
}
|
||||
|
||||
const canSubmit =
|
||||
name.trim().length > 0 &&
|
||||
(!mintToken || selectedScopes.size > 0) &&
|
||||
!isDiscoveryPending &&
|
||||
!(mintToken && prereqs !== null && !isMintSupported) &&
|
||||
!(spawnAfterCreate && prereqs !== null && !isSpawnSupported) &&
|
||||
!createMutation.isPending;
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
const input: CreateManagedAgentInput = {
|
||||
name: name.trim(),
|
||||
relayUrl: relayUrl.trim() || undefined,
|
||||
acpCommand: acpCommand.trim() || undefined,
|
||||
agentCommand: agentCommand.trim() || undefined,
|
||||
agentArgs: agentArgs
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0),
|
||||
mcpCommand: mcpCommand.trim() || undefined,
|
||||
turnTimeoutSeconds:
|
||||
Number.parseInt(turnTimeoutSeconds, 10) > 0
|
||||
? Number.parseInt(turnTimeoutSeconds, 10)
|
||||
: undefined,
|
||||
mintToken,
|
||||
tokenName: tokenName.trim() || undefined,
|
||||
tokenScopes: [...selectedScopes],
|
||||
spawnAfterCreate,
|
||||
};
|
||||
const created = await createMutation.mutateAsync(input);
|
||||
handleOpenChange(false);
|
||||
onCreated(created);
|
||||
} catch {
|
||||
// React Query stores the error; keep the dialog open and render it inline.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogContent className="max-w-3xl overflow-hidden p-0">
|
||||
<div className="flex max-h-[85vh] flex-col">
|
||||
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
|
||||
<DialogTitle>Create agent</DialogTitle>
|
||||
<DialogDescription>
|
||||
This creates a local agent identity, optionally mints a relay
|
||||
token, and can spawn `sprout-acp` immediately.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-5">
|
||||
<CreateAgentBasicsFields
|
||||
name={name}
|
||||
onNameChange={setName}
|
||||
onRelayUrlChange={setRelayUrl}
|
||||
relayUrl={relayUrl}
|
||||
/>
|
||||
|
||||
<CreateAgentRuntimeFields
|
||||
acpCommand={acpCommand}
|
||||
agentArgs={agentArgs}
|
||||
agentCommand={agentCommand}
|
||||
mcpCommand={mcpCommand}
|
||||
onAcpCommandChange={setAcpCommand}
|
||||
onAgentArgsChange={setAgentArgs}
|
||||
onAgentCommandChange={setAgentCommand}
|
||||
onMcpCommandChange={setMcpCommand}
|
||||
onProviderChange={handleProviderChange}
|
||||
onTurnTimeoutChange={setTurnTimeoutSeconds}
|
||||
providers={providers}
|
||||
providersLoading={providersQuery.isLoading}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedProviderId={selectedProviderId}
|
||||
turnTimeoutSeconds={turnTimeoutSeconds}
|
||||
/>
|
||||
|
||||
<CreateAgentPrerequisitesCard
|
||||
isLoading={prereqsQuery.isLoading}
|
||||
prereqs={prereqs}
|
||||
prerequisiteCards={prerequisiteCards}
|
||||
/>
|
||||
|
||||
{providersQuery.error instanceof Error ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{providersQuery.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{prereqsQuery.error instanceof Error ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{prereqsQuery.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<CreateAgentOptionToggles
|
||||
isMintSupported={isMintSupported}
|
||||
isSpawnSupported={isSpawnSupported}
|
||||
mintToken={mintToken}
|
||||
mintToggleDisabled={mintToggleDisabled}
|
||||
onToggleMintToken={() => {
|
||||
if (!mintToggleDisabled) {
|
||||
setMintToken((current) => !current);
|
||||
}
|
||||
}}
|
||||
onToggleSpawnAfterCreate={() => {
|
||||
if (!spawnToggleDisabled) {
|
||||
setSpawnAfterCreate((current) => !current);
|
||||
}
|
||||
}}
|
||||
prereqs={prereqs}
|
||||
spawnAfterCreate={spawnAfterCreate}
|
||||
spawnToggleDisabled={spawnToggleDisabled}
|
||||
/>
|
||||
|
||||
{mintToken ? (
|
||||
<CreateAgentTokenSection
|
||||
onScopeToggle={toggleScope}
|
||||
onTokenNameChange={setTokenName}
|
||||
selectedScopes={selectedScopes}
|
||||
tokenName={tokenName}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{createMutation.error instanceof Error ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{createMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-border/60 px-6 py-4">
|
||||
<Button
|
||||
onClick={() => handleOpenChange(false)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="create-agent-submit"
|
||||
disabled={!canSubmit}
|
||||
onClick={() => void handleSubmit()}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
{createMutation.isPending ? "Creating..." : "Create agent"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
392
desktop/src/features/agents/ui/CreateAgentDialogSections.tsx
Normal file
392
desktop/src/features/agents/ui/CreateAgentDialogSections.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import type {
|
||||
AcpProvider,
|
||||
CommandAvailability,
|
||||
ManagedAgentPrereqs,
|
||||
TokenScope,
|
||||
} from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { AGENT_SCOPE_OPTIONS, describeResolvedCommand } from "./agentUi";
|
||||
|
||||
export type PrerequisiteCard = {
|
||||
id: string;
|
||||
label: string;
|
||||
info: CommandAvailability | null;
|
||||
command: string;
|
||||
};
|
||||
|
||||
export function CreateAgentBasicsFields({
|
||||
name,
|
||||
relayUrl,
|
||||
onNameChange,
|
||||
onRelayUrlChange,
|
||||
}: {
|
||||
name: string;
|
||||
relayUrl: string;
|
||||
onNameChange: (value: string) => void;
|
||||
onRelayUrlChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="agent-name">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
data-testid="agent-name-input"
|
||||
id="agent-name"
|
||||
onChange={(event) => onNameChange(event.target.value)}
|
||||
placeholder="alice"
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="agent-relay-url">
|
||||
Relay URL
|
||||
</label>
|
||||
<Input
|
||||
id="agent-relay-url"
|
||||
onChange={(event) => onRelayUrlChange(event.target.value)}
|
||||
placeholder="Leave blank to use the desktop relay"
|
||||
value={relayUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateAgentRuntimeFields({
|
||||
acpCommand,
|
||||
agentArgs,
|
||||
agentCommand,
|
||||
mcpCommand,
|
||||
providers,
|
||||
providersLoading,
|
||||
selectedProvider,
|
||||
selectedProviderId,
|
||||
turnTimeoutSeconds,
|
||||
onAcpCommandChange,
|
||||
onAgentArgsChange,
|
||||
onAgentCommandChange,
|
||||
onMcpCommandChange,
|
||||
onProviderChange,
|
||||
onTurnTimeoutChange,
|
||||
}: {
|
||||
acpCommand: string;
|
||||
agentArgs: string;
|
||||
agentCommand: string;
|
||||
mcpCommand: string;
|
||||
providers: AcpProvider[];
|
||||
providersLoading: boolean;
|
||||
selectedProvider: AcpProvider | null;
|
||||
selectedProviderId: string;
|
||||
turnTimeoutSeconds: string;
|
||||
onAcpCommandChange: (value: string) => void;
|
||||
onAgentArgsChange: (value: string) => void;
|
||||
onAgentCommandChange: (value: string) => void;
|
||||
onMcpCommandChange: (value: string) => void;
|
||||
onProviderChange: (value: string) => void;
|
||||
onTurnTimeoutChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="agent-acp-command">
|
||||
ACP command
|
||||
</label>
|
||||
<Input
|
||||
id="agent-acp-command"
|
||||
onChange={(event) => onAcpCommandChange(event.target.value)}
|
||||
value={acpCommand}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="agent-provider">
|
||||
Agent runtime
|
||||
</label>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm"
|
||||
id="agent-provider"
|
||||
onChange={(event) => onProviderChange(event.target.value)}
|
||||
value={selectedProviderId}
|
||||
>
|
||||
{providers.map((provider) => (
|
||||
<option key={provider.id} value={provider.id}>
|
||||
{provider.label}
|
||||
</option>
|
||||
))}
|
||||
<option value="custom">Custom command</option>
|
||||
</select>
|
||||
{selectedProvider ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Detected via{" "}
|
||||
<span className="font-medium">
|
||||
{describeResolvedCommand(
|
||||
selectedProvider.command,
|
||||
selectedProvider.binaryPath,
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
) : providersLoading ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Looking for installed ACP runtimes...
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No known ACP runtime was detected. You can still enter a custom
|
||||
command below.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedProviderId === "custom" ? (
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="agent-runtime-command"
|
||||
>
|
||||
Custom agent runtime command
|
||||
</label>
|
||||
<Input
|
||||
id="agent-runtime-command"
|
||||
onChange={(event) => onAgentCommandChange(event.target.value)}
|
||||
value={agentCommand}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-[1.5fr,1.2fr,0.8fr]">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="agent-runtime-args">
|
||||
Agent runtime args
|
||||
</label>
|
||||
<Input
|
||||
id="agent-runtime-args"
|
||||
onChange={(event) => onAgentArgsChange(event.target.value)}
|
||||
placeholder="Comma-separated"
|
||||
value={agentArgs}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
`sprout-acp` splits args on commas, matching the testing guide.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="agent-mcp-command">
|
||||
MCP command
|
||||
</label>
|
||||
<Input
|
||||
id="agent-mcp-command"
|
||||
onChange={(event) => onMcpCommandChange(event.target.value)}
|
||||
value={mcpCommand}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="agent-timeout">
|
||||
Turn timeout
|
||||
</label>
|
||||
<Input
|
||||
id="agent-timeout"
|
||||
onChange={(event) => onTurnTimeoutChange(event.target.value)}
|
||||
value={turnTimeoutSeconds}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateAgentPrerequisitesCard({
|
||||
isLoading,
|
||||
prereqs,
|
||||
prerequisiteCards,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
prereqs: ManagedAgentPrereqs | null;
|
||||
prerequisiteCards: PrerequisiteCard[];
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold tracking-tight">
|
||||
Local Sprout binaries
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The desktop app uses these commands to mint tokens and spawn
|
||||
harnesses.
|
||||
</p>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<span className="text-xs text-muted-foreground">Checking...</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||||
{prerequisiteCards.map((card) => (
|
||||
<div
|
||||
className="rounded-2xl border border-border/70 bg-background/80 px-3 py-3"
|
||||
key={card.id}
|
||||
>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{card.label}
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-medium">{card.command}</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 text-xs",
|
||||
card.info?.available
|
||||
? "text-muted-foreground"
|
||||
: "text-destructive",
|
||||
)}
|
||||
>
|
||||
{card.info?.resolvedPath
|
||||
? `Available via ${describeResolvedCommand(card.command, card.info.resolvedPath)}`
|
||||
: isLoading
|
||||
? "Looking for a matching binary..."
|
||||
: "Not currently available."}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{prereqs &&
|
||||
(!prereqs.admin.available ||
|
||||
!prereqs.acp.available ||
|
||||
!prereqs.mcp.available) ? (
|
||||
<p className="mt-4 rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
|
||||
Build the workspace binaries with `cargo build --release --workspace`
|
||||
or point the command fields at installed binaries before enabling
|
||||
token minting or spawn.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateAgentOptionToggles({
|
||||
isMintSupported,
|
||||
isSpawnSupported,
|
||||
mintToken,
|
||||
mintToggleDisabled,
|
||||
prereqs,
|
||||
spawnAfterCreate,
|
||||
spawnToggleDisabled,
|
||||
onToggleMintToken,
|
||||
onToggleSpawnAfterCreate,
|
||||
}: {
|
||||
isMintSupported: boolean;
|
||||
isSpawnSupported: boolean;
|
||||
mintToken: boolean;
|
||||
mintToggleDisabled: boolean;
|
||||
prereqs: ManagedAgentPrereqs | null;
|
||||
spawnAfterCreate: boolean;
|
||||
spawnToggleDisabled: boolean;
|
||||
onToggleMintToken: () => void;
|
||||
onToggleSpawnAfterCreate: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<button
|
||||
aria-pressed={mintToken}
|
||||
className={cn(
|
||||
"rounded-2xl border px-4 py-3 text-left transition-colors",
|
||||
mintToggleDisabled && "cursor-not-allowed opacity-60",
|
||||
mintToken
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border/70 bg-background/70",
|
||||
)}
|
||||
disabled={mintToggleDisabled}
|
||||
onClick={onToggleMintToken}
|
||||
type="button"
|
||||
>
|
||||
<p className="text-sm font-semibold tracking-tight">Mint token</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{prereqs !== null && !isMintSupported
|
||||
? `Unavailable until ${prereqs.admin.command} is installed.`
|
||||
: "Use `sprout-admin` to create a bearer token for this agent."}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
aria-pressed={spawnAfterCreate}
|
||||
className={cn(
|
||||
"rounded-2xl border px-4 py-3 text-left transition-colors",
|
||||
spawnToggleDisabled && "cursor-not-allowed opacity-60",
|
||||
spawnAfterCreate
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border/70 bg-background/70",
|
||||
)}
|
||||
disabled={spawnToggleDisabled}
|
||||
onClick={onToggleSpawnAfterCreate}
|
||||
type="button"
|
||||
>
|
||||
<p className="text-sm font-semibold tracking-tight">
|
||||
Spawn after create
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{prereqs !== null && !isSpawnSupported
|
||||
? "Requires both the ACP harness and MCP server binaries."
|
||||
: "Start the local ACP harness immediately after the profile is saved."}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateAgentTokenSection({
|
||||
selectedScopes,
|
||||
tokenName,
|
||||
onScopeToggle,
|
||||
onTokenNameChange,
|
||||
}: {
|
||||
selectedScopes: Set<TokenScope>;
|
||||
tokenName: string;
|
||||
onScopeToggle: (scope: TokenScope) => void;
|
||||
onTokenNameChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl border border-border/70 bg-muted/20 p-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="agent-token-name">
|
||||
Token name
|
||||
</label>
|
||||
<Input
|
||||
id="agent-token-name"
|
||||
onChange={(event) => onTokenNameChange(event.target.value)}
|
||||
placeholder="Leave blank to reuse the agent name"
|
||||
value={tokenName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Token scopes</p>
|
||||
<div className="grid gap-2 md:grid-cols-3">
|
||||
{AGENT_SCOPE_OPTIONS.map((scope) => {
|
||||
const selected = selectedScopes.has(scope.value);
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"rounded-xl border px-3 py-2 text-left text-sm transition-colors",
|
||||
selected
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border/70 bg-background/80 hover:bg-accent",
|
||||
)}
|
||||
key={scope.value}
|
||||
onClick={() => onScopeToggle(scope.value)}
|
||||
type="button"
|
||||
>
|
||||
{scope.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
desktop/src/features/agents/ui/ManagedAgentCard.tsx
Normal file
163
desktop/src/features/agents/ui/ManagedAgentCard.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
KeyRound,
|
||||
Play,
|
||||
Square,
|
||||
TerminalSquare,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { formatTimestamp, truncatePubkey } from "./agentUi";
|
||||
|
||||
export function ManagedAgentCard({
|
||||
agent,
|
||||
onAddToChannel,
|
||||
isSelected,
|
||||
onDelete,
|
||||
onMintToken,
|
||||
onSelect,
|
||||
onStart,
|
||||
onStop,
|
||||
}: {
|
||||
agent: ManagedAgent;
|
||||
onAddToChannel: (agent: ManagedAgent) => void;
|
||||
isSelected: boolean;
|
||||
onDelete: (pubkey: string) => void;
|
||||
onMintToken: (pubkey: string, name: string) => void;
|
||||
onSelect: (pubkey: string) => void;
|
||||
onStart: (pubkey: string) => void;
|
||||
onStop: (pubkey: string) => void;
|
||||
}) {
|
||||
const statusBadgeClass =
|
||||
agent.status === "running"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground";
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"rounded-3xl border p-4 shadow-sm transition-colors",
|
||||
isSelected
|
||||
? "border-primary/60 bg-primary/5"
|
||||
: "border-border/70 bg-card/80 hover:bg-card",
|
||||
)}
|
||||
data-testid={`managed-agent-${agent.pubkey}`}
|
||||
>
|
||||
<button
|
||||
className="w-full text-left"
|
||||
onClick={() => onSelect(agent.pubkey)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold tracking-tight">
|
||||
{agent.name}
|
||||
</h3>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em]",
|
||||
statusBadgeClass,
|
||||
)}
|
||||
>
|
||||
{agent.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{truncatePubkey(agent.pubkey)}
|
||||
{agent.pid ? ` · pid ${agent.pid}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
<TerminalSquare className="h-4 w-4" />
|
||||
<span>{agent.agentCommand}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-border/60 bg-background/70 px-3 py-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Relay
|
||||
</p>
|
||||
<p className="mt-1 truncate text-sm">{agent.relayUrl}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-background/70 px-3 py-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Auth
|
||||
</p>
|
||||
<p className="mt-1 text-sm">
|
||||
{agent.hasApiToken ? "Bearer token saved" : "Key-only dev mode"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<CopyButton label="Copy pubkey" value={agent.pubkey} />
|
||||
|
||||
{agent.status === "running" ? (
|
||||
<Button
|
||||
onClick={() => onStop(agent.pubkey)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
Stop
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => onStart(agent.pubkey)} size="sm" type="button">
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
Spawn
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => onAddToChannel(agent)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5" />
|
||||
Add to channel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => onMintToken(agent.pubkey, agent.name)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<KeyRound className="h-3.5 w-3.5" />
|
||||
Mint token
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => onDelete(agent.pubkey)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-2 text-xs text-muted-foreground sm:grid-cols-2">
|
||||
<p>Started {formatTimestamp(agent.lastStartedAt)}</p>
|
||||
<p>Stopped {formatTimestamp(agent.lastStoppedAt)}</p>
|
||||
</div>
|
||||
|
||||
{agent.lastError ? (
|
||||
<p className="mt-3 rounded-2xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{agent.lastError}
|
||||
</p>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
71
desktop/src/features/agents/ui/ManagedAgentLogPanel.tsx
Normal file
71
desktop/src/features/agents/ui/ManagedAgentLogPanel.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { CircleAlert } from "lucide-react";
|
||||
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { describeLogFile } from "./agentUi";
|
||||
|
||||
export function ManagedAgentLogPanel({
|
||||
error,
|
||||
isLoading,
|
||||
logContent,
|
||||
selectedAgent,
|
||||
}: {
|
||||
error: Error | null;
|
||||
isLoading: boolean;
|
||||
logContent: string | null;
|
||||
selectedAgent: ManagedAgent | null;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-[28px] border border-border/70 bg-card/90 p-5 shadow-sm">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold tracking-tight">Harness log</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedAgent
|
||||
? `${selectedAgent.name} · ${describeLogFile(selectedAgent.logPath)}`
|
||||
: "Select a local agent to inspect recent output."}
|
||||
</p>
|
||||
</div>
|
||||
{selectedAgent ? (
|
||||
<CopyButton label="Copy log" value={logContent ?? ""} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!selectedAgent ? (
|
||||
<div className="mt-4 rounded-3xl border border-dashed border-border/80 bg-background/70 px-6 py-10 text-center">
|
||||
<p className="text-sm font-semibold tracking-tight">
|
||||
No local agent selected
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Pick a managed agent to view the latest ACP log output.
|
||||
</p>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="mt-4 rounded-3xl border border-border/70 bg-background/80 p-4">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="mt-3 h-4 w-full" />
|
||||
<Skeleton className="mt-2 h-4 w-full" />
|
||||
<Skeleton className="mt-2 h-4 w-3/4" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 overflow-hidden rounded-3xl border border-border/70 bg-[#17171d] text-[12px] text-zinc-100">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-2 text-[11px] uppercase tracking-[0.18em] text-zinc-400">
|
||||
<span>{selectedAgent.name}</span>
|
||||
<span>{selectedAgent.status}</span>
|
||||
</div>
|
||||
<pre className="max-h-[22rem] overflow-auto whitespace-pre-wrap px-4 py-4">
|
||||
{logContent?.trim() ? logContent : "No log output yet."}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<p className="mt-4 inline-flex items-center gap-2 rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
<CircleAlert className="h-4 w-4" />
|
||||
{error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
144
desktop/src/features/agents/ui/ManagedAgentsSection.tsx
Normal file
144
desktop/src/features/agents/ui/ManagedAgentsSection.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { Plus, RefreshCcw } from "lucide-react";
|
||||
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
import { ManagedAgentCard } from "./ManagedAgentCard";
|
||||
|
||||
export function ManagedAgentsSection({
|
||||
actionErrorMessage,
|
||||
actionNoticeMessage,
|
||||
agents,
|
||||
error,
|
||||
isActionPending,
|
||||
isLoading,
|
||||
selectedAgentPubkey,
|
||||
onAddToChannel,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onMintToken,
|
||||
onRefresh,
|
||||
onSelect,
|
||||
onStart,
|
||||
onStop,
|
||||
}: {
|
||||
actionErrorMessage: string | null;
|
||||
actionNoticeMessage: string | null;
|
||||
agents: ManagedAgent[];
|
||||
error: Error | null;
|
||||
isActionPending: boolean;
|
||||
isLoading: boolean;
|
||||
selectedAgentPubkey: string | null;
|
||||
onAddToChannel: (agent: ManagedAgent) => void;
|
||||
onCreate: () => void;
|
||||
onDelete: (pubkey: string) => void;
|
||||
onMintToken: (pubkey: string, name: string) => void;
|
||||
onRefresh: () => void;
|
||||
onSelect: (pubkey: string) => void;
|
||||
onStart: (pubkey: string) => void;
|
||||
onStop: (pubkey: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold tracking-tight">
|
||||
Managed locally
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Saved agent profiles and local ACP process state.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={onCreate} type="button">
|
||||
<Plus className="h-4 w-4" />
|
||||
Create agent
|
||||
</Button>
|
||||
<Button onClick={onRefresh} type="button" variant="outline">
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid gap-3">
|
||||
{["first", "second"].map((key) => (
|
||||
<div
|
||||
className="rounded-3xl border border-border/70 bg-card/80 p-4"
|
||||
key={key}
|
||||
>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="mt-3 h-4 w-48" />
|
||||
<Skeleton className="mt-4 h-16 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && agents.length === 0 ? (
|
||||
<div className="rounded-3xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center">
|
||||
<p className="text-sm font-semibold tracking-tight">
|
||||
No local agents yet
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Create one to generate a keypair, mint a token, and launch the ACP
|
||||
harness from the desktop app.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{agents.map((agent) => (
|
||||
<ManagedAgentCard
|
||||
agent={agent}
|
||||
isSelected={selectedAgentPubkey === agent.pubkey}
|
||||
key={agent.pubkey}
|
||||
onAddToChannel={(managedAgent) => {
|
||||
if (!isActionPending) {
|
||||
onAddToChannel(managedAgent);
|
||||
}
|
||||
}}
|
||||
onDelete={(pubkey) => {
|
||||
if (!isActionPending) {
|
||||
onDelete(pubkey);
|
||||
}
|
||||
}}
|
||||
onMintToken={(pubkey, name) => {
|
||||
if (!isActionPending) {
|
||||
onMintToken(pubkey, name);
|
||||
}
|
||||
}}
|
||||
onSelect={onSelect}
|
||||
onStart={(pubkey) => {
|
||||
if (!isActionPending) {
|
||||
onStart(pubkey);
|
||||
}
|
||||
}}
|
||||
onStop={(pubkey) => {
|
||||
if (!isActionPending) {
|
||||
onStop(pubkey);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{actionNoticeMessage ? (
|
||||
<p className="rounded-2xl border border-primary/20 bg-primary/10 px-4 py-3 text-sm text-primary">
|
||||
{actionNoticeMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{actionErrorMessage ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{actionErrorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
61
desktop/src/features/agents/ui/RelayAgentCard.tsx
Normal file
61
desktop/src/features/agents/ui/RelayAgentCard.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { RelayAgent } from "@/shared/api/types";
|
||||
import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
|
||||
import { truncatePubkey } from "./agentUi";
|
||||
|
||||
export function RelayAgentCard({
|
||||
agent,
|
||||
isManagedLocally,
|
||||
}: {
|
||||
agent: RelayAgent;
|
||||
isManagedLocally: boolean;
|
||||
}) {
|
||||
const visibleCapabilities = agent.capabilities.slice(0, 4);
|
||||
const hiddenCapabilityCount =
|
||||
agent.capabilities.length - visibleCapabilities.length;
|
||||
|
||||
return (
|
||||
<article className="rounded-3xl border border-border/70 bg-card/80 p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold tracking-tight">
|
||||
{agent.name}
|
||||
</h3>
|
||||
{isManagedLocally ? (
|
||||
<span className="rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-primary-foreground">
|
||||
Local
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{truncatePubkey(agent.pubkey)}
|
||||
{agent.agentType ? ` · ${agent.agentType}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<PresenceBadge status={agent.status} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{visibleCapabilities.map((capability) => (
|
||||
<span
|
||||
className="rounded-full border border-border/70 bg-background/70 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground"
|
||||
key={capability}
|
||||
>
|
||||
{capability}
|
||||
</span>
|
||||
))}
|
||||
{hiddenCapabilityCount > 0 ? (
|
||||
<span className="rounded-full border border-border/70 bg-background/70 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
+{hiddenCapabilityCount}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-xs text-muted-foreground">
|
||||
{agent.channels.length > 0
|
||||
? `Visible in ${agent.channels.join(", ")}`
|
||||
: "No visible channel memberships yet."}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
69
desktop/src/features/agents/ui/RelayDirectorySection.tsx
Normal file
69
desktop/src/features/agents/ui/RelayDirectorySection.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { RelayAgent } from "@/shared/api/types";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
import { RelayAgentCard } from "./RelayAgentCard";
|
||||
|
||||
export function RelayDirectorySection({
|
||||
error,
|
||||
isLoading,
|
||||
managedPubkeys,
|
||||
relayAgents,
|
||||
}: {
|
||||
error: Error | null;
|
||||
isLoading: boolean;
|
||||
managedPubkeys: Set<string>;
|
||||
relayAgents: RelayAgent[];
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold tracking-tight">
|
||||
Relay directory
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bot and agent identities visible to the current desktop user.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid gap-3">
|
||||
{["directory-1", "directory-2"].map((key) => (
|
||||
<div
|
||||
className="rounded-3xl border border-border/70 bg-card/80 p-4"
|
||||
key={key}
|
||||
>
|
||||
<Skeleton className="h-5 w-36" />
|
||||
<Skeleton className="mt-3 h-4 w-44" />
|
||||
<Skeleton className="mt-4 h-12 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && relayAgents.length === 0 ? (
|
||||
<div className="rounded-3xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center">
|
||||
<p className="text-sm font-semibold tracking-tight">
|
||||
No relay-visible agents yet
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Start one of your local harnesses or join an existing bot to a
|
||||
channel and it will appear here.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{relayAgents.map((agent) => (
|
||||
<RelayAgentCard
|
||||
agent={agent}
|
||||
isManagedLocally={managedPubkeys.has(agent.pubkey)}
|
||||
key={agent.pubkey}
|
||||
/>
|
||||
))}
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
102
desktop/src/features/agents/ui/SecretRevealDialog.tsx
Normal file
102
desktop/src/features/agents/ui/SecretRevealDialog.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { CreateManagedAgentResponse } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
|
||||
export function SecretRevealDialog({
|
||||
created,
|
||||
onOpenChange,
|
||||
}: {
|
||||
created: CreateManagedAgentResponse | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={created !== null}>
|
||||
<DialogContent className="max-w-2xl overflow-hidden p-0">
|
||||
<div className="flex max-h-[85vh] flex-col">
|
||||
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
|
||||
<DialogTitle>Agent created</DialogTitle>
|
||||
<DialogDescription>
|
||||
Save the private key and token now. The app can keep running the
|
||||
harness locally, but these secrets are only revealed here.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
|
||||
{created ? (
|
||||
<>
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold tracking-tight">
|
||||
Private key (nsec)
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This is the agent identity used by `sprout-acp`.
|
||||
</p>
|
||||
</div>
|
||||
<CopyButton
|
||||
label="Copy key"
|
||||
value={created.privateKeyNsec}
|
||||
/>
|
||||
</div>
|
||||
<code className="mt-3 block break-all rounded-xl border border-border/70 bg-background/80 px-3 py-2 text-xs">
|
||||
{created.privateKeyNsec}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{created.apiToken ? (
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold tracking-tight">
|
||||
API token
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Optional for local dev, required when the relay
|
||||
enforces bearer auth.
|
||||
</p>
|
||||
</div>
|
||||
<CopyButton label="Copy token" value={created.apiToken} />
|
||||
</div>
|
||||
<code className="mt-3 block break-all rounded-xl border border-border/70 bg-background/80 px-3 py-2 text-xs">
|
||||
{created.apiToken}
|
||||
</code>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{created.spawnError ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{created.spawnError}
|
||||
</p>
|
||||
) : (
|
||||
<p className="rounded-2xl border border-primary/20 bg-primary/10 px-4 py-3 text-sm text-primary">
|
||||
{created.agent.name} is ready
|
||||
{created.agent.status === "running" ? " and running." : "."}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end border-t border-border/60 px-6 py-4">
|
||||
<Button
|
||||
onClick={() => onOpenChange(false)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
65
desktop/src/features/agents/ui/TokenRevealDialog.tsx
Normal file
65
desktop/src/features/agents/ui/TokenRevealDialog.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
|
||||
export function TokenRevealDialog({
|
||||
name,
|
||||
token,
|
||||
onOpenChange,
|
||||
}: {
|
||||
name: string | null;
|
||||
token: string | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={token !== null}>
|
||||
<DialogContent className="max-w-xl overflow-hidden p-0">
|
||||
<div className="flex max-h-[85vh] flex-col">
|
||||
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
|
||||
<DialogTitle>Agent token minted</DialogTitle>
|
||||
<DialogDescription>
|
||||
Save this token now. Restart the harness if you want the running
|
||||
agent to pick it up immediately.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold tracking-tight">{name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Token shown once only.
|
||||
</p>
|
||||
</div>
|
||||
{token ? <CopyButton label="Copy token" value={token} /> : null}
|
||||
</div>
|
||||
{token ? (
|
||||
<code className="mt-3 block break-all rounded-xl border border-border/70 bg-background/80 px-3 py-2 text-xs">
|
||||
{token}
|
||||
</code>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end border-t border-border/60 px-6 py-4">
|
||||
<Button
|
||||
onClick={() => onOpenChange(false)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
72
desktop/src/features/agents/ui/agentUi.ts
Normal file
72
desktop/src/features/agents/ui/agentUi.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { TokenScope } from "@/shared/api/types";
|
||||
|
||||
export const AGENT_SCOPE_OPTIONS: Array<{ value: TokenScope; label: string }> =
|
||||
[
|
||||
{ value: "messages:read", label: "Messages read" },
|
||||
{ value: "messages:write", label: "Messages write" },
|
||||
{ value: "channels:read", label: "Channels read" },
|
||||
{ value: "channels:write", label: "Channels write" },
|
||||
{ value: "users:read", label: "Users read" },
|
||||
{ value: "files:read", label: "Files read" },
|
||||
{ value: "files:write", label: "Files write" },
|
||||
];
|
||||
|
||||
export function formatTimestamp(value: string | null) {
|
||||
if (!value) {
|
||||
return "Never";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function truncatePubkey(pubkey: string) {
|
||||
return `${pubkey.slice(0, 8)}…${pubkey.slice(-6)}`;
|
||||
}
|
||||
|
||||
function commandLooksLikePath(command: string) {
|
||||
const trimmed = command.trim();
|
||||
return (
|
||||
trimmed.startsWith(".") ||
|
||||
trimmed.startsWith("~") ||
|
||||
trimmed.includes("/") ||
|
||||
trimmed.includes("\\")
|
||||
);
|
||||
}
|
||||
|
||||
export function describeResolvedCommand(command: string, resolvedPath: string) {
|
||||
const normalized = resolvedPath.replace(/\\/g, "/");
|
||||
|
||||
if (normalized.includes("/target/release/")) {
|
||||
return "workspace release build";
|
||||
}
|
||||
if (normalized.includes("/target/debug/")) {
|
||||
return "workspace debug build";
|
||||
}
|
||||
|
||||
if (commandLooksLikePath(command)) {
|
||||
return "custom command";
|
||||
}
|
||||
|
||||
return "installed on PATH";
|
||||
}
|
||||
|
||||
export function describeLogFile(path: string) {
|
||||
const normalized = path.replace(/\\/g, "/");
|
||||
const basename = normalized.split("/").pop() ?? path;
|
||||
|
||||
if (!basename.endsWith(".log")) {
|
||||
return "local harness log";
|
||||
}
|
||||
|
||||
const stem = basename.slice(0, -4);
|
||||
if (stem.length <= 18) {
|
||||
return basename;
|
||||
}
|
||||
|
||||
return `${stem.slice(0, 8)}…${stem.slice(-6)}.log`;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { updateChannelLastMessageAt } from "@/features/channels/hooks";
|
||||
import { getChannelIdFromTags } from "@/features/messages/lib/threading";
|
||||
import { mergeMessages } from "@/features/messages/hooks";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import type { Channel, RelayEvent } from "@/shared/api/types";
|
||||
@@ -165,72 +166,74 @@ export function useUnreadChannels(
|
||||
markChannelRead(activeChannelId, activeChannelLastMessageAt);
|
||||
}, [activeChannelId, activeChannelLastMessageAt, markChannelRead]);
|
||||
|
||||
const inactiveLiveChannelKey = React.useMemo(
|
||||
const liveChannelIds = React.useMemo(
|
||||
() =>
|
||||
channels
|
||||
.filter(
|
||||
(channel) =>
|
||||
channel.channelType !== "forum" && channel.id !== activeChannelId,
|
||||
)
|
||||
.map((channel) => channel.id)
|
||||
.join("|"),
|
||||
[activeChannelId, channels],
|
||||
new Set(
|
||||
channels
|
||||
.filter((channel) => channel.channelType !== "forum")
|
||||
.map((channel) => channel.id),
|
||||
),
|
||||
[channels],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const inactiveLiveChannelIds = inactiveLiveChannelKey
|
||||
? inactiveLiveChannelKey.split("|")
|
||||
: [];
|
||||
const handleIncomingMessage = React.useEffectEvent((event: RelayEvent) => {
|
||||
const channelId = getChannelIdFromTags(event.tags);
|
||||
if (
|
||||
!channelId ||
|
||||
channelId === activeChannelId ||
|
||||
!liveChannelIds.has(channelId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (inactiveLiveChannelIds.length === 0) {
|
||||
const messageTimestamp = getMessageTimestamp(event);
|
||||
|
||||
updateChannelLastMessageAt(queryClient, channelId, messageTimestamp);
|
||||
queryClient.setQueryData<RelayEvent[]>(
|
||||
["channel-messages", channelId],
|
||||
(current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return mergeMessages(current, event);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (liveChannelIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isDisposed = false;
|
||||
const cleanupCallbacks: Array<() => Promise<void>> = [];
|
||||
let cleanup: (() => Promise<void>) | undefined;
|
||||
|
||||
function handleIncomingMessage(channelId: string, event: RelayEvent) {
|
||||
const messageTimestamp = getMessageTimestamp(event);
|
||||
relayClient
|
||||
.subscribeToAllStreamMessages((event) => {
|
||||
if (!isDisposed) {
|
||||
handleIncomingMessage(event);
|
||||
}
|
||||
})
|
||||
.then((dispose) => {
|
||||
if (isDisposed) {
|
||||
void dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
updateChannelLastMessageAt(queryClient, channelId, messageTimestamp);
|
||||
queryClient.setQueryData<RelayEvent[]>(
|
||||
["channel-messages", channelId],
|
||||
(current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return mergeMessages(current, event);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void Promise.all(
|
||||
inactiveLiveChannelIds.map((channelId) =>
|
||||
relayClient
|
||||
.subscribeToChannel(channelId, (event) => {
|
||||
handleIncomingMessage(channelId, event);
|
||||
})
|
||||
.then((dispose) => {
|
||||
if (isDisposed) {
|
||||
void dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupCallbacks.push(dispose);
|
||||
}),
|
||||
),
|
||||
).catch((error) => {
|
||||
console.error("Failed to subscribe to unread channel updates", error);
|
||||
});
|
||||
cleanup = dispose;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to subscribe to unread channel updates", error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
for (const cleanup of cleanupCallbacks) {
|
||||
if (cleanup) {
|
||||
void cleanup();
|
||||
}
|
||||
};
|
||||
}, [inactiveLiveChannelKey, queryClient]);
|
||||
}, [liveChannelIds]);
|
||||
|
||||
const unreadChannelIds = React.useMemo(
|
||||
() =>
|
||||
|
||||
@@ -93,16 +93,27 @@ function createOptimisticMessage(
|
||||
}
|
||||
|
||||
export function useChannelMessagesQuery(channel: Channel | null) {
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = ["channel-messages", channel?.id ?? "none"] as const;
|
||||
|
||||
return useQuery({
|
||||
enabled: channel !== null && channel.channelType !== "forum",
|
||||
queryKey: ["channel-messages", channel?.id ?? "none"],
|
||||
placeholderData: () => queryClient.getQueryData<RelayEvent[]>(queryKey),
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
if (!channel) {
|
||||
throw new Error("No channel selected.");
|
||||
}
|
||||
|
||||
const history = await relayClient.fetchChannelHistory(channel.id, 200);
|
||||
return dedupeMessagesById(history);
|
||||
const currentMessages =
|
||||
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
|
||||
const mergedHistory = dedupeMessagesById([
|
||||
...currentMessages,
|
||||
...history,
|
||||
]).sort((left, right) => left.created_at - right.created_at);
|
||||
|
||||
return mergedHistory;
|
||||
},
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
gcTime: 30 * 60 * 1_000,
|
||||
@@ -113,6 +124,24 @@ export function useChannelSubscription(channel: Channel | null) {
|
||||
const queryClient = useQueryClient();
|
||||
const channelId = channel?.id ?? null;
|
||||
const channelType = channel?.channelType ?? null;
|
||||
const syncLatestHistory = useEffectEvent(async () => {
|
||||
if (!channelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const history = await relayClient.fetchChannelHistory(channelId, 200);
|
||||
|
||||
queryClient.setQueryData<RelayEvent[]>(
|
||||
["channel-messages", channelId],
|
||||
(current = []) => {
|
||||
const mergedHistory = dedupeMessagesById([...current, ...history]).sort(
|
||||
(left, right) => left.created_at - right.created_at,
|
||||
);
|
||||
|
||||
return mergedHistory;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const appendMessage = useEffectEvent((event: RelayEvent) => {
|
||||
if (!channelId) {
|
||||
@@ -151,6 +180,16 @@ export function useChannelSubscription(channel: Channel | null) {
|
||||
}
|
||||
|
||||
cleanup = dispose;
|
||||
|
||||
void syncLatestHistory().catch((error) => {
|
||||
if (!isDisposed) {
|
||||
console.error(
|
||||
"Failed to refresh channel history after subscribing",
|
||||
channelId,
|
||||
error,
|
||||
);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to subscribe to channel", channelId, error);
|
||||
|
||||
174
desktop/src/features/messages/ui/MessageActionBar.tsx
Normal file
174
desktop/src/features/messages/ui/MessageActionBar.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { CornerUpLeft, LoaderCircle, SmilePlus } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import type {
|
||||
TimelineMessage,
|
||||
TimelineReaction,
|
||||
} from "@/features/messages/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { getReactionOptions } from "./messageTimelineUtils";
|
||||
|
||||
export function MessageActionBar({
|
||||
activeReplyTargetId = null,
|
||||
message,
|
||||
onReactionSelect,
|
||||
onReply,
|
||||
reactionErrorMessage = null,
|
||||
reactions,
|
||||
reactionPending = false,
|
||||
}: {
|
||||
activeReplyTargetId?: string | null;
|
||||
message: TimelineMessage;
|
||||
onReactionSelect?: (emoji: string) => Promise<void>;
|
||||
onReply?: (message: TimelineMessage) => void;
|
||||
reactionErrorMessage?: string | null;
|
||||
reactions: TimelineReaction[];
|
||||
reactionPending?: boolean;
|
||||
}) {
|
||||
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
|
||||
const hasReplyAction = Boolean(onReply);
|
||||
const hasReactionAction = Boolean(onReactionSelect);
|
||||
|
||||
if (!hasReplyAction && !hasReactionAction) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isReplyingToMessage = activeReplyTargetId === message.id;
|
||||
const selectedReactionCount = reactions.filter(
|
||||
(reaction) => reaction.reactedByCurrentUser,
|
||||
).length;
|
||||
const reactionOptions = getReactionOptions(reactions);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-20 overflow-hidden rounded-full border border-border/70 bg-background/95 shadow-sm backdrop-blur supports-[backdrop-filter]:bg-background/85 transition-all duration-150 ease-out",
|
||||
"translate-y-0 opacity-100 sm:max-w-0 sm:translate-y-1 sm:opacity-0",
|
||||
"sm:group-hover/message:max-w-20 sm:group-hover/message:translate-y-0 sm:group-hover/message:opacity-100",
|
||||
"sm:group-focus-within/message:max-w-20 sm:group-focus-within/message:translate-y-0 sm:group-focus-within/message:opacity-100",
|
||||
isReplyingToMessage || isReactionPickerOpen
|
||||
? "sm:max-w-20 sm:translate-y-0 sm:opacity-100"
|
||||
: "",
|
||||
)}
|
||||
data-testid={`message-action-bar-${message.id}`}
|
||||
>
|
||||
<div className="flex items-center gap-1 p-1">
|
||||
{hasReactionAction ? (
|
||||
<Popover
|
||||
onOpenChange={setIsReactionPickerOpen}
|
||||
open={isReactionPickerOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
aria-label="Open reactions"
|
||||
className="h-6 w-6 rounded-full p-0"
|
||||
data-testid={`react-message-${message.id}`}
|
||||
disabled={reactionPending}
|
||||
size="sm"
|
||||
title="React"
|
||||
type="button"
|
||||
variant={
|
||||
isReactionPickerOpen || selectedReactionCount > 0
|
||||
? "secondary"
|
||||
: "ghost"
|
||||
}
|
||||
>
|
||||
{reactionPending ? (
|
||||
<LoaderCircle className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<SmilePlus className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-56 rounded-2xl p-3"
|
||||
side="top"
|
||||
sideOffset={10}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
React
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs",
|
||||
reactionErrorMessage
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{reactionErrorMessage ??
|
||||
"Click any emoji. Click it again to remove your own reaction."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{reactionOptions.map((emoji) => {
|
||||
const isActive = reactions.some(
|
||||
(reaction) =>
|
||||
reaction.emoji === emoji &&
|
||||
reaction.reactedByCurrentUser,
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={`React with ${emoji}`}
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
"flex h-10 items-center justify-center rounded-xl border text-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
isActive
|
||||
? "border-primary/40 bg-primary/10"
|
||||
: "border-border/70 bg-muted/40 hover:bg-accent",
|
||||
)}
|
||||
data-emoji={emoji}
|
||||
data-testid={`react-option-${message.id}`}
|
||||
disabled={reactionPending}
|
||||
key={`${message.id}-${emoji}`}
|
||||
onClick={() => {
|
||||
if (!onReactionSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
void onReactionSelect(emoji)
|
||||
.then(() => {
|
||||
setIsReactionPickerOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
return;
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
|
||||
{hasReplyAction ? (
|
||||
<Button
|
||||
aria-label={isReplyingToMessage ? "Cancel reply" : "Reply"}
|
||||
className="h-6 w-6 rounded-full p-0"
|
||||
data-testid={`reply-message-${message.id}`}
|
||||
onClick={() => {
|
||||
onReply?.(message);
|
||||
}}
|
||||
size="sm"
|
||||
title={isReplyingToMessage ? "Cancel reply" : "Reply"}
|
||||
type="button"
|
||||
variant={isReplyingToMessage ? "secondary" : "ghost"}
|
||||
>
|
||||
<CornerUpLeft className="h-3 w-3" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
322
desktop/src/features/messages/ui/MessageRow.tsx
Normal file
322
desktop/src/features/messages/ui/MessageRow.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type {
|
||||
TimelineMessage,
|
||||
TimelineReaction,
|
||||
} from "@/features/messages/types";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { DiffMessage } from "./DiffMessage";
|
||||
import { MessageActionBar } from "./MessageActionBar";
|
||||
|
||||
const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded"));
|
||||
|
||||
export function MessageRow({
|
||||
activeReplyTargetId = null,
|
||||
message,
|
||||
onToggleReaction,
|
||||
onReply,
|
||||
}: {
|
||||
activeReplyTargetId?: string | null;
|
||||
message: TimelineMessage;
|
||||
onToggleReaction?: (
|
||||
message: TimelineMessage,
|
||||
emoji: string,
|
||||
remove: boolean,
|
||||
) => Promise<void>;
|
||||
onReply?: (message: TimelineMessage) => void;
|
||||
}) {
|
||||
const [hasAvatarError, setHasAvatarError] = React.useState(false);
|
||||
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [reactionErrorMessage, setReactionErrorMessage] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [reactionPending, setReactionPending] = React.useState(false);
|
||||
const visibleDepth = Math.min(message.depth, 6);
|
||||
const compressedDepth = Math.max(message.depth - visibleDepth, 0);
|
||||
const indentPx = visibleDepth * 28;
|
||||
const initials = message.author
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
const getTag = (name: string) =>
|
||||
message.tags?.find((tag) => tag[0] === name)?.[1];
|
||||
|
||||
const renderBody = () => {
|
||||
switch (message.kind) {
|
||||
case KIND_STREAM_MESSAGE_DIFF:
|
||||
return (
|
||||
<DiffMessage
|
||||
commitSha={getTag("commit")}
|
||||
content={message.body}
|
||||
description={getTag("description")}
|
||||
filePath={getTag("file")}
|
||||
onExpand={() => {
|
||||
setExpandedDiffId(message.id);
|
||||
}}
|
||||
repoUrl={getTag("repo")}
|
||||
truncated={getTag("truncated") === "true"}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <Markdown className="max-w-3xl" content={message.body} tight />;
|
||||
}
|
||||
};
|
||||
|
||||
const reactions = [...(message.reactions ?? [])].sort((left, right) => {
|
||||
if (left.count !== right.count) {
|
||||
return right.count - left.count;
|
||||
}
|
||||
|
||||
return left.emoji.localeCompare(right.emoji);
|
||||
});
|
||||
const canToggleReactions = Boolean(onToggleReaction && !message.pending);
|
||||
|
||||
const handleReactionSelect = React.useCallback(
|
||||
async (emoji: string) => {
|
||||
if (!onToggleReaction || reactionPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remove = reactions.some(
|
||||
(reaction) => reaction.emoji === emoji && reaction.reactedByCurrentUser,
|
||||
);
|
||||
|
||||
setReactionErrorMessage(null);
|
||||
setReactionPending(true);
|
||||
|
||||
try {
|
||||
await onToggleReaction(message, emoji, remove);
|
||||
} catch (error) {
|
||||
const nextMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to update the reaction.";
|
||||
setReactionErrorMessage(nextMessage);
|
||||
throw error;
|
||||
} finally {
|
||||
setReactionPending(false);
|
||||
}
|
||||
},
|
||||
[message, onToggleReaction, reactionPending, reactions],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
style={indentPx > 0 ? { paddingLeft: `${indentPx}px` } : undefined}
|
||||
>
|
||||
{message.depth > 0 ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute bottom-2 left-3 top-2 rounded-full border-l border-border/70"
|
||||
style={{ left: `${Math.max(indentPx - 14, 12)}px` }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<article
|
||||
className={cn(
|
||||
"group/message flex gap-3 rounded-2xl px-2 py-2 transition-colors",
|
||||
message.highlighted ? "bg-primary/10 ring-1 ring-primary/30" : "",
|
||||
activeReplyTargetId === message.id
|
||||
? "bg-muted/60 ring-1 ring-border"
|
||||
: "",
|
||||
)}
|
||||
data-message-id={message.id}
|
||||
data-testid="message-row"
|
||||
>
|
||||
{message.pubkey ? (
|
||||
<UserProfilePopover pubkey={message.pubkey}>
|
||||
<button
|
||||
className="shrink-0 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
{message.avatarUrl && !hasAvatarError ? (
|
||||
<img
|
||||
alt={`${message.author} avatar`}
|
||||
className="h-9 w-9 rounded-xl object-cover shadow-sm"
|
||||
data-testid="message-avatar-image"
|
||||
onError={() => {
|
||||
setHasAvatarError(true);
|
||||
}}
|
||||
referrerPolicy="no-referrer"
|
||||
src={message.avatarUrl}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 w-9 items-center justify-center rounded-xl text-xs font-semibold shadow-sm",
|
||||
message.accent
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-secondary-foreground",
|
||||
)}
|
||||
data-testid="message-avatar-fallback"
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
) : message.avatarUrl && !hasAvatarError ? (
|
||||
<img
|
||||
alt={`${message.author} avatar`}
|
||||
className="h-9 w-9 shrink-0 rounded-xl object-cover shadow-sm"
|
||||
data-testid="message-avatar-image"
|
||||
onError={() => {
|
||||
setHasAvatarError(true);
|
||||
}}
|
||||
referrerPolicy="no-referrer"
|
||||
src={message.avatarUrl}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-xs font-semibold shadow-sm",
|
||||
message.accent
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-secondary-foreground",
|
||||
)}
|
||||
data-testid="message-avatar-fallback"
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-0">
|
||||
{message.parentId ? (
|
||||
<div className="mb-1 flex min-w-0 flex-wrap items-center gap-2 text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px]">
|
||||
Reply
|
||||
</span>
|
||||
<span className="truncate normal-case tracking-normal">
|
||||
{message.replyToAuthor
|
||||
? `to ${message.replyToAuthor}`
|
||||
: "to an earlier message"}
|
||||
</span>
|
||||
{compressedDepth > 0 ? (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px]">
|
||||
+{compressedDepth} more level
|
||||
{compressedDepth === 1 ? "" : "s"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{message.pubkey ? (
|
||||
<UserProfilePopover pubkey={message.pubkey}>
|
||||
<button
|
||||
className="truncate rounded text-sm font-semibold tracking-tight hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
{message.author}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
) : (
|
||||
<h3 className="truncate text-sm font-semibold tracking-tight">
|
||||
{message.author}
|
||||
</h3>
|
||||
)}
|
||||
{message.role ? (
|
||||
<p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{message.role}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<MessageActionBar
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
message={message}
|
||||
onReactionSelect={
|
||||
canToggleReactions ? handleReactionSelect : undefined
|
||||
}
|
||||
onReply={onReply}
|
||||
reactionErrorMessage={reactionErrorMessage}
|
||||
reactionPending={reactionPending}
|
||||
reactions={reactions}
|
||||
/>
|
||||
{message.pending ? (
|
||||
<p className="font-medium uppercase tracking-[0.14em] text-primary/80">
|
||||
Sending
|
||||
</p>
|
||||
) : null}
|
||||
<p className="whitespace-nowrap">{message.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
{renderBody()}
|
||||
{reactions.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{reactions.map((reaction: TimelineReaction) => (
|
||||
<button
|
||||
aria-label={`Toggle ${reaction.emoji} reaction`}
|
||||
aria-pressed={reaction.reactedByCurrentUser}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
reaction.reactedByCurrentUser
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border/70 bg-muted/70 text-foreground/90",
|
||||
canToggleReactions
|
||||
? "hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
: "cursor-default",
|
||||
)}
|
||||
disabled={!canToggleReactions || reactionPending}
|
||||
key={`${message.id}-${reaction.emoji}`}
|
||||
onClick={() => {
|
||||
if (!canToggleReactions) {
|
||||
return;
|
||||
}
|
||||
|
||||
void handleReactionSelect(reaction.emoji).catch(() => {
|
||||
return;
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span>{reaction.emoji}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{reaction.count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{reactionErrorMessage ? (
|
||||
<p className="mt-2 text-xs text-destructive">
|
||||
{reactionErrorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
{message.replyToSnippet ? (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{message.replyToSnippet}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{expandedDiffId === message.id ? (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
Loading diff viewer…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DiffMessageExpanded
|
||||
content={message.body}
|
||||
filePath={getTag("file")}
|
||||
onClose={() => {
|
||||
setExpandedDiffId(null);
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,11 @@
|
||||
import { ArrowDown, CornerUpLeft, LoaderCircle, SmilePlus } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { ArrowDown } from "lucide-react";
|
||||
|
||||
import type {
|
||||
TimelineMessage,
|
||||
TimelineReaction,
|
||||
} from "@/features/messages/types";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
import { DiffMessage } from "./DiffMessage";
|
||||
|
||||
const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded"));
|
||||
import { MessageRow } from "./MessageRow";
|
||||
import { TimelineSkeleton } from "./TimelineSkeleton";
|
||||
import { useTimelineScrollManager } from "./useTimelineScrollManager";
|
||||
|
||||
type MessageTimelineProps = {
|
||||
messages: TimelineMessage[];
|
||||
@@ -33,540 +23,6 @@ type MessageTimelineProps = {
|
||||
onTargetReached?: (messageId: string) => void;
|
||||
};
|
||||
|
||||
const BOTTOM_THRESHOLD_PX = 72;
|
||||
const DEFAULT_REACTION_OPTIONS = [
|
||||
"👍",
|
||||
"❤️",
|
||||
"🎉",
|
||||
"🚀",
|
||||
"👀",
|
||||
"✅",
|
||||
"🔥",
|
||||
"👎",
|
||||
];
|
||||
|
||||
function isNearBottom(container: HTMLDivElement) {
|
||||
return (
|
||||
container.scrollHeight - container.clientHeight - container.scrollTop <=
|
||||
BOTTOM_THRESHOLD_PX
|
||||
);
|
||||
}
|
||||
|
||||
function getReactionOptions(reactions: TimelineReaction[]) {
|
||||
const seen = new Set<string>();
|
||||
const options: string[] = [];
|
||||
|
||||
for (const reaction of reactions) {
|
||||
if (seen.has(reaction.emoji)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(reaction.emoji);
|
||||
options.push(reaction.emoji);
|
||||
}
|
||||
|
||||
for (const emoji of DEFAULT_REACTION_OPTIONS) {
|
||||
if (seen.has(emoji)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(emoji);
|
||||
options.push(emoji);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function MessageActionBar({
|
||||
activeReplyTargetId = null,
|
||||
message,
|
||||
onReactionSelect,
|
||||
onReply,
|
||||
reactionErrorMessage = null,
|
||||
reactions,
|
||||
reactionPending = false,
|
||||
}: {
|
||||
activeReplyTargetId?: string | null;
|
||||
message: TimelineMessage;
|
||||
onReactionSelect?: (emoji: string) => Promise<void>;
|
||||
onReply?: (message: TimelineMessage) => void;
|
||||
reactionErrorMessage?: string | null;
|
||||
reactions: TimelineReaction[];
|
||||
reactionPending?: boolean;
|
||||
}) {
|
||||
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
|
||||
const hasReplyAction = Boolean(onReply);
|
||||
const hasReactionAction = Boolean(onReactionSelect);
|
||||
|
||||
if (!hasReplyAction && !hasReactionAction) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isReplyingToMessage = activeReplyTargetId === message.id;
|
||||
const selectedReactionCount = reactions.filter(
|
||||
(reaction) => reaction.reactedByCurrentUser,
|
||||
).length;
|
||||
const reactionOptions = getReactionOptions(reactions);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-20 overflow-hidden rounded-full border border-border/70 bg-background/95 shadow-sm backdrop-blur supports-[backdrop-filter]:bg-background/85 transition-all duration-150 ease-out",
|
||||
"opacity-100 translate-y-0 sm:max-w-0 sm:opacity-0 sm:translate-y-1",
|
||||
"sm:group-hover/message:max-w-20 sm:group-hover/message:opacity-100 sm:group-hover/message:translate-y-0",
|
||||
"sm:group-focus-within/message:max-w-20 sm:group-focus-within/message:opacity-100 sm:group-focus-within/message:translate-y-0",
|
||||
isReplyingToMessage || isReactionPickerOpen
|
||||
? "sm:max-w-20 sm:opacity-100 sm:translate-y-0"
|
||||
: "",
|
||||
)}
|
||||
data-testid={`message-action-bar-${message.id}`}
|
||||
>
|
||||
<div className="flex items-center gap-1 p-1">
|
||||
{hasReactionAction ? (
|
||||
<Popover
|
||||
onOpenChange={setIsReactionPickerOpen}
|
||||
open={isReactionPickerOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
aria-label="Open reactions"
|
||||
className="h-6 w-6 rounded-full p-0"
|
||||
data-testid={`react-message-${message.id}`}
|
||||
disabled={reactionPending}
|
||||
size="sm"
|
||||
title="React"
|
||||
type="button"
|
||||
variant={
|
||||
isReactionPickerOpen || selectedReactionCount > 0
|
||||
? "secondary"
|
||||
: "ghost"
|
||||
}
|
||||
>
|
||||
{reactionPending ? (
|
||||
<LoaderCircle className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<SmilePlus className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-56 rounded-2xl p-3"
|
||||
side="top"
|
||||
sideOffset={10}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
React
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs",
|
||||
reactionErrorMessage
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{reactionErrorMessage ??
|
||||
"Click any emoji. Click it again to remove your own reaction."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{reactionOptions.map((emoji) => {
|
||||
const isActive = reactions.some(
|
||||
(reaction) =>
|
||||
reaction.emoji === emoji &&
|
||||
reaction.reactedByCurrentUser,
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={`React with ${emoji}`}
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
"flex h-10 items-center justify-center rounded-xl border text-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
isActive
|
||||
? "border-primary/40 bg-primary/10"
|
||||
: "border-border/70 bg-muted/40 hover:bg-accent",
|
||||
)}
|
||||
data-emoji={emoji}
|
||||
data-testid={`react-option-${message.id}`}
|
||||
disabled={reactionPending}
|
||||
key={`${message.id}-${emoji}`}
|
||||
onClick={() => {
|
||||
if (!onReactionSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
void onReactionSelect(emoji)
|
||||
.then(() => {
|
||||
setIsReactionPickerOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
return;
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
|
||||
{hasReplyAction ? (
|
||||
<Button
|
||||
aria-label={isReplyingToMessage ? "Cancel reply" : "Reply"}
|
||||
className="h-6 w-6 rounded-full p-0"
|
||||
data-testid={`reply-message-${message.id}`}
|
||||
onClick={() => {
|
||||
onReply?.(message);
|
||||
}}
|
||||
size="sm"
|
||||
title={isReplyingToMessage ? "Cancel reply" : "Reply"}
|
||||
type="button"
|
||||
variant={isReplyingToMessage ? "secondary" : "ghost"}
|
||||
>
|
||||
<CornerUpLeft className="h-3 w-3" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageRow({
|
||||
activeReplyTargetId = null,
|
||||
message,
|
||||
onToggleReaction,
|
||||
onReply,
|
||||
}: {
|
||||
activeReplyTargetId?: string | null;
|
||||
message: TimelineMessage;
|
||||
onToggleReaction?: (
|
||||
message: TimelineMessage,
|
||||
emoji: string,
|
||||
remove: boolean,
|
||||
) => Promise<void>;
|
||||
onReply?: (message: TimelineMessage) => void;
|
||||
}) {
|
||||
const [hasAvatarError, setHasAvatarError] = React.useState(false);
|
||||
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [reactionErrorMessage, setReactionErrorMessage] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [reactionPending, setReactionPending] = React.useState(false);
|
||||
const visibleDepth = Math.min(message.depth, 6);
|
||||
const compressedDepth = Math.max(message.depth - visibleDepth, 0);
|
||||
const indentPx = visibleDepth * 28;
|
||||
const initials = message.author
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
const getTag = (name: string) =>
|
||||
message.tags?.find((t) => t[0] === name)?.[1];
|
||||
|
||||
const renderBody = () => {
|
||||
switch (message.kind) {
|
||||
case KIND_STREAM_MESSAGE_DIFF:
|
||||
return (
|
||||
<DiffMessage
|
||||
commitSha={getTag("commit")}
|
||||
content={message.body}
|
||||
description={getTag("description")}
|
||||
filePath={getTag("file")}
|
||||
onExpand={() => {
|
||||
setExpandedDiffId(message.id);
|
||||
}}
|
||||
repoUrl={getTag("repo")}
|
||||
truncated={getTag("truncated") === "true"}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <Markdown className="max-w-3xl" content={message.body} tight />;
|
||||
}
|
||||
};
|
||||
|
||||
const reactions = [...(message.reactions ?? [])].sort((left, right) => {
|
||||
if (left.count !== right.count) {
|
||||
return right.count - left.count;
|
||||
}
|
||||
|
||||
return left.emoji.localeCompare(right.emoji);
|
||||
});
|
||||
const canToggleReactions = Boolean(onToggleReaction && !message.pending);
|
||||
|
||||
const handleReactionSelect = React.useCallback(
|
||||
async (emoji: string) => {
|
||||
if (!onToggleReaction || reactionPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remove = reactions.some(
|
||||
(reaction) => reaction.emoji === emoji && reaction.reactedByCurrentUser,
|
||||
);
|
||||
|
||||
setReactionErrorMessage(null);
|
||||
setReactionPending(true);
|
||||
|
||||
try {
|
||||
await onToggleReaction(message, emoji, remove);
|
||||
} catch (error) {
|
||||
const nextMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to update the reaction.";
|
||||
setReactionErrorMessage(nextMessage);
|
||||
throw error;
|
||||
} finally {
|
||||
setReactionPending(false);
|
||||
}
|
||||
},
|
||||
[message, onToggleReaction, reactionPending, reactions],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
style={indentPx > 0 ? { paddingLeft: `${indentPx}px` } : undefined}
|
||||
>
|
||||
{message.depth > 0 ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute bottom-2 left-3 top-2 rounded-full border-l border-border/70"
|
||||
style={{ left: `${Math.max(indentPx - 14, 12)}px` }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<article
|
||||
className={cn(
|
||||
"group/message flex gap-3 rounded-2xl px-2 py-2 transition-colors",
|
||||
message.highlighted ? "bg-primary/10 ring-1 ring-primary/30" : "",
|
||||
activeReplyTargetId === message.id
|
||||
? "bg-muted/60 ring-1 ring-border"
|
||||
: "",
|
||||
)}
|
||||
data-message-id={message.id}
|
||||
data-testid="message-row"
|
||||
>
|
||||
{message.pubkey ? (
|
||||
<UserProfilePopover pubkey={message.pubkey}>
|
||||
<button
|
||||
className="shrink-0 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
{message.avatarUrl && !hasAvatarError ? (
|
||||
<img
|
||||
alt={`${message.author} avatar`}
|
||||
className="h-9 w-9 rounded-xl object-cover shadow-sm"
|
||||
data-testid="message-avatar-image"
|
||||
onError={() => {
|
||||
setHasAvatarError(true);
|
||||
}}
|
||||
referrerPolicy="no-referrer"
|
||||
src={message.avatarUrl}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 w-9 items-center justify-center rounded-xl text-xs font-semibold shadow-sm",
|
||||
message.accent
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-secondary-foreground",
|
||||
)}
|
||||
data-testid="message-avatar-fallback"
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
) : message.avatarUrl && !hasAvatarError ? (
|
||||
<img
|
||||
alt={`${message.author} avatar`}
|
||||
className="h-9 w-9 shrink-0 rounded-xl object-cover shadow-sm"
|
||||
data-testid="message-avatar-image"
|
||||
onError={() => {
|
||||
setHasAvatarError(true);
|
||||
}}
|
||||
referrerPolicy="no-referrer"
|
||||
src={message.avatarUrl}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-xs font-semibold shadow-sm",
|
||||
message.accent
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-secondary-foreground",
|
||||
)}
|
||||
data-testid="message-avatar-fallback"
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-0">
|
||||
{message.parentId ? (
|
||||
<div className="mb-1 flex min-w-0 flex-wrap items-center gap-2 text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px]">
|
||||
Reply
|
||||
</span>
|
||||
<span className="truncate normal-case tracking-normal">
|
||||
{message.replyToAuthor
|
||||
? `to ${message.replyToAuthor}`
|
||||
: "to an earlier message"}
|
||||
</span>
|
||||
{compressedDepth > 0 ? (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px]">
|
||||
+{compressedDepth} more level
|
||||
{compressedDepth === 1 ? "" : "s"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{message.pubkey ? (
|
||||
<UserProfilePopover pubkey={message.pubkey}>
|
||||
<button
|
||||
className="truncate text-sm font-semibold tracking-tight hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
|
||||
type="button"
|
||||
>
|
||||
{message.author}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
) : (
|
||||
<h3 className="truncate text-sm font-semibold tracking-tight">
|
||||
{message.author}
|
||||
</h3>
|
||||
)}
|
||||
{message.role ? (
|
||||
<p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{message.role}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<MessageActionBar
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
message={message}
|
||||
onReactionSelect={
|
||||
canToggleReactions ? handleReactionSelect : undefined
|
||||
}
|
||||
onReply={onReply}
|
||||
reactionErrorMessage={reactionErrorMessage}
|
||||
reactionPending={reactionPending}
|
||||
reactions={reactions}
|
||||
/>
|
||||
{message.pending ? (
|
||||
<p className="font-medium uppercase tracking-[0.14em] text-primary/80">
|
||||
Sending
|
||||
</p>
|
||||
) : null}
|
||||
<p className="whitespace-nowrap">{message.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
{renderBody()}
|
||||
{reactions.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{reactions.map((reaction) => (
|
||||
<button
|
||||
aria-label={`Toggle ${reaction.emoji} reaction`}
|
||||
aria-pressed={reaction.reactedByCurrentUser}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
reaction.reactedByCurrentUser
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border/70 bg-muted/70 text-foreground/90",
|
||||
canToggleReactions
|
||||
? "hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
: "cursor-default",
|
||||
)}
|
||||
disabled={!canToggleReactions || reactionPending}
|
||||
key={`${message.id}-${reaction.emoji}`}
|
||||
onClick={() => {
|
||||
if (!canToggleReactions) {
|
||||
return;
|
||||
}
|
||||
|
||||
void handleReactionSelect(reaction.emoji).catch(() => {
|
||||
return;
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span>{reaction.emoji}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{reaction.count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{reactionErrorMessage ? (
|
||||
<p className="mt-2 text-xs text-destructive">
|
||||
{reactionErrorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
{message.replyToSnippet ? (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{message.replyToSnippet}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{expandedDiffId === message.id && (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
Loading diff viewer…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DiffMessageExpanded
|
||||
content={message.body}
|
||||
filePath={getTag("file")}
|
||||
onClose={() => {
|
||||
setExpandedDiffId(null);
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineSkeleton() {
|
||||
const skeletonRows = ["first", "second", "third", "fourth"];
|
||||
|
||||
return (
|
||||
<>
|
||||
{skeletonRows.map((row) => (
|
||||
<div className="flex gap-3" key={row}>
|
||||
<Skeleton className="h-9 w-9 rounded-xl" />
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3.5 w-44" />
|
||||
<Skeleton className="h-4 w-full max-w-2xl" />
|
||||
<Skeleton className="h-4 w-full max-w-xl" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageTimeline({
|
||||
messages,
|
||||
isLoading = false,
|
||||
@@ -578,317 +34,24 @@ export function MessageTimeline({
|
||||
targetMessageId = null,
|
||||
onTargetReached,
|
||||
}: MessageTimelineProps) {
|
||||
const timelineRef = React.useRef<HTMLDivElement>(null);
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const bottomAnchorRef = React.useRef<HTMLDivElement>(null);
|
||||
const hasInitializedRef = React.useRef(false);
|
||||
const shouldStickToBottomRef = React.useRef(true);
|
||||
const isAtBottomRef = React.useRef(true);
|
||||
const isProgrammaticBottomScrollRef = React.useRef(false);
|
||||
const previousTimelineHeightRef = React.useRef<number | null>(null);
|
||||
const previousScrollTopRef = React.useRef(0);
|
||||
const lockedScrollTopRef = React.useRef<number | null>(null);
|
||||
const previousLastMessageIdRef = React.useRef<string | undefined>(undefined);
|
||||
const previousMessageCountRef = React.useRef(0);
|
||||
const handledTargetMessageIdRef = React.useRef<string | null>(null);
|
||||
const [isAtBottom, setIsAtBottom] = React.useState(true);
|
||||
const [highlightedMessageId, setHighlightedMessageId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [newMessageCount, setNewMessageCount] = React.useState(0);
|
||||
const latestMessage =
|
||||
messages.length > 0 ? messages[messages.length - 1] : undefined;
|
||||
|
||||
const syncScrollState = React.useCallback(() => {
|
||||
const timeline = timelineRef.current;
|
||||
if (!timeline) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollTop = lockedScrollTopRef.current ?? timeline.scrollTop;
|
||||
const atBottom = isNearBottom(timeline);
|
||||
const movedAwayFromBottom = scrollTop + 1 < previousScrollTopRef.current;
|
||||
|
||||
if (isProgrammaticBottomScrollRef.current) {
|
||||
previousScrollTopRef.current = scrollTop;
|
||||
|
||||
if (movedAwayFromBottom) {
|
||||
isProgrammaticBottomScrollRef.current = false;
|
||||
} else if (!atBottom) {
|
||||
shouldStickToBottomRef.current = true;
|
||||
isAtBottomRef.current = true;
|
||||
setIsAtBottom((current) => (current ? current : true));
|
||||
return;
|
||||
} else {
|
||||
isProgrammaticBottomScrollRef.current = false;
|
||||
shouldStickToBottomRef.current = true;
|
||||
isAtBottomRef.current = true;
|
||||
setIsAtBottom((current) => (current ? current : true));
|
||||
setNewMessageCount(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldStickToBottomRef.current && !atBottom && !movedAwayFromBottom) {
|
||||
previousScrollTopRef.current = scrollTop;
|
||||
shouldStickToBottomRef.current = true;
|
||||
isAtBottomRef.current = true;
|
||||
setIsAtBottom((current) => (current ? current : true));
|
||||
setNewMessageCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
previousScrollTopRef.current = scrollTop;
|
||||
shouldStickToBottomRef.current = atBottom;
|
||||
isAtBottomRef.current = atBottom;
|
||||
setIsAtBottom((current) => (current === atBottom ? current : atBottom));
|
||||
|
||||
if (atBottom) {
|
||||
setNewMessageCount(0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const restoreScrollPosition = React.useCallback(
|
||||
(scrollTop: number) => {
|
||||
const timeline = timelineRef.current;
|
||||
|
||||
if (!timeline) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProgrammaticBottomScrollRef.current = false;
|
||||
lockedScrollTopRef.current = scrollTop;
|
||||
|
||||
const restore = (remainingFrames: number) => {
|
||||
timeline.scrollTop = scrollTop;
|
||||
|
||||
if (remainingFrames > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
restore(remainingFrames - 1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
lockedScrollTopRef.current = null;
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
syncScrollState();
|
||||
};
|
||||
|
||||
restore(2);
|
||||
},
|
||||
[syncScrollState],
|
||||
);
|
||||
|
||||
const scrollToBottom = React.useCallback(
|
||||
(behavior: ScrollBehavior) => {
|
||||
const timeline = timelineRef.current;
|
||||
|
||||
if (!timeline) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProgrammaticBottomScrollRef.current = true;
|
||||
|
||||
const alignToBottom = (nextBehavior: ScrollBehavior) => {
|
||||
bottomAnchorRef.current?.scrollIntoView({
|
||||
block: "end",
|
||||
behavior: nextBehavior,
|
||||
});
|
||||
timeline.scrollTo({
|
||||
top: timeline.scrollHeight,
|
||||
behavior: nextBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
alignToBottom(behavior);
|
||||
lockedScrollTopRef.current = null;
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
shouldStickToBottomRef.current = true;
|
||||
isAtBottomRef.current = true;
|
||||
setIsAtBottom(true);
|
||||
setNewMessageCount(0);
|
||||
|
||||
if (behavior === "smooth") {
|
||||
requestAnimationFrame(() => {
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
syncScrollState();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const settleAlignment = (remainingFrames: number) => {
|
||||
requestAnimationFrame(() => {
|
||||
alignToBottom("auto");
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
|
||||
if (remainingFrames > 0) {
|
||||
settleAlignment(remainingFrames - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
syncScrollState();
|
||||
});
|
||||
};
|
||||
|
||||
settleAlignment(2);
|
||||
},
|
||||
[syncScrollState],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timeline = timelineRef.current;
|
||||
|
||||
if (!timeline || typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
previousTimelineHeightRef.current = timeline.clientHeight;
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
const previousTimelineHeight = previousTimelineHeightRef.current;
|
||||
const nextTimelineHeight = entry.contentRect.height;
|
||||
previousTimelineHeightRef.current = nextTimelineHeight;
|
||||
|
||||
if (
|
||||
previousTimelineHeight === null ||
|
||||
Math.abs(nextTimelineHeight - previousTimelineHeight) < 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldStickToBottomRef.current || isAtBottomRef.current) {
|
||||
scrollToBottom("auto");
|
||||
return;
|
||||
}
|
||||
|
||||
restoreScrollPosition(previousScrollTopRef.current);
|
||||
});
|
||||
|
||||
observer.observe(timeline);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [restoreScrollPosition, scrollToBottom]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const content = contentRef.current;
|
||||
|
||||
if (!content || typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (shouldStickToBottomRef.current) {
|
||||
scrollToBottom("auto");
|
||||
return;
|
||||
}
|
||||
|
||||
syncScrollState();
|
||||
});
|
||||
|
||||
observer.observe(content);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [scrollToBottom, syncScrollState]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!hasInitializedRef.current) {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollToBottom("auto");
|
||||
hasInitializedRef.current = true;
|
||||
previousLastMessageIdRef.current = latestMessage?.id;
|
||||
previousMessageCountRef.current = messages.length;
|
||||
return;
|
||||
}
|
||||
|
||||
const previousLastMessageId = previousLastMessageIdRef.current;
|
||||
const previousMessageCount = previousMessageCountRef.current;
|
||||
const hasNewLatestMessage =
|
||||
latestMessage !== undefined && latestMessage.id !== previousLastMessageId;
|
||||
|
||||
if (!hasNewLatestMessage) {
|
||||
previousLastMessageIdRef.current = latestMessage?.id;
|
||||
previousMessageCountRef.current = messages.length;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
shouldStickToBottomRef.current ||
|
||||
isAtBottomRef.current ||
|
||||
latestMessage.accent
|
||||
) {
|
||||
scrollToBottom(latestMessage.accent ? "smooth" : "auto");
|
||||
} else {
|
||||
setNewMessageCount((current) => {
|
||||
const addedMessages = Math.max(
|
||||
1,
|
||||
messages.length - previousMessageCount,
|
||||
);
|
||||
return current + addedMessages;
|
||||
});
|
||||
}
|
||||
|
||||
previousLastMessageIdRef.current = latestMessage.id;
|
||||
previousMessageCountRef.current = messages.length;
|
||||
}, [isLoading, latestMessage, messages.length, scrollToBottom]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!targetMessageId) {
|
||||
handledTargetMessageIdRef.current = null;
|
||||
setHighlightedMessageId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (handledTargetMessageIdRef.current === targetMessageId || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeline = timelineRef.current;
|
||||
if (!timeline) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetElement = timeline.querySelector<HTMLElement>(
|
||||
`[data-message-id="${targetMessageId}"]`,
|
||||
);
|
||||
if (!targetElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
handledTargetMessageIdRef.current = targetMessageId;
|
||||
shouldStickToBottomRef.current = false;
|
||||
isAtBottomRef.current = false;
|
||||
isProgrammaticBottomScrollRef.current = false;
|
||||
targetElement.scrollIntoView({
|
||||
block: "center",
|
||||
behavior: "smooth",
|
||||
});
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
setIsAtBottom(false);
|
||||
setHighlightedMessageId(targetMessageId);
|
||||
setNewMessageCount(0);
|
||||
onTargetReached?.(targetMessageId);
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
setHighlightedMessageId((current) =>
|
||||
current === targetMessageId ? null : current,
|
||||
);
|
||||
}, 2_000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [isLoading, onTargetReached, targetMessageId]);
|
||||
const {
|
||||
bottomAnchorRef,
|
||||
contentRef,
|
||||
highlightedMessageId,
|
||||
isAtBottom,
|
||||
newMessageCount,
|
||||
scrollToBottom,
|
||||
syncScrollState,
|
||||
timelineRef,
|
||||
} = useTimelineScrollManager({
|
||||
isLoading,
|
||||
messages,
|
||||
onTargetReached,
|
||||
targetMessageId,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 min-h-0">
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div
|
||||
className="h-full overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-4 [overflow-anchor:none] sm:px-6"
|
||||
data-testid="message-timeline"
|
||||
|
||||
20
desktop/src/features/messages/ui/TimelineSkeleton.tsx
Normal file
20
desktop/src/features/messages/ui/TimelineSkeleton.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
|
||||
export function TimelineSkeleton() {
|
||||
const skeletonRows = ["first", "second", "third", "fourth"];
|
||||
|
||||
return (
|
||||
<>
|
||||
{skeletonRows.map((row) => (
|
||||
<div className="flex gap-3" key={row}>
|
||||
<Skeleton className="h-9 w-9 rounded-xl" />
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3.5 w-44" />
|
||||
<Skeleton className="h-4 w-full max-w-2xl" />
|
||||
<Skeleton className="h-4 w-full max-w-xl" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
45
desktop/src/features/messages/ui/messageTimelineUtils.ts
Normal file
45
desktop/src/features/messages/ui/messageTimelineUtils.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { TimelineReaction } from "@/features/messages/types";
|
||||
|
||||
const BOTTOM_THRESHOLD_PX = 72;
|
||||
const DEFAULT_REACTION_OPTIONS = [
|
||||
"👍",
|
||||
"❤️",
|
||||
"🎉",
|
||||
"🚀",
|
||||
"👀",
|
||||
"✅",
|
||||
"🔥",
|
||||
"👎",
|
||||
];
|
||||
|
||||
export function isNearBottom(container: HTMLDivElement) {
|
||||
return (
|
||||
container.scrollHeight - container.clientHeight - container.scrollTop <=
|
||||
BOTTOM_THRESHOLD_PX
|
||||
);
|
||||
}
|
||||
|
||||
export function getReactionOptions(reactions: TimelineReaction[]) {
|
||||
const seen = new Set<string>();
|
||||
const options: string[] = [];
|
||||
|
||||
for (const reaction of reactions) {
|
||||
if (seen.has(reaction.emoji)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(reaction.emoji);
|
||||
options.push(reaction.emoji);
|
||||
}
|
||||
|
||||
for (const emoji of DEFAULT_REACTION_OPTIONS) {
|
||||
if (seen.has(emoji)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(emoji);
|
||||
options.push(emoji);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
336
desktop/src/features/messages/ui/useTimelineScrollManager.ts
Normal file
336
desktop/src/features/messages/ui/useTimelineScrollManager.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { isNearBottom } from "./messageTimelineUtils";
|
||||
|
||||
export function useTimelineScrollManager({
|
||||
isLoading,
|
||||
messages,
|
||||
onTargetReached,
|
||||
targetMessageId,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
messages: TimelineMessage[];
|
||||
onTargetReached?: (messageId: string) => void;
|
||||
targetMessageId?: string | null;
|
||||
}) {
|
||||
const timelineRef = React.useRef<HTMLDivElement>(null);
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const bottomAnchorRef = React.useRef<HTMLDivElement>(null);
|
||||
const hasInitializedRef = React.useRef(false);
|
||||
const shouldStickToBottomRef = React.useRef(true);
|
||||
const isAtBottomRef = React.useRef(true);
|
||||
const isProgrammaticBottomScrollRef = React.useRef(false);
|
||||
const previousTimelineHeightRef = React.useRef<number | null>(null);
|
||||
const previousScrollTopRef = React.useRef(0);
|
||||
const lockedScrollTopRef = React.useRef<number | null>(null);
|
||||
const previousLastMessageIdRef = React.useRef<string | undefined>(undefined);
|
||||
const previousMessageCountRef = React.useRef(0);
|
||||
const handledTargetMessageIdRef = React.useRef<string | null>(null);
|
||||
const [isAtBottom, setIsAtBottom] = React.useState(true);
|
||||
const [highlightedMessageId, setHighlightedMessageId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [newMessageCount, setNewMessageCount] = React.useState(0);
|
||||
const latestMessage =
|
||||
messages.length > 0 ? messages[messages.length - 1] : undefined;
|
||||
|
||||
const syncScrollState = React.useCallback(() => {
|
||||
const timeline = timelineRef.current;
|
||||
if (!timeline) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollTop = lockedScrollTopRef.current ?? timeline.scrollTop;
|
||||
const atBottom = isNearBottom(timeline);
|
||||
const movedAwayFromBottom = scrollTop + 1 < previousScrollTopRef.current;
|
||||
|
||||
if (isProgrammaticBottomScrollRef.current) {
|
||||
previousScrollTopRef.current = scrollTop;
|
||||
|
||||
if (movedAwayFromBottom) {
|
||||
isProgrammaticBottomScrollRef.current = false;
|
||||
} else if (!atBottom) {
|
||||
shouldStickToBottomRef.current = true;
|
||||
isAtBottomRef.current = true;
|
||||
setIsAtBottom((current) => (current ? current : true));
|
||||
return;
|
||||
} else {
|
||||
isProgrammaticBottomScrollRef.current = false;
|
||||
shouldStickToBottomRef.current = true;
|
||||
isAtBottomRef.current = true;
|
||||
setIsAtBottom((current) => (current ? current : true));
|
||||
setNewMessageCount(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldStickToBottomRef.current && !atBottom && !movedAwayFromBottom) {
|
||||
previousScrollTopRef.current = scrollTop;
|
||||
shouldStickToBottomRef.current = true;
|
||||
isAtBottomRef.current = true;
|
||||
setIsAtBottom((current) => (current ? current : true));
|
||||
setNewMessageCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
previousScrollTopRef.current = scrollTop;
|
||||
shouldStickToBottomRef.current = atBottom;
|
||||
isAtBottomRef.current = atBottom;
|
||||
setIsAtBottom((current) => (current === atBottom ? current : atBottom));
|
||||
|
||||
if (atBottom) {
|
||||
setNewMessageCount(0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const restoreScrollPosition = React.useCallback(
|
||||
(scrollTop: number) => {
|
||||
const timeline = timelineRef.current;
|
||||
|
||||
if (!timeline) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProgrammaticBottomScrollRef.current = false;
|
||||
lockedScrollTopRef.current = scrollTop;
|
||||
|
||||
const restore = (remainingFrames: number) => {
|
||||
timeline.scrollTop = scrollTop;
|
||||
|
||||
if (remainingFrames > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
restore(remainingFrames - 1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
lockedScrollTopRef.current = null;
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
syncScrollState();
|
||||
};
|
||||
|
||||
restore(2);
|
||||
},
|
||||
[syncScrollState],
|
||||
);
|
||||
|
||||
const scrollToBottom = React.useCallback(
|
||||
(behavior: ScrollBehavior) => {
|
||||
const timeline = timelineRef.current;
|
||||
|
||||
if (!timeline) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProgrammaticBottomScrollRef.current = true;
|
||||
|
||||
const alignToBottom = (nextBehavior: ScrollBehavior) => {
|
||||
bottomAnchorRef.current?.scrollIntoView({
|
||||
block: "end",
|
||||
behavior: nextBehavior,
|
||||
});
|
||||
timeline.scrollTo({
|
||||
top: timeline.scrollHeight,
|
||||
behavior: nextBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
alignToBottom(behavior);
|
||||
lockedScrollTopRef.current = null;
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
shouldStickToBottomRef.current = true;
|
||||
isAtBottomRef.current = true;
|
||||
setIsAtBottom(true);
|
||||
setNewMessageCount(0);
|
||||
|
||||
if (behavior === "smooth") {
|
||||
requestAnimationFrame(() => {
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
syncScrollState();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const settleAlignment = (remainingFrames: number) => {
|
||||
requestAnimationFrame(() => {
|
||||
alignToBottom("auto");
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
|
||||
if (remainingFrames > 0) {
|
||||
settleAlignment(remainingFrames - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
syncScrollState();
|
||||
});
|
||||
};
|
||||
|
||||
settleAlignment(2);
|
||||
},
|
||||
[syncScrollState],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timeline = timelineRef.current;
|
||||
|
||||
if (!timeline || typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
previousTimelineHeightRef.current = timeline.clientHeight;
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
const previousTimelineHeight = previousTimelineHeightRef.current;
|
||||
const nextTimelineHeight = entry.contentRect.height;
|
||||
previousTimelineHeightRef.current = nextTimelineHeight;
|
||||
|
||||
if (
|
||||
previousTimelineHeight === null ||
|
||||
Math.abs(nextTimelineHeight - previousTimelineHeight) < 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldStickToBottomRef.current || isAtBottomRef.current) {
|
||||
scrollToBottom("auto");
|
||||
return;
|
||||
}
|
||||
|
||||
restoreScrollPosition(previousScrollTopRef.current);
|
||||
});
|
||||
|
||||
observer.observe(timeline);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [restoreScrollPosition, scrollToBottom]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const content = contentRef.current;
|
||||
|
||||
if (!content || typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (shouldStickToBottomRef.current) {
|
||||
scrollToBottom("auto");
|
||||
return;
|
||||
}
|
||||
|
||||
syncScrollState();
|
||||
});
|
||||
|
||||
observer.observe(content);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [scrollToBottom, syncScrollState]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!hasInitializedRef.current) {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollToBottom("auto");
|
||||
hasInitializedRef.current = true;
|
||||
previousLastMessageIdRef.current = latestMessage?.id;
|
||||
previousMessageCountRef.current = messages.length;
|
||||
return;
|
||||
}
|
||||
|
||||
const previousLastMessageId = previousLastMessageIdRef.current;
|
||||
const previousMessageCount = previousMessageCountRef.current;
|
||||
const hasNewLatestMessage =
|
||||
latestMessage !== undefined && latestMessage.id !== previousLastMessageId;
|
||||
|
||||
if (!hasNewLatestMessage) {
|
||||
previousLastMessageIdRef.current = latestMessage?.id;
|
||||
previousMessageCountRef.current = messages.length;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
shouldStickToBottomRef.current ||
|
||||
isAtBottomRef.current ||
|
||||
latestMessage.accent
|
||||
) {
|
||||
scrollToBottom(latestMessage.accent ? "smooth" : "auto");
|
||||
} else {
|
||||
setNewMessageCount((current) => {
|
||||
const addedMessages = Math.max(
|
||||
1,
|
||||
messages.length - previousMessageCount,
|
||||
);
|
||||
return current + addedMessages;
|
||||
});
|
||||
}
|
||||
|
||||
previousLastMessageIdRef.current = latestMessage.id;
|
||||
previousMessageCountRef.current = messages.length;
|
||||
}, [isLoading, latestMessage, messages.length, scrollToBottom]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!targetMessageId) {
|
||||
handledTargetMessageIdRef.current = null;
|
||||
setHighlightedMessageId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (handledTargetMessageIdRef.current === targetMessageId || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeline = timelineRef.current;
|
||||
if (!timeline) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetElement = timeline.querySelector<HTMLElement>(
|
||||
`[data-message-id="${targetMessageId}"]`,
|
||||
);
|
||||
if (!targetElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
handledTargetMessageIdRef.current = targetMessageId;
|
||||
shouldStickToBottomRef.current = false;
|
||||
isAtBottomRef.current = false;
|
||||
isProgrammaticBottomScrollRef.current = false;
|
||||
targetElement.scrollIntoView({
|
||||
block: "center",
|
||||
behavior: "smooth",
|
||||
});
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
setIsAtBottom(false);
|
||||
setHighlightedMessageId(targetMessageId);
|
||||
setNewMessageCount(0);
|
||||
onTargetReached?.(targetMessageId);
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
setHighlightedMessageId((current) =>
|
||||
current === targetMessageId ? null : current,
|
||||
);
|
||||
}, 2_000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [isLoading, onTargetReached, targetMessageId]);
|
||||
|
||||
return {
|
||||
bottomAnchorRef,
|
||||
contentRef,
|
||||
highlightedMessageId,
|
||||
isAtBottom,
|
||||
newMessageCount,
|
||||
scrollToBottom,
|
||||
syncScrollState,
|
||||
timelineRef,
|
||||
};
|
||||
}
|
||||
@@ -7,66 +7,13 @@ import {
|
||||
} from "@/shared/api/tauri";
|
||||
import type { PresenceStatus, RelayEvent } from "@/shared/api/types";
|
||||
import { STREAM_MESSAGE_KINDS } from "@/shared/constants/kinds";
|
||||
|
||||
type RelaySubscriptionFilter = {
|
||||
kinds: number[];
|
||||
"#h": string[];
|
||||
limit: number;
|
||||
};
|
||||
|
||||
type HistorySubscription = {
|
||||
mode: "history";
|
||||
events: RelayEvent[];
|
||||
resolve: (events: RelayEvent[]) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
type LiveSubscription = {
|
||||
mode: "live";
|
||||
onEvent: (event: RelayEvent) => void;
|
||||
};
|
||||
|
||||
type PendingEvent = {
|
||||
event: RelayEvent;
|
||||
resolve: (event: RelayEvent) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
type RelaySubscription = HistorySubscription | LiveSubscription;
|
||||
|
||||
function sortEvents(events: RelayEvent[]) {
|
||||
return [...events].sort((left, right) => left.created_at - right.created_at);
|
||||
}
|
||||
|
||||
function getTextPayload(message: unknown) {
|
||||
if (typeof message === "string") {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
"type" in message &&
|
||||
message.type === "Text" &&
|
||||
"data" in message &&
|
||||
typeof message.data === "string"
|
||||
) {
|
||||
return message.data;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
"Text" in message &&
|
||||
typeof message.Text === "string"
|
||||
) {
|
||||
return message.Text;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
import {
|
||||
getTextPayload,
|
||||
sortEvents,
|
||||
type PendingEvent,
|
||||
type RelaySubscription,
|
||||
type RelaySubscriptionFilter,
|
||||
} from "@/shared/api/relayClientShared";
|
||||
|
||||
class RelayClient {
|
||||
private wsId: number | null = null;
|
||||
@@ -161,26 +108,15 @@ class RelayClient {
|
||||
channelId: string,
|
||||
onEvent: (event: RelayEvent) => void,
|
||||
) {
|
||||
return this.subscribe(this.buildChannelFilter(channelId, 50), onEvent);
|
||||
}
|
||||
|
||||
async subscribeToAllStreamMessages(onEvent: (event: RelayEvent) => void) {
|
||||
return this.subscribe(this.buildGlobalStreamFilter(50), onEvent);
|
||||
}
|
||||
|
||||
async preconnect() {
|
||||
await this.ensureConnected();
|
||||
|
||||
const subId = `live-${crypto.randomUUID()}`;
|
||||
|
||||
this.subscriptions.set(subId, {
|
||||
mode: "live",
|
||||
onEvent,
|
||||
});
|
||||
|
||||
await this.sendRaw(["REQ", subId, this.buildChannelFilter(channelId, 50)]);
|
||||
|
||||
return async () => {
|
||||
const active = this.subscriptions.get(subId);
|
||||
if (!active || active.mode !== "live") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.subscriptions.delete(subId);
|
||||
await this.closeSubscription(subId);
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureConnected() {
|
||||
@@ -245,6 +181,53 @@ class RelayClient {
|
||||
};
|
||||
}
|
||||
|
||||
private buildGlobalStreamFilter(limit: number): RelaySubscriptionFilter {
|
||||
return {
|
||||
kinds: [...STREAM_MESSAGE_KINDS],
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
private async subscribe(
|
||||
filter: RelaySubscriptionFilter,
|
||||
onEvent: (event: RelayEvent) => void,
|
||||
) {
|
||||
await this.ensureConnected();
|
||||
|
||||
const subId = `live-${crypto.randomUUID()}`;
|
||||
let resolveReady = () => {
|
||||
return;
|
||||
};
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
resolveReady = () => {
|
||||
window.clearTimeout(fallbackTimeout);
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
const fallbackTimeout = window.setTimeout(() => {
|
||||
resolveReady();
|
||||
}, 250);
|
||||
|
||||
this.subscriptions.set(subId, {
|
||||
mode: "live",
|
||||
onEvent,
|
||||
resolveReady,
|
||||
});
|
||||
|
||||
await this.sendRaw(["REQ", subId, filter]);
|
||||
await ready;
|
||||
|
||||
return async () => {
|
||||
const active = this.subscriptions.get(subId);
|
||||
if (!active || active.mode !== "live") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.subscriptions.delete(subId);
|
||||
await this.closeSubscription(subId);
|
||||
};
|
||||
}
|
||||
|
||||
private async sendRaw(payload: unknown[]) {
|
||||
if (this.wsId === null) {
|
||||
throw new Error("Relay socket is not connected.");
|
||||
@@ -393,7 +376,13 @@ class RelayClient {
|
||||
|
||||
private handleEose(subId: string) {
|
||||
const subscription = this.subscriptions.get(subId);
|
||||
if (!subscription || subscription.mode !== "history") {
|
||||
if (!subscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (subscription.mode === "live") {
|
||||
subscription.resolveReady?.();
|
||||
subscription.resolveReady = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
62
desktop/src/shared/api/relayClientShared.ts
Normal file
62
desktop/src/shared/api/relayClientShared.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
|
||||
export type RelaySubscriptionFilter = {
|
||||
kinds: number[];
|
||||
"#h"?: string[];
|
||||
limit: number;
|
||||
};
|
||||
|
||||
type HistorySubscription = {
|
||||
mode: "history";
|
||||
events: RelayEvent[];
|
||||
resolve: (events: RelayEvent[]) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
type LiveSubscription = {
|
||||
mode: "live";
|
||||
onEvent: (event: RelayEvent) => void;
|
||||
resolveReady?: () => void;
|
||||
};
|
||||
|
||||
export type PendingEvent = {
|
||||
event: RelayEvent;
|
||||
resolve: (event: RelayEvent) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
export type RelaySubscription = HistorySubscription | LiveSubscription;
|
||||
|
||||
export function sortEvents(events: RelayEvent[]) {
|
||||
return [...events].sort((left, right) => left.created_at - right.created_at);
|
||||
}
|
||||
|
||||
export function getTextPayload(message: unknown) {
|
||||
if (typeof message === "string") {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
"type" in message &&
|
||||
message.type === "Text" &&
|
||||
"data" in message &&
|
||||
typeof message.data === "string"
|
||||
) {
|
||||
return message.data;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
"Text" in message &&
|
||||
typeof message.Text === "string"
|
||||
) {
|
||||
return message.Text;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -268,6 +268,7 @@ type MockManagedAgent = RawManagedAgent & {
|
||||
};
|
||||
|
||||
type WsHandler = (message: unknown) => void;
|
||||
const GLOBAL_MOCK_SUBSCRIPTION = "*";
|
||||
|
||||
type MockSocket = {
|
||||
handler: WsHandler;
|
||||
@@ -1046,7 +1047,10 @@ function emitMockHistory(socket: MockSocket, subId: string, channelId: string) {
|
||||
function emitMockLiveEvent(channelId: string, event: RelayEvent) {
|
||||
for (const socket of mockSockets.values()) {
|
||||
for (const [subId, subscribedChannelId] of socket.subscriptions) {
|
||||
if (subscribedChannelId === channelId) {
|
||||
if (
|
||||
subscribedChannelId === channelId ||
|
||||
subscribedChannelId === GLOBAL_MOCK_SUBSCRIPTION
|
||||
) {
|
||||
sendWsText(socket.handler, ["EVENT", subId, event]);
|
||||
}
|
||||
}
|
||||
@@ -2534,13 +2538,15 @@ function sendToMockSocket(args: {
|
||||
const subId = rest[0] as string;
|
||||
const filter = rest[1] as { "#h"?: string[] };
|
||||
const channelId = filter["#h"]?.[0];
|
||||
if (!channelId) {
|
||||
|
||||
if (subId.startsWith("live-")) {
|
||||
socket.subscriptions.set(subId, channelId ?? GLOBAL_MOCK_SUBSCRIPTION);
|
||||
sendWsText(socket.handler, ["EOSE", subId]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subId.startsWith("live-")) {
|
||||
socket.subscriptions.set(subId, channelId);
|
||||
if (!channelId) {
|
||||
sendWsText(socket.handler, ["EOSE", subId]);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user