diff --git a/Cargo.lock b/Cargo.lock
index c20230354cbd0c4082e8d7b66ffffcbd39e29adf..73614e6257995ba65fcc6ce46b1ad995fd821485 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2193,7 +2193,7 @@ version = "3.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89ec27229c38ed0eb3c0feee3d2c1d6a4379ae44f418a29a658890e062d8f365"
dependencies = [
- "darling 0.20.11",
+ "darling 0.21.3",
"ident_case",
"prettyplease",
"proc-macro2",
@@ -3319,6 +3319,7 @@ dependencies = [
"futures 0.3.31",
"fuzzy",
"gpui",
+ "livekit_client",
"log",
"menu",
"notifications",
@@ -3338,6 +3339,7 @@ dependencies = [
"ui",
"util",
"workspace",
+ "zed_actions",
]
[[package]]
@@ -17753,6 +17755,8 @@ dependencies = [
"feature_flags",
"git_ui",
"gpui",
+ "icons",
+ "livekit_client",
"notifications",
"platform_title_bar",
"project",
diff --git a/assets/icons/signal_high.svg b/assets/icons/signal_high.svg
new file mode 100644
index 0000000000000000000000000000000000000000..6c1fec96098242444407fb9f66a025d03a10e50b
--- /dev/null
+++ b/assets/icons/signal_high.svg
@@ -0,0 +1,6 @@
+
diff --git a/assets/icons/signal_low.svg b/assets/icons/signal_low.svg
new file mode 100644
index 0000000000000000000000000000000000000000..b0ebccdd4c8897e8fdaf013a56cc4498dc5e0fe7
--- /dev/null
+++ b/assets/icons/signal_low.svg
@@ -0,0 +1,6 @@
+
diff --git a/assets/icons/signal_medium.svg b/assets/icons/signal_medium.svg
new file mode 100644
index 0000000000000000000000000000000000000000..3652724dc8b095dd68eb9977108711e71ffe67cb
--- /dev/null
+++ b/assets/icons/signal_medium.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/call/src/call_impl/diagnostics.rs b/crates/call/src/call_impl/diagnostics.rs
new file mode 100644
index 0000000000000000000000000000000000000000..1aa1774dfb0f598f6024c72b67e2079c01b2b8f0
--- /dev/null
+++ b/crates/call/src/call_impl/diagnostics.rs
@@ -0,0 +1,232 @@
+use gpui::{Context, Task, WeakEntity};
+use livekit_client::ConnectionQuality;
+use std::time::Duration;
+
+use super::room::Room;
+
+#[derive(Clone, Default)]
+pub struct CallStats {
+ pub connection_quality: Option,
+ pub effective_quality: Option,
+ pub latency_ms: Option,
+ pub jitter_ms: Option,
+ pub packet_loss_pct: Option,
+ pub input_lag: Option,
+}
+
+pub struct CallDiagnostics {
+ stats: CallStats,
+ room: WeakEntity,
+ poll_task: Option>,
+ stats_update_task: Option>,
+}
+
+impl CallDiagnostics {
+ pub fn new(room: WeakEntity, cx: &mut Context) -> Self {
+ let mut this = Self {
+ stats: CallStats::default(),
+ room,
+ poll_task: None,
+ stats_update_task: None,
+ };
+ this.start_polling(cx);
+ this
+ }
+
+ pub fn stats(&self) -> &CallStats {
+ &self.stats
+ }
+
+ fn start_polling(&mut self, cx: &mut Context) {
+ self.poll_task = Some(cx.spawn(async move |this, cx| {
+ loop {
+ if this.update(cx, |this, cx| this.poll_stats(cx)).is_err() {
+ break;
+ }
+ cx.background_executor().timer(Duration::from_secs(1)).await;
+ }
+ }));
+ }
+
+ fn poll_stats(&mut self, cx: &mut Context) {
+ let Some(room) = self.room.upgrade() else {
+ return;
+ };
+
+ let connection_quality = room.read(cx).connection_quality();
+ self.stats.connection_quality = Some(connection_quality);
+ self.stats.input_lag = room.read(cx).input_lag();
+
+ let stats_future = room.read(cx).get_stats(cx);
+
+ let background_task = cx.background_executor().spawn(async move {
+ let session_stats = stats_future.await;
+ session_stats.map(|stats| compute_network_stats(&stats))
+ });
+
+ self.stats_update_task = Some(cx.spawn(async move |this, cx| {
+ let result = background_task.await;
+ this.update(cx, |this, cx| {
+ if let Some(computed) = result {
+ this.stats.latency_ms = computed.latency_ms;
+ this.stats.jitter_ms = computed.jitter_ms;
+ this.stats.packet_loss_pct = computed.packet_loss_pct;
+ }
+ let quality = this
+ .stats
+ .connection_quality
+ .unwrap_or(ConnectionQuality::Lost);
+ this.stats.effective_quality =
+ Some(effective_connection_quality(quality, &this.stats));
+ cx.notify();
+ })
+ .ok();
+ }));
+ }
+}
+
+struct ComputedNetworkStats {
+ latency_ms: Option,
+ jitter_ms: Option,
+ packet_loss_pct: Option,
+}
+
+fn compute_network_stats(stats: &livekit_client::SessionStats) -> ComputedNetworkStats {
+ let mut min_rtt: Option = None;
+ let mut max_jitter: Option = None;
+ let mut total_packets_received: u64 = 0;
+ let mut total_packets_lost: i64 = 0;
+
+ let all_stats = stats
+ .publisher_stats
+ .iter()
+ .chain(stats.subscriber_stats.iter());
+
+ for stat in all_stats {
+ extract_metrics(
+ stat,
+ &mut min_rtt,
+ &mut max_jitter,
+ &mut total_packets_received,
+ &mut total_packets_lost,
+ );
+ }
+
+ let total_expected = total_packets_received as i64 + total_packets_lost;
+ let packet_loss_pct = if total_expected > 0 {
+ Some((total_packets_lost as f64 / total_expected as f64) * 100.0)
+ } else {
+ None
+ };
+
+ ComputedNetworkStats {
+ latency_ms: min_rtt.map(|rtt| rtt * 1000.0),
+ jitter_ms: max_jitter.map(|j| j * 1000.0),
+ packet_loss_pct,
+ }
+}
+
+#[cfg(all(
+ not(rust_analyzer),
+ any(
+ test,
+ feature = "test-support",
+ all(target_os = "windows", target_env = "gnu"),
+ target_os = "freebsd"
+ )
+))]
+fn extract_metrics(
+ _stat: &livekit_client::RtcStats,
+ _min_rtt: &mut Option,
+ _max_jitter: &mut Option,
+ _total_packets_received: &mut u64,
+ _total_packets_lost: &mut i64,
+) {
+}
+
+#[cfg(any(
+ rust_analyzer,
+ not(any(
+ test,
+ feature = "test-support",
+ all(target_os = "windows", target_env = "gnu"),
+ target_os = "freebsd"
+ ))
+))]
+fn extract_metrics(
+ stat: &livekit_client::RtcStats,
+ min_rtt: &mut Option,
+ max_jitter: &mut Option,
+ total_packets_received: &mut u64,
+ total_packets_lost: &mut i64,
+) {
+ use livekit_client::RtcStats;
+
+ match stat {
+ RtcStats::CandidatePair(pair) => {
+ let rtt = pair.candidate_pair.current_round_trip_time;
+ if rtt > 0.0 {
+ *min_rtt = Some(match *min_rtt {
+ Some(current) => current.min(rtt),
+ None => rtt,
+ });
+ }
+ }
+ RtcStats::InboundRtp(inbound) => {
+ let jitter = inbound.received.jitter;
+ if jitter > 0.0 {
+ *max_jitter = Some(match *max_jitter {
+ Some(current) => current.max(jitter),
+ None => jitter,
+ });
+ }
+ *total_packets_received += inbound.received.packets_received;
+ *total_packets_lost += inbound.received.packets_lost;
+ }
+ RtcStats::RemoteInboundRtp(remote_inbound) => {
+ let rtt = remote_inbound.remote_inbound.round_trip_time;
+ if rtt > 0.0 {
+ *min_rtt = Some(match *min_rtt {
+ Some(current) => current.min(rtt),
+ None => rtt,
+ });
+ }
+ }
+ _ => {}
+ }
+}
+
+fn metric_quality(value: f64, warn_threshold: f64, error_threshold: f64) -> ConnectionQuality {
+ if value < warn_threshold {
+ ConnectionQuality::Excellent
+ } else if value < error_threshold {
+ ConnectionQuality::Poor
+ } else {
+ ConnectionQuality::Lost
+ }
+}
+
+/// Computes the effective connection quality by taking the worst of the
+/// LiveKit-reported quality and each individual metric rating.
+fn effective_connection_quality(
+ livekit_quality: ConnectionQuality,
+ stats: &CallStats,
+) -> ConnectionQuality {
+ let mut worst = livekit_quality;
+
+ if let Some(latency) = stats.latency_ms {
+ worst = worst.max(metric_quality(latency, 100.0, 300.0));
+ }
+ if let Some(jitter) = stats.jitter_ms {
+ worst = worst.max(metric_quality(jitter, 30.0, 75.0));
+ }
+ if let Some(loss) = stats.packet_loss_pct {
+ worst = worst.max(metric_quality(loss, 1.0, 5.0));
+ }
+ if let Some(lag) = stats.input_lag {
+ let lag_ms = lag.as_secs_f64() * 1000.0;
+ worst = worst.max(metric_quality(lag_ms, 20.0, 50.0));
+ }
+
+ worst
+}
diff --git a/crates/call/src/call_impl/mod.rs b/crates/call/src/call_impl/mod.rs
index e3945cf2c746f4c598caa7996deb2c76fc859e64..e060ec5edae6277a92c2c09ab54ded449bc56e11 100644
--- a/crates/call/src/call_impl/mod.rs
+++ b/crates/call/src/call_impl/mod.rs
@@ -1,3 +1,4 @@
+pub mod diagnostics;
pub mod participant;
pub mod room;
diff --git a/crates/call/src/call_impl/room.rs b/crates/call/src/call_impl/room.rs
index 701d7dd65423f97b3f4d5cfa4a198083593211e6..117789e233ab6fbc101b28e5e9f485ec17c1f79d 100644
--- a/crates/call/src/call_impl/room.rs
+++ b/crates/call/src/call_impl/room.rs
@@ -23,7 +23,10 @@ use livekit_client::{self as livekit, AudioStream, TrackSid};
use postage::{sink::Sink, stream::Stream, watch};
use project::Project;
use settings::Settings as _;
+use std::sync::atomic::AtomicU64;
use std::{future::Future, mem, rc::Rc, sync::Arc, time::Duration, time::Instant};
+
+use super::diagnostics::CallDiagnostics;
use util::{ResultExt, TryFutureExt, paths::PathStyle, post_inc};
use workspace::ParticipantLocation;
@@ -69,6 +72,7 @@ pub struct Room {
id: u64,
channel_id: Option,
live_kit: Option,
+ diagnostics: Option>,
status: RoomStatus,
shared_projects: HashSet>,
joined_projects: HashSet>,
@@ -136,6 +140,7 @@ impl Room {
id,
channel_id,
live_kit: None,
+ diagnostics: None,
status: RoomStatus::Online,
shared_projects: Default::default(),
joined_projects: Default::default(),
@@ -350,6 +355,7 @@ impl Room {
self.participant_user_ids.clear();
self.client_subscriptions.clear();
self.live_kit.take();
+ self.diagnostics.take();
self.pending_room_update.take();
self.maintain_connection.take();
}
@@ -540,6 +546,42 @@ impl Room {
}
}
+ pub fn get_stats(&self, cx: &App) -> Task