Refactor desktop modules and fix channel loading (#60)

This commit is contained in:
Wes
2026-03-13 16:26:07 -07:00
committed by GitHub
parent 3c440a1328
commit f4b9aadade
44 changed files with 5515 additions and 4743 deletions

View File

@@ -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",

View 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);
}

View 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()),
}
}

View 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(),
})
}

View 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
}

View 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())
}

View 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}"))
}

View 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::*;

View 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
}

View 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

View 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)
}

View 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::*;

View 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(())
}

View 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"))
}

View 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;

View 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
}

View 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(())
}

View 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
}

View File

@@ -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>

View 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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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`;
}

View File

@@ -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(
() =>

View File

@@ -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);

View 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>
);
}

View 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>
);
}

View File

@@ -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"

View 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>
))}
</>
);
}

View 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;
}

View 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,
};
}

View File

@@ -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;
}

View 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;
}

View File

@@ -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;
}