mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add multi-workspace support to desktop app (#409)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -67,7 +67,7 @@ const overrides = new Map([
|
||||
["src-tauri/src/huddle/tts.rs", 1030], // TTS pipeline + session warmup + cancel/shutdown handling + apply_fades + 18 unit tests for remote interrupt mechanism
|
||||
["src-tauri/src/commands/pairing.rs", 550], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + event parsing helpers
|
||||
["src-tauri/src/lib.rs", 715], // +4 lines for PairingHandle managed state + 3 pairing command registrations
|
||||
["src/shared/api/tauri.ts", 1110], // +14 lines for 3 NIP-AB pairing command wrappers
|
||||
["src/shared/api/tauri.ts", 1125], // +14 lines for 3 NIP-AB pairing command wrappers + applyWorkspace
|
||||
]);
|
||||
|
||||
async function walkFiles(directory) {
|
||||
|
||||
@@ -15,6 +15,9 @@ pub struct AppState {
|
||||
pub http_client: reqwest::Client,
|
||||
pub configured_api_token: Option<String>,
|
||||
pub session_token: Mutex<Option<String>>,
|
||||
/// Workspace-provided relay URL override. Set by `apply_workspace` on app
|
||||
/// init and takes priority over env vars and compile-time defaults.
|
||||
pub relay_url_override: Mutex<Option<String>>,
|
||||
pub managed_agents_store_lock: Mutex<()>,
|
||||
pub managed_agent_processes: Mutex<HashMap<String, ManagedAgentProcess>>,
|
||||
pub huddle_state: Mutex<HuddleState>,
|
||||
@@ -70,6 +73,7 @@ pub fn build_app_state() -> AppState {
|
||||
http_client: reqwest::Client::new(),
|
||||
configured_api_token: api_token,
|
||||
session_token: Mutex::new(None),
|
||||
relay_url_override: Mutex::new(None),
|
||||
managed_agents_store_lock: Mutex::new(()),
|
||||
managed_agent_processes: Mutex::new(HashMap::new()),
|
||||
huddle_state: Mutex::new(HuddleState::default()),
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::{
|
||||
normalize_agent_args, resolve_command, save_managed_agents, sync_managed_agent_processes,
|
||||
AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest, UpdateManagedAgentResponse,
|
||||
},
|
||||
relay::{relay_ws_url, sync_managed_agent_profile},
|
||||
relay::{relay_ws_url_with_override, sync_managed_agent_profile},
|
||||
util::now_iso,
|
||||
};
|
||||
|
||||
@@ -154,7 +154,7 @@ pub async fn update_managed_agent(
|
||||
if let Some(relay_url) = input.relay_url {
|
||||
let trimmed = relay_url.trim();
|
||||
record.relay_url = if trimmed.is_empty() {
|
||||
relay_ws_url()
|
||||
relay_ws_url_with_override(&state)
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::{
|
||||
DEFAULT_AGENT_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
|
||||
DEFAULT_MCP_COMMAND,
|
||||
},
|
||||
relay::{relay_ws_url, sync_managed_agent_profile},
|
||||
relay::{relay_ws_url_with_override, sync_managed_agent_profile},
|
||||
util::now_iso,
|
||||
};
|
||||
|
||||
@@ -221,7 +221,7 @@ pub async fn create_managed_agent(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(relay_ws_url);
|
||||
.unwrap_or_else(|| relay_ws_url_with_override(&state));
|
||||
|
||||
let mint_token = input.mint_token;
|
||||
(
|
||||
|
||||
@@ -4,7 +4,7 @@ use tauri::State;
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
models::IdentityInfo,
|
||||
relay::{relay_api_base_url, relay_ws_url},
|
||||
relay::{self, relay_api_base_url_with_override, relay_ws_url_with_override},
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
@@ -28,13 +28,18 @@ pub fn get_identity(state: State<'_, AppState>) -> Result<IdentityInfo, String>
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_relay_ws_url() -> String {
|
||||
relay_ws_url()
|
||||
pub fn get_default_relay_url() -> String {
|
||||
relay::relay_ws_url()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_relay_http_url() -> String {
|
||||
relay_api_base_url()
|
||||
pub fn get_relay_ws_url(state: State<'_, AppState>) -> String {
|
||||
relay_ws_url_with_override(&state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_relay_http_url(state: State<'_, AppState>) -> String {
|
||||
relay_api_base_url_with_override(&state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -89,9 +94,23 @@ pub fn create_auth_event(
|
||||
.map_err(|error| format!("challenge tag failed: {error}"))?,
|
||||
];
|
||||
|
||||
if let Some(token) = state.configured_api_token.as_deref() {
|
||||
// Use configured API token first, then fall back to session token
|
||||
// (set by workspace apply).
|
||||
let auth_token = state
|
||||
.configured_api_token
|
||||
.as_deref()
|
||||
.map(String::from)
|
||||
.or_else(|| {
|
||||
state
|
||||
.session_token
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|guard| guard.clone())
|
||||
});
|
||||
|
||||
if let Some(token) = auth_token {
|
||||
tags.push(
|
||||
Tag::parse(vec!["auth_token", token])
|
||||
Tag::parse(vec!["auth_token", &token])
|
||||
.map_err(|error| format!("auth token tag failed: {error}"))?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use sha2::{Digest, Sha256};
|
||||
use tauri::State;
|
||||
|
||||
use crate::app_state::AppState;
|
||||
use crate::relay::relay_api_base_url;
|
||||
use crate::relay::relay_api_base_url_with_override;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BlobDescriptor {
|
||||
@@ -123,6 +123,7 @@ fn sign_blossom_upload_auth(
|
||||
keys: &Keys,
|
||||
sha256: &str,
|
||||
expiry_secs: u64,
|
||||
base_url: &str,
|
||||
) -> Result<nostr::Event, String> {
|
||||
let now = Timestamp::now().as_u64();
|
||||
let mut tags = vec![
|
||||
@@ -131,7 +132,6 @@ fn sign_blossom_upload_auth(
|
||||
Tag::parse(vec!["expiration", &(now + expiry_secs).to_string()])
|
||||
.map_err(|e| e.to_string())?,
|
||||
];
|
||||
let base_url = relay_api_base_url();
|
||||
if let Some(domain) = extract_server_authority(&base_url) {
|
||||
tags.push(Tag::parse(vec!["server".to_string(), domain]).map_err(|e| e.to_string())?);
|
||||
}
|
||||
@@ -161,17 +161,16 @@ async fn do_upload(
|
||||
} else {
|
||||
300
|
||||
};
|
||||
let base_url = relay_api_base_url_with_override(state);
|
||||
let auth_event = {
|
||||
let keys = state.keys.lock().map_err(|e| e.to_string())?;
|
||||
sign_blossom_upload_auth(&keys, &sha256, expiry_secs)?
|
||||
sign_blossom_upload_auth(&keys, &sha256, expiry_secs, &base_url)?
|
||||
};
|
||||
|
||||
let auth_header = format!(
|
||||
"Nostr {}",
|
||||
URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes())
|
||||
);
|
||||
|
||||
let base_url = relay_api_base_url();
|
||||
let mut req = state
|
||||
.http_client
|
||||
.put(format!("{base_url}/media/upload"))
|
||||
|
||||
@@ -16,6 +16,7 @@ mod social;
|
||||
mod teams;
|
||||
pub mod tokens;
|
||||
mod workflows;
|
||||
mod workspace;
|
||||
|
||||
pub use agent_discovery::*;
|
||||
pub use agent_models::*;
|
||||
@@ -34,3 +35,4 @@ pub use social::*;
|
||||
pub use teams::*;
|
||||
pub use tokens::*;
|
||||
pub use workflows::*;
|
||||
pub use workspace::*;
|
||||
|
||||
@@ -15,7 +15,7 @@ use tokio_util::sync::CancellationToken;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::app_state::AppState;
|
||||
use crate::relay::{relay_api_base_url, relay_ws_url};
|
||||
use crate::relay::{relay_api_base_url_with_override, relay_ws_url_with_override};
|
||||
|
||||
use super::tokens::{mint_token_internal_with_auth_mode, MintTokenAuthMode};
|
||||
|
||||
@@ -118,8 +118,8 @@ pub async fn start_pairing(
|
||||
(nsec, pubkey)
|
||||
};
|
||||
|
||||
let ws_url = relay_ws_url();
|
||||
let http_url = relay_api_base_url();
|
||||
let ws_url = relay_ws_url_with_override(&state);
|
||||
let http_url = relay_api_base_url_with_override(&state);
|
||||
|
||||
let (session, qr_payload) = PairingSession::new_source(ws_url.clone());
|
||||
let qr_uri = encode_qr(&qr_payload);
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::{
|
||||
models::{ListTokensResponse, MintTokenBody, MintTokenResponse, RevokeAllTokensResponse},
|
||||
relay::{
|
||||
api_path, build_authed_request, build_nip98_auth_header, build_token_management_request,
|
||||
relay_api_base_url, send_empty_request, send_json_request,
|
||||
relay_api_base_url_with_override, send_empty_request, send_json_request,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -45,7 +45,11 @@ fn build_mint_token_request(
|
||||
);
|
||||
}
|
||||
|
||||
let url = format!("{}{}", relay_api_base_url(), "/api/tokens");
|
||||
let url = format!(
|
||||
"{}{}",
|
||||
relay_api_base_url_with_override(state),
|
||||
"/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)?;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
use nostr::Keys;
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
|
||||
use crate::app_state::AppState;
|
||||
use crate::relay;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ActiveWorkspaceInfo {
|
||||
relay_url: String,
|
||||
pubkey: String,
|
||||
}
|
||||
|
||||
/// Returns the current active workspace info (relay URL + pubkey).
|
||||
#[tauri::command]
|
||||
pub fn get_active_workspace(state: State<'_, AppState>) -> Result<ActiveWorkspaceInfo, String> {
|
||||
let keys = state.keys.lock().map_err(|e| e.to_string())?;
|
||||
let relay_url = relay::relay_ws_url_with_override(&state);
|
||||
Ok(ActiveWorkspaceInfo {
|
||||
relay_url,
|
||||
pubkey: keys.public_key().to_hex(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply a workspace's configuration to the backend session.
|
||||
///
|
||||
/// Called by the frontend on app init (after reload) to configure the
|
||||
/// Tauri backend with the selected workspace's relay URL, keys, and token.
|
||||
#[tauri::command]
|
||||
pub fn apply_workspace(
|
||||
relay_url: String,
|
||||
nsec: Option<String>,
|
||||
token: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
// ── Validate before mutating ──────────────────────────────────────────
|
||||
let parsed_keys = match nsec.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||
Some(nsec_trimmed) => {
|
||||
Some(Keys::parse(nsec_trimmed).map_err(|e| format!("invalid nsec: {e}"))?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
// ── Apply all state changes (nothing below can fail) ──────────────────
|
||||
{
|
||||
let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?;
|
||||
*override_guard = Some(relay_url);
|
||||
}
|
||||
|
||||
if let Some(keys) = parsed_keys {
|
||||
let mut keys_guard = state.keys.lock().map_err(|e| e.to_string())?;
|
||||
*keys_guard = keys;
|
||||
}
|
||||
|
||||
{
|
||||
let mut token_guard = state.session_token.lock().map_err(|e| e.to_string())?;
|
||||
*token_guard = token;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -477,8 +477,8 @@ pub fn build_contact_list(
|
||||
/// Post a pre-signed event to the relay.
|
||||
///
|
||||
/// Standalone helper for async tasks that don't have access to `&AppState`.
|
||||
/// The caller pre-captures `http_client`, `api_token`, and `pubkey_hex` at
|
||||
/// spawn time and passes them here.
|
||||
/// The caller pre-captures `http_client`, `api_token`, `pubkey_hex`, and
|
||||
/// `relay_base_url` at spawn time and passes them here.
|
||||
///
|
||||
/// Returns `Err` on transport failure OR non-2xx HTTP status.
|
||||
pub async fn post_event_raw(
|
||||
@@ -486,8 +486,9 @@ pub async fn post_event_raw(
|
||||
api_token: Option<&str>,
|
||||
pubkey_hex: &str,
|
||||
event_json: String,
|
||||
relay_base_url: &str,
|
||||
) -> Result<(), String> {
|
||||
let url = format!("{}/api/events", crate::relay::relay_api_base_url());
|
||||
let url = format!("{relay_base_url}/api/events");
|
||||
let req = match api_token {
|
||||
Some(token) => http_client
|
||||
.post(&url)
|
||||
|
||||
@@ -248,6 +248,7 @@ pub(crate) fn spawn_transcription_task(
|
||||
Err(_) => return,
|
||||
};
|
||||
let configured_api_token = state.configured_api_token.clone();
|
||||
let relay_base_url = crate::relay::relay_api_base_url_with_override(state);
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// recv().await yields (not blocks) until text arrives or sender is dropped.
|
||||
@@ -288,9 +289,14 @@ pub(crate) fn spawn_transcription_task(
|
||||
let api_token_ref = configured_api_token.as_deref();
|
||||
let pubkey_hex = keys.public_key().to_hex();
|
||||
|
||||
if let Err(e) =
|
||||
crate::events::post_event_raw(&http_client, api_token_ref, &pubkey_hex, event_json)
|
||||
.await
|
||||
if let Err(e) = crate::events::post_event_raw(
|
||||
&http_client,
|
||||
api_token_ref,
|
||||
&pubkey_hex,
|
||||
event_json,
|
||||
&relay_base_url,
|
||||
)
|
||||
.await
|
||||
{
|
||||
eprintln!("sprout-desktop: STT kind:9 post failed: {e}");
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ pub(crate) async fn connect_audio_relay(
|
||||
) -> Result<(CancellationToken, tokio::sync::mpsc::Sender<Vec<u8>>), String> {
|
||||
use nostr::JsonUtil;
|
||||
|
||||
let relay_url = crate::relay::relay_ws_url();
|
||||
let relay_url = crate::relay::relay_ws_url_with_override(state);
|
||||
let ws_url = format!("{relay_url}/huddle/{channel_id}/audio");
|
||||
|
||||
let keys = state.keys.lock().map_err(|e| e.to_string())?.clone();
|
||||
|
||||
@@ -306,9 +306,10 @@ pub fn run() {
|
||||
// client so WARP tunnelling applies. The port is stored in AppState
|
||||
// and exposed to the frontend via the `get_media_proxy_port` command.
|
||||
let proxy_client = state.http_client.clone();
|
||||
let proxy_base_url = relay::relay_api_base_url_with_override(&state);
|
||||
let proxy_handle = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let port = media_proxy::spawn_media_proxy(proxy_client).await;
|
||||
let port = media_proxy::spawn_media_proxy(proxy_client, proxy_base_url).await;
|
||||
let state = proxy_handle.state::<AppState>();
|
||||
state
|
||||
.media_proxy_port
|
||||
@@ -364,6 +365,7 @@ pub fn run() {
|
||||
search_users,
|
||||
get_presence,
|
||||
set_presence,
|
||||
get_default_relay_url,
|
||||
get_relay_ws_url,
|
||||
get_relay_http_url,
|
||||
get_media_proxy_port,
|
||||
@@ -473,6 +475,8 @@ pub fn run() {
|
||||
start_pairing,
|
||||
confirm_pairing_sas,
|
||||
cancel_pairing,
|
||||
apply_workspace,
|
||||
get_active_workspace,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application");
|
||||
|
||||
@@ -111,10 +111,10 @@ async fn proxy_handler(AxumState(state): AxumState<ProxyState>, req: Request) ->
|
||||
/// Spawn a localhost HTTP proxy that streams media via reqwest, avoiding the
|
||||
/// Tauri protocol handler's requirement to buffer the entire response into
|
||||
/// `Vec<u8>`. Returns the OS-assigned port.
|
||||
pub async fn spawn_media_proxy(http_client: reqwest::Client) -> u16 {
|
||||
pub async fn spawn_media_proxy(http_client: reqwest::Client, base_url: String) -> u16 {
|
||||
let proxy_state = ProxyState {
|
||||
client: http_client,
|
||||
base_url: relay::relay_api_base_url(),
|
||||
base_url,
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
@@ -146,7 +146,7 @@ pub async fn handle_sprout_media(
|
||||
use tauri::Manager;
|
||||
|
||||
let state = app.state::<AppState>();
|
||||
let base = relay::relay_api_base_url();
|
||||
let base = relay::relay_api_base_url_with_override(&state);
|
||||
|
||||
// Preserve path + query (thumbnails may have query params).
|
||||
// Only proxy /media/ paths — reject anything else.
|
||||
|
||||
@@ -23,6 +23,31 @@ pub fn relay_ws_url() -> String {
|
||||
.unwrap_or_else(|| DEFAULT_RELAY_WS_URL.to_string())
|
||||
}
|
||||
|
||||
/// Read the workspace relay URL override, if set. Returns `None` when no
|
||||
/// override is active or when the mutex is poisoned (best-effort).
|
||||
fn workspace_relay_override(state: &AppState) -> Option<String> {
|
||||
state
|
||||
.relay_url_override
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|guard| guard.clone())
|
||||
}
|
||||
|
||||
/// Returns the relay WebSocket URL, checking the workspace override first.
|
||||
/// Precedence: workspace override > env vars > build-time vars > default.
|
||||
pub fn relay_ws_url_with_override(state: &AppState) -> String {
|
||||
workspace_relay_override(state).unwrap_or_else(relay_ws_url)
|
||||
}
|
||||
|
||||
/// Returns the relay HTTP API base URL, checking the workspace override first.
|
||||
/// Precedence: workspace override > env vars > build-time vars > default.
|
||||
pub fn relay_api_base_url_with_override(state: &AppState) -> String {
|
||||
match workspace_relay_override(state) {
|
||||
Some(url) => relay_http_base_url(&url),
|
||||
None => relay_api_base_url(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn relay_http_base_url(relay_url: &str) -> String {
|
||||
let trimmed = relay_url.trim().trim_end_matches('/');
|
||||
|
||||
@@ -86,13 +111,17 @@ pub fn build_authed_request(
|
||||
state: &AppState,
|
||||
) -> Result<reqwest::RequestBuilder, String> {
|
||||
validate_api_path(path)?;
|
||||
let url = format!("{}{}", relay_api_base_url(), path);
|
||||
let url = format!("{}{}", relay_api_base_url_with_override(state), 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))
|
||||
}
|
||||
@@ -177,7 +206,7 @@ pub fn build_token_management_request(
|
||||
state: &AppState,
|
||||
) -> Result<reqwest::RequestBuilder, String> {
|
||||
validate_api_path(path)?;
|
||||
let url = format!("{}{}", relay_api_base_url(), path);
|
||||
let url = format!("{}{}", relay_api_base_url_with_override(state), path);
|
||||
let request = client.request(method, url);
|
||||
|
||||
if let Some(token) = state.configured_api_token.as_deref() {
|
||||
@@ -323,14 +352,17 @@ pub async fn submit_event(
|
||||
.sign_with_keys(&keys)
|
||||
.map_err(|e| format!("failed to sign event: {e}"))?;
|
||||
let json = event.as_json();
|
||||
let auth = match state.configured_api_token.as_deref() {
|
||||
Some(token) => format!("Bearer {token}"),
|
||||
None => format!("X-Pubkey {}", keys.public_key().to_hex()),
|
||||
let auth = if let Some(token) = state.configured_api_token.as_deref() {
|
||||
format!("Bearer {token}")
|
||||
} else if let Some(token) = session_api_token(state)? {
|
||||
format!("Bearer {token}")
|
||||
} else {
|
||||
format!("X-Pubkey {}", keys.public_key().to_hex())
|
||||
};
|
||||
(json, auth)
|
||||
}; // keys lock dropped here
|
||||
|
||||
let url = format!("{}/api/events", relay_api_base_url());
|
||||
let url = format!("{}/api/events", relay_api_base_url_with_override(state));
|
||||
let request = if auth_header.starts_with("Bearer ") {
|
||||
state
|
||||
.http_client
|
||||
|
||||
+36
-6
@@ -1,10 +1,12 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
import { useLayoutEffect } from "react";
|
||||
import { useCallback, useLayoutEffect } from "react";
|
||||
|
||||
import { router } from "@/app/router";
|
||||
import { useAppOnboardingState } from "@/features/onboarding/hooks";
|
||||
import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow";
|
||||
import { useWorkspaceInit } from "@/features/workspaces/useWorkspaceInit";
|
||||
import { WelcomeSetup } from "@/features/workspaces/ui/WelcomeSetup";
|
||||
|
||||
function AppLoadingGate() {
|
||||
return (
|
||||
@@ -24,11 +26,7 @@ function AppLoadingGate() {
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
useLayoutEffect(() => {
|
||||
void getCurrentWindow().show();
|
||||
}, []);
|
||||
|
||||
function AppReady() {
|
||||
const onboarding = useAppOnboardingState();
|
||||
|
||||
if (onboarding.stage === "onboarding") {
|
||||
@@ -48,3 +46,35 @@ export function App() {
|
||||
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
useLayoutEffect(() => {
|
||||
void getCurrentWindow().show();
|
||||
}, []);
|
||||
|
||||
const workspace = useWorkspaceInit();
|
||||
|
||||
const handleSetupComplete = useCallback(() => {
|
||||
// Force a full reload so useWorkspaceInit re-runs and picks up
|
||||
// the newly-created workspace from localStorage.
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
// Show welcome setup for first-run users with no workspaces
|
||||
if (workspace.needsSetup) {
|
||||
return (
|
||||
<WelcomeSetup
|
||||
defaultRelayUrl={workspace.defaultRelayUrl}
|
||||
onComplete={handleSetupComplete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for workspace config to be applied to the backend before
|
||||
// rendering anything that connects to the relay.
|
||||
if (!workspace.isReady) {
|
||||
return <AppLoadingGate />;
|
||||
}
|
||||
|
||||
return <AppReady />;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from "@/features/settings/ui/SettingsPanels";
|
||||
import { HuddleBar, HuddleProvider } from "@/features/huddle";
|
||||
import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
|
||||
import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup";
|
||||
@@ -116,6 +117,9 @@ function deriveShellRoute(pathname: string): {
|
||||
export function AppShell() {
|
||||
useWebviewZoomShortcuts();
|
||||
|
||||
const workspacesHook = useWorkspaces();
|
||||
const [isAddWorkspaceOpen, setIsAddWorkspaceOpen] = React.useState(false);
|
||||
|
||||
const [settingsOpen, setSettingsOpen] = React.useState(false);
|
||||
const [settingsSection, setSettingsSection] = React.useState<SettingsSection>(
|
||||
DEFAULT_SETTINGS_SECTION,
|
||||
@@ -487,6 +491,7 @@ export function AppShell() {
|
||||
/>
|
||||
</div>
|
||||
<AppSidebar
|
||||
activeWorkspace={workspacesHook.activeWorkspace}
|
||||
channels={sidebarChannels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
errorMessage={
|
||||
@@ -496,14 +501,25 @@ export function AppShell() {
|
||||
}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
homeBadgeCount={homeBadgeCount}
|
||||
isAddWorkspaceOpen={isAddWorkspaceOpen}
|
||||
isCreatingChannel={createChannelMutation.isPending}
|
||||
isCreatingForum={createForumMutation.isPending}
|
||||
isLoading={channelsQuery.isLoading}
|
||||
isOpeningDm={openDmMutation.isPending}
|
||||
isNewDmOpen={isNewDmOpen}
|
||||
isPresencePending={presenceSession.isPending}
|
||||
onAddWorkspace={(workspace) => {
|
||||
const id = workspacesHook.addWorkspace(workspace);
|
||||
workspacesHook.switchWorkspace(id);
|
||||
}}
|
||||
onAddWorkspaceOpenChange={setIsAddWorkspaceOpen}
|
||||
onNewDmOpenChange={setIsNewDmOpen}
|
||||
onOpenAddWorkspace={() => setIsAddWorkspaceOpen(true)}
|
||||
onUpdateWorkspace={workspacesHook.updateWorkspace}
|
||||
onRemoveWorkspace={workspacesHook.removeWorkspace}
|
||||
onSwitchWorkspace={workspacesHook.switchWorkspace}
|
||||
selfPresenceStatus={presenceSession.currentStatus}
|
||||
workspaces={workspacesHook.workspaces}
|
||||
onCreateChannel={async ({
|
||||
description,
|
||||
name,
|
||||
|
||||
@@ -230,7 +230,7 @@ export function useLiveChannelUpdates(
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
let retryTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let retryTimeout: number | undefined;
|
||||
let retryAttempt = 0;
|
||||
|
||||
const syncSubs = async (): Promise<boolean> => {
|
||||
|
||||
@@ -35,7 +35,7 @@ type ForumComposerProps = {
|
||||
content: string,
|
||||
mentionPubkeys: string[],
|
||||
mediaTags?: string[][],
|
||||
) => void | Promise<unknown>;
|
||||
) => undefined | Promise<unknown>;
|
||||
/** When true, autocomplete renders below the input (for top-of-view composers). */
|
||||
autocompleteBelow?: boolean;
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ type ForumThreadPanelProps = {
|
||||
content: string,
|
||||
mentionPubkeys: string[],
|
||||
mediaTags?: string[][],
|
||||
) => void | Promise<unknown>;
|
||||
) => undefined | Promise<unknown>;
|
||||
onDeletePost?: (eventId: string) => void;
|
||||
onDeleteReply?: (eventId: string) => void;
|
||||
onTargetReached?: (eventId: string) => void;
|
||||
|
||||
@@ -3,6 +3,9 @@ import { Activity, Bot, Home, PenSquare, Plus, Search, Zap } from "lucide-react"
|
||||
import * as React from "react";
|
||||
|
||||
import { useManagedAgentsQuery } from "@/features/agents/hooks";
|
||||
import type { Workspace } from "@/features/workspaces/types";
|
||||
import { AddWorkspaceDialog } from "@/features/workspaces/ui/AddWorkspaceDialog";
|
||||
import { WorkspaceSwitcher } from "@/features/workspaces/ui/WorkspaceSwitcher";
|
||||
import { useDeferredLoad } from "@/shared/hooks/useDeferredStartup";
|
||||
import { getPresenceLabel } from "@/features/presence/lib/presence";
|
||||
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
|
||||
@@ -53,10 +56,12 @@ const SECTION_ICON_BUTTON_CLASS =
|
||||
type CreateChannelKind = "stream" | "forum";
|
||||
|
||||
type AppSidebarProps = {
|
||||
activeWorkspace: Workspace | null;
|
||||
channels: Channel[];
|
||||
currentPubkey?: string;
|
||||
fallbackDisplayName?: string;
|
||||
homeBadgeCount: number;
|
||||
isAddWorkspaceOpen?: boolean;
|
||||
isLoading: boolean;
|
||||
isCreatingChannel: boolean;
|
||||
isCreatingForum: boolean;
|
||||
@@ -67,6 +72,9 @@ type AppSidebarProps = {
|
||||
selectedChannelId: string | null;
|
||||
selectedView: "home" | "channel" | "agents" | "workflows" | "pulse";
|
||||
unreadChannelIds: Set<string>;
|
||||
workspaces: Workspace[];
|
||||
onAddWorkspace: (workspace: Workspace) => void;
|
||||
onAddWorkspaceOpenChange?: (open: boolean) => void;
|
||||
onCreateChannel: (input: {
|
||||
name: string;
|
||||
description?: string;
|
||||
@@ -79,11 +87,17 @@ type AppSidebarProps = {
|
||||
visibility: ChannelVisibility;
|
||||
ttlSeconds?: number;
|
||||
}) => Promise<void>;
|
||||
onOpenAddWorkspace: () => void;
|
||||
onOpenBrowseChannels: () => void;
|
||||
onOpenBrowseForums: () => void;
|
||||
onOpenSearch: () => void;
|
||||
onHideDm: (channelId: string) => void;
|
||||
onOpenDm: (input: { pubkeys: string[] }) => Promise<void>;
|
||||
onUpdateWorkspace: (
|
||||
id: string,
|
||||
updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token">>,
|
||||
) => void;
|
||||
onRemoveWorkspace: (id: string) => void;
|
||||
onSelectAgents: () => void;
|
||||
onSelectPulse: () => void;
|
||||
onSelectWorkflows: () => void;
|
||||
@@ -91,6 +105,7 @@ type AppSidebarProps = {
|
||||
onSelectChannel: (channelId: string) => void;
|
||||
onSelectSettings: () => void;
|
||||
onSetPresenceStatus?: (status: "online" | "away" | "offline") => void;
|
||||
onSwitchWorkspace: (id: string) => void;
|
||||
isPresencePending?: boolean;
|
||||
isNewDmOpen?: boolean;
|
||||
onNewDmOpenChange?: (open: boolean) => void;
|
||||
@@ -204,10 +219,12 @@ function ChannelGroupSection({
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AppSidebar({
|
||||
activeWorkspace,
|
||||
channels,
|
||||
currentPubkey,
|
||||
fallbackDisplayName,
|
||||
homeBadgeCount,
|
||||
isAddWorkspaceOpen,
|
||||
isLoading,
|
||||
isCreatingChannel,
|
||||
isCreatingForum,
|
||||
@@ -218,13 +235,19 @@ export function AppSidebar({
|
||||
selectedChannelId,
|
||||
selectedView,
|
||||
unreadChannelIds,
|
||||
workspaces,
|
||||
onAddWorkspace,
|
||||
onAddWorkspaceOpenChange,
|
||||
onCreateChannel,
|
||||
onCreateForum,
|
||||
onOpenAddWorkspace,
|
||||
onOpenBrowseChannels,
|
||||
onOpenBrowseForums,
|
||||
onOpenSearch,
|
||||
onHideDm,
|
||||
onOpenDm,
|
||||
onUpdateWorkspace,
|
||||
onRemoveWorkspace,
|
||||
onSelectAgents,
|
||||
onSelectPulse,
|
||||
onSelectWorkflows,
|
||||
@@ -232,6 +255,7 @@ export function AppSidebar({
|
||||
onSelectChannel,
|
||||
onSelectSettings,
|
||||
onSetPresenceStatus,
|
||||
onSwitchWorkspace,
|
||||
isPresencePending,
|
||||
isNewDmOpen: isNewDmOpenProp,
|
||||
onNewDmOpenChange,
|
||||
@@ -316,6 +340,16 @@ export function AppSidebar({
|
||||
variant="sidebar"
|
||||
>
|
||||
<SidebarHeader className="gap-3 pt-10" data-tauri-drag-region>
|
||||
<div className="px-0.5">
|
||||
<WorkspaceSwitcher
|
||||
activeWorkspace={activeWorkspace}
|
||||
onAddWorkspace={onOpenAddWorkspace}
|
||||
onRemoveWorkspace={onRemoveWorkspace}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
onUpdateWorkspace={onUpdateWorkspace}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full justify-between rounded-xl border border-sidebar-border/80 bg-sidebar-accent/60 px-3 text-sidebar-foreground/80 shadow-sm hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
data-testid="open-search"
|
||||
@@ -481,8 +515,6 @@ export function AppSidebar({
|
||||
) : null}
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarSeparator className="mx-0 w-full" />
|
||||
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
@@ -557,6 +589,12 @@ export function AppSidebar({
|
||||
onSubmit={onOpenDm}
|
||||
open={isNewDmOpen}
|
||||
/>
|
||||
|
||||
<AddWorkspaceDialog
|
||||
onOpenChange={onAddWorkspaceOpenChange ?? (() => {})}
|
||||
onSubmit={onAddWorkspace}
|
||||
open={isAddWorkspaceOpen ?? false}
|
||||
/>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export type Workspace = {
|
||||
id: string;
|
||||
name: string;
|
||||
relayUrl: string;
|
||||
token?: string;
|
||||
nsec?: string;
|
||||
pubkey?: string;
|
||||
addedAt: string;
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { Workspace } from "@/features/workspaces/types";
|
||||
import {
|
||||
deriveWorkspaceName,
|
||||
normalizeRelayUrl,
|
||||
} from "@/features/workspaces/workspaceStorage";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
|
||||
type AddWorkspaceDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (workspace: Workspace) => void;
|
||||
};
|
||||
|
||||
export function AddWorkspaceDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: AddWorkspaceDialogProps) {
|
||||
const [name, setName] = React.useState("");
|
||||
const [relayUrl, setRelayUrl] = React.useState("");
|
||||
const [token, setToken] = React.useState("");
|
||||
const [nsec, setNsec] = React.useState("");
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
onOpenChange(false);
|
||||
setName("");
|
||||
setRelayUrl("");
|
||||
setToken("");
|
||||
setNsec("");
|
||||
}, [onOpenChange]);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!relayUrl.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspace: Workspace = {
|
||||
id: crypto.randomUUID(),
|
||||
name: name.trim() || deriveWorkspaceName(relayUrl.trim()),
|
||||
relayUrl: normalizeRelayUrl(relayUrl.trim()),
|
||||
token: token.trim() || undefined,
|
||||
nsec: nsec.trim() || undefined,
|
||||
addedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
onSubmit(workspace);
|
||||
handleClose();
|
||||
},
|
||||
[name, relayUrl, token, nsec, onSubmit, handleClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Workspace</DialogTitle>
|
||||
<DialogDescription>
|
||||
Connect to another Sprout relay. Each workspace has its own
|
||||
channels, messages, and identity.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="ws-relay-url"
|
||||
>
|
||||
Relay URL
|
||||
</label>
|
||||
<Input
|
||||
autoFocus
|
||||
id="ws-relay-url"
|
||||
onChange={(e) => setRelayUrl(e.target.value)}
|
||||
placeholder="wss://relay.example.com"
|
||||
type="text"
|
||||
value={relayUrl}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="ws-name"
|
||||
>
|
||||
Name
|
||||
<span className="ml-1 text-xs font-normal text-muted-foreground">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<Input
|
||||
id="ws-name"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My Workspace"
|
||||
type="text"
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="ws-token"
|
||||
>
|
||||
API Token
|
||||
<span className="ml-1 text-xs font-normal text-muted-foreground">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<Input
|
||||
id="ws-token"
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="sprout_..."
|
||||
type="password"
|
||||
value={token}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="ws-nsec"
|
||||
>
|
||||
Private Key (nsec)
|
||||
<span className="ml-1 text-xs font-normal text-muted-foreground">
|
||||
(optional — uses current identity if blank)
|
||||
</span>
|
||||
</label>
|
||||
<Input
|
||||
id="ws-nsec"
|
||||
onChange={(e) => setNsec(e.target.value)}
|
||||
placeholder="nsec1..."
|
||||
type="password"
|
||||
value={nsec}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button onClick={handleClose} type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={!relayUrl.trim()} type="submit">
|
||||
Add Workspace
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { Workspace } from "@/features/workspaces/types";
|
||||
import { normalizeRelayUrl } from "@/features/workspaces/workspaceStorage";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
|
||||
type EditWorkspaceDialogProps = {
|
||||
workspace: Workspace | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (
|
||||
id: string,
|
||||
updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token">>,
|
||||
) => void;
|
||||
onRemove?: (id: string) => void;
|
||||
canRemove?: boolean;
|
||||
};
|
||||
|
||||
export function EditWorkspaceDialog({
|
||||
workspace,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
onRemove,
|
||||
canRemove,
|
||||
}: EditWorkspaceDialogProps) {
|
||||
const [name, setName] = React.useState("");
|
||||
const [relayUrl, setRelayUrl] = React.useState("");
|
||||
const [token, setToken] = React.useState("");
|
||||
|
||||
// Sync form state when the dialog opens with a workspace
|
||||
React.useEffect(() => {
|
||||
if (workspace && open) {
|
||||
setName(workspace.name);
|
||||
setRelayUrl(workspace.relayUrl);
|
||||
setToken(workspace.token ?? "");
|
||||
}
|
||||
}, [workspace, open]);
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange]);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!workspace || !relayUrl.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token">> =
|
||||
{};
|
||||
|
||||
const trimmedName = name.trim();
|
||||
if (trimmedName && trimmedName !== workspace.name) {
|
||||
updates.name = trimmedName;
|
||||
}
|
||||
|
||||
const normalizedUrl = normalizeRelayUrl(relayUrl.trim());
|
||||
if (normalizedUrl !== workspace.relayUrl) {
|
||||
updates.relayUrl = normalizedUrl;
|
||||
}
|
||||
|
||||
const trimmedToken = token.trim() || undefined;
|
||||
if (trimmedToken !== workspace.token) {
|
||||
updates.token = trimmedToken;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
onSave(workspace.id, updates);
|
||||
}
|
||||
|
||||
handleClose();
|
||||
},
|
||||
[workspace, name, relayUrl, token, onSave, handleClose],
|
||||
);
|
||||
|
||||
const handleRemove = React.useCallback(() => {
|
||||
if (workspace && onRemove) {
|
||||
onRemove(workspace.id);
|
||||
handleClose();
|
||||
}
|
||||
}, [workspace, onRemove, handleClose]);
|
||||
|
||||
if (!workspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Workspace</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update this workspace's name or relay URL.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="edit-ws-name"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
autoFocus
|
||||
id="edit-ws-name"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My Workspace"
|
||||
type="text"
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="edit-ws-relay-url"
|
||||
>
|
||||
Relay URL
|
||||
</label>
|
||||
<Input
|
||||
id="edit-ws-relay-url"
|
||||
onChange={(e) => setRelayUrl(e.target.value)}
|
||||
placeholder="wss://relay.example.com"
|
||||
type="text"
|
||||
value={relayUrl}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="edit-ws-token"
|
||||
>
|
||||
API Token
|
||||
<span className="ml-1 text-xs font-normal text-muted-foreground">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<Input
|
||||
id="edit-ws-token"
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="sprout_..."
|
||||
type="password"
|
||||
value={token}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div>
|
||||
{canRemove && onRemove ? (
|
||||
<Button
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={handleRemove}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Remove Workspace
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleClose} type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={!name.trim() || !relayUrl.trim()} type="submit">
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { getIdentity, getNsec } from "@/shared/api/tauri";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
|
||||
import type { Workspace } from "../types";
|
||||
import {
|
||||
deriveWorkspaceName,
|
||||
normalizeRelayUrl,
|
||||
saveActiveWorkspaceId,
|
||||
saveWorkspaces,
|
||||
} from "../workspaceStorage";
|
||||
|
||||
const LOCAL_RELAY_URL = "ws://localhost:3000";
|
||||
|
||||
type WelcomeSetupProps = {
|
||||
defaultRelayUrl: string;
|
||||
onComplete: () => void;
|
||||
};
|
||||
|
||||
export function WelcomeSetup({
|
||||
defaultRelayUrl,
|
||||
onComplete,
|
||||
}: WelcomeSetupProps) {
|
||||
const isInternalBuild = defaultRelayUrl !== LOCAL_RELAY_URL;
|
||||
const [relayUrl, setRelayUrl] = React.useState(defaultRelayUrl);
|
||||
const [isConnecting, setIsConnecting] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const handleConnect = React.useCallback(async () => {
|
||||
const trimmedUrl = relayUrl.trim();
|
||||
if (!trimmedUrl) {
|
||||
setError("Please enter a relay URL.");
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedUrl = normalizeRelayUrl(trimmedUrl);
|
||||
setIsConnecting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [identity, nsec] = await Promise.all([getIdentity(), getNsec()]);
|
||||
|
||||
const workspace: Workspace = {
|
||||
id: crypto.randomUUID(),
|
||||
name: deriveWorkspaceName(normalizedUrl),
|
||||
relayUrl: normalizedUrl,
|
||||
nsec,
|
||||
pubkey: identity.pubkey,
|
||||
addedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
saveWorkspaces([workspace]);
|
||||
saveActiveWorkspaceId(workspace.id);
|
||||
|
||||
// The reload triggered by onComplete() will re-run useWorkspaceInit,
|
||||
// which calls applyWorkspace with the saved config. No need to apply here.
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to connect. Try again.",
|
||||
);
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, [relayUrl, onComplete]);
|
||||
|
||||
const workspaceName = React.useMemo(
|
||||
() => deriveWorkspaceName(relayUrl.trim() || LOCAL_RELAY_URL),
|
||||
[relayUrl],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-[radial-gradient(circle_at_top,hsl(var(--primary)/0.14),transparent_48%),linear-gradient(180deg,hsl(var(--background)),hsl(var(--muted)/0.55))] px-4 py-8">
|
||||
<div className="w-full max-w-sm rounded-[28px] border border-border/70 bg-background/92 p-8 shadow-2xl backdrop-blur">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Sprout
|
||||
</p>
|
||||
<h1 className="mt-3 text-2xl font-semibold tracking-tight text-foreground">
|
||||
Welcome
|
||||
</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
{isInternalBuild
|
||||
? "Connect to your workspace to get started."
|
||||
: "Running a local relay? Connect now. Or enter a custom relay URL."}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
{!isInternalBuild ? (
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
htmlFor="relay-url"
|
||||
>
|
||||
Relay URL
|
||||
</label>
|
||||
<Input
|
||||
id="relay-url"
|
||||
onChange={(e) => {
|
||||
setRelayUrl(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="ws://localhost:3000"
|
||||
type="url"
|
||||
value={relayUrl}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={isConnecting || !relayUrl.trim()}
|
||||
onClick={handleConnect}
|
||||
size="default"
|
||||
type="button"
|
||||
>
|
||||
{isConnecting
|
||||
? "Connecting..."
|
||||
: isInternalBuild
|
||||
? `Connect to ${workspaceName}`
|
||||
: "Connect"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Check, ChevronDown, MoreHorizontal, Plus } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import type { Workspace } from "@/features/workspaces/types";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/shared/ui/sidebar";
|
||||
|
||||
import { EditWorkspaceDialog } from "./EditWorkspaceDialog";
|
||||
|
||||
type WorkspaceSwitcherProps = {
|
||||
activeWorkspace: Workspace | null;
|
||||
workspaces: Workspace[];
|
||||
onSwitchWorkspace: (id: string) => void;
|
||||
onAddWorkspace: () => void;
|
||||
onUpdateWorkspace: (
|
||||
id: string,
|
||||
updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token">>,
|
||||
) => void;
|
||||
onRemoveWorkspace: (id: string) => void;
|
||||
};
|
||||
|
||||
export function WorkspaceSwitcher({
|
||||
activeWorkspace,
|
||||
workspaces,
|
||||
onSwitchWorkspace,
|
||||
onAddWorkspace,
|
||||
onUpdateWorkspace,
|
||||
onRemoveWorkspace,
|
||||
}: WorkspaceSwitcherProps) {
|
||||
const [editingWorkspace, setEditingWorkspace] =
|
||||
React.useState<Workspace | null>(null);
|
||||
const [dropdownOpen, setDropdownOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
className="h-auto gap-2 rounded-xl px-2.5 py-2 data-[state=open]:bg-sidebar-accent"
|
||||
data-testid="workspace-switcher"
|
||||
type="button"
|
||||
>
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-primary/15 text-xs leading-none">
|
||||
🌱
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{activeWorkspace?.name ?? "No workspace"}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-sidebar-foreground/50" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-[--radix-dropdown-menu-trigger-width] min-w-[220px]"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
side="bottom"
|
||||
sideOffset={4}
|
||||
>
|
||||
{workspaces.map((workspace) => (
|
||||
<DropdownMenuItem
|
||||
key={workspace.id}
|
||||
className="group flex items-center gap-2 pr-1"
|
||||
onSelect={() => {
|
||||
onSwitchWorkspace(workspace.id);
|
||||
}}
|
||||
>
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
{activeWorkspace?.id === workspace.id ? (
|
||||
<Check className="h-3.5 w-3.5 text-primary" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{workspace.name}
|
||||
</span>
|
||||
<button
|
||||
aria-label={`Edit ${workspace.name}`}
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded opacity-0 hover:bg-accent group-hover:opacity-100 group-focus:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setDropdownOpen(false);
|
||||
setEditingWorkspace(workspace);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onAddWorkspace}>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Add Workspace</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
|
||||
<EditWorkspaceDialog
|
||||
canRemove={workspaces.length > 1}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditingWorkspace(null);
|
||||
}}
|
||||
onRemove={onRemoveWorkspace}
|
||||
onSave={onUpdateWorkspace}
|
||||
open={editingWorkspace !== null}
|
||||
workspace={editingWorkspace}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { applyWorkspace, getDefaultRelayUrl } from "@/shared/api/tauri";
|
||||
|
||||
import {
|
||||
loadActiveWorkspaceId,
|
||||
loadWorkspaces,
|
||||
saveActiveWorkspaceId,
|
||||
} from "./workspaceStorage";
|
||||
|
||||
type WorkspaceInitResult =
|
||||
| { isReady: true; needsSetup: false }
|
||||
| { isReady: false; needsSetup: true; defaultRelayUrl: string }
|
||||
| { isReady: false; needsSetup: false };
|
||||
|
||||
/**
|
||||
* Runs once on mount. Loads the active workspace from localStorage
|
||||
* and calls the Tauri backend to apply the workspace config
|
||||
* (keys, relay URL, token).
|
||||
*
|
||||
* Returns a discriminated union — only render the app after the
|
||||
* workspace is applied. When `needsSetup` is true, the caller
|
||||
* should show a first-run welcome screen.
|
||||
*/
|
||||
export function useWorkspaceInit(): WorkspaceInitResult {
|
||||
const [result, setResult] = useState<WorkspaceInitResult>({
|
||||
isReady: false,
|
||||
needsSetup: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function init() {
|
||||
const workspaces = loadWorkspaces();
|
||||
|
||||
if (workspaces.length === 0) {
|
||||
// No workspaces at all — fetch the build default relay URL
|
||||
// so the welcome screen can pre-fill it.
|
||||
try {
|
||||
const defaultRelayUrl = await getDefaultRelayUrl();
|
||||
if (!cancelled) {
|
||||
setResult({ isReady: false, needsSetup: true, defaultRelayUrl });
|
||||
}
|
||||
} catch {
|
||||
// If we can't get the default, fall back to localhost
|
||||
if (!cancelled) {
|
||||
setResult({
|
||||
isReady: false,
|
||||
needsSetup: true,
|
||||
defaultRelayUrl: "ws://localhost:3000",
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine active workspace
|
||||
let activeId = loadActiveWorkspaceId();
|
||||
if (!activeId || !workspaces.find((w) => w.id === activeId)) {
|
||||
activeId = workspaces[0].id;
|
||||
saveActiveWorkspaceId(activeId);
|
||||
}
|
||||
|
||||
const active = workspaces.find((w) => w.id === activeId);
|
||||
if (!active) {
|
||||
if (!cancelled) {
|
||||
setResult({ isReady: true, needsSetup: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply workspace config to the Tauri backend
|
||||
try {
|
||||
await applyWorkspace(active.relayUrl, active.nsec, active.token);
|
||||
} catch (error) {
|
||||
console.error("Failed to apply workspace to backend:", error);
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setResult({ isReady: true, needsSetup: false });
|
||||
}
|
||||
}
|
||||
|
||||
void init();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { Workspace } from "./types";
|
||||
import {
|
||||
loadActiveWorkspaceId,
|
||||
loadWorkspaces,
|
||||
saveActiveWorkspaceId,
|
||||
saveWorkspaces,
|
||||
} from "./workspaceStorage";
|
||||
|
||||
export type UseWorkspacesReturn = {
|
||||
workspaces: Workspace[];
|
||||
activeWorkspace: Workspace | null;
|
||||
/** Add a workspace, deduplicating by relayUrl. Returns the final ID in the list. */
|
||||
addWorkspace: (workspace: Workspace) => string;
|
||||
removeWorkspace: (id: string) => void;
|
||||
switchWorkspace: (id: string) => void;
|
||||
updateWorkspace: (
|
||||
id: string,
|
||||
updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token">>,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function useWorkspaces(): UseWorkspacesReturn {
|
||||
const [workspaces, setWorkspacesState] =
|
||||
useState<Workspace[]>(loadWorkspaces);
|
||||
const [activeId, setActiveId] = useState<string | null>(
|
||||
loadActiveWorkspaceId,
|
||||
);
|
||||
const workspacesRef = useRef(workspaces);
|
||||
workspacesRef.current = workspaces;
|
||||
|
||||
const activeWorkspace = useMemo(
|
||||
() => workspaces.find((w) => w.id === activeId) ?? workspaces[0] ?? null,
|
||||
[workspaces, activeId],
|
||||
);
|
||||
|
||||
const addWorkspace = useCallback((workspace: Workspace): string => {
|
||||
const existing = workspacesRef.current.find(
|
||||
(w) => w.relayUrl === workspace.relayUrl,
|
||||
);
|
||||
const resolvedId = existing?.id ?? workspace.id;
|
||||
setWorkspacesState((prev) => {
|
||||
const dup = prev.find((w) => w.relayUrl === workspace.relayUrl);
|
||||
let next: Workspace[];
|
||||
if (dup) {
|
||||
next = prev.map((w) =>
|
||||
w.id === dup.id
|
||||
? {
|
||||
...w,
|
||||
name: workspace.name || w.name,
|
||||
token: workspace.token ?? w.token,
|
||||
nsec: workspace.nsec ?? w.nsec,
|
||||
pubkey: workspace.pubkey ?? w.pubkey,
|
||||
}
|
||||
: w,
|
||||
);
|
||||
} else {
|
||||
next = [...prev, workspace];
|
||||
}
|
||||
saveWorkspaces(next);
|
||||
return next;
|
||||
});
|
||||
return resolvedId;
|
||||
}, []);
|
||||
|
||||
const removeWorkspace = useCallback(
|
||||
(id: string) => {
|
||||
setWorkspacesState((prev) => {
|
||||
// Never allow removing the last workspace
|
||||
if (prev.length <= 1) {
|
||||
return prev;
|
||||
}
|
||||
const next = prev.filter((w) => w.id !== id);
|
||||
saveWorkspaces(next);
|
||||
|
||||
// If removing the active workspace, switch to first remaining
|
||||
if (activeId === id && next.length > 0) {
|
||||
setActiveId(next[0].id);
|
||||
saveActiveWorkspaceId(next[0].id);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[activeId],
|
||||
);
|
||||
|
||||
const switchWorkspace = useCallback(
|
||||
(id: string) => {
|
||||
if (id === activeId) {
|
||||
return;
|
||||
}
|
||||
saveActiveWorkspaceId(id);
|
||||
window.location.reload();
|
||||
},
|
||||
[activeId],
|
||||
);
|
||||
|
||||
const updateWorkspace = useCallback(
|
||||
(
|
||||
id: string,
|
||||
updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token">>,
|
||||
) => {
|
||||
setWorkspacesState((prev) => {
|
||||
// Prevent duplicate relay URLs across workspaces
|
||||
if (
|
||||
updates.relayUrl &&
|
||||
prev.some((w) => w.id !== id && w.relayUrl === updates.relayUrl)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
const next = prev.map((w) => (w.id === id ? { ...w, ...updates } : w));
|
||||
saveWorkspaces(next);
|
||||
return next;
|
||||
});
|
||||
// If the active workspace's relay URL or token changed, reload to reconnect
|
||||
if (
|
||||
id === activeId &&
|
||||
(updates.relayUrl || updates.token !== undefined)
|
||||
) {
|
||||
window.location.reload();
|
||||
}
|
||||
},
|
||||
[activeId],
|
||||
);
|
||||
|
||||
return {
|
||||
workspaces,
|
||||
activeWorkspace,
|
||||
addWorkspace,
|
||||
removeWorkspace,
|
||||
switchWorkspace,
|
||||
updateWorkspace,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Workspace } from "./types";
|
||||
|
||||
const WORKSPACES_KEY = "sprout-workspaces";
|
||||
const ACTIVE_WORKSPACE_KEY = "sprout-active-workspace-id";
|
||||
|
||||
export function loadWorkspaces(): Workspace[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(WORKSPACES_KEY);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
return parsed as Workspace[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveWorkspaces(workspaces: Workspace[]): void {
|
||||
localStorage.setItem(WORKSPACES_KEY, JSON.stringify(workspaces));
|
||||
}
|
||||
|
||||
export function loadActiveWorkspaceId(): string | null {
|
||||
return localStorage.getItem(ACTIVE_WORKSPACE_KEY);
|
||||
}
|
||||
|
||||
export function saveActiveWorkspaceId(id: string): void {
|
||||
localStorage.setItem(ACTIVE_WORKSPACE_KEY, id);
|
||||
}
|
||||
|
||||
export function normalizeRelayUrl(url: string): string {
|
||||
if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
|
||||
return `wss://${url}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function deriveWorkspaceName(relayUrl: string): string {
|
||||
try {
|
||||
const url = new URL(
|
||||
relayUrl.replace("ws://", "http://").replace("wss://", "https://"),
|
||||
);
|
||||
const host = url.hostname;
|
||||
if (host === "localhost" || host === "127.0.0.1") {
|
||||
return "Local Dev";
|
||||
}
|
||||
const parts = host.split(".");
|
||||
// Detect staging environments (e.g. sprout-oss.stage.blox.sqprod.co)
|
||||
if (parts.some((p) => p === "stage" || p === "staging")) {
|
||||
return "Sprout (staging)";
|
||||
}
|
||||
// Use the first subdomain segment or the domain itself
|
||||
if (parts.length >= 2) {
|
||||
return parts[0] === "relay" ? parts[1] : parts[0];
|
||||
}
|
||||
return host;
|
||||
} catch {
|
||||
return "Workspace";
|
||||
}
|
||||
}
|
||||
@@ -30,19 +30,19 @@ export class RelayClient {
|
||||
private wsId: number | null = null;
|
||||
private relayUrl: string | null = null;
|
||||
private connectPromise: Promise<void> | null = null;
|
||||
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectTimeout: number | null = null;
|
||||
private reconnectDelayMs = RECONNECT_BASE_DELAY_MS;
|
||||
private keepAliveRequested = false;
|
||||
private authRequest: {
|
||||
pendingEventId: string;
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
timeout: number;
|
||||
} | null = null;
|
||||
private subscriptions = new Map<string, RelaySubscription>();
|
||||
private pendingEvents = new Map<string, PendingEvent>();
|
||||
private eventBuffer: Array<{ subId: string; event: RelayEvent }> = [];
|
||||
private flushTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private flushTimeout: number | null = null;
|
||||
private reconnectListeners = new Set<() => void>();
|
||||
private hasConnectedOnce = false;
|
||||
private notifyReconnectListeners = false;
|
||||
|
||||
@@ -12,7 +12,7 @@ type HistorySubscription = {
|
||||
events: RelayEvent[];
|
||||
resolve: (events: RelayEvent[]) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
timeout: number;
|
||||
};
|
||||
|
||||
type LiveSubscription = {
|
||||
@@ -27,7 +27,7 @@ export type PendingEvent = {
|
||||
event: RelayEvent;
|
||||
resolve: (event: RelayEvent) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
timeout: number;
|
||||
};
|
||||
|
||||
export type RelaySubscription = HistorySubscription | LiveSubscription;
|
||||
|
||||
@@ -528,6 +528,10 @@ export async function setPresence(
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultRelayUrl(): Promise<string> {
|
||||
return invokeTauri<string>("get_default_relay_url");
|
||||
}
|
||||
|
||||
export function getRelayWsUrl(): Promise<string> {
|
||||
return invokeTauri<string>("get_relay_ws_url");
|
||||
}
|
||||
@@ -1106,3 +1110,15 @@ export async function confirmPairingSas(): Promise<void> {
|
||||
export async function cancelPairing(): Promise<void> {
|
||||
await invokeTauri("cancel_pairing");
|
||||
}
|
||||
|
||||
export async function applyWorkspace(
|
||||
relayUrl: string,
|
||||
nsec?: string,
|
||||
token?: string,
|
||||
): Promise<void> {
|
||||
await invokeTauri("apply_workspace", {
|
||||
relayUrl,
|
||||
nsec: nsec ?? null,
|
||||
token: token ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4348,6 +4348,10 @@ export function maybeInstallE2eTauriMocks() {
|
||||
}
|
||||
|
||||
return DEFAULT_MOCK_IDENTITY;
|
||||
case "get_nsec":
|
||||
return "nsec1mock000000000000000000000000000000000000000000000000000000";
|
||||
case "apply_workspace":
|
||||
return;
|
||||
case "get_profile":
|
||||
return handleGetProfile(activeConfig);
|
||||
case "update_profile":
|
||||
@@ -4389,6 +4393,8 @@ export function maybeInstallE2eTauriMocks() {
|
||||
);
|
||||
case "get_relay_ws_url":
|
||||
return getRelayWsUrl(activeConfig);
|
||||
case "get_default_relay_url":
|
||||
return getRelayWsUrl(activeConfig);
|
||||
case "get_relay_http_url":
|
||||
return getRelayHttpUrl(activeConfig);
|
||||
case "discover_acp_providers":
|
||||
|
||||
@@ -84,6 +84,7 @@ type BridgeOptions = {
|
||||
const ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX =
|
||||
"sprout-onboarding-complete.v1:";
|
||||
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
|
||||
const DEFAULT_RELAY_WS_URL = "ws://localhost:3000";
|
||||
|
||||
async function seedOnboardingCompletionForKnownIdentities(page: Page) {
|
||||
const pubkeys = [
|
||||
@@ -100,12 +101,35 @@ async function seedOnboardingCompletionForKnownIdentities(page: Page) {
|
||||
);
|
||||
}
|
||||
|
||||
async function seedDefaultWorkspace(page: Page, relayWsUrl?: string) {
|
||||
await page.addInitScript(
|
||||
({ relayUrl }) => {
|
||||
const workspaceId = "e2e-default-workspace";
|
||||
const workspace = {
|
||||
id: workspaceId,
|
||||
name: "E2E Test",
|
||||
relayUrl,
|
||||
addedAt: new Date().toISOString(),
|
||||
};
|
||||
window.localStorage.setItem(
|
||||
"sprout-workspaces",
|
||||
JSON.stringify([workspace]),
|
||||
);
|
||||
window.localStorage.setItem("sprout-active-workspace-id", workspaceId);
|
||||
},
|
||||
{ relayUrl: relayWsUrl ?? DEFAULT_RELAY_WS_URL },
|
||||
);
|
||||
}
|
||||
|
||||
export async function installBridge(page: Page, options: BridgeOptions) {
|
||||
const identity =
|
||||
options.mode === "relay"
|
||||
? TEST_IDENTITIES[options.user ?? "tyler"]
|
||||
: undefined;
|
||||
|
||||
// Always seed a workspace so useWorkspaceInit doesn't show WelcomeSetup.
|
||||
// skipOnboardingSeed only controls the onboarding-completion flag.
|
||||
await seedDefaultWorkspace(page, options.relayWsUrl);
|
||||
if (!options.skipOnboardingSeed) {
|
||||
await seedOnboardingCompletionForKnownIdentities(page);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user