Add multi-workspace support to desktop app (#409)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-28 04:47:11 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent f1cef90376
commit a9024c2c78
35 changed files with 1212 additions and 55 deletions
+1 -1
View File
@@ -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) {
+4
View File
@@ -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()
};
+2 -2
View File
@@ -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;
(
+26 -7
View File
@@ -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}"))?,
);
}
+4 -5
View File
@@ -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"))
+2
View File
@@ -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::*;
+3 -3
View File
@@ -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 -2
View File
@@ -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(())
}
+4 -3
View File
@@ -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)
+9 -3
View File
@@ -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}");
}
+1 -1
View File
@@ -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();
+5 -1
View File
@@ -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");
+3 -3
View File
@@ -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.
+38 -6
View File
@@ -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
View File
@@ -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 />;
}
+16
View File
@@ -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;
+40 -2
View File
@@ -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>
);
}
+9
View File
@@ -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";
}
}
+3 -3
View File
@@ -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;
+2 -2
View File
@@ -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;
+16
View File
@@ -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,
});
}
+6
View File
@@ -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":
+24
View File
@@ -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);
}