···66#[cfg(all(target_family = "wasm", target_os = "unknown"))]
77fn main() {
88 console_error_panic_hook::set_once();
99+ use tracing::Level;
1010+ use tracing::subscriber::set_global_default;
1111+ use tracing_subscriber::Registry;
1212+ use tracing_subscriber::filter::EnvFilter;
1313+ use tracing_subscriber::layer::SubscriberExt;
1414+1515+ let console_level = if cfg!(debug_assertions) {
1616+ Level::DEBUG
1717+ } else {
1818+ Level::DEBUG
1919+ };
2020+2121+ let wasm_layer = tracing_wasm::WASMLayer::new(
2222+ tracing_wasm::WASMLayerConfigBuilder::new()
2323+ .set_max_level(console_level)
2424+ .build(),
2525+ );
2626+2727+ // Filter out noisy crates
2828+ let filter = EnvFilter::new(
2929+ "debug,loro_internal=warn,jacquard_identity=info,jacquard_common=info,iroh=info",
3030+ );
3131+3232+ let reg = Registry::default().with(filter).with(wasm_layer);
3333+3434+ let _ = set_global_default(reg);
9351036 use gloo_worker::Registrable;
1111- use weaver_app::components::editor::EditorWorker;
3737+ use weaver_app::components::editor::EditorReactor;
12381313- EditorWorker::registrar().register();
3939+ EditorReactor::registrar().register();
1440}
15411642#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
+4-95
crates/weaver-app/src/collab_context.rs
···11-//! Real-time collaboration context for P2P editing sessions.
22-//!
33-//! This module provides the CollabNode as a Dioxus context, allowing editor
44-//! components to join gossip sessions for real-time collaboration.
11+//! Real-time collaboration debug state.
52//!
66-//! The CollabNode is only active in WASM builds where iroh works via relays.
33+//! This module provides CollabDebugState which is set as context by
44+//! the CollabCoordinator component for display in the editor debug panel.
7586use dioxus::prelude::*;
99-use std::sync::Arc;
1010-use weaver_common::transport::CollabNode;
117128/// Debug state for the collab session, displayed in editor debug panel.
139#[derive(Clone, Default)]
···2824 pub last_error: Option<String>,
2925}
30263131-/// Context state for the collaboration node.
3232-///
3333-/// This is provided as a Dioxus context and can be accessed by editor components
3434-/// to join/leave collaborative editing sessions.
3535-#[derive(Clone)]
3636-pub struct CollabContext {
3737- /// The collaboration node, if successfully spawned.
3838- /// None while loading or if spawn failed.
3939- pub node: Option<Arc<CollabNode>>,
4040- /// Error message if spawn failed.
4141- pub error: Option<String>,
4242-}
4343-4444-impl Default for CollabContext {
4545- fn default() -> Self {
4646- Self {
4747- node: None,
4848- error: None,
4949- }
5050- }
5151-}
5252-5353-/// Provider component that spawns the CollabNode and provides it as context.
5454-///
5555-/// Should be placed near the root of the app, wrapping any components that
5656-/// need access to real-time collaboration.
5757-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
5858-#[component]
5959-pub fn CollabProvider(children: Element) -> Element {
6060- let mut collab_ctx = use_signal(CollabContext::default);
6161- let debug_state = use_signal(CollabDebugState::default);
6262-6363- // Spawn the CollabNode on mount
6464- let _spawn_result = use_resource(move || async move {
6565- tracing::info!("Spawning CollabNode...");
6666-6767- match CollabNode::spawn(None).await {
6868- Ok(node) => {
6969- tracing::info!(node_id = %node.node_id_string(), "CollabNode spawned");
7070- collab_ctx.set(CollabContext {
7171- node: Some(node),
7272- error: None,
7373- });
7474- }
7575- Err(e) => {
7676- tracing::error!("Failed to spawn CollabNode: {}", e);
7777- collab_ctx.set(CollabContext {
7878- node: None,
7979- error: Some(e.to_string()),
8080- });
8181- }
8282- }
8383- });
8484-8585- // Provide the contexts
8686- use_context_provider(|| collab_ctx);
8787- use_context_provider(|| debug_state);
8888-8989- rsx! { {children} }
9090-}
9191-9292-/// No-op provider for non-WASM builds.
9393-#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
9494-#[component]
9595-pub fn CollabProvider(children: Element) -> Element {
9696- // On server/native, provide an empty context (collab happens in browser)
9797- let collab_ctx = use_signal(CollabContext::default);
9898- let debug_state = use_signal(CollabDebugState::default);
9999- use_context_provider(|| collab_ctx);
100100- use_context_provider(|| debug_state);
101101- rsx! { {children} }
102102-}
103103-104104-/// Hook to get the CollabNode from context.
105105-///
106106-/// Returns None if the node hasn't spawned yet or failed to spawn.
107107-pub fn use_collab_node() -> Option<Arc<CollabNode>> {
108108- let ctx = use_context::<Signal<CollabContext>>();
109109- ctx.read().node.clone()
110110-}
111111-112112-/// Hook to check if collab is available.
113113-pub fn use_collab_available() -> bool {
114114- let ctx = use_context::<Signal<CollabContext>>();
115115- ctx.read().node.is_some()
116116-}
117117-11827/// Hook to get the collab debug state signal.
119119-/// Returns None if called outside CollabProvider.
2828+/// Returns None if called outside CollabCoordinator.
12029pub fn try_use_collab_debug() -> Option<Signal<CollabDebugState>> {
12130 try_use_context::<Signal<CollabDebugState>>()
12231}
+545
crates/weaver-app/src/components/editor/collab.rs
···11+//! Collab coordinator - bridges EditorWorker and authenticated PDS ops.
22+//!
33+//! This component handles the main-thread side of real-time collaboration:
44+//! - Spawns the editor worker and manages its lifecycle
55+//! - Performs authenticated PDS operations (session records, peer discovery)
66+//! - Forwards local Loro updates to the worker for gossip broadcast
77+//! - Receives remote updates from worker and applies to main document
88+//! - Provides CollabDebugState context for debug UI
99+//!
1010+//! The worker handles all iroh/gossip networking off the main thread.
1111+1212+// Only compile for WASM - no-op stub provided at end
1313+1414+use super::document::EditorDocument;
1515+1616+use dioxus::prelude::*;
1717+1818+#[cfg(target_arch = "wasm32")]
1919+use jacquard::types::string::AtUri;
2020+2121+use weaver_common::transport::PresenceSnapshot;
2222+2323+/// Session record TTL in minutes.
2424+#[cfg(target_arch = "wasm32")]
2525+const SESSION_TTL_MINUTES: u32 = 15;
2626+2727+/// How often to refresh session record (ms).
2828+#[cfg(target_arch = "wasm32")]
2929+const SESSION_REFRESH_INTERVAL_MS: u32 = 5 * 60 * 1000; // 5 minutes
3030+3131+/// How often to poll for new peers (ms).
3232+#[cfg(target_arch = "wasm32")]
3333+const PEER_DISCOVERY_INTERVAL_MS: u32 = 30 * 1000; // 30 seconds
3434+3535+/// Props for the CollabCoordinator component.
3636+#[derive(Props, Clone, PartialEq)]
3737+pub struct CollabCoordinatorProps {
3838+ /// The editor document to sync
3939+ pub document: EditorDocument,
4040+ /// Resource URI for the document being edited
4141+ pub resource_uri: String,
4242+ /// Presence state signal (updated by coordinator)
4343+ pub presence: Signal<PresenceSnapshot>,
4444+ /// Children to render (this component wraps the editor)
4545+ pub children: Element,
4646+}
4747+4848+/// Coordinator state machine states.
4949+#[cfg(target_arch = "wasm32")]
5050+#[derive(Debug, Clone, PartialEq)]
5151+enum CoordinatorState {
5252+ /// Initial state - waiting for worker to be ready
5353+ Initializing,
5454+ /// Creating session record on PDS
5555+ CreatingSession {
5656+ node_id: String,
5757+ relay_url: Option<String>,
5858+ },
5959+ /// Active collab session
6060+ Active { session_uri: AtUri<'static> },
6161+ /// Error state
6262+ Error(String),
6363+}
6464+6565+/// Coordinator component that bridges worker and PDS.
6666+///
6767+/// This is a wrapper component that:
6868+/// 1. Provides CollabDebugState context
6969+/// 2. Manages collab lifecycle (worker, PDS records, peer discovery)
7070+/// 3. Renders children
7171+///
7272+/// Lifecycle:
7373+/// 1. Worker spawned on mount, sends CollabReady with node_id
7474+/// 2. Coordinator creates session record on PDS
7575+/// 3. Coordinator discovers existing peers
7676+/// 4. Worker joins gossip session
7777+/// 5. Local updates forwarded to worker via subscribe_local_update
7878+/// 6. Remote updates from worker applied to main document
7979+/// 7. Session record deleted on unmount
8080+#[component]
8181+pub fn CollabCoordinator(props: CollabCoordinatorProps) -> Element {
8282+ #[cfg(target_arch = "wasm32")]
8383+ {
8484+ use super::worker::{WorkerInput, WorkerOutput};
8585+ use crate::collab_context::CollabDebugState;
8686+ use crate::fetch::Fetcher;
8787+ use futures_util::stream::SplitSink;
8888+ use futures_util::{SinkExt, StreamExt};
8989+ use gloo_worker::Spawnable;
9090+ use gloo_worker::reactor::ReactorBridge;
9191+ use jacquard::IntoStatic;
9292+ use weaver_common::WeaverExt;
9393+9494+ use super::worker::EditorReactor;
9595+9696+ let fetcher = use_context::<Fetcher>();
9797+9898+ // Provide debug state context
9999+ let mut debug_state = use_signal(CollabDebugState::default);
100100+ use_context_provider(|| debug_state);
101101+102102+ // Coordinator state
103103+ let mut state: Signal<CoordinatorState> = use_signal(|| CoordinatorState::Initializing);
104104+105105+ // Worker sink for sending messages - Signal persists across renders
106106+ type WorkerSink = SplitSink<ReactorBridge<EditorReactor>, WorkerInput>;
107107+ let mut worker_sink: Signal<Option<WorkerSink>> = use_signal(|| None);
108108+109109+ // Session record URI for cleanup
110110+ let mut session_uri: Signal<Option<AtUri<'static>>> = use_signal(|| None);
111111+112112+ // Loro subscription handle (keep alive)
113113+ let mut loro_sub: Signal<Option<loro::Subscription>> = use_signal(|| None);
114114+115115+ // Clone for closures
116116+ let resource_uri = props.resource_uri.clone();
117117+ let mut doc = props.document.clone();
118118+ let mut presence = props.presence;
119119+120120+ // Spawn worker and set up message handling
121121+ let fetcher_for_spawn = fetcher.clone();
122122+ let resource_uri_for_spawn = resource_uri.clone();
123123+ use_effect(move || {
124124+ let mut worker_sink = worker_sink;
125125+ let fetcher = fetcher_for_spawn.clone();
126126+ let resource_uri = resource_uri_for_spawn.clone();
127127+ // Channel for local updates (Loro callback is Send+Sync, but ReactorBridge isn't)
128128+ let (local_update_tx, mut local_update_rx) =
129129+ tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
130130+131131+ let tx = local_update_tx.clone();
132132+133133+ // Subscribe to local Loro updates - just send to channel (Send+Sync)
134134+ let sub = doc
135135+ .loro_doc()
136136+ .subscribe_local_update(Box::new(move |update| {
137137+ let _ = tx.send(update.to_vec());
138138+ true // Keep subscription active
139139+ }));
140140+141141+ loro_sub.set(Some(sub));
142142+143143+ // Spawn the reactor
144144+ let bridge = EditorReactor::spawner().spawn("/editor_worker.js");
145145+ let (sink, mut stream) = bridge.split();
146146+ worker_sink.set(Some(sink));
147147+148148+ // Initialize worker with current document snapshot
149149+ let snapshot = doc.export_snapshot();
150150+ let draft_key = resource_uri.clone(); // Use resource URI as the key
151151+ spawn(async move {
152152+ if let Some(ref mut sink) = *worker_sink.write() {
153153+ if let Err(e) = sink
154154+ .send(WorkerInput::Init {
155155+ snapshot,
156156+ draft_key,
157157+ })
158158+ .await
159159+ {
160160+ tracing::error!("Failed to send Init to worker: {e}");
161161+ }
162162+ }
163163+ });
164164+165165+ // Task 1: Forward local updates from channel to worker
166166+ spawn(async move {
167167+ while let Some(data) = local_update_rx.recv().await {
168168+ if let Some(ref mut s) = *worker_sink.write() {
169169+ if let Err(e) = s.send(WorkerInput::BroadcastUpdate { data }).await {
170170+ tracing::warn!("Failed to send BroadcastUpdate to worker: {e}");
171171+ }
172172+ }
173173+ }
174174+ });
175175+176176+ // Task 2: Handle worker output messages
177177+ let doc_for_handler = doc.clone();
178178+ spawn(async move {
179179+ let mut doc = doc_for_handler;
180180+ while let Some(output) = stream.next().await {
181181+ match output {
182182+ WorkerOutput::Ready => {
183183+ tracing::info!("CollabCoordinator: worker ready, starting collab");
184184+185185+ // Compute topic from resource URI
186186+ let hash = weaver_common::blake3::hash(resource_uri.as_bytes());
187187+ let topic: [u8; 32] = *hash.as_bytes();
188188+189189+ // Send StartCollab to worker immediately (no blocking on profile fetch)
190190+ if let Some(ref mut s) = *worker_sink.write() {
191191+ if let Err(e) = s
192192+ .send(WorkerInput::StartCollab {
193193+ topic,
194194+ bootstrap_peers: vec![],
195195+ })
196196+ .await
197197+ {
198198+ tracing::error!("Failed to send StartCollab to worker: {e}");
199199+ }
200200+ }
201201+ }
202202+203203+ WorkerOutput::CollabReady { node_id, relay_url } => {
204204+ tracing::info!(
205205+ node_id = %node_id,
206206+ relay_url = ?relay_url,
207207+ "CollabCoordinator: collab node ready"
208208+ );
209209+210210+ // Update debug state
211211+ debug_state.with_mut(|ds| {
212212+ ds.node_id = Some(node_id.clone());
213213+ ds.relay_url = relay_url.clone();
214214+ });
215215+216216+ state.set(CoordinatorState::CreatingSession {
217217+ node_id: node_id.clone(),
218218+ relay_url: relay_url.clone(),
219219+ });
220220+221221+ // Create session record on PDS
222222+ let fetcher = fetcher.clone();
223223+ let resource_uri = resource_uri.clone();
224224+225225+ spawn(async move {
226226+ // Parse resource URI to get StrongRef
227227+ let uri = match AtUri::new(&resource_uri) {
228228+ Ok(u) => u.into_static(),
229229+ Err(e) => {
230230+ let err = format!("Invalid resource URI: {e}");
231231+ debug_state
232232+ .with_mut(|ds| ds.last_error = Some(err.clone()));
233233+ state.set(CoordinatorState::Error(err));
234234+ return;
235235+ }
236236+ };
237237+238238+ // Get StrongRef for the resource
239239+ let strong_ref = match fetcher.confirm_record_ref(&uri).await {
240240+ Ok(r) => r,
241241+ Err(e) => {
242242+ let err = format!("Failed to get resource ref: {e}");
243243+ debug_state
244244+ .with_mut(|ds| ds.last_error = Some(err.clone()));
245245+ state.set(CoordinatorState::Error(err));
246246+ return;
247247+ }
248248+ };
249249+250250+ // Create session record
251251+ match fetcher
252252+ .create_collab_session(
253253+ &strong_ref,
254254+ &node_id,
255255+ relay_url.as_deref(),
256256+ Some(SESSION_TTL_MINUTES),
257257+ )
258258+ .await
259259+ {
260260+ Ok(session_record_uri) => {
261261+ tracing::info!(
262262+ uri = %session_record_uri,
263263+ "CollabCoordinator: session record created"
264264+ );
265265+ session_uri.set(Some(session_record_uri.clone()));
266266+ debug_state.with_mut(|ds| {
267267+ ds.session_record_uri =
268268+ Some(session_record_uri.to_string());
269269+ });
270270+271271+ // Discover existing peers
272272+ let bootstrap_peers = match fetcher
273273+ .find_session_peers(&uri)
274274+ .await
275275+ {
276276+ Ok(peers) => {
277277+ tracing::info!(
278278+ count = peers.len(),
279279+ "CollabCoordinator: found peers"
280280+ );
281281+ debug_state.with_mut(|ds| {
282282+ ds.discovered_peers = peers.len();
283283+ });
284284+ peers
285285+ .into_iter()
286286+ .map(|p| p.node_id)
287287+ .collect::<Vec<_>>()
288288+ }
289289+ Err(e) => {
290290+ tracing::warn!(
291291+ "CollabCoordinator: peer discovery failed: {e}"
292292+ );
293293+ vec![]
294294+ }
295295+ };
296296+297297+ // Send discovered peers to worker
298298+ if !bootstrap_peers.is_empty() {
299299+ tracing::info!(
300300+ count = bootstrap_peers.len(),
301301+ peers = ?bootstrap_peers,
302302+ "CollabCoordinator: sending AddPeers to worker"
303303+ );
304304+ if let Some(ref mut s) = *worker_sink.write() {
305305+ if let Err(e) = s
306306+ .send(WorkerInput::AddPeers {
307307+ peers: bootstrap_peers,
308308+ })
309309+ .await
310310+ {
311311+ tracing::error!("CollabCoordinator: AddPeers send failed: {e}");
312312+ }
313313+ } else {
314314+ tracing::error!("CollabCoordinator: sink is None!");
315315+ }
316316+ } else {
317317+ tracing::info!("CollabCoordinator: no peers to add");
318318+ }
319319+320320+ state.set(CoordinatorState::Active {
321321+ session_uri: session_record_uri,
322322+ });
323323+ }
324324+ Err(e) => {
325325+ let err = format!("Failed to create session: {e}");
326326+ debug_state
327327+ .with_mut(|ds| ds.last_error = Some(err.clone()));
328328+ state.set(CoordinatorState::Error(err));
329329+ }
330330+ }
331331+ });
332332+ }
333333+334334+ WorkerOutput::CollabJoined => {
335335+ tracing::info!("CollabCoordinator: joined gossip session");
336336+ debug_state.with_mut(|ds| ds.is_joined = true);
337337+ }
338338+339339+ WorkerOutput::RemoteUpdates { data } => {
340340+ if let Err(e) = doc.import_updates(&data) {
341341+ tracing::warn!(
342342+ "CollabCoordinator: failed to import updates: {:?}",
343343+ e
344344+ );
345345+ }
346346+ }
347347+348348+ WorkerOutput::PresenceUpdate(snapshot) => {
349349+ debug_state.with_mut(|ds| {
350350+ ds.connected_peers = snapshot.peer_count;
351351+ });
352352+ presence.set(snapshot);
353353+ }
354354+355355+ WorkerOutput::CollabStopped => {
356356+ tracing::info!("CollabCoordinator: collab stopped");
357357+ debug_state.with_mut(|ds| {
358358+ ds.is_joined = false;
359359+ ds.connected_peers = 0;
360360+ });
361361+ }
362362+363363+ WorkerOutput::PeerConnected => {
364364+ tracing::info!("CollabCoordinator: peer connected, sending our Join");
365365+ use weaver_api::sh_weaver::actor::ProfileDataViewInner;
366366+367367+ let fetcher = fetcher.clone();
368368+369369+ // Get our profile info and send BroadcastJoin
370370+ let (our_did, our_display_name) = match fetcher.current_did().await {
371371+ Some(did) => {
372372+ let display_name = match fetcher.fetch_profile(&did.clone().into()).await {
373373+ Ok(profile) => {
374374+ match &profile.inner {
375375+ ProfileDataViewInner::ProfileView(p) => {
376376+ p.display_name.as_ref().map(|s| s.to_string()).unwrap_or_else(|| did.to_string())
377377+ }
378378+ ProfileDataViewInner::ProfileViewDetailed(p) => {
379379+ p.display_name.as_ref().map(|s| s.to_string()).unwrap_or_else(|| did.to_string())
380380+ }
381381+ ProfileDataViewInner::TangledProfileView(p) => {
382382+ p.handle.to_string()
383383+ }
384384+ _ => did.to_string(),
385385+ }
386386+ }
387387+ Err(_) => did.to_string(),
388388+ };
389389+ (did.to_string(), display_name)
390390+ }
391391+ None => {
392392+ tracing::warn!("CollabCoordinator: no current DID for Join message");
393393+ ("unknown".to_string(), "Anonymous".to_string())
394394+ }
395395+ };
396396+397397+ if let Some(ref mut s) = *worker_sink.write() {
398398+ if let Err(e) = s
399399+ .send(WorkerInput::BroadcastJoin {
400400+ did: our_did,
401401+ display_name: our_display_name,
402402+ })
403403+ .await
404404+ {
405405+ tracing::error!("CollabCoordinator: BroadcastJoin send failed: {e}");
406406+ }
407407+ }
408408+ }
409409+410410+ WorkerOutput::Error { message } => {
411411+ tracing::error!("CollabCoordinator: worker error: {message}");
412412+ debug_state.with_mut(|ds| ds.last_error = Some(message.clone()));
413413+ state.set(CoordinatorState::Error(message));
414414+ }
415415+416416+ WorkerOutput::Snapshot { .. } => {}
417417+ }
418418+ }
419419+ tracing::info!("CollabCoordinator: worker stream ended");
420420+ });
421421+422422+ tracing::info!("CollabCoordinator: spawned worker");
423423+ });
424424+425425+ // Forward cursor updates to worker - memo re-runs when cursor/selection signals change
426426+ let cursor_signal = props.document.cursor;
427427+ let selection_signal = props.document.selection;
428428+429429+ let _cursor_broadcaster = use_memo(move || {
430430+ let cursor = cursor_signal.read();
431431+ let selection = *selection_signal.read();
432432+ let position = cursor.offset;
433433+ let sel = selection.map(|s| (s.anchor, s.head));
434434+435435+ tracing::debug!(position, ?sel, "CollabCoordinator: cursor changed, broadcasting");
436436+437437+ spawn(async move {
438438+ if let Some(ref mut s) = *worker_sink.write() {
439439+ tracing::debug!(position, "CollabCoordinator: sending BroadcastCursor to worker");
440440+ if let Err(e) = s
441441+ .send(WorkerInput::BroadcastCursor {
442442+ position,
443443+ selection: sel,
444444+ })
445445+ .await
446446+ {
447447+ tracing::warn!("Failed to send BroadcastCursor to worker: {e}");
448448+ }
449449+ } else {
450450+ tracing::debug!(position, "CollabCoordinator: worker sink not ready, skipping cursor broadcast");
451451+ }
452452+ });
453453+ });
454454+455455+ // Periodic peer discovery
456456+ let fetcher_for_discovery = fetcher.clone();
457457+ let resource_uri_for_discovery = resource_uri.clone();
458458+ dioxus_sdk::time::use_interval(
459459+ std::time::Duration::from_millis(PEER_DISCOVERY_INTERVAL_MS as u64),
460460+ move |_| {
461461+ let fetcher = fetcher_for_discovery.clone();
462462+ let resource_uri = resource_uri_for_discovery.clone();
463463+464464+ spawn(async move {
465465+ let uri = match AtUri::new(&resource_uri) {
466466+ Ok(u) => u,
467467+ Err(_) => return,
468468+ };
469469+470470+ match fetcher.find_session_peers(&uri).await {
471471+ Ok(peers) => {
472472+ debug_state.with_mut(|ds| ds.discovered_peers = peers.len());
473473+ if !peers.is_empty() {
474474+ let peer_ids: Vec<String> =
475475+ peers.into_iter().map(|p| p.node_id).collect();
476476+477477+ if let Some(ref mut s) = *worker_sink.write() {
478478+ if let Err(e) =
479479+ s.send(WorkerInput::AddPeers { peers: peer_ids }).await
480480+ {
481481+ tracing::warn!("Periodic AddPeers send failed: {e}");
482482+ }
483483+ }
484484+ }
485485+ }
486486+ Err(e) => {
487487+ tracing::debug!("Peer discovery failed: {e}");
488488+ }
489489+ }
490490+ });
491491+ },
492492+ );
493493+494494+ // Periodic session refresh
495495+ let fetcher_for_refresh = fetcher.clone();
496496+ dioxus_sdk::time::use_interval(
497497+ std::time::Duration::from_millis(SESSION_REFRESH_INTERVAL_MS as u64),
498498+ move |_| {
499499+ let fetcher = fetcher_for_refresh.clone();
500500+501501+ if let Some(ref uri) = *session_uri.peek() {
502502+ let uri = uri.clone();
503503+ spawn(async move {
504504+ match fetcher
505505+ .refresh_collab_session(&uri, SESSION_TTL_MINUTES)
506506+ .await
507507+ {
508508+ Ok(_) => {
509509+ tracing::debug!("Session refreshed");
510510+ }
511511+ Err(e) => {
512512+ tracing::warn!("Session refresh failed: {e}");
513513+ }
514514+ }
515515+ });
516516+ }
517517+ },
518518+ );
519519+520520+ // Cleanup on unmount
521521+ let fetcher_for_cleanup = fetcher.clone();
522522+ use_drop(move || {
523523+ // Stop collab in worker
524524+ spawn(async move {
525525+ if let Some(ref mut s) = *worker_sink.write() {
526526+ if let Err(e) = s.send(WorkerInput::StopCollab).await {
527527+ tracing::warn!("Failed to send StopCollab to worker: {e}");
528528+ }
529529+ }
530530+ });
531531+532532+ // Delete session record
533533+ if let Some(uri) = session_uri.peek().clone() {
534534+ let fetcher = fetcher_for_cleanup.clone();
535535+ spawn(async move {
536536+ if let Err(e) = fetcher.delete_collab_session(&uri).await {
537537+ tracing::warn!("Failed to delete session record: {e}");
538538+ }
539539+ });
540540+ }
541541+ });
542542+ }
543543+ // Render children - this component is a wrapper that provides context
544544+ rsx! { {props.children} }
545545+}
···1010// Re-export jacquard for convenience
1111pub use agent::{SessionPeer, WeaverExt};
1212pub use error::WeaverError;
1313+1414+// Re-export blake3 for topic hashing
1515+pub use blake3;
1316pub use resolve::{EntryIndex, ExtractedRef, RefCollector, ResolvedContent, ResolvedEntry};
14171518pub use jacquard;
+1-1
crates/weaver-common/src/transport/presence.rs
···3838}
39394040/// Tracks all collaborators in a session.
4141-#[derive(Debug, Default)]
4141+#[derive(Debug, Default, Clone)]
4242pub struct PresenceTracker {
4343 /// Collaborators by EndpointId.
4444 collaborators: HashMap<EndpointId, Collaborator>,
+14-6
crates/weaver-common/src/transport/session.rs
···9292 }
93939494 // Subscribe to the gossip topic
9595- let (sender, receiver) = node
9696- .gossip()
9797- .subscribe_and_join(topic, bootstrap_peers)
9898- .await
9999- .map_err(|e| SessionError::Subscribe(Box::new(e)))?
100100- .split();
9595+ // Use subscribe (non-blocking) if no bootstrap peers, otherwise subscribe_and_join
9696+ let (sender, receiver) = if bootstrap_peers.is_empty() {
9797+ node.gossip()
9898+ .subscribe(topic, vec![])
9999+ .await
100100+ .map_err(|e| SessionError::Subscribe(Box::new(e)))?
101101+ .split()
102102+ } else {
103103+ node.gossip()
104104+ .subscribe_and_join(topic, bootstrap_peers)
105105+ .await
106106+ .map_err(|e| SessionError::Subscribe(Box::new(e)))?
107107+ .split()
108108+ };
101109102110 tracing::info!("CollabSession: subscribed to gossip topic");
103111