Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
@@ -0,0 +1,18 @@
use anyhow::Result;
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _};
use prost::Message;
use warp_multi_agent_api::ResponseEvent;
/// Decodes a serialized response event string by base64-decoding
/// and then decoding the protobuf payload into a ResponseEvent.
pub fn decode_agent_response_event(encoded: &str) -> Result<ResponseEvent> {
let bytes = STANDARD_NO_PAD.decode(encoded)?;
let event = ResponseEvent::decode(bytes.as_slice())?;
Ok(event)
}
/// Encodes a ResponseEvent by protobuf-encoding it and base64-encoding the bytes.
pub fn encode_agent_response_event(event: &ResponseEvent) -> String {
let bytes = event.encode_to_vec();
STANDARD_NO_PAD.encode(bytes)
}
+241
View File
@@ -0,0 +1,241 @@
use std::collections::HashMap;
use itertools::Itertools;
use session_sharing_protocol::common::SessionId;
use warpui::{
AppContext, Entity, EntityId, ModelContext, SingletonEntity, ViewHandle, WeakViewHandle,
WindowId,
};
use crate::terminal::TerminalView;
use super::SharedSessionActionSource;
struct SharedSessionState {
session_id: SessionId,
view_handle: WeakViewHandle<TerminalView>,
}
/// A global model that tracks shared session metadata for sessions across all windows.
pub struct Manager {
/// Sessions that were shared by this client.
shared: HashMap<EntityId, SharedSessionState>,
/// Sessions that were joined by this client.
joined: HashMap<EntityId, SharedSessionState>,
/// IDs of sessions that were shared or joined by this client,
/// but have since been stopped. This state is maintained so that the
/// copy link button can still work for ended sessions.
ended_session_ids: HashMap<EntityId, SessionId>,
}
impl Manager {
pub fn new(_ctx: &mut ModelContext<Self>) -> Self {
Self {
shared: Default::default(),
joined: Default::default(),
ended_session_ids: Default::default(),
}
}
/// Returns true iff there are >= 1 active shares.
pub fn is_some_session_being_shared(&self) -> bool {
!self.shared.is_empty()
}
/// Returns true iff there are >= 1 active joined sessions.
pub fn is_some_session_being_viewed(&self) -> bool {
!self.joined.is_empty()
}
/// Returns the session id for the given terminal view.
pub fn session_id(&self, terminal_view_id: &EntityId) -> Option<SessionId> {
self.shared
.get(terminal_view_id)
.or(self.joined.get(terminal_view_id))
.map(|state| state.session_id)
}
/// Returns the most recently ended session id for the given terminal view.
pub fn ended_session_id(&self, terminal_view_id: &EntityId) -> Option<SessionId> {
self.ended_session_ids.get(terminal_view_id).copied()
}
/// Returns the view handle to the shared terminal view, identified by `terminal_view_id`, if it's being shared.
pub fn shared_view_by_id(
&self,
terminal_view_id: &EntityId,
ctx: &AppContext,
) -> Option<ViewHandle<TerminalView>> {
let weak_handle = self
.shared
.get(terminal_view_id)
.map(|state| state.view_handle.clone())?;
let view_handle = weak_handle.upgrade(ctx);
if view_handle.is_none() {
log::warn!("Failed to upgrade a terminal view in the shared session manager");
}
view_handle
}
/// Returns the view handle to the joined terminal view, identified by `terminal_view_id`, if it's being viewed.
pub fn joined_view_by_id(
&self,
terminal_view_id: &EntityId,
ctx: &AppContext,
) -> Option<ViewHandle<TerminalView>> {
let weak_handle = self
.joined
.get(terminal_view_id)
.map(|state| state.view_handle.clone())?;
let view_handle = weak_handle.upgrade(ctx);
if view_handle.is_none() {
log::warn!("Failed to upgrade a terminal view in the joined session manager");
}
view_handle
}
pub fn shared_view_ids(&self) -> impl Iterator<Item = EntityId> + '_ {
self.shared.keys().cloned()
}
pub fn joined_view_ids(&self) -> impl Iterator<Item = EntityId> + '_ {
self.joined.keys().cloned()
}
/// Returns an iterator over the set of all shared sessions.
pub fn shared_views<'a>(
&'a self,
ctx: &'a AppContext,
) -> impl Iterator<Item = ViewHandle<TerminalView>> + 'a {
self.shared
.values()
.filter_map(move |state| state.view_handle.upgrade(ctx))
}
pub fn started_share(
&mut self,
terminal_view: WeakViewHandle<TerminalView>,
session_id: SessionId,
window_id: WindowId,
ctx: &mut ModelContext<Self>,
) {
let view_id = terminal_view.id();
let state = SharedSessionState {
session_id,
view_handle: terminal_view,
};
self.shared.insert(view_id, state);
ctx.emit(ManagerEvent::StartedShare {
session_id,
window_id,
});
}
pub fn joined_share(
&mut self,
terminal_view: WeakViewHandle<TerminalView>,
session_id: SessionId,
ctx: &mut ModelContext<Self>,
) {
let view_id = terminal_view.id();
let state = SharedSessionState {
session_id,
view_handle: terminal_view,
};
self.joined.insert(view_id, state);
ctx.emit(ManagerEvent::JoinedSession {
session_id,
view_id,
});
}
pub fn left_share(&mut self, terminal_view_id: EntityId) {
// Remove the shared session from the shared sessions map and persist the session id.
if let Some(removed_session) = self.joined.remove(&terminal_view_id) {
self.ended_session_ids
.insert(terminal_view_id, removed_session.session_id);
}
}
pub fn stopped_share(&mut self, terminal_view_id: EntityId, ctx: &mut ModelContext<Self>) {
// Remove the shared session from the shared sessions map and persist the session id.
if let Some(removed_session) = self.shared.remove(&terminal_view_id) {
self.ended_session_ids
.insert(terminal_view_id, removed_session.session_id);
}
ctx.emit(ManagerEvent::StoppedShare);
}
pub fn share_failed(&mut self, window_id: WindowId, ctx: &mut ModelContext<Self>) {
ctx.emit(ManagerEvent::FailedToShare { window_id });
}
pub fn clear_joined(&mut self) {
self.joined.clear();
}
pub fn stop_all_shared_sessions(&mut self, ctx: &mut ModelContext<Self>) {
let view_ids = self.shared_view_ids().collect_vec();
for view_id in view_ids {
if let Some(terminal_view) = self.shared_view_by_id(&view_id, ctx) {
terminal_view.update(ctx, |view, ctx| {
view.stop_sharing_session(SharedSessionActionSource::NonUser, ctx);
});
}
}
}
pub fn rejoin_all_shared_sessions(&mut self, ctx: &mut ModelContext<Self>) {
if self.is_some_session_being_viewed() {
let view_ids = self.joined_view_ids().collect_vec();
for view_id in view_ids {
if let Some(terminal_view) = self.joined_view_by_id(&view_id, ctx) {
terminal_view.update(ctx, |view, ctx| {
view.rejoin_session_share(ctx);
});
}
}
}
}
}
pub enum ManagerEvent {
/// There was an attempt to share a session.
ShareAttempted,
/// A shared session was started.
StartedShare {
session_id: SessionId,
/// The window that the session resides in.
window_id: WindowId,
},
/// A shared session has been successfully joined.
JoinedSession {
/// the session_id of the session that was joined.
session_id: SessionId,
/// The view_id of the terminal that joined the session.
view_id: EntityId,
},
/// A shared session was stopped.
StoppedShare,
/// There was an attempt to share a session but it failed.
FailedToShare {
/// The window that the session resides in.
window_id: WindowId,
},
}
impl Entity for Manager {
type Event = ManagerEvent;
}
impl SingletonEntity for Manager {}
+422
View File
@@ -0,0 +1,422 @@
use byte_unit::Byte;
use instant::Duration;
use serde::{Deserialize, Serialize};
use session_sharing_protocol::common::{Role, Scrollback, ScrollbackBlock, SessionId};
use session_sharing_protocol::sharer::SessionSourceType;
use warpui::{id, keymap::ContextPredicate, AppContext};
use crate::{
channel::{Channel, ChannelState},
editor::{InteractionState, ReplicaId},
features::FeatureFlag,
};
use super::{
model::{block::SerializedBlock, terminal_model::BlockIndex},
GridType, TerminalModel,
};
pub mod ai_agent;
pub mod manager;
pub mod network;
pub mod participant_avatar_view;
pub mod permissions_manager;
pub mod presence_manager;
pub mod render_util;
pub mod replay_agent_conversations;
pub mod role_change_modal;
mod selections;
pub mod settings;
pub mod share_modal;
pub(super) mod shared_handlers;
pub mod sharer;
pub mod viewer;
#[cfg(test)]
pub use tests::MAX_BYTES_SHAREABLE;
/// The toast copy when copying a shared session link.
pub const COPY_LINK_TEXT: &str = "Sharing link copied";
/// Throttle period for selection updates. We throttle instead of debounce because we want
/// to send selections even when it updates fast, so it appears live.
/// Our throttle implementation throttles on the trailing edge (does not drop messages at the end, so the
/// most up to date will always be sent after some delay)
const SELECTION_THROTTLE_PERIOD: Duration = Duration::from_millis(20);
/// Whether or not a local session is also being shared.
/// Since a shared session creator is also the creator of a local session,
/// we make use of the local_tty::TerminalManager for shared session creators.
/// Otherwise, there would be a lot of overlap between a shared session creator
/// and a regular, purely local session.
#[derive(Debug, Clone, Default)]
pub enum IsSharedSessionCreator {
/// This session should be shared automatically once bootstrapped, using the
/// provided source type.
Yes { source_type: SessionSourceType },
#[default]
No,
}
/// The type of shared session a particular session is, if applicable.
#[derive(Debug, Clone)]
pub enum SharedSessionStatus {
/// This session is not a shared session.
/// When a sharer ends a session, the status
/// changes back to [`SharedSessionStatus::NotShared`].
NotShared,
/// We're in the process of joining the session but have not
/// established the connection with the server yet, or have not received all the events that occurred before the viewer joined yet.
ViewPending,
/// This session is a shared session that we are actively viewing.
/// We have received all the scrollback and events for the shared session that occurred before the viewer joined, and are caught up and receiving events live.
ActiveViewer { role: Role },
/// We were viewing a shared session but it ended.
FinishedViewer,
/// We haven't yet attempted to share the session because it is not bootstrapped yet.
/// The `source_type` encodes what kind of shared session will be created once the
/// session finishes bootstrapping.
SharePendingPreBootstrap { source_type: SessionSourceType },
/// The session is bootstrapped and we're in the process of
/// sharing the session but have not yet established the
/// connection with the server.
SharePending,
/// This session is actively being shared.
ActiveSharer,
}
impl SharedSessionStatus {
pub fn reader() -> Self {
Self::ActiveViewer { role: Role::Reader }
}
pub fn executor() -> Self {
Self::ActiveViewer {
role: Role::Executor,
}
}
pub fn is_view_pending(&self) -> bool {
matches!(self, SharedSessionStatus::ViewPending)
}
pub fn is_active_viewer(&self) -> bool {
matches!(self, SharedSessionStatus::ActiveViewer { .. })
}
pub fn is_finished_viewer(&self) -> bool {
matches!(self, SharedSessionStatus::FinishedViewer)
}
pub fn is_viewer(&self) -> bool {
self.is_view_pending() || self.is_active_viewer() || self.is_finished_viewer()
}
pub fn is_executor(&self) -> bool {
matches!(self, SharedSessionStatus::ActiveViewer { role } if role.can_execute())
}
pub fn is_reader(&self) -> bool {
matches!(
self,
SharedSessionStatus::ActiveViewer { role: Role::Reader }
)
}
pub fn is_share_pending(&self) -> bool {
matches!(
self,
SharedSessionStatus::SharePending
| SharedSessionStatus::SharePendingPreBootstrap { .. }
)
}
pub fn is_active_sharer(&self) -> bool {
matches!(self, SharedSessionStatus::ActiveSharer)
}
pub fn is_sharer(&self) -> bool {
self.is_share_pending() || self.is_active_sharer()
}
pub fn is_sharer_or_viewer(&self) -> bool {
!matches!(self, Self::NotShared)
}
pub fn as_keymap_context(&self) -> &'static str {
match self {
Self::NotShared => "SharedSessionStatus_NotShared",
Self::ViewPending => "SharedSessionStatus_ViewPending",
Self::ActiveViewer { role: Role::Reader } => "SharedSessionStatus_Reader",
Self::ActiveViewer {
role: Role::Executor | Role::Full,
} => "SharedSessionStatus_Executor",
Self::FinishedViewer => "SharedSessionStatus_FinishedViewer",
Self::SharePendingPreBootstrap { .. } => "SharedSessionStatus_SharePendingPreBootstrap",
Self::SharePending => "SharedSessionStatus_SharePending",
Self::ActiveSharer => "SharedSessionStatus_ActiveSharer",
}
}
pub fn active_viewer_keymap_context() -> ContextPredicate {
id!(Self::reader().as_keymap_context()) | id!(Self::executor().as_keymap_context())
}
}
/// The scrollback options when starting a shared session.
/// Note: currently, these options only encode the point at which
/// scrollback _starts_. We do not yet support more
/// selective scrollback (e.g. a closed range).
/// The active block is always included in scrollback for the prompt.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SharedSessionScrollbackType {
/// Do not include any scrollback in this shared session.
/// Note the active block is still sent as part of scrollback for the prompt.
/// TODO(suraj): consider renaming this to "from active block" or encapsulating
/// this with the `FromBlock` variant with the block_index equal to the
/// active block index.
None,
/// Include scrollback starting at `block_index`.
FromBlock { block_index: BlockIndex },
/// The entire blocklist should be part of the scrollback.
All,
}
impl SharedSessionScrollbackType {
/// Returns the set of scrollback that adheres to the scrollback type.
/// Note that some blocks might not actually be included in the scrollback
/// even if they were specified as part of the scrollback type.
/// For example, if the [`Self::All]` variant is used, restored blocks
/// _won't_ be included in scrollback.
fn to_scrollback(self, model: &TerminalModel) -> Scrollback {
let first_block_index = self.first_block_index(model);
let blocks = model
.block_list()
.blocks()
.iter()
.skip(first_block_index.into())
.filter(|block| {
block.is_scrollback_block_for_shared_session(model.block_list().agent_view_state())
})
.filter_map(|block| {
let serialized_block: SerializedBlock = block.into();
let bytes = serde_json::to_vec(&serialized_block);
bytes.ok().map(|raw| ScrollbackBlock { raw })
})
.collect();
let is_alt_screen_active = model.is_alt_screen_active();
Scrollback {
blocks,
is_alt_screen_active,
}
}
/// Returns the first block index that will be used for scrollback.
pub fn first_block_index(self, model: &TerminalModel) -> BlockIndex {
match self {
Self::None => model.block_list().active_block_index(),
Self::FromBlock { block_index } => model
.block_list()
.blocks()
.iter()
.skip(block_index.into())
.find(|block| {
block.is_scrollback_block_for_shared_session(
model.block_list().agent_view_state(),
)
})
.map_or(model.block_list().active_block_index(), |block| {
block.index()
}),
Self::All => Self::FromBlock {
block_index: BlockIndex::zero(),
}
.first_block_index(model),
}
}
}
#[cfg(not(test))]
pub fn max_session_size(ctx: &AppContext) -> Byte {
use crate::workspaces::user_workspaces::UserWorkspaces;
use warpui::SingletonEntity;
UserWorkspaces::as_ref(ctx)
.current_team()
.and_then(|team| team.billing_metadata.tier.session_sharing_policy)
.map(|policy| Byte::from_u64(policy.max_session_size))
.unwrap_or(Byte::from_u64_with_unit(100, byte_unit::Unit::MB).unwrap())
}
#[cfg(test)]
pub fn max_session_size(_ctx: &AppContext) -> Byte {
Byte::from_u64(MAX_BYTES_SHAREABLE as u64)
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)]
pub enum SharedSessionActionSource {
/// From right-click menu in blocklist
/// * `block_index`: provided with selected block, none when no blocks selected
BlocklistContextMenu {
block_index: Option<BlockIndex>,
},
Tab,
PaneHeader,
/// Includes keybindings.
CommandPalette,
OnboardingBlock,
Closed {
is_confirm_close_session: bool,
},
InactivityModal,
/// The user did not initiate this action themselves.
NonUser,
/// The object-specific sharing dialog.
SharingDialog,
/// From the session sharing context menu items.
RightClickMenu,
/// From the agent/CLI footer chip.
FooterChip,
}
/// Returns the native intent URL to join a shared session.
/// This should be used when opening the session from within Warp.
pub fn join_native_intent(session_id: &SessionId) -> String {
format!(
"{}://shared_session/{}",
ChannelState::url_scheme(),
session_id
)
}
/// Returns the link to join a shared session.
pub fn join_link(session_id: &SessionId) -> String {
// For non-bundled builds against the staging server, use the native app intent
// because the staging web URL won't resolve to a local build.
let use_web_url = !ChannelState::uses_staging_server() || cfg!(feature = "release_bundle");
let mut link = if use_web_url {
format!("{}/session/{}", ChannelState::server_root_url(), session_id,)
} else {
join_native_intent(session_id)
};
// If this is a preview build, route the sharing link to the preview server.
if matches!(ChannelState::channel(), Channel::Preview) {
link.push_str("?preview=true");
}
link
}
/// Returns the full session sharing URL given a path.
pub fn connect_endpoint(path: String) -> Option<String> {
let base = ChannelState::session_sharing_server_url()?;
if FeatureFlag::SessionSharingAcls.is_enabled() {
let version = ChannelState::app_version().unwrap_or("v0.00.000");
if path.contains("?") {
return Some(format!("{base}{path}&version={version}"));
} else {
return Some(format!("{base}{path}?version={version}"));
}
}
Some(format!("{base}{path}"))
}
/// The event number for events sent to the server. The newtype
/// ensures that events are incremented correctly.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
struct EventNumber(usize);
impl EventNumber {
fn new() -> Self {
Self(0)
}
/// Returns the current event number and increments
/// it for the next usage. The event number returned
/// is the event number that should be used for the next
/// event to send to the server.
pub fn advance(&mut self) -> usize {
let next = self.0;
self.0 += 1;
next
}
}
impl From<EventNumber> for usize {
fn from(value: EventNumber) -> Self {
value.0
}
}
impl From<GridType> for session_sharing_protocol::common::GridType {
fn from(val: GridType) -> Self {
match val {
GridType::Prompt => session_sharing_protocol::common::GridType::Prompt,
GridType::Rprompt => session_sharing_protocol::common::GridType::Rprompt,
GridType::Output => session_sharing_protocol::common::GridType::Output,
GridType::PromptAndCommand => {
session_sharing_protocol::common::GridType::PromptAndCommand
}
}
}
}
impl From<session_sharing_protocol::common::GridType> for GridType {
fn from(value: session_sharing_protocol::common::GridType) -> Self {
match value {
session_sharing_protocol::common::GridType::Prompt => Self::Prompt,
session_sharing_protocol::common::GridType::Rprompt => Self::Rprompt,
session_sharing_protocol::common::GridType::Output => Self::Output,
session_sharing_protocol::common::GridType::PromptAndCommand => Self::PromptAndCommand,
}
}
}
impl From<ReplicaId> for session_sharing_protocol::common::InputReplicaId {
fn from(value: ReplicaId) -> Self {
value.to_string().into()
}
}
impl From<session_sharing_protocol::common::InputReplicaId> for ReplicaId {
fn from(value: session_sharing_protocol::common::InputReplicaId) -> Self {
ReplicaId::new(value)
}
}
impl From<&Role> for InteractionState {
fn from(value: &Role) -> InteractionState {
match value {
Role::Reader => InteractionState::Selectable,
Role::Executor => InteractionState::Editable,
Role::Full => InteractionState::Editable,
}
}
}
/// Decode scrollback blocks from their JSON wire format into [`SerializedBlock`]s.
///
/// Blocks that fail to deserialize are silently dropped.
pub(crate) fn decode_scrollback(scrollback: &Scrollback) -> Vec<SerializedBlock> {
scrollback
.blocks
.iter()
.filter_map(|block| serde_json::from_slice(&block.raw).ok())
.collect()
}
#[cfg(test)]
#[path = "mod_test.rs"]
mod tests;
+348
View File
@@ -0,0 +1,348 @@
use super::{decode_scrollback, SharedSessionScrollbackType};
use crate::ai::blocklist::agent_view::AgentViewState;
use crate::assert_lines_approx_eq;
use crate::channel::ChannelState;
use crate::terminal::color::List;
use crate::terminal::model::test_utils::block_size;
use crate::uri::web_intent_parser::maybe_rewrite_web_url_to_intent;
use crate::terminal::model::ObfuscateSecrets;
use crate::terminal::TerminalModel;
use crate::terminal::{event_listener::ChannelEventListener, model::block::SerializedBlock};
use crate::themes::default_themes::dark_theme;
use serde_json::Value;
use session_sharing_protocol::common::{Scrollback, ScrollbackBlock};
use std::sync::Arc;
use url::Url;
use warpui::r#async::executor::Background;
use warpui::units::Lines;
pub const MAX_BYTES_SHAREABLE: usize = 5000;
#[test]
fn maybe_rewrite_web_url_to_shared_session_intent_rewrites_matching_web_url() {
let server_root = ChannelState::server_root_url();
let web_url = Url::parse(&format!(
"{server_root}/session/00000000-0000-0000-0000-000000000000?pwd=secret&preview=true"
))
.expect("valid shared session web URL");
let maybe_intent = maybe_rewrite_web_url_to_intent(&web_url)
.expect("expected shared session web URL to rewrite to an intent URL");
assert_eq!(maybe_intent.scheme(), ChannelState::url_scheme());
assert_eq!(maybe_intent.host_str(), Some("shared_session"));
assert_eq!(maybe_intent.path(), "/00000000-0000-0000-0000-000000000000");
assert_eq!(maybe_intent.query(), Some("pwd=secret&preview=true"));
}
#[test]
fn maybe_rewrite_web_url_to_shared_session_intent_ignores_non_matching_host() {
let web_url =
Url::parse("https://example.com/session/00000000-0000-0000-0000-000000000000?pwd=secret")
.expect("valid web URL with non-matching host");
let maybe_intent = maybe_rewrite_web_url_to_intent(&web_url);
assert!(maybe_intent.is_none());
}
#[test]
fn maybe_rewrite_web_url_to_shared_session_intent_ignores_invalid_session_id() {
let server_root = ChannelState::server_root_url();
let web_url = Url::parse(&format!(
"{server_root}/session/not-a-valid-session-id?pwd=secret&preview=true",
))
.expect("valid web URL with invalid session id path segment");
let maybe_intent = maybe_rewrite_web_url_to_intent(&web_url);
assert!(maybe_intent.is_none());
}
pub fn terminal_model_for_viewer(event_proxy: ChannelEventListener) -> TerminalModel {
TerminalModel::new_for_shared_session_viewer(
block_size(),
List::from(&dark_theme().into()),
event_proxy,
Arc::new(Background::default()),
false, /* show_memory_stats */
false, /* honor_ps1 */
false, /* is_inverted */
ObfuscateSecrets::No,
)
}
#[test]
fn test_get_no_scrollback() {
let restored_blocks = &[SerializedBlock::new_for_test("a".into(), "b".into()).into()];
let channel_event_proxy = ChannelEventListener::new_for_test();
let mut model = TerminalModel::mock(Some(restored_blocks), Some(channel_event_proxy));
model.simulate_block("block1", "block1");
model.simulate_block("block2", "block2");
let scrollback = SharedSessionScrollbackType::None.to_scrollback(&model);
// Should only contain the active block
assert_eq!(scrollback.blocks.len(), 1);
}
#[test]
fn test_get_scrollback_starting_at_block() {
let restored_blocks = &[SerializedBlock::new_for_test("a".into(), "b".into()).into()];
let channel_event_proxy = ChannelEventListener::new_for_test();
let mut model = TerminalModel::mock(Some(restored_blocks), Some(channel_event_proxy));
model.simulate_block("block1", "block1");
model.simulate_block("block2", "block2");
let starting_block = model
.block_list()
.last_non_hidden_block()
.expect("there is a non-hidden block");
let scrollback = SharedSessionScrollbackType::FromBlock {
block_index: starting_block.index(),
}
.to_scrollback(&model);
// Should contain 1 completed block + active block
assert_eq!(scrollback.blocks.len(), 2);
}
#[test]
fn test_get_all_scrollback() {
let restored_blocks = &[SerializedBlock::new_for_test("a".into(), "b".into()).into()];
let channel_event_proxy = ChannelEventListener::new_for_test();
let mut model = TerminalModel::mock(Some(restored_blocks), Some(channel_event_proxy));
// Restored blocks and bootstrap blocks don't count towards scrollback,
let scrollback = SharedSessionScrollbackType::All.to_scrollback(&model);
// Only active block
assert_eq!(scrollback.blocks.len(), 1);
model.simulate_block("block1", "block1");
let scrollback = SharedSessionScrollbackType::All.to_scrollback(&model);
assert_eq!(scrollback.blocks.len(), 2);
model.simulate_block("block2", "block2");
let scrollback = SharedSessionScrollbackType::All.to_scrollback(&model);
// Should contain 2 completed blocks + active block
assert_eq!(scrollback.blocks.len(), 3);
}
#[test]
fn test_scrollback_round_trip() {
let channel_event_proxy = ChannelEventListener::new_for_test();
let mut model = TerminalModel::mock(None, Some(channel_event_proxy));
model.simulate_block("hello", "world");
// Capture the expected stylized bytes from the completed block before serialization.
let completed_block = model.block_list().block_at(1.into()).unwrap();
let expected: SerializedBlock = completed_block.into();
let scrollback = SharedSessionScrollbackType::All.to_scrollback(&model);
let decoded = decode_scrollback(&scrollback);
// The completed block is first; the active (empty) block is second.
assert_eq!(decoded.len(), 2);
assert_eq!(decoded[0].stylized_command, expected.stylized_command);
assert_eq!(decoded[0].stylized_output, expected.stylized_output);
}
#[test]
fn test_scrollback_serialization() {
let channel_event_proxy = ChannelEventListener::new_for_test();
let mut model = TerminalModel::mock(None, Some(channel_event_proxy));
model.simulate_block("hello", "world");
let scrollback = SharedSessionScrollbackType::All.to_scrollback(&model);
let first_block = scrollback
.blocks
.first()
.expect("expected first scrollback block");
let json: Value = serde_json::from_slice(&first_block.raw).expect("valid scrollback json");
// Capture the expected bytes from the model so we can assert exact JSON array contents.
let completed_block = model.block_list().block_at(1.into()).unwrap();
let expected: SerializedBlock = completed_block.into();
let expected_command: Vec<Value> = expected
.stylized_command
.iter()
.map(|&b| Value::from(b))
.collect();
let expected_output: Vec<Value> = expected
.stylized_output
.iter()
.map(|&b| Value::from(b))
.collect();
assert_eq!(
json.get("stylized_command"),
Some(&Value::Array(expected_command)),
);
assert_eq!(
json.get("stylized_output"),
Some(&Value::Array(expected_output)),
);
}
#[test]
fn test_scrollback_deserialization() {
let raw = serde_json::json!({
"id": "00000000-0000-0000-0000-000000000000",
"stylized_command": [104, 101, 108, 108, 111],
"stylized_output": [119, 111, 114, 108, 100],
"pwd": null,
"git_head": null,
"virtual_env": null,
"conda_env": null,
"node_version": null,
"exit_code": 0,
"did_execute": true,
"completed_ts": null,
"start_ts": null,
"ps1": null,
"rprompt": null,
"honor_ps1": false,
"is_background": false,
"session_id": null,
"shell_host": null,
"prompt_snapshot": null,
"ai_metadata": null
});
let scrollback = Scrollback {
blocks: vec![ScrollbackBlock {
raw: serde_json::to_vec(&raw).expect("serialize scrollback json"),
}],
is_alt_screen_active: false,
};
let decoded = decode_scrollback(&scrollback);
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0].stylized_command, b"hello");
assert_eq!(decoded[0].stylized_output, b"world");
}
#[test]
fn test_loading_scrollback() {
let session_id = 42.into();
let mut active_block = SerializedBlock::new_active_block_for_test();
active_block.session_id = Some(session_id);
let scrollback_blocks = &[
SerializedBlock::new_for_test("block1".into(), "block1".into()),
SerializedBlock::new_for_test("block2".into(), "block2".into()),
// We expect the active block as part of scrollback to get the prompt.
active_block,
];
let channel_event_proxy = ChannelEventListener::new_for_test();
let mut model = terminal_model_for_viewer(channel_event_proxy);
model.load_shared_session_scrollback(scrollback_blocks, false);
// 4 blocks: first is the bootstrap block, the next two are completed scrollback blocks.
// The last is the active block, whose prompt came from the last scrollback.
assert_eq!(model.block_list().blocks().len(), 4);
assert_eq!(
model
.block_list()
.block_at(1.into())
.unwrap()
.command_to_string(),
"block1"
);
assert_eq!(
model
.block_list()
.block_at(1.into())
.unwrap()
.output_to_string(),
"block1"
);
assert_eq!(
model
.block_list()
.block_at(2.into())
.unwrap()
.command_to_string(),
"block2"
);
assert_eq!(
model
.block_list()
.block_at(2.into())
.unwrap()
.output_to_string(),
"block2"
);
// The last scrollback block is the active block and contains the prompt.
assert_eq!(model.block_list().active_block_index(), 3.into());
assert_eq!(
model
.block_list()
.active_block()
.height(&AgentViewState::Inactive),
Lines::zero()
);
assert!(!model.block_list().active_block().started());
assert_eq!(
model.block_list().active_block().session_id(),
Some(session_id)
);
}
#[test]
fn test_loading_scrollback_in_alt_screen() {
let scrollback_blocks = &[
SerializedBlock::new_for_test("block1".into(), "block1".into()),
// We expect the active block as part of scrollback to get the prompt.
SerializedBlock::new_active_block_for_test(),
];
let channel_event_proxy = ChannelEventListener::new_for_test();
let mut model = terminal_model_for_viewer(channel_event_proxy);
model.load_shared_session_scrollback(scrollback_blocks, true);
// 3 blocks: first is the bootstrap block, the second is the completed scrollback blocks.
// The last is the active block, whose prompt came from the last scrollback.
assert_eq!(model.block_list().blocks().len(), 3);
assert_eq!(
model
.block_list()
.block_at(1.into())
.unwrap()
.command_to_string(),
"block1"
);
assert_eq!(
model
.block_list()
.block_at(1.into())
.unwrap()
.output_to_string(),
"block1"
);
// The last scrollback block is the active block and contains the prompt.
assert_lines_approx_eq!(
model
.block_list()
.block_at(2.into())
.unwrap()
.height(&AgentViewState::Inactive),
0.
);
assert!(!model.block_list().block_at(2.into()).unwrap().started());
// Make sure we're in the alt screen.
assert!(model.is_alt_screen_active());
}
@@ -0,0 +1,98 @@
use futures::stream::AbortHandle;
use std::time::Duration;
use warpui::r#async::Timer;
use warpui::{Entity, ModelContext};
const DEFAULT_PING_FREQUENCY: Duration = Duration::from_secs(5);
const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
/// A simple heartbeat mechanism to trigger ping notifications
/// on some cadence and to maintain a idle timer.
pub struct Heartbeat {
/// How often we want to trigger a [`Event::SendPing`] event.
ping_frequency: Duration,
/// The duration that we want to wait before firing a [`Event::Idle`] event.
/// To extend the timer by this duration, use [`Self::reset_idle_timeout`].
idle_timeout: Duration,
idle_timeout_abort_handle: Option<AbortHandle>,
periodic_ping_abort_handle: Option<AbortHandle>,
}
impl Default for Heartbeat {
fn default() -> Self {
Self {
idle_timeout_abort_handle: None,
periodic_ping_abort_handle: None,
ping_frequency: DEFAULT_PING_FREQUENCY,
idle_timeout: DEFAULT_IDLE_TIMEOUT,
}
}
}
impl Heartbeat {
pub fn with_idle_timeout(mut self, idle_timeout: Duration) -> Self {
self.idle_timeout = idle_timeout;
self
}
pub fn with_ping_frequency(mut self, ping_frequency: Duration) -> Self {
self.ping_frequency = ping_frequency;
self
}
/// Starts the periodic ping and the idle timeout tracker.
pub fn start(&mut self, ctx: &mut ModelContext<Self>) {
self.reset_idle_timeout(ctx);
self.periodic_ping(ctx);
}
/// Resets the idle timeout to expire after [`Self::idle_timeout`] from now.
pub fn reset_idle_timeout(&mut self, ctx: &mut ModelContext<Self>) {
if let Some(handle) = self.idle_timeout_abort_handle.take() {
handle.abort();
}
let idle_timeout = self.idle_timeout;
let handle = ctx.spawn(
async move { Timer::after(idle_timeout).await },
|me, _, ctx| {
// If the heartbeat has become idle, then don't ping anymore.
if let Some(handle) = me.periodic_ping_abort_handle.take() {
handle.abort();
}
ctx.emit(Event::Idle);
},
);
self.idle_timeout_abort_handle = Some(handle.abort_handle());
}
/// Emits a [`Event::SendPing`] event based on [`Self::ping_frequency`].
fn periodic_ping(&mut self, ctx: &mut ModelContext<Self>) {
// TODO: this would be simpler with a `spawn_stream_local`
// if our async timer supported an [`interval` API](https://docs.rs/async-io/latest/async_io/struct.Timer.html#method.interval).
let ping_frequency = self.ping_frequency;
let handle = ctx.spawn(
async move { Timer::after(ping_frequency).await },
|me, _, ctx| {
ctx.emit(Event::Ping);
me.periodic_ping(ctx);
},
);
self.periodic_ping_abort_handle = Some(handle.abort_handle());
}
}
pub enum Event {
Ping,
Idle,
}
impl Entity for Heartbeat {
type Event = Event;
}
#[cfg(test)]
#[path = "heartbeat_tests.rs"]
mod tests;
@@ -0,0 +1,83 @@
use super::{Event, Heartbeat};
use std::time::Duration;
use warpui::r#async::Timer;
use warpui::App;
#[test]
#[ignore = "Flakes in CI"]
fn test_periodic_ping() {
App::test((), |mut app| async move {
let heartbeat =
app.add_model(|_| Heartbeat::default().with_ping_frequency(Duration::from_millis(100)));
let (tx, rx) = async_channel::unbounded();
let tx_clone = tx.clone();
app.update(|ctx| {
ctx.subscribe_to_model(&heartbeat, move |_, event, _| {
if matches!(event, Event::Ping) {
tx_clone.try_send(()).expect("can send over channel");
}
});
});
// Start the heartbeat.
heartbeat.update(&mut app, |heartbeat, ctx| heartbeat.start(ctx));
// After 50ms, there shouldn't have been any pings.
Timer::after(Duration::from_millis(50)).await;
assert_eq!(rx.len(), 0);
// After 150ms, there should have been 1 ping.
Timer::after(Duration::from_millis(100)).await;
assert_eq!(rx.len(), 1);
// After 175ms, there still should have only been 1 ping.
Timer::after(Duration::from_millis(25)).await;
assert_eq!(rx.len(), 1);
// After 250ms, there should have been 2 pings in total.
Timer::after(Duration::from_millis(75)).await;
assert_eq!(rx.len(), 2);
})
}
#[test]
#[ignore = "Flakes in CI"]
fn test_idle_timeout() {
App::test((), |mut app| async move {
let heartbeat =
app.add_model(|_| Heartbeat::default().with_idle_timeout(Duration::from_millis(100)));
let (tx, rx) = async_channel::unbounded();
app.update(|ctx| {
ctx.subscribe_to_model(&heartbeat, move |_, event, _| {
if matches!(event, Event::Idle) {
tx.try_send(()).expect("can send over channel");
}
});
});
// Start the heartbeat.
heartbeat.update(&mut app, |heartbeat, ctx| heartbeat.start(ctx));
// The idle timeout should not have expired yet.
Timer::after(Duration::from_millis(50)).await;
assert_eq!(rx.len(), 0);
// Reset the idle timeout.
heartbeat.update(
&mut app,
|heartbeat, ctx: &mut warpui::ModelContext<Heartbeat>| {
heartbeat.reset_idle_timeout(ctx)
},
);
// If the idle timeout was reset properly, then there should not be a idle event yet
// even though one full idle timeout has elapsed since `start`.
Timer::after(Duration::from_millis(75)).await;
assert_eq!(rx.len(), 0);
// One full idle timeout has elapsed since the last reset, so
// we should have received an idle event.
Timer::after(Duration::from_millis(75)).await;
assert_eq!(rx.len(), 1);
})
}
@@ -0,0 +1 @@
pub mod heartbeat;
@@ -0,0 +1,674 @@
use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields};
use crate::pane_group::{PaneHeaderAction, PaneHeaderCustomAction};
use crate::terminal::view::TerminalAction;
use crate::{
appearance::Appearance,
ui_components::{buttons::icon_button, icons::Icon},
};
use instant::Duration;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use session_sharing_protocol::common::{ParticipantId, ParticipantInfo, Role};
use session_sharing_protocol::sharer::RoleUpdateReason;
use warpui::r#async::{SpawnedFutureHandle, Timer};
use warpui::{
accessibility::AccessibilityContent,
elements::{
Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Fill, Flex, Hoverable, MainAxisAlignment, MouseStateHandle,
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Stack,
},
platform::Cursor,
ui_components::components::{UiComponent, UiComponentStyles},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
};
use warpui::{FocusContext, ViewHandle};
use super::render_util::non_hoverable_participant_avatar;
#[derive(Debug, Clone)]
pub enum HoveredElement {
Avatar,
ContextMenu,
}
#[derive(Debug, Clone)]
pub enum ParticipantAvatarAction {
ScrollToSharedSessionParticipant {
participant_id: ParticipantId,
},
UpdateRole {
participant_id: ParticipantId,
role: Role,
},
OpenTooltip,
CloseTooltip,
/// Opens the context menu on hover
HoveredIn(HoveredElement),
/// Closes context menu only if both elements
/// have been hovered out of
HoveredOut(HoveredElement),
}
pub enum ParticipantAvatarEvent {
ScrollToSharedSessionParticipant {
participant_id: ParticipantId,
},
UpdateRole {
participant_id: ParticipantId,
role: Role,
},
MenuOpened {
participant_id: ParticipantId,
},
MenuClosed,
}
pub struct ParticipantAvatarView {
// Field from role of [`PresenceManager`]
// Indicates whether we ourselves are the sharer
is_manager_sharer: bool,
// Fields from [`Participant`] needed for rendering
participant_id: ParticipantId,
display_name: String,
image_url: Option<String>,
participant_color: ColorU,
is_muted: bool,
role: Option<Role>,
// Mouse state to handle hover and click on avatar
mouse_state_handle: MouseStateHandle,
// Context menu fields
menu: ViewHandle<Menu<ParticipantAvatarAction>>,
is_menu_open: bool,
is_menu_hovered: bool,
is_avatar_hovered: bool,
menu_mouse_state_handle: MouseStateHandle,
close_menu_abort_handle: Option<SpawnedFutureHandle>,
// Avatar context menu shouldn't trigger
// while the pane header overflow menu is open
is_pane_header_overflow_menu_open: bool,
}
impl ParticipantAvatarView {
pub fn new(
is_manager_sharer: bool,
info: ParticipantInfo,
participant_color: ColorU,
is_muted: bool,
role: Option<Role>,
ctx: &mut ViewContext<Self>,
) -> Self {
let menu = ctx.add_typed_action_view(|_| Menu::new().with_width(170.));
ctx.subscribe_to_view(&menu, move |me, _, event, ctx| {
me.handle_menu_event(event, ctx);
});
Self {
is_manager_sharer,
participant_id: info.id,
display_name: info.profile_data.display_name,
image_url: info.profile_data.photo_url,
participant_color,
is_muted,
role,
mouse_state_handle: Default::default(),
menu,
is_menu_open: false,
is_menu_hovered: false,
is_avatar_hovered: false,
menu_mouse_state_handle: Default::default(),
close_menu_abort_handle: None,
is_pane_header_overflow_menu_open: false,
}
}
pub fn set_participant_id(&mut self, id: ParticipantId) {
self.participant_id = id;
}
pub fn set_display_name(&mut self, name: String) {
self.display_name = name;
}
pub fn set_image_url(&mut self, path: Option<String>) {
self.image_url = path;
}
pub fn set_participant_color(&mut self, color: ColorU) {
self.participant_color = color;
}
pub fn set_is_muted(&mut self, is_muted: bool) {
self.is_muted = is_muted;
}
pub fn set_role(&mut self, role: Option<Role>) {
self.role = role;
}
pub fn set_is_pane_header_overflow_menu_open(&mut self, is_open: bool) {
self.is_pane_header_overflow_menu_open = is_open;
}
pub fn is_menu_open(&self) -> bool {
self.is_menu_open
}
fn context_menu_items(&self) -> Vec<MenuItem<ParticipantAvatarAction>> {
let participant_id = self.participant_id.clone();
let mut items = vec![MenuItemFields::new(self.display_name.clone())
.with_disabled(true)
.into_item()];
match self.role {
Some(Role::Reader) => items.extend([MenuItemFields::new("Make editor")
.with_on_select_action(ParticipantAvatarAction::UpdateRole {
participant_id,
role: Role::Executor,
})
.into_item()]),
Some(Role::Executor) => items.extend([MenuItemFields::new("Make viewer")
.with_on_select_action(ParticipantAvatarAction::UpdateRole {
participant_id,
role: Role::Reader,
})
.into_item()]),
// Sharer does not have context menu
_ => {}
}
items
}
fn handle_menu_event(&mut self, event: &MenuEvent, ctx: &mut ViewContext<Self>) {
if let MenuEvent::Close { .. } = event {
self.close_context_menu(ctx);
}
}
pub fn open_context_menu(&mut self, ctx: &mut ViewContext<Self>) {
self.is_menu_open = true;
self.menu.update(ctx, |menu, ctx| {
let items = self.context_menu_items();
menu.set_items(items, ctx);
});
ctx.notify();
}
pub fn close_context_menu(&mut self, ctx: &mut ViewContext<Self>) {
self.is_menu_open = false;
ctx.notify();
}
fn render_edit_icon(&self, appearance: &Appearance) -> Box<dyn Element> {
let background = appearance.theme().surface_3();
Container::new(
ConstrainedBox::new(
Icon::Edit
.to_warpui_icon(appearance.theme().foreground())
.finish(),
)
.with_height(8.)
.with_width(8.)
.finish(),
)
.with_uniform_padding(2.)
.with_background(background)
.with_corner_radius(CornerRadius::with_all(
warpui::elements::Radius::Percentage(50.),
))
.finish()
}
/// Helper function to render avatar context menu.
/// Handles events on hover.
fn render_menu(&self) -> Box<dyn Element> {
let is_menu_hovered = self.is_menu_hovered;
Hoverable::new(self.menu_mouse_state_handle.clone(), |_| {
ChildView::new(&self.menu).finish()
})
.on_hover(move |mouse_in, ctx, _, _| {
if mouse_in & !is_menu_hovered {
// Ensure menu isn't already being hovered over
ctx.dispatch_typed_action(ParticipantAvatarAction::HoveredIn(
HoveredElement::ContextMenu,
));
} else if !mouse_in {
ctx.dispatch_typed_action(ParticipantAvatarAction::HoveredOut(
HoveredElement::ContextMenu,
));
}
})
.finish()
}
/// Helper function to render non-hoverable participant avatar.
/// Specifically handles adding edit icon to participants with `Role::Executor`.
fn render_participant_avatar(
&self,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let is_executor = self.role.is_some_and(|r| r.can_execute());
let avatar = non_hoverable_participant_avatar(
self.display_name.clone(),
self.image_url.clone(),
self.participant_color,
self.is_muted,
is_executor,
app,
);
let mut stack = Stack::new();
stack.add_positioned_child(
avatar,
OffsetPositioning::offset_from_parent(
vec2f(0., 0.),
ParentOffsetBounds::ParentBySize,
ParentAnchor::TopLeft,
ChildAnchor::TopLeft,
),
);
if is_executor {
let icon = self.render_edit_icon(appearance);
stack.add_positioned_child(
icon,
OffsetPositioning::offset_from_parent(
vec2f(0., 0.),
ParentOffsetBounds::ParentBySize,
ParentAnchor::BottomRight,
ChildAnchor::BottomRight,
),
);
}
Container::new(
ConstrainedBox::new(stack.finish())
.with_min_height(20.)
.with_min_width(20.)
.finish(),
)
.with_vertical_padding(2.)
.finish()
}
/// Helper function to render hoverable participant avatar.
/// Handles hover and click events.
fn render_hoverable_participant_avatar(
&self,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let participant_id = self.participant_id.clone();
let is_manager_sharer = self.is_manager_sharer;
let is_avatar_hovered = self.is_avatar_hovered;
Hoverable::new(self.mouse_state_handle.clone(), |_| {
self.render_participant_avatar(appearance, app)
})
.with_cursor(Cursor::PointingHand)
.on_hover(move |mouse_in, ctx, _, _| {
match (mouse_in, is_manager_sharer) {
(true, true) => {
if !is_avatar_hovered {
ctx.dispatch_typed_action(ParticipantAvatarAction::HoveredIn(
HoveredElement::Avatar,
))
}
}
(true, false) => ctx.dispatch_typed_action(ParticipantAvatarAction::OpenTooltip),
(false, true) => ctx.dispatch_typed_action(ParticipantAvatarAction::HoveredOut(
HoveredElement::Avatar,
)),
(false, false) => ctx.dispatch_typed_action(ParticipantAvatarAction::CloseTooltip),
};
})
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ParticipantAvatarAction::ScrollToSharedSessionParticipant {
participant_id: participant_id.clone(),
});
})
.finish()
}
pub fn role(&self) -> Option<Role> {
self.role
}
}
impl Entity for ParticipantAvatarView {
type Event = ParticipantAvatarEvent;
}
impl View for ParticipantAvatarView {
fn ui_name() -> &'static str {
"ParticipantAvatar"
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
ctx.notify();
}
}
fn accessibility_contents(&self, _ctx: &AppContext) -> Option<AccessibilityContent> {
// TO DO
None
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let avatar_hoverable = self.render_hoverable_participant_avatar(appearance, app);
let mut stack = Stack::new().with_child(avatar_hoverable);
// Add tooltip if hovering over avatars as viewer
if !self.is_manager_sharer && self.is_avatar_hovered {
stack.add_positioned_overlay_child(
render_tooltip(self.display_name.clone(), appearance),
OffsetPositioning::offset_from_parent(
vec2f(0., 0.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopMiddle,
ChildAnchor::BottomMiddle,
),
);
// Render context menu if hovering over viewer avatar as a sharer
} else if self.is_manager_sharer
&& self.is_menu_open
&& !self.is_pane_header_overflow_menu_open
&& self.role.is_some()
{
stack.add_positioned_overlay_child(
self.render_menu(),
OffsetPositioning::offset_from_parent(
vec2f(0., 0.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::BottomRight,
ChildAnchor::TopRight,
),
);
}
stack.finish()
}
}
impl TypedActionView for ParticipantAvatarView {
type Action = ParticipantAvatarAction;
fn handle_action(&mut self, action: &ParticipantAvatarAction, ctx: &mut ViewContext<Self>) {
match action {
ParticipantAvatarAction::ScrollToSharedSessionParticipant { participant_id } => {
ctx.emit(ParticipantAvatarEvent::ScrollToSharedSessionParticipant {
participant_id: participant_id.clone(),
});
}
ParticipantAvatarAction::UpdateRole {
participant_id,
role,
} => {
ctx.emit(ParticipantAvatarEvent::UpdateRole {
participant_id: participant_id.clone(),
role: *role,
});
}
ParticipantAvatarAction::OpenTooltip => {
self.is_avatar_hovered = true;
}
ParticipantAvatarAction::CloseTooltip => {
self.is_avatar_hovered = false;
}
ParticipantAvatarAction::HoveredIn(menu_source) => {
// Abort closing timer on open
if let Some(old_abort_handle) = self.close_menu_abort_handle.take() {
old_abort_handle.abort();
}
// Update hover state
match menu_source {
HoveredElement::Avatar => self.is_avatar_hovered = true,
HoveredElement::ContextMenu => self.is_menu_hovered = true,
}
self.open_context_menu(ctx);
ctx.emit(ParticipantAvatarEvent::MenuOpened {
participant_id: self.participant_id.clone(),
});
}
ParticipantAvatarAction::HoveredOut(menu_source) => {
// Reset timer, if old one is still in progress
if let Some(old_abort_handle) = self.close_menu_abort_handle.take() {
old_abort_handle.abort();
}
// Update hover state
match menu_source {
HoveredElement::Avatar => self.is_avatar_hovered = false,
HoveredElement::ContextMenu => self.is_menu_hovered = false,
}
let should_close_menu = !self.is_avatar_hovered && !self.is_menu_hovered;
// Add delay before closing
if should_close_menu {
let close_menu_abort_handle = ctx.spawn_abortable(
Timer::after(Duration::from_millis(100)),
move |me, _, ctx| {
if should_close_menu {
me.close_context_menu(ctx);
ctx.emit(ParticipantAvatarEvent::MenuClosed);
}
},
|_, _| (),
);
self.close_menu_abort_handle = Some(close_menu_abort_handle);
}
}
}
}
}
pub fn render_tooltip(label: String, appearance: &Appearance) -> Box<dyn Element> {
let tooltip_background = appearance.theme().tooltip_background();
appearance
.ui_builder()
.tool_tip(label)
.with_style(UiComponentStyles {
font_size: Some(12.),
background: Some(Fill::Solid(tooltip_background)),
font_color: Some(appearance.theme().background().into_solid()),
..Default::default()
})
.build()
.finish()
}
/// Helper function to render a button that revokes executor role from all viewers.
pub fn render_revoke_all_button(
mouse_state_handle: MouseStateHandle,
appearance: &Appearance,
) -> Box<dyn Element> {
let edit = Icon::Edit
.to_warpui_icon(appearance.theme().foreground())
.finish();
let slash = Icon::Slash
.to_warpui_icon(appearance.theme().terminal_colors().normal.red.into())
.finish();
let mut stack = Stack::new().with_constrain_absolute_children();
stack.add_positioned_child(
edit,
OffsetPositioning::offset_from_parent(
vec2f(0., 0.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopLeft,
ChildAnchor::TopLeft,
),
);
stack.add_positioned_child(
slash,
OffsetPositioning::offset_from_parent(
vec2f(0., 0.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopLeft,
ChildAnchor::TopLeft,
),
);
Hoverable::new(mouse_state_handle, |state| {
let mut button = Container::new(
ConstrainedBox::new(stack.finish())
.with_width(16.)
.with_height(16.)
.finish(),
)
.with_border(Border::all(1.))
.with_uniform_padding(4.)
.with_margin_right(2.);
let mut stack = Stack::new();
if state.is_hovered() {
let background_color = if state.is_clicked() {
appearance.theme().background().into()
} else {
appearance.theme().surface_2().into()
};
button = button
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_background_color(background_color)
.with_border(
Border::all(1.).with_border_color(appearance.theme().surface_3().into()),
);
stack.add_positioned_child(
render_tooltip("Revoke all edit permissions".to_string(), appearance),
OffsetPositioning::offset_from_parent(
vec2f(0., 3.),
ParentOffsetBounds::Unbounded,
ParentAnchor::BottomMiddle,
ChildAnchor::TopMiddle,
),
);
}
stack.add_child(button.finish());
stack.finish()
})
.on_click(|ctx, _, _| {
// We have to dispatch a pane header action because the button is rendered in the pane header.
ctx.dispatch_typed_action(PaneHeaderCustomAction::<TerminalAction, TerminalAction>(
TerminalAction::MakeAllParticipantsReaders {
reason: RoleUpdateReason::UpdatedBySharer,
},
));
})
.finish()
}
/// Helper function to render a button that indicates a viewer's role.
pub fn render_viewer_role_button(
role: Option<Role>,
mouse_state_handle: MouseStateHandle,
menu_handle: Option<ViewHandle<Menu<PaneHeaderAction<TerminalAction, TerminalAction>>>>,
is_menu_open: bool,
appearance: &Appearance,
) -> Box<dyn Element> {
let icon = match role {
Some(role) if role.can_execute() => Icon::Edit,
_ => Icon::Eye,
};
let ui_builder = appearance.ui_builder().clone();
let mut stack = Stack::new();
let button = icon_button(appearance, icon, false, mouse_state_handle.clone())
.with_tooltip(move || {
ui_builder
.tool_tip("Change role".to_string())
.build()
.finish()
})
.build()
.on_click(|ctx, _, _| {
// We have to dispatch a pane header action because the button is rendered in the pane header.
ctx.dispatch_typed_action(PaneHeaderCustomAction::<TerminalAction, TerminalAction>(
TerminalAction::OpenSharedSessionViewerRoleMenu,
));
})
.finish();
stack.add_child(button);
if let Some(menu) = menu_handle {
if is_menu_open {
stack.add_positioned_overlay_child(
ChildView::new(&menu).finish(),
OffsetPositioning::offset_from_parent(
vec2f(0., 0.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::BottomRight,
ChildAnchor::TopRight,
),
);
}
}
Container::new(stack.finish()).with_margin_left(8.).finish()
}
/// Helper function to render participant avatar list and role buttons in the pane header.
pub fn render_participants_and_role_elements(
participants: Vec<ViewHandle<ParticipantAvatarView>>,
role: Option<Role>,
mouse_state_handle: MouseStateHandle,
menu_handle: Option<ViewHandle<Menu<PaneHeaderAction<TerminalAction, TerminalAction>>>>,
is_menu_open: bool,
hide_role_change_button: bool,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let mut row = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
// Only render button for sharer (client is sharer iff role is none)
// when there exists viewers that are executors.
let num_executors = participants
.iter()
.filter(|participant| {
participant
.as_ref(app)
.role()
.is_some_and(|r| r.can_execute())
})
.count();
if role.is_none() && num_executors > 0 {
row.add_child(render_revoke_all_button(
mouse_state_handle.clone(),
appearance,
));
}
for participant in participants.iter() {
row.add_child(
Container::new(ChildView::new(participant).finish())
.with_horizontal_margin(1.)
.finish(),
);
}
// Only render button for viewer, unless hide_role_change_button is true
// (e.g., in cloud mode conversations where role changes are not supported)
if role.is_some() && !hide_role_change_button {
row.add_child(render_viewer_role_button(
role,
mouse_state_handle.clone(),
menu_handle.clone(),
is_menu_open,
appearance,
));
Container::new(row.finish()).finish()
} else {
row.finish()
}
}
@@ -0,0 +1,74 @@
use session_sharing_protocol::common::{Guest, PendingGuest, Role, SessionId, TeamAclData};
use warpui::{Entity, ModelContext, SingletonEntity};
use crate::drive::sharing::SharingAccessLevel;
pub struct SessionPermissionsManager {}
impl SessionPermissionsManager {
pub(crate) fn new(_ctx: &mut ModelContext<Self>) -> Self {
Self {}
}
pub(crate) fn updated_guests(
&mut self,
ctx: &mut ModelContext<Self>,
session_id: SessionId,
guests: Vec<Guest>,
pending_guests: Vec<PendingGuest>,
) {
ctx.emit(SessionPermissionsEvent::GuestsUpdated {
session_id,
guests,
pending_guests,
});
}
pub(crate) fn updated_link_permissions(
&mut self,
session_id: SessionId,
role: Option<Role>,
ctx: &mut ModelContext<Self>,
) {
let access_level = role.map(|role| role.into());
ctx.emit(SessionPermissionsEvent::LinkPermissionsUpdated {
session_id,
access_level,
});
}
/// Sets the team ACL for the given session. For now, this assumes that
/// sessions can have only one team ACL.
pub(crate) fn updated_team_permissions(
&mut self,
session_id: SessionId,
team_acl: Option<TeamAclData>,
ctx: &mut ModelContext<Self>,
) {
ctx.emit(SessionPermissionsEvent::TeamPermissionsUpdated {
session_id,
team_acl,
});
}
}
pub enum SessionPermissionsEvent {
GuestsUpdated {
session_id: SessionId,
guests: Vec<Guest>,
pending_guests: Vec<PendingGuest>,
},
LinkPermissionsUpdated {
session_id: SessionId,
access_level: Option<SharingAccessLevel>,
},
TeamPermissionsUpdated {
session_id: SessionId,
team_acl: Option<TeamAclData>,
},
}
impl Entity for SessionPermissionsManager {
type Event = SessionPermissionsEvent;
}
impl SingletonEntity for SessionPermissionsManager {}
@@ -0,0 +1,785 @@
use std::{
collections::{HashMap, HashSet},
iter,
};
use futures::future::BoxFuture;
use futures_util::future::join_all;
use itertools::{Either, Itertools};
use pathfinder_color::ColorU;
use rand::Rng;
use session_sharing_protocol::common::{
InputReplicaId, ParticipantInfo, ParticipantList, ParticipantPresenceUpdate, PresenceUpdate,
Role, RoleRequestId, Selection,
};
use asset_cache::AssetCacheExt as _;
use warpui::{
assets::asset_cache::{AssetCache, AssetState},
image_cache::ImageType,
r#async::SpawnedFutureHandle,
AppContext, Entity, ModelContext, SingletonEntity,
};
use session_sharing_protocol::common::ParticipantId;
use crate::{
auth::UserUid,
editor::{CursorColors, PeerSelectionData},
terminal::model::{block::BlockId, blocks::BlockList, terminal_model::BlockIndex},
util::color::coloru_with_opacity,
};
/// Selections have 25% opacity.
pub fn text_selection_color(participant_color: ColorU) -> ColorU {
coloru_with_opacity(participant_color, 25)
}
pub const MUTED_PARTICIPANT_COLOR: ColorU = ColorU {
r: 176,
g: 176,
b: 176,
a: 255,
};
pub const MUTED_AVATAR_BORDER_COLOR: ColorU = ColorU {
r: 138,
g: 138,
b: 138,
a: 255,
};
/// A set of pre-assigned colors that we use for shared session participants.
/// These come from https://www.figma.com/file/chk9pwt35jTJhf9KnHmZyE/Components?type=design&node-id=1650-1410&mode=design&t=RTHbE9G6NLhFRqLQ-0.
const PRESET_COLORS: &[ColorU] = &[
ColorU {
r: 93,
g: 202,
b: 60,
a: 255,
},
ColorU {
r: 174,
g: 67,
b: 255,
a: 255,
},
ColorU {
r: 224,
g: 222,
b: 19,
a: 255,
},
ColorU {
r: 255,
g: 125,
b: 38,
a: 255,
},
ColorU {
r: 68,
g: 233,
b: 237,
a: 255,
},
ColorU {
r: 54,
g: 98,
b: 236,
a: 255,
},
ColorU {
r: 255,
g: 13,
b: 226,
a: 255,
},
];
/// Helper struct containing participant info and anything else necessary for rendering
/// for an present participant.
#[derive(Clone)]
pub struct Participant {
pub info: ParticipantInfo,
/// The color assigned to this participant
pub color: ColorU,
/// Is None iff participant is sharer.
pub role: Option<Role>,
}
impl Participant {
pub fn id(&self) -> &ParticipantId {
&self.info.id
}
pub fn input_replica_id(&self) -> &InputReplicaId {
&self.info.profile_data.input_replica_id
}
/// Returns the selected block index that the avatar should be rendered at.
/// This is the block at the top of the last continuous selection.
/// Returns None if the participant doesn't have a block selected.
pub fn get_selected_block_index_for_avatar(
&self,
block_list: &BlockList,
) -> Option<BlockIndex> {
let session_sharing_protocol::common::Selection::Blocks { block_ids } =
&self.info.selection
else {
return None;
};
let mut block_index_for_avatar = None;
// Sort selected block indices in decreasing order.
let block_indices = block_ids
.iter()
.filter_map(|block_id| block_list.block_index_for_id(&(block_id.to_string().into())))
.sorted_unstable()
.rev();
for idx in block_indices {
let Some(block_index) = block_index_for_avatar else {
block_index_for_avatar = Some(idx);
continue;
};
// If this is part of the same continuous selection, update the index since we want the avatar at the top of the last continuous selection.
if idx
== std::convert::Into::<usize>::into(block_index)
.saturating_sub(1)
.into()
{
block_index_for_avatar = Some(idx);
} else {
// Once we reach a smaller index that's not part of the same continuous selection, return
return block_index_for_avatar;
}
}
block_index_for_avatar
}
}
/// Helper struct containing presence information about a participant who selected a particular block.
pub struct ParticipantAtSelectedBlock<'a> {
/// The participant who selected the block.
pub participant: &'a Participant,
/// This block is the top of a continuous block selection by this participant.
/// True for single selected block as well.
pub is_top_of_continuous_selection: bool,
/// This block is the bottom of a continuous block selection by this participant.
/// True for single selected block as well.
pub is_bottom_of_continuous_selection: bool,
pub should_show_avatar: bool,
}
/// A viewer who was once part of the session
/// but no longer is.
#[derive(Clone)]
pub struct AbsentViewer {
/// The last known info we had about the viewer.
participant_info: ParticipantInfo,
}
impl AbsentViewer {
pub fn id(&self) -> &ParticipantId {
&self.participant_info.id
}
pub fn input_replica_id(&self) -> &InputReplicaId {
&self.participant_info.profile_data.input_replica_id
}
}
/// Manager for assigning colors to shared session participants as they join and leave.
/// This should contain the data needed to render presence-related UIs.
/// The presence manager does not store participant data about ourselves, whether we are the sharer or viewer.
pub struct PresenceManager {
/// Our own Participant ID.
id: ParticipantId,
/// Our own Firebase UID.
firebase_uid: UserUid,
/// Our own role, None iff is sharer.
pub role: Option<Role>,
/// Participant ID of the sharer.
sharer_id: ParticipantId,
/// Is None iff we ourselves are the sharer.
sharer: Option<Participant>,
/// The set of viewers who are still part of the session.
///
/// If we are ourselves a viewer, this map does _not_ include our own state.
present_viewers: HashMap<ParticipantId, Participant>,
/// The set of viewers who were once part of the session but no longer are.
/// By default, all of the `get_*` APIs that return a list of participants
/// _do not_ include the absent viewers.
absent_viewers: HashMap<ParticipantId, AbsentViewer>,
chosen_colors: HashSet<ColorU>,
/// Loading participants is a future because we may need to download an image.
/// Note even if there is no image, the participant is still loaded as a future.
load_participants_imgs_future_handle: Option<SpawnedFutureHandle>,
/// Whether we ourselves are attempting to reconnect to the server.
/// If this is true, all avatars should have a muted color.
is_reconnecting: bool,
// Map from block ID to the shared session participant IDs that have it selected.
block_id_to_participants_selected: HashMap<BlockId, Vec<ParticipantId>>,
role_requests: HashMap<ParticipantId, RoleRequestId>,
}
/// Returns the first available preset color, or a random color if all are taken.
pub fn get_available_color(chosen_colors: &HashSet<ColorU>) -> ColorU {
for color in PRESET_COLORS {
if !chosen_colors.contains(color) {
return *color;
}
}
// If we ran out of colors, generate a random one.
ColorU::new(
rand::thread_rng().gen_range(0..=255),
rand::thread_rng().gen_range(0..=255),
rand::thread_rng().gen_range(0..=255),
255,
)
}
impl PresenceManager {
pub fn new_for_sharer(id: ParticipantId, firebase_uid: UserUid) -> Self {
Self {
id: id.clone(),
firebase_uid,
role: None,
sharer_id: id,
sharer: None,
present_viewers: HashMap::new(),
absent_viewers: HashMap::new(),
chosen_colors: HashSet::new(),
load_participants_imgs_future_handle: None,
block_id_to_participants_selected: HashMap::new(),
is_reconnecting: false,
role_requests: HashMap::new(),
}
}
pub fn new_for_viewer(
id: ParticipantId,
firebase_uid: UserUid,
participants: ParticipantList,
ctx: &mut ModelContext<Self>,
) -> Self {
// Populate sharer info, remaining fields for sharer and viewer
// will be populated in the call to `update_participants`.
let mut chosen_colors = HashSet::new();
let color = get_available_color(&chosen_colors);
chosen_colors.insert(color);
let sharer = Participant {
info: participants.sharer.info.clone(),
color,
role: None,
};
let mut manager = Self {
id,
firebase_uid,
role: Some(Role::default()),
sharer_id: participants.sharer.info.id.clone(),
sharer: Some(sharer),
present_viewers: HashMap::new(),
absent_viewers: HashMap::new(),
chosen_colors,
load_participants_imgs_future_handle: None,
block_id_to_participants_selected: HashMap::new(),
is_reconnecting: false,
role_requests: HashMap::new(),
};
manager.update_participants(participants, ctx);
manager
}
/// Returns our own participant id.
pub fn id(&self) -> ParticipantId {
self.id.clone()
}
/// Returns our own Firebase UID.
pub fn firebase_uid(&self) -> UserUid {
self.firebase_uid
}
/// Returns the sharer's participant id.
pub fn sharer_id(&self) -> ParticipantId {
self.sharer_id.clone()
}
/// Returns our own role, `None` iff we are the sharer.
pub fn role(&self) -> Option<Role> {
self.role
}
/// Returns the viewer's role, if the viewer is known to us.
pub fn viewer_role(&self, viewer_id: &ParticipantId) -> Option<Role> {
self.present_viewers.get(viewer_id).and_then(|v| v.role)
}
/// Returns a viewer's role request id given their participant id,
/// `None` if the viewer does not have a pending request.
pub fn get_role_request(&self, participant_id: &ParticipantId) -> Option<&RoleRequestId> {
self.role_requests.get(participant_id)
}
/// Returns the number of present viewers (not including ourselves).
pub(crate) fn present_viewer_count(&self) -> usize {
self.present_viewers.len()
}
/// Returns the present viewers of this shared session, not including ourselves.
/// There is no guarantee of the ordering of viewers, so callers should sort by ID for a stable ordering.
pub fn get_present_viewers(&self) -> impl Iterator<Item = &Participant> {
self.present_viewers.values()
}
/// Returns the sharer of this shared session.
/// Returns None if we are the sharer ourselves (we should not need presence data for ourselves).
pub fn get_sharer(&self) -> Option<&Participant> {
self.sharer.as_ref()
}
/// Returns all present participants of this shared session, including sharer and viewers,
/// but not including ourselves.
pub fn all_present_participants(&self) -> impl Iterator<Item = &Participant> {
if let Some(sharer) = self.get_sharer() {
return Either::Left(iter::once(sharer).chain(self.get_present_viewers()));
}
Either::Right(self.get_present_viewers())
}
/// Returns the participant identified by id iff the participant is present.
pub fn get_participant(&self, id: &ParticipantId) -> Option<&Participant> {
if let Some(viewer) = self.present_viewers.get(id) {
return Some(viewer);
} else if let Some(sharer) = self.sharer.as_ref() {
if self.sharer_id == *id {
return Some(sharer);
}
}
None
}
/// Returns the participants who have the block at the block index selected.
pub fn get_participants_selected_block_index(
&self,
block_index: BlockIndex,
block_list: &BlockList,
) -> Vec<&Participant> {
let Some(block) = block_list.block_at(block_index) else {
return vec![];
};
let Some(participant_ids) = self.block_id_to_participants_selected.get(block.id()) else {
return vec![];
};
participant_ids
.iter()
.filter_map(|id| self.get_participant(id))
.collect_vec()
}
/// Returns the participants who have the block at the block index selected,
/// with some additional info helpful for rendering.
pub fn get_participants_at_selected_block(
&self,
block_index: BlockIndex,
block_list: &BlockList,
) -> Vec<ParticipantAtSelectedBlock<'_>> {
let participants_selected_this_block =
self.get_participants_selected_block_index(block_index, block_list);
if participants_selected_this_block.is_empty() {
return vec![];
}
let participant_ids_selected_prev_block = if block_index == 0.into() {
HashSet::new()
} else {
HashSet::<_>::from_iter(
self.get_participants_selected_block_index(block_index - 1.into(), block_list)
.into_iter()
.map(|p| p.info.id.clone()),
)
};
let participant_ids_selected_next_block = HashSet::<_>::from_iter(
self.get_participants_selected_block_index(block_index + 1.into(), block_list)
.into_iter()
.map(|p| p.info.id.clone()),
);
participants_selected_this_block
.into_iter()
.map(|participant| {
let should_show_avatar = participant
.get_selected_block_index_for_avatar(block_list)
.is_some_and(|idx| idx == block_index);
ParticipantAtSelectedBlock {
participant,
is_top_of_continuous_selection: !participant_ids_selected_prev_block
.contains(&participant.info.id),
is_bottom_of_continuous_selection: !participant_ids_selected_next_block
.contains(&participant.info.id),
should_show_avatar,
}
})
.collect_vec()
}
pub fn update_participants(
&mut self,
participants: ParticipantList,
ctx: &mut ModelContext<Self>,
) {
// If there was a previous in-flight future updating participants, cancel it since our new list is more up to date.
if let Some(old_abort_handle) = self.load_participants_imgs_future_handle.take() {
old_abort_handle.abort();
}
// The new or updated participants.
let mut latest_participants = Vec::new();
// A list of futures. Each one represents a profile image that's being loaded for a participant.
let mut participant_image_loading_futures = Vec::new();
// Update sharer info
let incoming_sharer_info = participants.sharer.info;
if let Some(sharer) = self.sharer.as_mut() {
sharer.info = incoming_sharer_info.clone();
if let Some(future) = Self::when_profile_image_is_loaded(sharer, ctx) {
participant_image_loading_futures.push(future);
}
latest_participants.push(sharer.clone());
}
for viewer in participants.viewers {
if !viewer.is_present {
if let Some(viewer) = self.present_viewers.remove(&viewer.info.id) {
self.chosen_colors.remove(&viewer.color);
}
self.absent_viewers.insert(
viewer.info.id.clone(),
AbsentViewer {
participant_info: viewer.info,
},
);
continue;
}
let info = viewer.info;
// Only store role data for ourselves.
if info.id == self.id {
self.role = Some(viewer.role);
continue;
}
// If this participant already existed, update the info and role
// while keeping their color.
if let Some(existing_participant) = self.present_viewers.get_mut(&info.id) {
existing_participant.info = info;
existing_participant.role = Some(viewer.role);
continue;
};
// Otherwise, pick an available color and add them.
let color = get_available_color(&self.chosen_colors);
self.chosen_colors.insert(color);
let new_viewer = Participant {
info,
color,
role: Some(viewer.role),
};
if let Some(future) = Self::when_profile_image_is_loaded(&new_viewer, ctx) {
participant_image_loading_futures.push(future);
}
latest_participants.push(new_viewer);
}
// Spawn a future that waits for all the new profile images to be loaded into memory.
let load_participants_future_handle = ctx.spawn(
async move {
join_all(participant_image_loading_futures).await;
},
|manager, _, ctx| {
manager.on_participant_images_loaded(latest_participants, ctx);
},
);
self.load_participants_imgs_future_handle = Some(load_participants_future_handle.clone());
}
/// Returns a future that resolves when the participant's profile image is loaded. If the participant
/// doesn't have a profile image OR their image is already available in memory, returns None.
fn when_profile_image_is_loaded(
participant: &Participant,
app: &AppContext,
) -> Option<BoxFuture<'static, ()>> {
let url = participant.info.profile_data.photo_url.as_ref()?;
let asset_cache = AssetCache::as_ref(app);
// Make a non-blocking check to see if the image is loaded yet. If the image hasn't been seen
// before, this call spawns a task to fetch the bytes and parse it into an image.
match asset_cache.load_asset_from_url::<ImageType>(url, None) {
AssetState::Loading { handle } => handle.when_loaded(asset_cache),
_ => None,
}
}
pub fn update_participant_presence(
&mut self,
update: ParticipantPresenceUpdate,
_ctx: &mut ModelContext<Self>,
) {
let participant = if self.sharer_id == update.participant_id {
self.sharer.as_mut()
} else {
self.present_viewers.get_mut(&update.participant_id)
};
let Some(participant) = participant else {
if self.id != update.participant_id {
log::warn!("Received shared session participant presence update for participant that doesn't exist");
}
return;
};
let PresenceUpdate::Selection(selection) = update.update;
// Selection info is needed for rendering remote cursors in input
participant.info.selection = selection;
self.refresh_block_id_to_participants_selected();
}
pub fn update_participant_role(
&mut self,
participant_id: &ParticipantId,
role: Role,
_ctx: &mut ModelContext<Self>,
) {
if participant_id == &self.id {
self.role = Some(role);
} else {
let Some(participant) = self.present_viewers.get_mut(participant_id) else {
log::warn!("Received shared session participant role update for participant that doesn't exist");
return;
};
participant.role = Some(role);
}
}
pub fn make_all_participants_readers(&mut self, _ctx: &mut ModelContext<Self>) {
for viewer in self.present_viewers.values_mut() {
viewer.role = Some(Role::Reader);
}
}
/// Called when the sharer is notified of a role request from a viewer.
pub fn on_role_requested(
&mut self,
participant_id: ParticipantId,
role_request_id: RoleRequestId,
role: Role,
_ctx: &mut ModelContext<Self>,
) {
// TODO: handle pending role requests on reconnection
// Ensure only the sharer can update its role requests
if self.sharer_id != self.id {
return;
}
// Ensure viewer doesn't already have requested role
if let Some(old_role) = self.viewer_role(&participant_id) {
if role == old_role {
return;
}
}
self.role_requests
.insert(participant_id.clone(), role_request_id.clone());
}
/// Called when the sharer is notified of a cancelled role request
pub fn on_role_request_cancelled(
&mut self,
participant_id: ParticipantId,
_ctx: &mut ModelContext<Self>,
) {
// Ensure only the sharer can remove its role requests
if self.sharer_id != self.id {
return;
}
self.role_requests.remove(&participant_id);
}
/// Called as the sharer responds to a role request
pub fn on_role_request_responded_to(
&mut self,
participant_id: ParticipantId,
_ctx: &mut ModelContext<Self>,
) {
// Ensure only the sharer can remove its role requests
if self.sharer_id != self.id {
return;
}
self.role_requests.remove(&participant_id);
}
pub fn set_is_reconnecting(
&mut self,
is_self_reconnecting: bool,
_ctx: &mut ModelContext<Self>,
) {
self.is_reconnecting = is_self_reconnecting;
}
pub fn is_reconnecting(&self) -> bool {
self.is_reconnecting
}
/// Refreshes the block ID to participants selected cache to be consistent with the current participant data stored.
fn refresh_block_id_to_participants_selected(&mut self) {
self.block_id_to_participants_selected.clear();
let participants = if self.sharer.is_some() {
Either::Left(
iter::once(self.sharer.as_ref().expect("sharer should exist"))
.chain(self.present_viewers.values()),
)
} else {
Either::Right(self.present_viewers.values())
};
for participant in participants {
if let session_sharing_protocol::common::Selection::Blocks { block_ids } =
&participant.info.selection
{
for block_id in block_ids {
self.block_id_to_participants_selected
.entry(block_id.to_string().into())
.or_default()
.push(participant.info.id.clone());
}
}
}
}
fn on_participant_images_loaded(
&mut self,
latest_participants: Vec<Participant>,
ctx: &mut ModelContext<Self>,
) {
// Once all participant futures have completed, update the participant list and emit an event.
for participant in latest_participants {
if let session_sharing_protocol::common::Selection::Blocks { block_ids } =
&participant.info.selection
{
for block_id in block_ids {
self.block_id_to_participants_selected
.entry(block_id.to_string().into())
.or_default()
.push(participant.info.id.clone());
}
}
if participant.info.id == self.sharer_id {
self.sharer = Some(participant);
} else {
self.present_viewers
.insert(participant.info.id.clone(), participant);
}
}
self.refresh_block_id_to_participants_selected();
ctx.emit(Event::ParticipantListUpdated);
}
pub fn input_data_for_participant(
&self,
participant: &Participant,
) -> (InputReplicaId, PeerSelectionData) {
let input_replica_id = participant.input_replica_id().clone();
let participant_color = if self.is_reconnecting() {
MUTED_PARTICIPANT_COLOR
} else {
participant.color
};
let colors = CursorColors {
cursor: participant_color.into(),
selection: text_selection_color(participant_color).into(),
};
let cursor_data = PeerSelectionData {
colors,
display_name: participant.info.profile_data.display_name.clone(),
image_url: participant.info.profile_data.photo_url.clone(),
should_draw_cursors: matches!(participant.info.selection, Selection::None),
};
(input_replica_id, cursor_data)
}
pub fn absent_viewers(&self) -> impl Iterator<Item = &AbsentViewer> + '_ {
self.absent_viewers.values()
}
/// Returns a viewer's firebase uid, if the viewer is known to us.
pub fn viewer_firebase_uid(&self, viewer_id: &ParticipantId) -> Option<UserUid> {
if *viewer_id == self.id {
return Some(self.firebase_uid);
}
self.present_viewers
.get(viewer_id)
.map(|v| v.info.profile_data.firebase_uid.as_str())
.or_else(|| {
self.absent_viewers
.get(viewer_id)
.map(|v| v.participant_info.profile_data.firebase_uid.as_str())
})
.map(UserUid::new)
}
/// Returns all of the present viewer IDs associated with the given Firebase
/// UID, including ourselves if applicable.
pub fn present_viewer_ids_for_uid(
&self,
viewer_uid: UserUid,
) -> impl Iterator<Item = &ParticipantId> + '_ {
let is_viewer_self = self.firebase_uid == viewer_uid;
let viewer_ids = self
.present_viewers
.values()
.filter(move |v| viewer_uid.as_string() == v.info.profile_data.firebase_uid)
.map(|v| &v.info.id);
viewer_ids.chain(is_viewer_self.then_some(&self.id))
}
/// Returns a participant ID for a participant associated with the given
/// Firebase UID.
pub fn present_viewer_id_for_uid(&self, viewer_uid: UserUid) -> Option<&ParticipantId> {
self.present_viewer_ids_for_uid(viewer_uid).next()
}
}
pub enum Event {
ParticipantListUpdated,
}
impl Entity for PresenceManager {
type Event = Event;
}
#[cfg(test)]
#[path = "presence_manager_test.rs"]
mod tests;
@@ -0,0 +1,374 @@
use crate::auth::UserUid;
use crate::terminal::model::ansi::{CommandFinishedValue, Handler};
use crate::terminal::model::blocks::BlockList;
use crate::terminal::model::test_utils::TestBlockListBuilder;
use crate::terminal::shared_session::presence_manager::{PresenceManager, PRESET_COLORS};
use std::collections::{HashMap, HashSet};
use std::iter;
use itertools::Itertools;
use session_sharing_protocol::common::{
ParticipantId, ParticipantInfo, ParticipantList, ProfileData, Role, Selection, Sharer, Viewer,
};
use warp_core::command::ExitCode;
use warpui::App;
#[test]
fn test_choosing_preset_colors() {
App::test((), |mut app| async move {
// Initialize with a sharer.
let firebase_uid = UserUid::new("mock_firebase_uid");
let presence_manager =
app.add_model(|_| PresenceManager::new_for_sharer(ParticipantId::new(), firebase_uid));
let sharer_id = ParticipantId::new();
let sharer = Sharer {
info: ParticipantInfo {
id: sharer_id.clone(),
profile_data: ProfileData {
..Default::default()
},
..Default::default()
},
};
let mut viewers = Vec::new();
let sharer_clone = sharer.clone();
let viewers_clone = viewers.clone();
presence_manager
.update(&mut app, |presence_manager, ctx| {
presence_manager.update_participants(
ParticipantList {
sharer: sharer_clone,
viewers: viewers_clone,
present_viewers: Default::default(),
absent_viewers: Default::default(),
guests: Default::default(),
pending_guests: Default::default(),
},
ctx,
);
let spawned_future = presence_manager
.load_participants_imgs_future_handle
.as_ref()
.expect("should have future handle");
ctx.await_spawned_future(spawned_future.future_id())
})
.await;
// We ourselves are the sharer, so no color is saved
presence_manager.read(&app, |presence_manager: &PresenceManager, _ctx| {
let sharer = presence_manager.get_sharer();
assert!(sharer.is_none());
let viewers = presence_manager.get_present_viewers().collect_vec();
assert_eq!(viewers.len(), 0);
});
// Add new viewers one-by-one. Each new viewer should take the next preset color, while existing viewers keep their colors.
let viewer_ids = iter::repeat_with(ParticipantId::new).take(PRESET_COLORS.len());
let mut id_to_expected_color = HashMap::new();
for (i, id) in viewer_ids.enumerate() {
// Add a new viewer.
viewers.push(Viewer {
info: ParticipantInfo {
id: id.clone(),
..Default::default()
},
role: Role::Reader,
is_present: true,
});
let sharer_clone = sharer.clone();
let viewers_clone = viewers.clone();
presence_manager
.update(&mut app, |presence_manager, ctx| {
presence_manager.update_participants(
ParticipantList {
sharer: sharer_clone,
viewers: viewers_clone,
present_viewers: Default::default(),
absent_viewers: Default::default(),
guests: Default::default(),
pending_guests: Default::default(),
},
ctx,
);
let spawned_future = presence_manager
.load_participants_imgs_future_handle
.as_ref()
.expect("should have future handle");
ctx.await_spawned_future(spawned_future.future_id())
})
.await;
// Expect the new viewer to take the next preset color, while continuing to expect old viewers to keep their colors.
id_to_expected_color.insert(id, PRESET_COLORS[i]);
presence_manager.read(&app, |presence_manager, _ctx| {
let viewers = presence_manager.get_present_viewers().collect_vec();
assert_eq!(viewers.len(), i + 1);
for viewer in presence_manager.get_present_viewers() {
let expected_color = *id_to_expected_color
.get(&viewer.info.id)
.expect("should have expected viewer ids only");
assert_eq!(viewer.color, expected_color);
assert!(matches!(viewer.role, Some(Role::Reader)));
}
});
}
// Set the first viewer as no longer present, and add a new participant.
viewers.get_mut(0).unwrap().is_present = false;
assert!(!viewers.first().unwrap().is_present);
let old_participant_id = viewers.first().unwrap().info.id.clone();
let new_id = ParticipantId::new();
viewers.push(Viewer {
info: ParticipantInfo {
id: new_id.clone(),
..Default::default()
},
role: Role::Reader,
is_present: true,
});
presence_manager
.update(&mut app, |presence_manager, ctx| {
presence_manager.update_participants(
ParticipantList {
sharer,
viewers,
present_viewers: Default::default(),
absent_viewers: Default::default(),
guests: Default::default(),
pending_guests: Default::default(),
},
ctx,
);
let spawned_future = presence_manager
.load_participants_imgs_future_handle
.as_ref()
.expect("should have future handle");
ctx.await_spawned_future(spawned_future.future_id())
})
.await;
// The color previously taken by the first viewer should be reused for the new participant, while other participants keep their existing colors.
let old_participant_color = id_to_expected_color
.remove(&old_participant_id)
.expect("old participant exists");
id_to_expected_color.insert(new_id, old_participant_color);
presence_manager.read(&app, |presence_manager, _ctx| {
let viewers = presence_manager.get_present_viewers().collect_vec();
assert_eq!(viewers.len(), PRESET_COLORS.len());
for viewer in viewers {
assert_eq!(
viewer.color,
*id_to_expected_color
.get(&viewer.info.id)
.expect("should have expected viewer ids only")
);
assert!(matches!(viewer.role, Some(Role::Reader)));
}
});
});
}
#[test]
fn test_dont_include_self_in_viewers() {
App::test((), |mut app| async move {
let self_id = ParticipantId::new();
let self_firebase_uid = UserUid::new("mock_firebase_uid");
let sharer = Sharer {
..Default::default()
};
let viewers = vec![
Viewer {
info: ParticipantInfo {
id: self_id.clone(),
..Default::default()
},
role: Role::Reader,
is_present: true,
},
Viewer {
info: ParticipantInfo {
..Default::default()
},
role: Role::Reader,
is_present: true,
},
Viewer {
info: ParticipantInfo {
..Default::default()
},
role: Role::Reader,
is_present: true,
},
Viewer {
info: ParticipantInfo {
..Default::default()
},
role: Role::Reader,
is_present: true,
},
];
let participant_list = ParticipantList {
sharer,
viewers,
present_viewers: Default::default(),
absent_viewers: Default::default(),
guests: Default::default(),
pending_guests: Default::default(),
};
let presence_manager = app.add_model(|ctx| {
PresenceManager::new_for_viewer(
self_id.clone(),
self_firebase_uid,
participant_list.clone(),
ctx,
)
});
// Ensure participants are loaded before continuing.
presence_manager
.update(&mut app, |presence_manager, ctx| {
let spawned_future = presence_manager
.load_participants_imgs_future_handle
.as_ref()
.expect("should have future handle");
ctx.await_spawned_future(spawned_future.future_id())
})
.await;
presence_manager.read(&app, |presence_manager, _ctx| {
let mut participant_colors = HashSet::new();
let sharer = presence_manager.get_sharer().expect("should have sharer");
participant_colors.insert(sharer.color);
// The viewers returned by presence manager should not include ourselves.
let viewers = presence_manager.get_present_viewers().collect_vec();
assert_eq!(viewers.len(), 3);
for viewer in viewers {
assert_ne!(viewer.info.id, self_id);
participant_colors.insert(viewer.color);
}
// The sharer and 3 other viewers should all use colors from the preset colors.
let preset_colors = HashSet::from_iter(PRESET_COLORS[..4].iter().copied());
assert!(participant_colors.eq(&preset_colors));
});
});
}
fn block_list_for_test(max_block_index: usize) -> BlockList {
let mut block_list = TestBlockListBuilder::new().build();
// Block 0 already exists as part of creating the blocklist
for i in 1..max_block_index {
block_list.command_finished(CommandFinishedValue {
exit_code: ExitCode::from(0),
next_block_id: i.to_string().into(),
});
block_list.precmd(Default::default());
}
block_list
}
#[test]
fn test_selected_block_index_for_avatar() {
App::test((), |mut app| async move {
// Initialize with a sharer who has blocks selected.
let mut sharer = Sharer {
info: ParticipantInfo {
id: ParticipantId::new(),
profile_data: ProfileData {
..Default::default()
},
selection: Selection::Blocks {
block_ids: vec![
"1".to_string().into(),
"4".to_string().into(),
"2".to_string().into(),
"10".to_string().into(),
"9".to_string().into(),
],
},
},
};
let viewers = Vec::new();
let participant_list = ParticipantList {
sharer: sharer.clone(),
viewers: viewers.clone(),
present_viewers: Default::default(),
absent_viewers: Default::default(),
guests: Default::default(),
pending_guests: Default::default(),
};
let firebase_uid = UserUid::new("mock_firebase_uid");
let presence_manager = app.add_model(|ctx| {
PresenceManager::new_for_viewer(
ParticipantId::new(),
firebase_uid,
participant_list.clone(),
ctx,
)
});
// Ensure participants are loaded before continuing.
presence_manager
.update(&mut app, |presence_manager, ctx| {
let spawned_future = presence_manager
.load_participants_imgs_future_handle
.as_ref()
.expect("should have future handle");
ctx.await_spawned_future(spawned_future.future_id())
})
.await;
let block_list = block_list_for_test(15);
// Check the selected block index for sharer avatar
presence_manager.read(&app, |presence_manager, _ctx| {
let sharer = presence_manager.get_sharer().expect("should have sharer");
let index = sharer
.get_selected_block_index_for_avatar(&block_list)
.expect("sharer should have selected block index for avatar");
// 9 is the top of the last continuous block selection
assert_eq!(index, 9.into())
});
// Now try with just one block selected.
sharer.info.selection = Selection::Blocks {
block_ids: vec!["7".to_string().into()],
};
presence_manager
.update(&mut app, |presence_manager, ctx| {
presence_manager.update_participants(
ParticipantList {
sharer,
viewers,
present_viewers: Default::default(),
absent_viewers: Default::default(),
guests: Default::default(),
pending_guests: Default::default(),
},
ctx,
);
let spawned_future = presence_manager
.load_participants_imgs_future_handle
.as_ref()
.expect("should have future handle");
ctx.await_spawned_future(spawned_future.future_id())
})
.await;
presence_manager.read(&app, |presence_manager, _ctx| {
let sharer = presence_manager.get_sharer().expect("should have sharer");
let index = sharer
.get_selected_block_index_for_avatar(&block_list)
.expect("sharer should have selected block index for avatar");
assert_eq!(index, 7.into())
});
});
}
@@ -0,0 +1,151 @@
use crate::{
appearance::Appearance,
ui_components::avatar::{Avatar, AvatarContent},
};
use warpui::{elements::CornerRadius, fonts::Weight};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use warpui::{
elements::{
ChildAnchor, Fill, Hoverable, MouseStateHandle, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, Radius, Stack,
},
ui_components::components::{UiComponent, UiComponentStyles},
AppContext, Element, SingletonEntity,
};
use super::presence_manager::{Participant, MUTED_AVATAR_BORDER_COLOR, MUTED_PARTICIPANT_COLOR};
pub fn shared_session_indicator_color(appearance: &Appearance) -> ColorU {
appearance.theme().terminal_colors().normal.red.into()
}
/// Diameter including the border
pub const SHARED_SESSION_AVATAR_DIAMETER: f32 = 20.;
pub const SHARED_SESSION_AVATAR_EXECUTOR_DIAMETER: f32 = 16.;
const SHARED_SESSION_DIAMETER_BORDER_WIDTH: f32 = 1.;
/// Shared helper function for rendering avatar in pane header and selected blocks.
/// Actions on hover and click are handled separately.
pub fn non_hoverable_participant_avatar(
display_name: String,
image_url: Option<String>,
participant_color: ColorU,
is_muted: bool,
is_executor: bool,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let background = if is_muted {
MUTED_PARTICIPANT_COLOR
} else {
participant_color
};
let border_color = if is_muted {
MUTED_AVATAR_BORDER_COLOR.into()
} else if image_url.is_none() {
appearance.theme().surface_2()
} else {
participant_color.into()
};
let diameter = if is_executor {
SHARED_SESSION_AVATAR_EXECUTOR_DIAMETER
} else {
SHARED_SESSION_AVATAR_DIAMETER
};
let font = if is_executor { 10. } else { 12. };
Avatar::new(
image_url
.map(|url| AvatarContent::Image {
url,
display_name: display_name.clone(),
})
.unwrap_or(AvatarContent::DisplayName(display_name)),
UiComponentStyles {
width: Some(diameter - 2. * SHARED_SESSION_DIAMETER_BORDER_WIDTH),
height: Some(diameter - 2. * SHARED_SESSION_DIAMETER_BORDER_WIDTH),
border_radius: Some(CornerRadius::with_all(Radius::Percentage(50.))),
border_width: Some(SHARED_SESSION_DIAMETER_BORDER_WIDTH),
border_color: Some(border_color.into()),
background: Some(background.into()),
font_color: Some(ColorU::black()),
font_family_id: Some(appearance.ui_font_family()),
font_weight: Some(Weight::Bold),
font_size: Some(font),
..Default::default()
},
)
.build()
.finish()
}
/// Struct containing just fields from the [`Participant`] needed for rendering the avatar,
/// to avoid unnecessary cloning of the other fields in the participant.
#[derive(Clone)]
pub struct ParticipantAvatarParams {
pub display_name: String,
pub image_url: Option<String>,
pub participant_color: ColorU,
pub is_muted: bool,
pub tooltip_parent_anchor: ParentAnchor,
pub tooltip_child_anchor: ChildAnchor,
}
impl ParticipantAvatarParams {
pub fn new(participant: &Participant, is_self_reconnecting: bool) -> Self {
Self {
display_name: participant.info.profile_data.display_name.clone(),
image_url: participant.info.profile_data.photo_url.clone(),
participant_color: participant.color.to_owned(),
is_muted: is_self_reconnecting,
tooltip_parent_anchor: ParentAnchor::TopRight,
tooltip_child_anchor: ChildAnchor::BottomRight,
}
}
}
/// Helper function to render participant avatar and handle hover in selected blocks.
pub fn participant_avatar_for_selected_block(
params: ParticipantAvatarParams,
mouse_state_handle: MouseStateHandle,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let avatar = non_hoverable_participant_avatar(
params.display_name.clone(),
params.image_url,
params.participant_color,
params.is_muted,
false,
app,
);
Hoverable::new(mouse_state_handle, |state| {
let mut stack = Stack::new().with_child(avatar);
if state.is_hovered() {
let tooltip_background = appearance.theme().tooltip_background();
let tool_tip = appearance
.ui_builder()
.tool_tip(params.display_name)
.with_style(UiComponentStyles {
font_size: Some(12.),
background: Some(Fill::Solid(tooltip_background)),
font_color: Some(appearance.theme().background().into_solid()),
..Default::default()
});
stack.add_positioned_overlay_child(
tool_tip.build().finish(),
OffsetPositioning::offset_from_parent(
vec2f(0., 0.),
ParentOffsetBounds::WindowByPosition,
params.tooltip_parent_anchor,
params.tooltip_child_anchor,
),
);
}
stack.finish()
})
.finish()
}
@@ -0,0 +1,196 @@
use crate::ai::agent::conversation::AIConversation;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::AIAgentExchange;
use crate::ai::agent::MessageId;
use api::client_action as api_client_action;
use api::response_event as api_response_event;
use api::response_event::stream_finished as stream_finished_event;
use std::collections::HashMap;
use warp_multi_agent_api::{self as api, ResponseEvent};
// Reconstructs all response events from conversations for use in session sharing.
// These messages are used to replay conversations as if they were happening live.
pub fn reconstruct_response_events_from_conversations(
conversations: &[AIConversation],
) -> Vec<ResponseEvent> {
let mut events = vec![];
// Build a map of message_id -> (task_id, message, conversation) for quick lookup
let mut message_map: HashMap<MessageId, (&TaskId, &api::Message, &AIConversation)> =
HashMap::new();
for conversation in conversations {
for task in conversation.all_tasks() {
for message in task.messages() {
message_map.insert(
MessageId::new(message.id.clone()),
(task.id(), message, conversation),
);
}
}
}
// Collect all exchanges from all conversations and sort by start time
let mut all_exchanges: Vec<(&AIConversation, &AIAgentExchange)> = conversations
.iter()
.flat_map(|conv| {
conv.all_exchanges()
.into_iter()
.map(move |exchange| (conv, exchange))
})
.collect();
all_exchanges.sort_by_key(|(_, exchange)| exchange.start_time);
// Track which conversations have had their tasks created.
// We need to send CreateTask on the first exchange to upgrade local task IDs
// to server task IDs (required for AddMessagesToTask to find the correct task).
let mut initialized_conversations = std::collections::HashSet::new();
// For each exchange (in chronological order), emit events
for (conversation, exchange) in all_exchanges {
// Collect messages for this exchange in chronological order
let mut exchange_messages: Vec<(&TaskId, &api::Message)> = exchange
.added_message_ids
.iter()
.filter_map(|msg_id| {
message_map
.get(msg_id)
.map(|(task_id, msg, _)| (*task_id, *msg))
})
.collect();
if exchange_messages.is_empty() {
continue;
}
// Sort by timestamp to ensure chronological order
exchange_messages.sort_by_key(|(_, message)| {
message.timestamp.as_ref().map(|ts| (ts.seconds, ts.nanos))
});
// Use the server conversation token if it's available.
// Otherwise, fall back to the id that this conversation was forked from.
// This ensures viewers can properly group historical exchanges together.
let token = conversation
.server_conversation_token()
.or_else(|| conversation.forked_from_server_conversation_token())
.map(|t| t.as_str().to_string())
.unwrap_or_default();
let request_id = exchange_messages
.first()
.map(|(_, msg)| msg.request_id.clone())
.unwrap_or_default();
// Start this exchange
events.push(ResponseEvent {
r#type: Some(api_response_event::Type::Init(
api_response_event::StreamInit {
request_id,
conversation_id: token.clone(),
// Shared session replays don't need a run_id; the empty
// string is filtered to None by initialize_output_for_response_stream.
run_id: String::new(),
},
)),
});
// On the first exchange of each conversation, send CreateTask events to upgrade
// local task IDs to server task IDs. We construct a task with empty messages
// because the messages will be added via AddMessagesToTask below - including them
// in CreateTask would cause duplicate content in the exchange.
let conversation_id = conversation.id();
let is_first_exchange = !initialized_conversations.contains(&conversation_id);
if is_first_exchange {
initialized_conversations.insert(conversation_id);
for task in conversation.all_tasks() {
if let Some(task_source) = task.source() {
let task_without_messages = api::Task {
id: task_source.id.clone(),
description: task_source.description.clone(),
dependencies: task_source.dependencies.clone(),
messages: vec![], // Empty - messages added via AddMessagesToTask
summary: task_source.summary.clone(),
server_data: task_source.server_data.clone(),
};
events.push(wrap_action_in_event(api_client_action::Action::CreateTask(
api_client_action::CreateTask {
task: Some(task_without_messages),
},
)));
}
}
}
// Send all messages for this exchange
for (task_id, message) in exchange_messages {
events.push(wrap_action_in_event(
api_client_action::Action::AddMessagesToTask(
api_client_action::AddMessagesToTask {
task_id: task_id.to_string(),
messages: vec![message.clone()],
},
),
));
}
// Finish this exchange
events.push(create_finished_event_from_conversation(conversation));
}
events
}
/// Wrap a ClientAction in a ResponseEvent.
fn wrap_action_in_event(action: api_client_action::Action) -> ResponseEvent {
ResponseEvent {
r#type: Some(api_response_event::Type::ClientActions(
api_response_event::ClientActions {
actions: vec![api::ClientAction {
action: Some(action),
}],
},
)),
}
}
/// Creates a StreamFinished event from a conversation.
fn create_finished_event_from_conversation(conversation: &AIConversation) -> ResponseEvent {
// Build conversation usage metadata from the conversation's metadata
let usage_metadata = Some(
api_response_event::stream_finished::ConversationUsageMetadata {
context_window_usage: conversation.context_window_usage(),
credits_spent: conversation.credits_spent(),
summarized: conversation.was_summarized(),
#[allow(deprecated)]
token_usage: conversation
.token_usage()
.iter()
.map(|u| u.to_proto_combined())
.collect(),
tool_usage_metadata: Some(conversation.tool_usage_metadata().into()),
warp_token_usage: conversation
.token_usage()
.iter()
.filter_map(|u| u.to_proto_warp_usage())
.collect(),
byok_token_usage: conversation
.token_usage()
.iter()
.filter_map(|u| u.to_proto_byok_usage())
.collect(),
},
);
ResponseEvent {
r#type: Some(api_response_event::Type::Finished(
api_response_event::StreamFinished {
reason: Some(stream_finished_event::Reason::Done(
stream_finished_event::Done {},
)),
conversation_usage_metadata: usage_metadata,
token_usage: vec![],
should_refresh_model_config: false,
request_cost: None,
},
)),
}
}
@@ -0,0 +1,419 @@
use session_sharing_protocol::common::{ParticipantId, Role, RoleRequestId};
use warpui::elements::Empty;
use warpui::presenter::ChildView;
use warpui::{
ui_components::components::{Coords, UiComponentStyles},
AppContext, Element, Entity, View, ViewContext, ViewHandle,
};
use crate::modal::Modal;
use crate::pane_group::TerminalPaneId;
use crate::terminal::shared_session::render_util::ParticipantAvatarParams;
mod sharer_grant_body;
mod sharer_response_body;
mod viewer_request_body;
use sharer_grant_body::{SharerGrantBody, SharerGrantBodyEvent};
use sharer_response_body::{SharerResponseBody, SharerResponseBodyEvent};
use viewer_request_body::{ViewerRequestBody, ViewerRequestBodyEvent};
pub const MODAL_WIDTH: f32 = 400.;
pub const MODAL_PADDING: f32 = 24.;
pub const BODY_PADDING: f32 = 8.;
pub const HEADER_FONT_SIZE: f32 = 16.;
pub const TEXT_FONT_SIZE: f32 = 14.;
#[derive(Debug, Clone)]
pub enum RoleChangeOpenSource {
ViewerRequest {
role: Role,
},
SharerResponse {
participant_id: ParticipantId,
role_request_id: RoleRequestId,
role: Role,
},
SharerGrant {
participant_id: ParticipantId,
},
}
#[derive(Debug, Clone, Copy)]
pub enum RoleChangeCloseSource {
ViewerRequest,
SharerResponse,
SharerGrant,
}
#[derive(Debug, Clone)]
pub enum RoleChangeModalEvent {
// Viewer request event
CancelRequest {
terminal_pane_id: TerminalPaneId,
role_request_id: RoleRequestId,
},
// Sharer response events
ApproveRequest {
terminal_pane_id: TerminalPaneId,
participant_id: ParticipantId,
role_request_id: RoleRequestId,
role: Role,
},
DenyRequest {
terminal_pane_id: TerminalPaneId,
participant_id: ParticipantId,
role_request_id: RoleRequestId,
},
Close {
source: RoleChangeCloseSource,
},
// Sharer grant events
CancelGrant,
GrantRole {
terminal_pane_id: TerminalPaneId,
participant_id: ParticipantId,
dont_show_again: bool,
},
}
pub struct RoleChangeModal {
terminal_pane_id: Option<TerminalPaneId>,
role_request_id: Option<RoleRequestId>,
participant_id: Option<ParticipantId>,
is_viewer_request_modal_open: bool,
viewer_request_modal: ViewHandle<Modal<ViewerRequestBody>>,
is_sharer_response_modal_open: bool,
sharer_response_modal: ViewHandle<Modal<SharerResponseBody>>,
is_sharer_grant_modal_open: bool,
sharer_grant_modal: ViewHandle<Modal<SharerGrantBody>>,
}
impl Entity for RoleChangeModal {
type Event = RoleChangeModalEvent;
}
impl RoleChangeModal {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let modal_style = UiComponentStyles {
width: Some(MODAL_WIDTH),
..Default::default()
};
let body_style = UiComponentStyles {
padding: Some(Coords {
top: MODAL_PADDING,
bottom: MODAL_PADDING,
left: MODAL_PADDING,
right: MODAL_PADDING,
}),
..Default::default()
};
let viewer_request_body = ctx.add_typed_action_view(|_| ViewerRequestBody::new());
ctx.subscribe_to_view(&viewer_request_body, |me, _, event, ctx| {
me.handle_viewer_event(event, ctx);
});
let viewer_request_modal = ctx.add_typed_action_view(|ctx| {
Modal::new(None, viewer_request_body, ctx)
.with_modal_style(modal_style)
.with_body_style(body_style)
.with_background_opacity(100)
.close_modal_button_disabled()
});
let sharer_response_body = ctx.add_typed_action_view(|_| SharerResponseBody::new());
ctx.subscribe_to_view(&sharer_response_body, |me, _, event, ctx| {
me.handle_sharer_response_event(event, ctx);
});
let sharer_response_modal = ctx.add_typed_action_view(|ctx| {
Modal::new(None, sharer_response_body, ctx)
.with_modal_style(modal_style)
.with_body_style(body_style)
.with_background_opacity(100)
.close_modal_button_disabled()
});
let sharer_grant_body = ctx.add_typed_action_view(|_| SharerGrantBody::new());
ctx.subscribe_to_view(&sharer_grant_body, |me, _, event, ctx| {
me.handle_sharer_grant_event(event, ctx);
});
let sharer_grant_modal = ctx.add_typed_action_view(|ctx| {
Modal::new(None, sharer_grant_body, ctx)
.with_modal_style(modal_style)
.with_body_style(body_style)
.with_background_opacity(100)
.close_modal_button_disabled()
});
Self {
terminal_pane_id: None,
role_request_id: None,
participant_id: None,
is_viewer_request_modal_open: false,
viewer_request_modal,
is_sharer_response_modal_open: false,
sharer_response_modal,
is_sharer_grant_modal_open: false,
sharer_grant_modal,
}
}
pub fn set_role_request_id(&mut self, role_request_id: RoleRequestId) {
self.role_request_id = Some(role_request_id);
}
/// Opens viewer's role request modal which awaits a sharer's response.
/// Viewer can cancel their request through this modal.
pub fn open_for_viewer_request(
&mut self,
terminal_pane_id: TerminalPaneId,
display_name: String,
role: Role,
ctx: &mut ViewContext<Self>,
) {
self.terminal_pane_id = Some(terminal_pane_id);
self.is_viewer_request_modal_open = true;
self.viewer_request_modal.update(ctx, |modal, ctx| {
modal.body().update(ctx, |modal, ctx| {
modal.open(display_name, role, ctx);
});
});
ctx.notify();
}
/// Opens sharer's role response modal.
/// Sharer can approve/deny role requests through this modal.
#[allow(clippy::too_many_arguments)]
pub fn open_for_sharer_response(
&mut self,
terminal_pane_id: TerminalPaneId,
participant_id: ParticipantId,
firebase_uid: String,
role_request_id: RoleRequestId,
params: ParticipantAvatarParams,
role: Role,
ctx: &mut ViewContext<Self>,
) {
self.terminal_pane_id = Some(terminal_pane_id);
self.role_request_id = Some(role_request_id.clone());
self.is_sharer_response_modal_open = true;
self.sharer_response_modal.update(ctx, |modal, ctx| {
modal.body().update(ctx, |modal, ctx| {
modal.add_role_request(
participant_id,
firebase_uid,
role_request_id.clone(),
role,
params,
ctx,
);
});
});
ctx.notify();
}
/// Opens sharer's role grant confirmation modal.
/// Sharer can cancel/continue the role grant through this modal.
pub fn open_for_sharer_grant(
&mut self,
terminal_pane_id: TerminalPaneId,
participant_id: ParticipantId,
ctx: &mut ViewContext<Self>,
) {
self.terminal_pane_id = Some(terminal_pane_id);
self.participant_id = Some(participant_id);
self.is_sharer_grant_modal_open = true;
ctx.notify();
}
pub fn all_child_modals_are_closed(&self) -> bool {
!self.is_viewer_request_modal_open
&& !self.is_sharer_response_modal_open
&& !self.is_sharer_grant_modal_open
}
/// Closes viewer's role request modal.
pub fn close_for_viewer_request(&mut self, ctx: &mut ViewContext<Self>) {
self.is_viewer_request_modal_open = false;
ctx.notify();
}
/// Closes sharer's role response modal.
pub fn close_for_sharer_response(&mut self, ctx: &mut ViewContext<Self>) {
self.is_sharer_response_modal_open = false;
ctx.notify();
}
/// Closes sharer's role grant modal.
/// Should only be closed when there are no pending role requests.
pub fn close_for_sharer_grant(&mut self, ctx: &mut ViewContext<Self>) {
self.is_sharer_grant_modal_open = false;
ctx.notify();
}
/// Cancels the role request identified by the role request id.
/// Can only cancel our own role request as a viewer.
pub fn cancel_request(&mut self, ctx: &mut ViewContext<Self>) {
// Ensure the right modal is open before cancelling
if !self.is_viewer_request_modal_open {
return;
}
let Some(terminal_pane_id) = self.terminal_pane_id else {
log::warn!("Tried to close role request modal when no terminal pane ID was present");
return;
};
if let Some(role_request_id) = self.role_request_id.as_ref() {
ctx.emit(RoleChangeModalEvent::CancelRequest {
terminal_pane_id,
role_request_id: role_request_id.clone(),
})
} else {
log::warn!("Tried to cancel role request when no role request ID was present");
// If no role request ID is present, we should still close the modal.
ctx.emit(RoleChangeModalEvent::Close {
source: RoleChangeCloseSource::ViewerRequest,
})
};
}
/// Removes a role request given the id, and is called when a sharer approves/denies one.
/// Only the sharer can remove the role requests for their shared session.
pub fn remove_role_request(
&mut self,
role_request_id: RoleRequestId,
ctx: &mut ViewContext<Self>,
) {
// Ensure the right modal is open before removing
if !self.is_sharer_response_modal_open {
return;
}
self.sharer_response_modal.update(ctx, |modal, ctx| {
modal.body().update(ctx, |modal, ctx| {
modal.remove_role_request(role_request_id, ctx);
});
ctx.notify();
});
}
fn handle_viewer_event(&mut self, event: &ViewerRequestBodyEvent, ctx: &mut ViewContext<Self>) {
match event {
ViewerRequestBodyEvent::Cancel => self.cancel_request(ctx),
}
}
fn handle_sharer_grant_event(
&mut self,
event: &SharerGrantBodyEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
SharerGrantBodyEvent::Cancel => {
if self.terminal_pane_id.is_none() {
log::warn!("Tried to cancel role grant when no terminal pane ID was present");
return;
};
ctx.emit(RoleChangeModalEvent::CancelGrant)
}
SharerGrantBodyEvent::GrantRole { dont_show_again } => {
let Some(terminal_pane_id) = self.terminal_pane_id else {
log::warn!("Tried to grant role when no terminal pane ID was present");
return;
};
let Some(participant_id) = self.participant_id.clone() else {
log::warn!("Tried to grant role without participant ID");
return;
};
ctx.emit(RoleChangeModalEvent::GrantRole {
terminal_pane_id,
participant_id,
dont_show_again: *dont_show_again,
})
}
}
}
fn handle_sharer_response_event(
&mut self,
event: &SharerResponseBodyEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
SharerResponseBodyEvent::Approve {
participant_id,
role_request_id,
role,
} => {
let Some(terminal_pane_id) = self.terminal_pane_id else {
log::warn!(
"Tried to close role request modal when no terminal pane ID was present"
);
return;
};
ctx.emit(RoleChangeModalEvent::ApproveRequest {
terminal_pane_id,
participant_id: participant_id.clone(),
role_request_id: role_request_id.clone(),
role: *role,
});
}
SharerResponseBodyEvent::Deny {
participant_id,
role_request_id,
} => {
let Some(terminal_pane_id) = self.terminal_pane_id else {
log::warn!(
"Tried to close role request modal when no terminal pane ID was present"
);
return;
};
ctx.emit(RoleChangeModalEvent::DenyRequest {
terminal_pane_id,
participant_id: participant_id.clone(),
role_request_id: role_request_id.clone(),
});
}
SharerResponseBodyEvent::Close => {
if self.terminal_pane_id.is_none() {
log::warn!(
"Tried to close role request modal when no terminal pane ID was present"
);
return;
};
ctx.emit(RoleChangeModalEvent::Close {
source: RoleChangeCloseSource::SharerResponse,
});
}
}
}
}
impl View for RoleChangeModal {
fn ui_name() -> &'static str {
"SharedSessionRoleChangeModal"
}
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
if self.is_sharer_grant_modal_open {
ChildView::new(&self.sharer_grant_modal).finish()
} else if self.is_sharer_response_modal_open {
ChildView::new(&self.sharer_response_modal).finish()
} else if self.is_viewer_request_modal_open {
ChildView::new(&self.viewer_request_modal).finish()
} else {
Empty::new().finish()
}
}
}
@@ -0,0 +1,191 @@
use warpui::elements::{
Container, CrossAxisAlignment, Flex, MainAxisAlignment, MouseStateHandle, ParentElement, Text,
};
use warpui::fonts::{Properties, Weight};
use warpui::platform::Cursor;
use warpui::ui_components::button::ButtonVariant;
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
use warpui::ui_components::text::Span;
use warpui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
use crate::appearance::Appearance;
use crate::ui_components::blended_colors;
use super::{MODAL_PADDING, TEXT_FONT_SIZE};
const BUTTON_HEIGHT: f32 = 40.;
const BUTTON_WIDTH: f32 = 172.;
#[derive(Debug)]
pub enum SharerGrantBodyAction {
Cancel,
GrantRole { dont_show_again: bool },
ToggleDontShowAgain,
}
#[derive(Debug)]
pub enum SharerGrantBodyEvent {
Cancel,
GrantRole { dont_show_again: bool },
}
pub struct SharerGrantBody {
cancel_button_mouse_state: MouseStateHandle,
approve_button_mouse_state: MouseStateHandle,
dont_show_again_mouse_state: MouseStateHandle,
dont_show_again: bool,
}
impl SharerGrantBody {
pub fn new() -> Self {
Self {
cancel_button_mouse_state: Default::default(),
approve_button_mouse_state: Default::default(),
dont_show_again_mouse_state: Default::default(),
dont_show_again: false,
}
}
fn render_button_row(&self, appearance: &Appearance) -> Box<dyn Element> {
let cancel_button = Container::new(
appearance
.ui_builder()
.button(
ButtonVariant::Outlined,
self.cancel_button_mouse_state.clone(),
)
.with_style(UiComponentStyles {
font_size: Some(TEXT_FONT_SIZE),
font_weight: Some(Weight::Bold),
height: Some(BUTTON_HEIGHT),
width: Some(BUTTON_WIDTH),
..Default::default()
})
.with_centered_text_label(String::from("Cancel"))
.build()
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(SharerGrantBodyAction::Cancel))
.finish(),
)
.with_padding_right(8.)
.finish();
let dont_show_again = self.dont_show_again;
let approve_button = appearance
.ui_builder()
.button(
ButtonVariant::Accent,
self.approve_button_mouse_state.clone(),
)
.with_style(UiComponentStyles {
font_size: Some(TEXT_FONT_SIZE),
font_weight: Some(Weight::Bold),
height: Some(BUTTON_HEIGHT),
width: Some(BUTTON_WIDTH),
..Default::default()
})
.with_centered_text_label(String::from("Make Editor"))
.build()
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(SharerGrantBodyAction::GrantRole { dont_show_again })
})
.finish();
Flex::row()
.with_child(cancel_button)
.with_child(approve_button)
.finish()
}
}
impl Entity for SharerGrantBody {
type Event = SharerGrantBodyEvent;
}
impl View for SharerGrantBody {
fn ui_name() -> &'static str {
"SharerGrantBody"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let button_row = self.render_button_row(appearance);
let text1 = "This grants the ability to execute commands on your";
let text2 = "behalf. Use with caution.";
let text_body = Container::new(
Flex::column()
.with_child(
Text::new_inline(text1, appearance.ui_font_family(), TEXT_FONT_SIZE)
.with_color(blended_colors::text_main(
appearance.theme(),
appearance.theme().background(),
))
.with_style(Properties::default().weight(Weight::Normal))
.finish(),
)
.with_child(
Text::new_inline(text2, appearance.ui_font_family(), TEXT_FONT_SIZE)
.with_color(blended_colors::text_main(
appearance.theme(),
appearance.theme().background(),
))
.with_style(Properties::default().weight(Weight::Normal))
.finish(),
)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.finish(),
)
.with_padding_bottom(16.)
.finish();
let dont_show_again_checkbox = Container::new(
appearance
.ui_builder()
.checkbox(
self.dont_show_again_mouse_state.clone(),
Some(TEXT_FONT_SIZE),
)
.with_label(Span::new("Don't show again.", Default::default()))
.check(self.dont_show_again)
.build()
.with_cursor(Cursor::PointingHand)
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(SharerGrantBodyAction::ToggleDontShowAgain)
})
.finish(),
)
.with_padding_bottom(MODAL_PADDING)
.finish();
Flex::column()
.with_child(text_body)
.with_child(dont_show_again_checkbox)
.with_child(button_row)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.finish()
}
}
impl TypedActionView for SharerGrantBody {
type Action = SharerGrantBodyAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
SharerGrantBodyAction::Cancel => {
ctx.emit(SharerGrantBodyEvent::Cancel);
self.dont_show_again = false;
}
SharerGrantBodyAction::GrantRole { dont_show_again } => {
ctx.emit(SharerGrantBodyEvent::GrantRole {
dont_show_again: *dont_show_again,
});
}
SharerGrantBodyAction::ToggleDontShowAgain => {
self.dont_show_again = !self.dont_show_again;
ctx.notify();
}
}
}
}
@@ -0,0 +1,373 @@
use std::collections::HashMap;
use crate::terminal::shared_session::render_util::{
non_hoverable_participant_avatar, ParticipantAvatarParams,
};
use crate::{appearance::Appearance, ui_components::blended_colors};
use session_sharing_protocol::common::{ParticipantId, Role, RoleRequestId};
use warpui::elements::{
ConstrainedBox, Container, Flex, MainAxisAlignment, MouseStateHandle, ParentElement, Text,
};
use warpui::fonts::Properties;
use warpui::{
elements::CrossAxisAlignment,
fonts::Weight,
platform::Cursor,
ui_components::{
button::ButtonVariant,
components::{UiComponent, UiComponentStyles},
},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
};
use warp_core::features::FeatureFlag;
use super::{BODY_PADDING, HEADER_FONT_SIZE, MODAL_PADDING, TEXT_FONT_SIZE};
pub const BUTTON_HEIGHT: f32 = 32.;
pub const BUTTON_WIDTH: f32 = 75.;
pub const BUTTON_FONT_SIZE: f32 = 12.;
#[derive(Clone)]
struct MouseStateHandles {
approve_button: MouseStateHandle,
deny_button: MouseStateHandle,
}
// Struct that contains fields needed to
// render a role request.
#[derive(Clone)]
struct RoleRequestParams {
participant_id: ParticipantId,
firebase_uid: String,
role: Role,
avatar: ParticipantAvatarParams,
button_mouse_states: MouseStateHandles,
}
#[derive(Debug)]
pub enum SharerResponseBodyAction {
Approve {
participant_id: ParticipantId,
role_request_id: RoleRequestId,
role: Role,
},
Deny {
participant_id: ParticipantId,
role_request_id: RoleRequestId,
},
}
pub enum SharerResponseBodyEvent {
Approve {
participant_id: ParticipantId,
role_request_id: RoleRequestId,
role: Role,
},
Deny {
participant_id: ParticipantId,
role_request_id: RoleRequestId,
},
Close,
}
pub struct SharerResponseBody {
role_requests: HashMap<RoleRequestId, RoleRequestParams>,
}
impl SharerResponseBody {
pub fn new() -> Self {
Self {
role_requests: HashMap::new(),
}
}
pub fn add_role_request(
&mut self,
participant_id: ParticipantId,
firebase_uid: String,
role_request_id: RoleRequestId,
role: Role,
params: ParticipantAvatarParams,
ctx: &mut ViewContext<Self>,
) {
let role_request = RoleRequestParams {
participant_id: participant_id.clone(),
firebase_uid: firebase_uid.clone(),
role,
avatar: params,
button_mouse_states: MouseStateHandles {
approve_button: Default::default(),
deny_button: Default::default(),
},
};
// Ensure there exists only one request per participant
// by removing the previous request (if it exists)
// If ACLs are enabled, we make sure there is only one request per user.
if let Some(request_id) = self.role_requests.iter().find_map(|(request_id, params)| {
let is_duplicate = if FeatureFlag::SessionSharingAcls.is_enabled() {
params.firebase_uid == firebase_uid
} else {
params.participant_id == participant_id
};
is_duplicate.then_some(request_id.clone())
}) {
self.role_requests.remove(&request_id);
}
self.role_requests.insert(role_request_id, role_request);
ctx.notify();
}
pub fn remove_role_request(
&mut self,
role_request_id: RoleRequestId,
ctx: &mut ViewContext<Self>,
) {
self.role_requests.remove(&role_request_id);
if self.role_requests.is_empty() {
ctx.emit(SharerResponseBodyEvent::Close)
}
ctx.notify();
}
fn render_button_row(
&self,
role_request_id: RoleRequestId,
role_request_params: RoleRequestParams,
appearance: &Appearance,
) -> Box<dyn Element> {
let participant_id = role_request_params.participant_id.clone();
let request_id = role_request_id.clone();
let deny_button = Container::new(
appearance
.ui_builder()
.button(
ButtonVariant::Outlined,
role_request_params.button_mouse_states.deny_button,
)
.with_centered_text_label(String::from("Deny"))
.with_style(UiComponentStyles {
font_size: Some(BUTTON_FONT_SIZE),
font_weight: Some(Weight::Bold),
height: Some(BUTTON_HEIGHT),
width: Some(BUTTON_WIDTH),
..Default::default()
})
.build()
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(SharerResponseBodyAction::Deny {
participant_id: participant_id.clone(),
role_request_id: request_id.clone(),
})
})
.finish(),
)
.with_padding_right(BODY_PADDING)
.finish();
let participant_id = role_request_params.participant_id.clone();
let request_id = role_request_id.clone();
let role = role_request_params.role;
let approve_button = appearance
.ui_builder()
.button(
ButtonVariant::Outlined,
role_request_params.button_mouse_states.approve_button,
)
.with_centered_text_label(String::from("Approve"))
.with_style(UiComponentStyles {
font_size: Some(BUTTON_FONT_SIZE),
font_weight: Some(Weight::Bold),
height: Some(BUTTON_HEIGHT),
width: Some(BUTTON_WIDTH),
..Default::default()
})
.build()
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(SharerResponseBodyAction::Approve {
participant_id: participant_id.clone(),
role_request_id: request_id.clone(),
role,
})
})
.finish();
Flex::row()
.with_child(deny_button)
.with_child(approve_button)
.finish()
}
fn render_role_request(
&self,
role_request_id: RoleRequestId,
role_request_params: RoleRequestParams,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let button_row =
self.render_button_row(role_request_id, role_request_params.clone(), appearance);
let avatar_params = role_request_params.avatar;
let avatar = non_hoverable_participant_avatar(
avatar_params.display_name.clone(),
avatar_params.image_url,
avatar_params.participant_color,
avatar_params.is_muted,
false,
app,
);
let participant = ConstrainedBox::new(
Flex::row()
.with_child(
Container::new(avatar)
.with_padding_right(BODY_PADDING)
.finish(),
)
.with_child(
Text::new_inline(
avatar_params.display_name,
appearance.ui_font_family(),
TEXT_FONT_SIZE,
)
.with_color(blended_colors::text_main(
appearance.theme(),
appearance.theme().background(),
))
.finish(),
)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::Start)
.finish(),
)
.with_width(220.)
.finish();
Flex::row()
.with_child(participant)
.with_child(button_row)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.finish()
}
}
impl Entity for SharerResponseBody {
type Event = SharerResponseBodyEvent;
}
impl View for SharerResponseBody {
fn ui_name() -> &'static str {
"SharerResponseBody"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let header = "Edit Requests";
let text1 = "This grants the ability to execute commands on your";
let text2 = "behalf. Use with caution.";
let text_body = Container::new(
Flex::column()
.with_child(
Container::new(
Text::new_inline(header, appearance.ui_font_family(), HEADER_FONT_SIZE)
.with_color(blended_colors::text_main(
appearance.theme(),
appearance.theme().background(),
))
.with_style(Properties::default().weight(Weight::Bold))
.finish(),
)
.with_padding_bottom(BODY_PADDING)
.finish(),
)
.with_child(
Flex::column()
.with_child(
Text::new(text1, appearance.ui_font_family(), TEXT_FONT_SIZE)
.with_color(blended_colors::text_sub(
appearance.theme(),
appearance.theme().background(),
))
.finish(),
)
.with_child(
Text::new(text2, appearance.ui_font_family(), TEXT_FONT_SIZE)
.with_color(blended_colors::text_sub(
appearance.theme(),
appearance.theme().background(),
))
.finish(),
)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.finish(),
)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.finish(),
)
.with_padding_bottom(MODAL_PADDING)
.finish();
let mut role_requests = Flex::column();
for (i, (id, params)) in self.role_requests.iter().enumerate() {
let mut role_request =
self.render_role_request(id.clone(), params.clone(), appearance, app);
// Don't add extra padding to the last element
if i != self.role_requests.len() - 1 {
role_request = Container::new(role_request)
.with_padding_bottom(BODY_PADDING)
.finish();
}
role_requests.add_child(role_request);
}
Flex::column()
.with_child(text_body)
.with_child(
role_requests
.with_main_axis_alignment(MainAxisAlignment::SpaceEvenly)
.finish(),
)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.finish()
}
}
impl TypedActionView for SharerResponseBody {
type Action = SharerResponseBodyAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
SharerResponseBodyAction::Approve {
participant_id,
role_request_id,
role,
} => {
ctx.emit(SharerResponseBodyEvent::Approve {
participant_id: participant_id.clone(),
role_request_id: role_request_id.clone(),
role: *role,
});
self.remove_role_request(role_request_id.clone(), ctx);
}
SharerResponseBodyAction::Deny {
participant_id,
role_request_id,
} => {
ctx.emit(SharerResponseBodyEvent::Deny {
participant_id: participant_id.clone(),
role_request_id: role_request_id.clone(),
});
self.remove_role_request(role_request_id.clone(), ctx);
}
}
}
}
@@ -0,0 +1,133 @@
use crate::{appearance::Appearance, ui_components::blended_colors};
use session_sharing_protocol::common::Role;
use warpui::elements::{Container, Flex, MainAxisAlignment, MouseStateHandle, ParentElement, Text};
use warpui::{
elements::CrossAxisAlignment,
fonts::Weight,
platform::Cursor,
ui_components::{
button::ButtonVariant,
components::{UiComponent, UiComponentStyles},
},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
};
use super::{BODY_PADDING, HEADER_FONT_SIZE, MODAL_PADDING, TEXT_FONT_SIZE};
pub const BUTTON_HEIGHT: f32 = 40.;
pub const BUTTON_WIDTH: f32 = 352.;
#[derive(Debug)]
pub enum ViewerRequestBodyAction {
Cancel,
}
pub enum ViewerRequestBodyEvent {
Cancel,
}
pub struct ViewerRequestBody {
role: Role,
display_name: String,
mouse_state_handle: MouseStateHandle,
}
impl ViewerRequestBody {
pub fn new() -> Self {
Self {
role: Default::default(),
display_name: Default::default(),
mouse_state_handle: Default::default(),
}
}
fn role_label(&self) -> &str {
match self.role {
Role::Executor => "edit",
_ => "view",
}
}
pub fn open(&mut self, display_name: String, role: Role, ctx: &mut ViewContext<Self>) {
self.role = role;
self.display_name = display_name;
ctx.notify();
}
}
impl Entity for ViewerRequestBody {
type Event = ViewerRequestBodyEvent;
}
impl View for ViewerRequestBody {
fn ui_name() -> &'static str {
"ViewerRequestBody"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let header = format!("You have requested {} mode", self.role_label());
let text = format!("Waiting for {}...", self.display_name);
let cancel_button = appearance
.ui_builder()
.button(ButtonVariant::Outlined, self.mouse_state_handle.clone())
.with_centered_text_label(String::from("Cancel request"))
.with_style(UiComponentStyles {
font_size: Some(TEXT_FONT_SIZE),
font_weight: Some(Weight::Bold),
height: Some(BUTTON_HEIGHT),
width: Some(BUTTON_WIDTH),
..Default::default()
})
.build()
.with_cursor(Cursor::PointingHand)
.on_click(|ctx, _, _| ctx.dispatch_typed_action(ViewerRequestBodyAction::Cancel))
.finish();
let text_body = Container::new(
Flex::column()
.with_child(
Container::new(
Text::new_inline(header, appearance.ui_font_family(), HEADER_FONT_SIZE)
.with_color(blended_colors::text_main(
appearance.theme(),
appearance.theme().background(),
))
.finish(),
)
.with_padding_bottom(BODY_PADDING)
.finish(),
)
.with_child(
Text::new_inline(text, appearance.ui_font_family(), TEXT_FONT_SIZE)
.with_color(blended_colors::text_sub(
appearance.theme(),
appearance.theme().background(),
))
.finish(),
)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.finish(),
)
.with_padding_bottom(MODAL_PADDING)
.finish();
Flex::column()
.with_child(text_body)
.with_child(cancel_button)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.finish()
}
}
impl TypedActionView for ViewerRequestBody {
type Action = ViewerRequestBodyAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
ViewerRequestBodyAction::Cancel => ctx.emit(ViewerRequestBodyEvent::Cancel),
}
}
}
@@ -0,0 +1,57 @@
use crate::terminal::model::{blocks::BlockList, index::Point, terminal_model::WithinBlock};
use session_sharing_protocol::common::BlockPoint;
impl WithinBlock<Point> {
/// Converts an un-transformed block point
/// to a transformed [`WithinBlock<Point>`].
///
/// We make the following transformations:
/// 1. ensure the point fits our grid,
/// 2. turn the grid-compatible point into a displayed point so that it respects filters
pub fn from_session_sharing_block_point(
point: BlockPoint,
block_list: &BlockList,
) -> Option<Self> {
let block_index = block_list.block_index_for_id(&point.block_id.to_string().into())?;
let grid_type = point.grid_type.into();
let grid = block_list
.block_at(block_index)?
.grid_of_type(grid_type)?
.grid_handler();
let inner = grid.compatible_point(point.point.into());
let inner = if !grid.is_displayed_row(inner.row) {
return None;
} else {
grid.maybe_translate_point_from_original_to_displayed(inner)
};
Some(Self {
block_index,
grid: grid_type,
inner,
})
}
/// Converts a transformed [`WithinBlock<Point>`]
/// to a view-agnostic [`BlockPoint`].
///
/// This should be the inverse of [`WithinBlock<Point>::from_session_sharing_block_point`].
pub fn to_session_sharing_block_point(self, block_list: &BlockList) -> Option<BlockPoint> {
let block_id = block_list.block_at(self.block_index)?.id();
let grid = block_list.grid_at_location(&self).grid_handler();
let point = grid.maybe_translate_point_from_displayed_to_original(self.inner);
let point = grid.grid_agnostic_point(point);
Some(BlockPoint {
block_id: block_id.to_string().into(),
point: point.into(),
grid_type: self.grid.into(),
})
}
}
#[cfg(test)]
#[path = "selections_test.rs"]
mod tests;
@@ -0,0 +1,459 @@
use warp_core::semantic_selection::SemanticSelection;
use warpui::App;
use crate::terminal::{
block_filter::BlockFilterQuery,
event_listener::ChannelEventListener,
model::{
block::SerializedBlock,
blocks::BlockListPoint,
index::{Point, Side},
terminal_model::WithinBlock,
},
shared_session::tests::terminal_model_for_viewer,
GridType, SizeInfo, SizeUpdate, SizeUpdateReason, TerminalModel,
};
use warpui::text::SelectionType;
/// Creates a [`SelectionType::Simple`], left-to-right text selection
/// from `start` to `end` in the `model`'s blocklist.
fn create_simple_text_selection(
model: &mut TerminalModel,
start: WithinBlock<Point>,
end: WithinBlock<Point>,
) {
let start_block_point = BlockListPoint::from_within_block_point(&start, model.block_list());
let end_block_point = BlockListPoint::from_within_block_point(&end, model.block_list());
model
.block_list_mut()
.start_selection(start_block_point, SelectionType::Simple, Side::Left);
model
.block_list_mut()
.update_selection(end_block_point, Side::Right);
}
fn create_sharer_and_viewer_models_with_same_block(
input: &str,
output: &str,
) -> (TerminalModel, TerminalModel) {
let mut sharer_model = TerminalModel::mock(None, None);
sharer_model.simulate_block(input, output);
let channel_event_proxy = ChannelEventListener::new_for_test();
let mut viewer_model = terminal_model_for_viewer(channel_event_proxy);
let block = sharer_model.block_list().last_non_hidden_block().unwrap();
let serialized_block = SerializedBlock::from(block);
viewer_model.load_shared_session_scrollback(
&[
serialized_block,
SerializedBlock::new_active_block_for_test(),
],
false,
);
assert_eq!(
viewer_model
.block_list()
.last_non_hidden_block()
.unwrap()
.output_grid()
.contents_to_string(true, None)
.trim(),
output,
);
(sharer_model, viewer_model)
}
#[test]
fn test_selections_across_different_filtered_blocklists() {
App::test((), |app| async move {
app.read(|ctx| {
// Create a model for sharer / viewer with the following block:
// the command grid in this test should simply be "ls", and
// the output grid should look like
// ```
// foo
// bar
// baz
// ```
let semantic_selection = SemanticSelection::mock(false, "");
let (mut sharer_model, mut viewer_model) =
create_sharer_and_viewer_models_with_same_block("ls", "foo\r\nbar\r\nbaz");
// Suppose the sharer filters the block down with filter="ba".
let block_index = sharer_model
.block_list()
.last_non_hidden_block_by_index()
.unwrap();
sharer_model.update_filter_on_block(
block_index,
BlockFilterQuery::new_for_test(String::from("ba")),
);
assert_eq!(
sharer_model
.block_list()
.num_matched_lines_in_filter_for_block(block_index),
Some(2)
);
// Now, suppose the sharer selects "bar", which is on the first row of the output grid, post-filter.
create_simple_text_selection(
&mut sharer_model,
WithinBlock {
block_index,
grid: GridType::Output,
inner: Point::new(0, 0),
},
WithinBlock {
block_index,
grid: GridType::Output,
inner: Point::new(0, 2),
},
);
assert_eq!(
sharer_model
.selection_to_string(&semantic_selection, false, ctx)
.unwrap(),
"bar"
);
// Convert the selection to session-sharing-compatible points
// that the viewer can apply locally.
let (start, end, _) = sharer_model
.block_list()
.text_selection_range(&semantic_selection, false)
.unwrap();
let start_converted = start
.to_session_sharing_block_point(sharer_model.block_list())
.unwrap();
let end_converted = end
.to_session_sharing_block_point(sharer_model.block_list())
.unwrap();
// Suppose the viewer receives these points; convert them to local points.
let viewer_start = WithinBlock::<Point>::from_session_sharing_block_point(
start_converted.clone(),
viewer_model.block_list(),
)
.unwrap();
let viewer_end = WithinBlock::<Point>::from_session_sharing_block_point(
end_converted.clone(),
viewer_model.block_list(),
)
.unwrap();
create_simple_text_selection(&mut viewer_model, viewer_start, viewer_end);
assert_eq!(
viewer_model
.selection_to_string(&semantic_selection, false, ctx)
.unwrap(),
"bar"
);
// Even if the viewer filters further, the selection should still be stable.
let block_index = viewer_model
.block_list()
.last_non_hidden_block_by_index()
.unwrap();
viewer_model.block_list_mut().clear_selection();
viewer_model.update_filter_on_block(
block_index,
BlockFilterQuery::new_for_test(String::from("ba")),
);
let viewer_start = WithinBlock::<Point>::from_session_sharing_block_point(
start_converted,
viewer_model.block_list(),
)
.unwrap();
let viewer_end = WithinBlock::<Point>::from_session_sharing_block_point(
end_converted,
viewer_model.block_list(),
)
.unwrap();
create_simple_text_selection(&mut viewer_model, viewer_start, viewer_end);
assert_eq!(
viewer_model
.selection_to_string(&semantic_selection, false, ctx)
.unwrap(),
"bar"
);
})
})
}
#[test]
fn test_selection_of_undisplayed_row() {
App::test((), |app| async move {
app.read(|ctx| {
// Create a model for the sharer and create the following block:
// the command grid in this test should simply be "ls", and
// the output grid should look like
// ```
// foo
// bar
// baz
// ```
let semantic_selection = SemanticSelection::mock(false, "");
let (mut sharer_model, mut viewer_model) =
create_sharer_and_viewer_models_with_same_block("ls", "foo\r\nbar\r\nbaz");
// Suppose the viewer filters down to lines with "ba".
let block_index = viewer_model
.block_list()
.last_non_hidden_block_by_index()
.unwrap();
viewer_model.block_list_mut().clear_selection();
viewer_model.update_filter_on_block(
block_index,
BlockFilterQuery::new_for_test(String::from("bar")),
);
// Suppose the sharer selects "foo".
let block_index = sharer_model
.block_list()
.last_non_hidden_block_by_index()
.unwrap();
create_simple_text_selection(
&mut sharer_model,
WithinBlock {
block_index,
grid: GridType::Output,
inner: Point::new(0, 0),
},
WithinBlock {
block_index,
grid: GridType::Output,
inner: Point::new(0, 2),
},
);
assert_eq!(
sharer_model
.selection_to_string(&semantic_selection, false, ctx)
.unwrap(),
"foo"
);
// Convert the selection to session-sharing-compatible points
// that the viewer can apply locally.
let (start, end, _) = sharer_model
.block_list()
.text_selection_range(&semantic_selection, false)
.unwrap();
let start_converted = start
.to_session_sharing_block_point(sharer_model.block_list())
.unwrap();
let end_converted = end
.to_session_sharing_block_point(sharer_model.block_list())
.unwrap();
// Suppose the viewer receives these points and tries to convert them to local points.
let viewer_start = WithinBlock::<Point>::from_session_sharing_block_point(
start_converted.clone(),
viewer_model.block_list(),
);
let viewer_end = WithinBlock::<Point>::from_session_sharing_block_point(
end_converted.clone(),
viewer_model.block_list(),
);
// These points shouldn't exist because the viewer has a filter that
// excludes "foo" from the output grid.
assert!(viewer_start.is_none());
assert!(viewer_end.is_none());
})
})
}
#[test]
fn test_selections_from_larger_grid_to_smaller_grid() {
App::test((), |app| async move {
app.read(|ctx| {
let semantic_selection = SemanticSelection::mock(false, "");
let (mut sharer_model, mut viewer_model) =
create_sharer_and_viewer_models_with_same_block(
"ls",
"\
this is some long line\r\n\
short line\r\n\
this is another long line\r\n\
short line 2",
);
// Make sure the viewer only has 10 columns, while the sharer has 50.
let sharer_update = SizeUpdate {
update_reason: SizeUpdateReason::Refresh,
last_size: *sharer_model.block_list().size(),
new_size: SizeInfo::new_without_font_metrics(100, 50),
new_gap_height: None,
natural_rows: 100,
natural_cols: 50,
};
sharer_model.block_list_mut().resize(&sharer_update, true);
let viewer_update = SizeUpdate {
update_reason: SizeUpdateReason::Refresh,
last_size: *viewer_model.block_list().size(),
new_size: SizeInfo::new_without_font_metrics(100, 10),
new_gap_height: None,
natural_rows: 100,
natural_cols: 10,
};
viewer_model.block_list_mut().resize(&viewer_update, true);
// Suppose the sharer selects "line" on the third line.
let block_index = sharer_model
.block_list()
.last_non_hidden_block_by_index()
.unwrap();
create_simple_text_selection(
&mut sharer_model,
WithinBlock {
block_index,
grid: GridType::Output,
inner: Point::new(2, 21),
},
WithinBlock {
block_index,
grid: GridType::Output,
inner: Point::new(2, 25),
},
);
assert_eq!(
sharer_model
.selection_to_string(&semantic_selection, false, ctx)
.unwrap(),
"line"
);
// Convert the selection to session-sharing-compatible points
// that the viewer can apply locally.
let (start, end, _) = sharer_model
.block_list()
.text_selection_range(&semantic_selection, false)
.unwrap();
let start_converted = start
.to_session_sharing_block_point(sharer_model.block_list())
.unwrap();
let end_converted = end
.to_session_sharing_block_point(sharer_model.block_list())
.unwrap();
// Suppose the viewer receives these points and tries to convert them to local points.
let viewer_start = WithinBlock::<Point>::from_session_sharing_block_point(
start_converted,
viewer_model.block_list(),
)
.unwrap();
let viewer_end = WithinBlock::<Point>::from_session_sharing_block_point(
end_converted,
viewer_model.block_list(),
)
.unwrap();
create_simple_text_selection(&mut viewer_model, viewer_start, viewer_end);
assert_eq!(
viewer_model
.selection_to_string(&semantic_selection, false, ctx)
.unwrap(),
"line"
);
})
})
}
#[test]
fn test_selections_from_smaller_grid_to_larger_grid() {
App::test((), |app| async move {
app.read(|ctx| {
let semantic_selection = SemanticSelection::mock(false, "");
let (mut sharer_model, mut viewer_model) =
create_sharer_and_viewer_models_with_same_block(
"ls",
"\
this is some long line\r\n\
short line\r\n\
this is another long line\r\n\
short line 2",
);
// Make sure the sharer only has 10 columns, while the viewer has 50.
let sharer_update = SizeUpdate {
update_reason: SizeUpdateReason::Refresh,
last_size: *sharer_model.block_list().size(),
new_size: SizeInfo::new_without_font_metrics(100, 10),
new_gap_height: None,
natural_rows: 100,
natural_cols: 10,
};
sharer_model.block_list_mut().resize(&sharer_update, true);
let viewer_update = SizeUpdate {
update_reason: SizeUpdateReason::Refresh,
last_size: *viewer_model.block_list().size(),
new_size: SizeInfo::new_without_font_metrics(100, 50),
new_gap_height: None,
natural_rows: 100,
natural_cols: 50,
};
viewer_model.block_list_mut().resize(&viewer_update, true);
// Suppose the sharer selects "line" on the third line
// (which is actually the 6th line due to wrapping).
let block_index = sharer_model
.block_list()
.last_non_hidden_block_by_index()
.unwrap();
create_simple_text_selection(
&mut sharer_model,
WithinBlock {
block_index,
grid: GridType::Output,
inner: Point::new(6, 1),
},
WithinBlock {
block_index,
grid: GridType::Output,
inner: Point::new(6, 4),
},
);
assert_eq!(
sharer_model
.selection_to_string(&semantic_selection, false, ctx)
.unwrap(),
"line"
);
// Convert the selection to session-sharing-compatible points
// that the viewer can apply locally.
let (start, end, _) = sharer_model
.block_list()
.text_selection_range(&semantic_selection, false)
.unwrap();
let start_converted = start
.to_session_sharing_block_point(sharer_model.block_list())
.unwrap();
let end_converted = end
.to_session_sharing_block_point(sharer_model.block_list())
.unwrap();
// Suppose the viewer receives these points and tries to convert them to local points.
let viewer_start = WithinBlock::<Point>::from_session_sharing_block_point(
start_converted.clone(),
viewer_model.block_list(),
)
.unwrap();
let viewer_end = WithinBlock::<Point>::from_session_sharing_block_point(
end_converted,
viewer_model.block_list(),
)
.unwrap();
create_simple_text_selection(&mut viewer_model, viewer_start, viewer_end);
assert_eq!(
viewer_model
.selection_to_string(&semantic_selection, false, ctx)
.unwrap(),
"line"
);
})
})
}
@@ -0,0 +1,61 @@
use std::time::Duration;
use settings::{
macros::define_settings_group, RespectUserSyncSetting, Setting, SupportedPlatforms, SyncToCloud,
};
define_settings_group!(SharedSessionSettings, settings: [
onboarding_block_shown: SessionSharingOnboardingBlockShown {
type: bool,
default: false,
supported_platforms: SupportedPlatforms::ALL,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: true,
},
inactivity_period_before_ending_session: InactivityPeriodBeforeEndingSession {
type: Duration,
// After a total of 30 min of inactivity, we will end the session
default: Duration::from_secs(1800),
supported_platforms: SupportedPlatforms::ALL,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: true,
},
inactivity_period_before_warning: InactivityPeriodBeforeWarning {
type: Duration,
// After a total of 25 min of inactivity, we will show a warning modal
default: Duration::from_secs(1500),
supported_platforms: SupportedPlatforms::ALL,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: true,
},
inactivity_period_before_revoking_roles: InactivityPeriodBeforeRevokingRoles {
type: Duration,
// After a total of 10 min of inactivity, we will revoke all executor roles
default: Duration::from_secs(600),
supported_platforms: SupportedPlatforms::ALL,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: true,
},
// Killswitch: when false, the sharer ignores viewer terminal size reports.
viewer_driven_sizing_enabled: ViewerDrivenSizingEnabled {
type: bool,
default: true,
supported_platforms: SupportedPlatforms::ALL,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: true,
},
]);
impl SharedSessionSettings {
/// Returns time between showing the inactivity warning modal and ending the session.
pub fn inactivity_period_between_warning_and_ending_session(&self) -> Duration {
*self.inactivity_period_before_ending_session.value()
- *self.inactivity_period_before_warning.value()
}
/// Returns time between revoking roles and showing the inactivity warning modal.
pub fn inactivity_period_between_revoking_roles_and_warning(&self) -> Duration {
*self.inactivity_period_before_warning.value()
- *self.inactivity_period_before_revoking_roles.value()
}
}
@@ -0,0 +1,411 @@
use crate::ai::blocklist::BlocklistAIHistoryModel;
use crate::appearance::Appearance;
use crate::terminal::shared_session::replay_agent_conversations::reconstruct_response_events_from_conversations;
use crate::terminal::shared_session::role_change_modal::TEXT_FONT_SIZE;
use crate::terminal::shared_session::{
ai_agent::encode_agent_response_event, max_session_size, SharedSessionActionSource,
SharedSessionScrollbackType,
};
use crate::terminal::TerminalModel;
use byte_unit::Byte;
use warp_core::features::FeatureFlag;
use std::default::Default;
use std::sync::Arc;
use parking_lot::FairMutex;
use warpui::elements::{
Container, Flex, MainAxisSize, MouseStateHandle, ParentElement, Shrinkable, Text,
};
use warpui::ui_components::button::ButtonVariant;
use warpui::ui_components::components::UiComponent;
use warpui::ui_components::radio_buttons::{
RadioButtonItem, RadioButtonLayout, RadioButtonStateHandle,
};
use super::style::{self, BUTTON_GAP, MODAL_MARGIN};
use warpui::{
platform::Cursor, AppContext, Element, Entity, SingletonEntity, TypedActionView, View,
ViewContext,
};
#[derive(Default)]
struct ButtonMouseStateHandles {
cancel_button: MouseStateHandle,
start_sharing_button: MouseStateHandle,
}
#[derive(Default)]
struct RadioButtonGroupState {
group_state_handle: RadioButtonStateHandle,
items: Vec<ScrollbackOption>,
}
struct ScrollbackOption {
label: &'static str,
scrollback_type: SharedSessionScrollbackType,
mouse_state_handle: MouseStateHandle,
is_disabled: bool,
}
pub struct Body {
button_mouse_states: ButtonMouseStateHandles,
radio_button_mouse_states: RadioButtonGroupState,
has_agent_conversations: bool,
}
#[derive(Debug)]
pub enum BodyAction {
StartSharing,
Cancel,
}
pub enum BodyEvent {
Close,
StartSharing {
scrollback_type: SharedSessionScrollbackType,
},
}
impl Body {
pub fn new(_ctx: &mut ViewContext<Self>) -> Self {
Self {
button_mouse_states: Default::default(),
radio_button_mouse_states: Default::default(),
has_agent_conversations: false,
}
}
/// Calculate the total size of agent conversation response events that will be sent
/// during session initialization. This is important because these events count toward
/// the session size quota, but are separate from the scrollback blocks.
fn calculate_agent_conversations_size(
terminal_view_id: warpui::EntityId,
ctx: &ViewContext<Self>,
) -> Byte {
let conversations: Vec<_> = BlocklistAIHistoryModel::as_ref(ctx)
.all_live_conversations_for_terminal_view(terminal_view_id)
.filter(|conv| conv.exchange_count() > 0)
.cloned()
.collect();
let total_bytes: usize = reconstruct_response_events_from_conversations(&conversations)
.iter()
.map(|event| encode_agent_response_event(event).len())
.sum();
Byte::from_u64(total_bytes as u64)
}
}
impl Body {
pub fn open(
&mut self,
open_source: SharedSessionActionSource,
model: Arc<FairMutex<TerminalModel>>,
terminal_view_id: warpui::EntityId,
ctx: &mut ViewContext<Self>,
) {
let model = model.lock();
let max_session_size = max_session_size(ctx);
// TODO: serializing the blocks to compute their sizes is
// inefficient but it matches how the server checks limits.
// Consider caching size in the blocklist as blocks are added
// so we can compute this more efficiently if latency becomes an issue.
// This is not an issue in release mode.
// TODO: technically, the size of the long-running block might change while the modal
// is open. We might want to watch for changes on the terminal model and recompute
// the size here accordingly. That being said, we still have guardrails on both
// client and server to ensure that the actual share won't be started if the size is
// too large.
// Check if agent shared sessions is enabled and there are active conversations
self.has_agent_conversations = if FeatureFlag::AgentSharedSessions.is_enabled() {
BlocklistAIHistoryModel::as_ref(ctx)
.all_live_conversations_for_terminal_view(terminal_view_id)
.any(|conv| conv.exchange_count() > 0)
} else {
false
};
// Calculate the size of agent conversation response events that will be sent during initialization.
// Only include this if the feature flag is enabled, since the events won't be sent otherwise.
let agent_conversations_size =
if FeatureFlag::AgentSharedSessions.is_enabled() && self.has_agent_conversations {
Self::calculate_agent_conversations_size(terminal_view_id, ctx)
} else {
Byte::from_u64(0)
};
let scrollback_from_active_block = SharedSessionScrollbackType::None.to_scrollback(&model);
let mut is_scrollback_from_active_block_disabled = scrollback_from_active_block
.num_bytes()
.as_u64()
.saturating_add(agent_conversations_size.as_u64())
> max_session_size.as_u64();
// Disable the "without scrollback" option if there are agent conversations
if self.has_agent_conversations {
is_scrollback_from_active_block_disabled = true;
}
let all_scrollback = SharedSessionScrollbackType::All.to_scrollback(&model);
let is_all_scrollback_disabled = all_scrollback
.num_bytes()
.as_u64()
.saturating_add(agent_conversations_size.as_u64())
> max_session_size.as_u64();
let scrollback_from_active_block_message = if model.is_alt_screen_active() {
"Share from current screen"
} else if model
.block_list()
.active_block()
.is_active_and_long_running()
{
"Share from current block"
} else {
"Share without scrollback"
};
let mut options = vec![
ScrollbackOption {
label: scrollback_from_active_block_message,
scrollback_type: SharedSessionScrollbackType::None,
mouse_state_handle: Default::default(),
is_disabled: is_scrollback_from_active_block_disabled,
},
ScrollbackOption {
label: "Share from start of session",
scrollback_type: SharedSessionScrollbackType::All,
mouse_state_handle: Default::default(),
is_disabled: is_all_scrollback_disabled,
},
];
if let SharedSessionActionSource::BlocklistContextMenu {
block_index: Some(block_index),
} = open_source
{
// Context menu from blocklist can be opened with or without block selection
// Add option only if a block is selected
let scrollback_type = SharedSessionScrollbackType::FromBlock { block_index };
let mut is_disabled = if !is_all_scrollback_disabled {
false
} else {
let block_scrollback = scrollback_type.to_scrollback(&model);
block_scrollback
.num_bytes()
.as_u64()
.saturating_add(agent_conversations_size.as_u64())
> max_session_size.as_u64()
};
// Disable this option if there are agent conversations in the current session.
if self.has_agent_conversations {
is_disabled = true;
}
options.insert(
0,
ScrollbackOption {
label: "Share from selected block and onwards",
scrollback_type,
mouse_state_handle: Default::default(),
is_disabled,
},
);
}
self.radio_button_mouse_states.items = options;
ctx.notify();
}
}
impl Entity for Body {
type Event = BodyEvent;
}
impl View for Body {
fn ui_name() -> &'static str {
"ShareSessionModalBody"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let mut start_sharing_button = appearance
.ui_builder()
.button(
ButtonVariant::Accent,
self.button_mouse_states.start_sharing_button.clone(),
)
.with_centered_text_label(String::from("Start sharing"))
.with_style(style::button_styles());
// If none of the scrollback options are available, the start sharing
// button should be disabled.
if self
.radio_button_mouse_states
.items
.iter()
.all(|item| item.is_disabled)
{
start_sharing_button = start_sharing_button.disabled();
}
let start_sharing_button = start_sharing_button
.build()
.with_cursor(Cursor::PointingHand)
.on_click(|ctx, _, _| ctx.dispatch_typed_action(BodyAction::StartSharing))
.finish();
let cancel_button = appearance
.ui_builder()
.button(
ButtonVariant::Outlined,
self.button_mouse_states.cancel_button.clone(),
)
.with_centered_text_label(String::from("Cancel"))
.with_style(style::button_styles())
.build()
.with_cursor(Cursor::PointingHand)
.on_click(|ctx, _, _| ctx.dispatch_typed_action(BodyAction::Cancel))
.finish();
// When agent conversations exist, default to "Share from start of session"
let default_option = self
.radio_button_mouse_states
.items
.iter()
.position(|i| !i.is_disabled);
let radio_buttons = appearance
.ui_builder()
.radio_buttons(
self.radio_button_mouse_states
.items
.iter()
.map(|i| i.mouse_state_handle.clone())
.collect(),
self.radio_button_mouse_states
.items
.iter()
.map(|i| RadioButtonItem::text(i.label).with_disabled(i.is_disabled))
.collect(),
self.radio_button_mouse_states.group_state_handle.clone(),
default_option,
TEXT_FONT_SIZE,
RadioButtonLayout::Column,
)
.with_style(style::radio_button_styles());
let button_row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_child(
Shrinkable::new(
1.0,
Container::new(cancel_button)
.with_margin_right(BUTTON_GAP)
.finish(),
)
.finish(),
)
.with_child(
Shrinkable::new(
1.0,
Container::new(start_sharing_button)
.with_margin_left(BUTTON_GAP)
.finish(),
)
.finish(),
);
let mut column = Flex::column();
// Determine which explanation message to show
let disabled_count = self
.radio_button_mouse_states
.items
.iter()
.filter(|i| i.is_disabled)
.count();
let explanation_message = if disabled_count == 0 {
None
} else if disabled_count > 1 {
// Multiple options disabled - mention both reasons if agent conversations exist
if self.has_agent_conversations {
Some("Some options are disabled due to sharing size limits and the presence of agent conversations in the session")
} else {
Some("Some options are disabled due to sharing size limits")
}
} else {
// Only one option disabled - use specific message if it's due to agent conversations
if self.has_agent_conversations {
Some("Sharing without scrollback is disabled because this session has agent conversations")
} else {
Some("Some options are disabled due to sharing size limits")
}
};
if let Some(message) = explanation_message {
column.add_child(
Container::new(
Text::new(
message,
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(
appearance
.theme()
.sub_text_color(appearance.theme().background())
.into(),
)
.finish(),
)
.with_margin_top(-MODAL_MARGIN)
.with_margin_bottom(MODAL_MARGIN)
.finish(),
);
}
column
.with_child(radio_buttons.build().finish())
.with_child(
Container::new(button_row.finish())
.with_margin_top(MODAL_MARGIN)
.finish(),
)
.finish()
}
}
impl TypedActionView for Body {
type Action = BodyAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
BodyAction::Cancel => ctx.emit(BodyEvent::Close),
BodyAction::StartSharing => {
if let Some(selected_option) = self
.radio_button_mouse_states
.group_state_handle
.get_selected_idx()
.and_then(|idx| self.radio_button_mouse_states.items.get(idx))
{
ctx.emit(BodyEvent::StartSharing {
scrollback_type: selected_option.scrollback_type,
});
}
}
}
}
}
#[cfg(test)]
#[path = "body_test.rs"]
mod tests;
@@ -0,0 +1,304 @@
use std::sync::Arc;
use parking_lot::FairMutex;
use warpui::App;
use crate::terminal::shared_session::MAX_BYTES_SHAREABLE;
use crate::terminal::TerminalModel;
use crate::{
terminal::shared_session::{SharedSessionActionSource, SharedSessionScrollbackType},
test_util::{add_window_with_terminal, terminal::initialize_app_for_terminal_view},
};
use super::Body;
#[test]
fn test_open_modal_from_non_block() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal_view = add_window_with_terminal(&mut app, None);
let window_id = app.read(|ctx| terminal_view.window_id(ctx));
let share_session_modal = app.add_typed_action_view(window_id, Body::new);
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
// Share from the tab.
let terminal_model_clone = terminal_model.clone();
share_session_modal.update(&mut app, |share_session_modal, ctx| {
let open_source = SharedSessionActionSource::Tab;
share_session_modal.open(open_source, terminal_model_clone, terminal_view.id(), ctx);
});
// Options should be no scrollback and from start. Both enabled.
share_session_modal.read(&app, |body, _ctx| {
assert_eq!(body.radio_button_mouse_states.items.len(), 2);
assert_eq!(
body.radio_button_mouse_states.items[0].scrollback_type,
SharedSessionScrollbackType::None
);
assert!(!body.radio_button_mouse_states.items[0].is_disabled);
assert_eq!(
body.radio_button_mouse_states.items[1].scrollback_type,
SharedSessionScrollbackType::All
);
assert!(!body.radio_button_mouse_states.items[1].is_disabled);
});
})
}
#[test]
fn test_open_modal_from_block() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal_view = add_window_with_terminal(&mut app, None);
let window_id = app.read(|ctx| terminal_view.window_id(ctx));
let share_session_modal = app.add_typed_action_view(window_id, Body::new);
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
// Add a block that is under the limit.
// `serde_json::to_vec` roughly triples the size of the SerializedBlock output, which is why we divide by 4.
terminal_model
.lock()
.simulate_block("ls", "a".repeat(MAX_BYTES_SHAREABLE / 4).as_str());
// Share from the very large block we just completed.
let block_index = terminal_model.lock().block_list().active_block_index() - 1.into();
let terminal_model_clone = terminal_model.clone();
share_session_modal.update(&mut app, |share_session_modal, ctx| {
let open_source = SharedSessionActionSource::BlocklistContextMenu {
block_index: Some(block_index),
};
share_session_modal.open(open_source, terminal_model_clone, terminal_view.id(), ctx);
});
// Options should be from block, no scrollback, and from start. All enabled.
share_session_modal.read(&app, |body, _ctx| {
assert_eq!(body.radio_button_mouse_states.items.len(), 3);
assert_eq!(
body.radio_button_mouse_states.items[0].scrollback_type,
SharedSessionScrollbackType::FromBlock { block_index },
);
assert!(!body.radio_button_mouse_states.items[0].is_disabled);
assert_eq!(
body.radio_button_mouse_states.items[1].scrollback_type,
SharedSessionScrollbackType::None
);
assert!(!body.radio_button_mouse_states.items[1].is_disabled);
assert_eq!(
body.radio_button_mouse_states.items[2].scrollback_type,
SharedSessionScrollbackType::All
);
assert!(!body.radio_button_mouse_states.items[2].is_disabled);
});
})
}
#[test]
fn test_open_modal_from_non_block_disabled() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal_view = add_window_with_terminal(&mut app, None);
let window_id = app.read(|ctx| terminal_view.window_id(ctx));
let share_session_modal = app.add_typed_action_view(window_id, Body::new);
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
// Add a block that is under the limit.
// `serde_json::to_vec` roughly triples the size of the SerializedBlock output, which is why we divide by 4.
terminal_model
.lock()
.simulate_block("ls", "a".repeat(MAX_BYTES_SHAREABLE / 4).as_str());
// Add another block that puts us over the sharing limit for the whole session.
// `serde_json::to_vec` roughly triples the size of the SerializedBlock output, which is why we divide by 4. This plus the earlier block puts us over the limit.
terminal_model
.lock()
.simulate_block("ls", "a".repeat(MAX_BYTES_SHAREABLE / 4).as_str());
// Share from the tab.
let terminal_model_clone = terminal_model.clone();
share_session_modal.update(&mut app, |share_session_modal, ctx| {
let open_source = SharedSessionActionSource::Tab;
share_session_modal.open(open_source, terminal_model_clone, terminal_view.id(), ctx);
});
// Options should be no scrollback and from start. From start is disabled.
share_session_modal.read(&app, |body, _ctx| {
assert_eq!(body.radio_button_mouse_states.items.len(), 2);
assert_eq!(
body.radio_button_mouse_states.items[0].scrollback_type,
SharedSessionScrollbackType::None
);
assert!(!body.radio_button_mouse_states.items[0].is_disabled);
assert_eq!(
body.radio_button_mouse_states.items[1].scrollback_type,
SharedSessionScrollbackType::All
);
assert!(body.radio_button_mouse_states.items[1].is_disabled);
});
})
}
#[test]
fn test_open_modal_from_block_disabled() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal_view = add_window_with_terminal(&mut app, None);
let window_id = app.read(|ctx| terminal_view.window_id(ctx));
let share_session_modal = app.add_typed_action_view(window_id, Body::new);
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
// Add a block that is under the limit.
// `serde_json::to_vec` roughly triples the size of the SerializedBlock output, which is why we divide by 4.
terminal_model
.lock()
.simulate_block("ls", "a".repeat(MAX_BYTES_SHAREABLE / 4).as_str());
let mut block_index = terminal_model.lock().block_list().active_block_index() - 1.into();
// Add another block that puts us over the sharing limit for the whole session.
// `serde_json::to_vec` roughly triples the size of the SerializedBlock output, which is why we divide by 4. This plus the earlier block puts us over the limit.
terminal_model
.lock()
.simulate_block("ls", "a".repeat(MAX_BYTES_SHAREABLE / 4).as_str());
// Open modal from the first very large block.
let terminal_model_clone = terminal_model.clone();
share_session_modal.update(&mut app, |share_session_modal, ctx| {
let open_source = SharedSessionActionSource::BlocklistContextMenu {
block_index: Some(block_index),
};
share_session_modal.open(open_source, terminal_model_clone, terminal_view.id(), ctx);
});
// From block and from start of the session are disabled because they are over the limit. No scrollback is enabled.
share_session_modal.read(&app, |body, _ctx| {
assert_eq!(body.radio_button_mouse_states.items.len(), 3);
assert_eq!(
body.radio_button_mouse_states.items[0].scrollback_type,
SharedSessionScrollbackType::FromBlock { block_index }
);
assert!(body.radio_button_mouse_states.items[0].is_disabled);
assert_eq!(
body.radio_button_mouse_states.items[1].scrollback_type,
SharedSessionScrollbackType::None
);
assert!(!body.radio_button_mouse_states.items[1].is_disabled);
assert_eq!(
body.radio_button_mouse_states.items[2].scrollback_type,
SharedSessionScrollbackType::All
);
assert!(body.radio_button_mouse_states.items[2].is_disabled);
});
// Open modal from the newly finished block, which excludes the very large first block we created.
block_index = terminal_model.lock().block_list().active_block_index() - 1.into();
let terminal_model_clone = terminal_model.clone();
share_session_modal.update(&mut app, |share_session_modal, ctx| {
let open_source = SharedSessionActionSource::BlocklistContextMenu {
block_index: Some(block_index),
};
share_session_modal.open(open_source, terminal_model_clone, terminal_view.id(), ctx);
});
// From block and no scrollback are enabled, but from start of session is disabled.
share_session_modal.read(&app, |body, _ctx| {
assert_eq!(body.radio_button_mouse_states.items.len(), 3);
assert_eq!(
body.radio_button_mouse_states.items[0].scrollback_type,
SharedSessionScrollbackType::FromBlock { block_index }
);
assert!(!body.radio_button_mouse_states.items[0].is_disabled);
assert_eq!(
body.radio_button_mouse_states.items[1].scrollback_type,
SharedSessionScrollbackType::None
);
assert!(!body.radio_button_mouse_states.items[1].is_disabled);
assert_eq!(
body.radio_button_mouse_states.items[2].scrollback_type,
SharedSessionScrollbackType::All
);
assert!(body.radio_button_mouse_states.items[2].is_disabled);
});
});
}
#[test]
fn test_open_modal_from_long_running_block() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal_view = add_window_with_terminal(&mut app, None);
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
let window_id = app.read(|ctx| terminal_view.window_id(ctx));
let share_session_modal = app.add_typed_action_view(window_id, Body::new);
// Add a long-running block that is under the limit.
// `serde_json::to_vec` roughly triples the size of the SerializedBlock output, which is why we divide by 4.
terminal_model
.lock()
.simulate_long_running_block("ls", "a".repeat(MAX_BYTES_SHAREABLE / 4).as_str());
assert_eq!(terminal_model.lock().block_list().blocks().len(), 2);
assert!(terminal_model
.lock()
.block_list()
.active_block()
.is_executing());
// Open the share modal.
let terminal_model_clone = terminal_model.clone();
share_session_modal.update(&mut app, |share_session_modal, ctx| {
let open_source = SharedSessionActionSource::Tab;
share_session_modal.open(open_source, terminal_model_clone, terminal_view.id(), ctx);
});
// Options should be no scrollback and from start. Both are enabled.
share_session_modal.read(&app, |body, _ctx| {
assert_eq!(body.radio_button_mouse_states.items.len(), 2);
assert_eq!(
body.radio_button_mouse_states.items[0].scrollback_type,
SharedSessionScrollbackType::None
);
assert!(!body.radio_button_mouse_states.items[0].is_disabled);
assert_eq!(
body.radio_button_mouse_states.items[1].scrollback_type,
SharedSessionScrollbackType::All
);
assert!(!body.radio_button_mouse_states.items[1].is_disabled);
});
// Add more output to block so that it's over the limit.
terminal_model
.lock()
.process_bytes("a".repeat(MAX_BYTES_SHAREABLE / 4).as_str());
assert_eq!(terminal_model.lock().block_list().blocks().len(), 2);
assert!(terminal_model
.lock()
.block_list()
.active_block()
.is_executing());
// Re-open the modal to refresh the options.
let terminal_model_clone = terminal_model.clone();
share_session_modal.update(&mut app, |share_session_modal, ctx| {
let open_source = SharedSessionActionSource::Tab;
share_session_modal.open(open_source, terminal_model_clone, terminal_view.id(), ctx);
});
// Options should be no scrollback and from start. Both are disabled.
share_session_modal.read(&app, |body, _ctx| {
assert_eq!(body.radio_button_mouse_states.items.len(), 2);
assert_eq!(
body.radio_button_mouse_states.items[0].scrollback_type,
SharedSessionScrollbackType::None
);
assert!(body.radio_button_mouse_states.items[0].is_disabled);
assert_eq!(
body.radio_button_mouse_states.items[1].scrollback_type,
SharedSessionScrollbackType::All
);
assert!(body.radio_button_mouse_states.items[1].is_disabled);
});
})
}
@@ -0,0 +1,86 @@
use crate::appearance::Appearance;
use warpui::elements::{Container, Flex, MainAxisSize, MouseStateHandle, ParentElement};
use warpui::ui_components::button::ButtonVariant;
use warpui::ui_components::components::UiComponent;
use warpui::{
platform::Cursor, AppContext, Element, Entity, SingletonEntity, TypedActionView, View,
ViewContext,
};
use super::style::{self, MODAL_PADDING};
const SESSION_BUILD_FREE_PLAN_SUBHEADER: &str = "Warp's free and pro plans come with a limited number of shared sessions.\n\nFor increased access to session sharing upgrade to the Build plan.";
const VIEW_PLANS_TEXT: &str = "View plans";
pub struct DeniedBody {
button_mouse_state: MouseStateHandle,
}
#[derive(Debug, Clone, Copy)]
pub enum DeniedBodyAction {
Upgrade,
}
pub enum DeniedBodyEvent {
Upgrade,
}
impl DeniedBody {
pub fn new(_ctx: &mut ViewContext<Self>) -> Self {
Self {
button_mouse_state: Default::default(),
}
}
}
impl Entity for DeniedBody {
type Event = DeniedBodyEvent;
}
impl View for DeniedBody {
fn ui_name() -> &'static str {
"ShareModalDeniedBody"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let mut col = Flex::column();
let subheader = SESSION_BUILD_FREE_PLAN_SUBHEADER;
let text = appearance
.ui_builder()
.wrappable_text(subheader, true)
.with_style(style::subheader_styles(appearance))
.build()
.finish();
let button = appearance
.ui_builder()
.button(ButtonVariant::Accent, self.button_mouse_state.clone())
.with_centered_text_label(VIEW_PLANS_TEXT.to_owned())
.with_style(style::button_styles())
.build()
.with_cursor(Cursor::PointingHand)
.on_click(|ctx, _, _| ctx.dispatch_typed_action(DeniedBodyAction::Upgrade))
.finish();
col.add_child(text);
col.add_child(
Container::new(button)
.with_margin_top(MODAL_PADDING)
.finish(),
);
col.with_main_axis_size(MainAxisSize::Min).finish()
}
}
impl TypedActionView for DeniedBody {
type Action = DeniedBodyAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
DeniedBodyAction::Upgrade => ctx.emit(DeniedBodyEvent::Upgrade),
}
}
}
@@ -0,0 +1,233 @@
use crate::modal::Modal;
use crate::modal::ModalEvent;
use crate::pane_group::TerminalPaneId;
use crate::terminal::TerminalModel;
use crate::ui_components::icons::Icon;
use std::default::Default;
use std::sync::Arc;
use parking_lot::FairMutex;
use style::{DENIED_MODAL_WIDTH, MODAL_HEIGHT, MODAL_WIDTH};
use warp_core::ui::appearance::Appearance;
use warpui::keymap::FixedBinding;
use warpui::EntityId;
use warpui::presenter::ChildView;
use warpui::ui_components::components::UiComponentStyles;
use warpui::AppContext;
use warpui::SingletonEntity;
use warpui::ViewHandle;
use warpui::{Element, Entity, TypedActionView, View, ViewContext};
mod body;
mod denied_body;
mod style;
use body::Body;
use denied_body::{DeniedBody, DeniedBodyEvent};
use self::body::BodyEvent;
use super::{SharedSessionActionSource, SharedSessionScrollbackType};
const MODAL_HEADER: &str = "Share session";
const SESSION_LIMIT_REACHED_HEADER: &str = "Shared session limit reached";
pub struct ShareSessionModal {
modal: ViewHandle<Modal<Body>>,
denied_modal: ViewHandle<Modal<DeniedBody>>,
is_denied_modal_open: bool,
terminal_pane_id: Option<TerminalPaneId>,
/// Where we opened the modal from.
open_source: SharedSessionActionSource,
}
#[derive(Debug)]
pub enum ShareSessionModalAction {
Cancel,
}
pub enum ShareSessionModalEvent {
Close,
StartSharing {
terminal_pane_id: TerminalPaneId,
scrollback_type: SharedSessionScrollbackType,
source: SharedSessionActionSource,
},
Upgrade,
}
pub fn init(app: &mut AppContext) {
use warpui::keymap::macros::*;
app.register_fixed_bindings([FixedBinding::new(
"escape",
ShareSessionModalAction::Cancel,
id!("ShareSessionModal"),
)]);
}
impl ShareSessionModal {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let body = ctx.add_typed_action_view(Body::new);
ctx.subscribe_to_view(&body, move |me, _, event, ctx| {
me.handle_body_event(event, ctx);
});
let modal = ctx.add_typed_action_view(|ctx| {
Modal::new(Some(MODAL_HEADER.to_string()), body, ctx)
.with_modal_style(UiComponentStyles {
width: Some(MODAL_WIDTH),
height: Some(MODAL_HEIGHT),
..Default::default()
})
.with_header_style(style::modal_header_styles())
.with_body_style(style::modal_body_styles())
.with_background_opacity(100)
.with_dismiss_on_click()
.close_modal_button_disabled()
});
let denied_body = ctx.add_typed_action_view(DeniedBody::new);
ctx.subscribe_to_view(&denied_body, move |me, _, event, ctx| {
me.handle_denied_body_event(event, ctx)
});
let denied_modal = ctx.add_typed_action_view(|ctx| {
let mut denied_modal = Modal::new(
Some(SESSION_LIMIT_REACHED_HEADER.to_string()),
denied_body,
ctx,
)
.with_modal_style(UiComponentStyles {
width: Some(DENIED_MODAL_WIDTH),
..Default::default()
})
.with_header_style(style::modal_header_styles())
.with_body_style(style::modal_body_styles())
.with_background_opacity(100)
.with_dismiss_on_click();
let appearance = Appearance::as_ref(ctx);
denied_modal.set_header_icon(Some(Icon::Share));
denied_modal.set_header_icon_color(Some(appearance.theme().accent()));
denied_modal
});
ctx.subscribe_to_view(&denied_modal, |me, _, event, ctx| match event {
ModalEvent::Close => me.close(ctx),
});
Self {
modal,
denied_modal,
is_denied_modal_open: false,
terminal_pane_id: None,
// This should get overwritten when the modal is actually opened.
open_source: SharedSessionActionSource::Tab,
}
}
/// Closes the share session modal. If `focus_pane` is `true` we will return focus to the most
/// recently focused pane.
fn close(&mut self, ctx: &mut ViewContext<Self>) {
if self.terminal_pane_id.is_none() {
log::warn!("Tried to close share modal when no terminal pane ID was present");
return;
};
self.is_denied_modal_open = false;
ctx.emit(ShareSessionModalEvent::Close);
ctx.notify();
}
fn start_sharing(
&mut self,
scrollback_type: SharedSessionScrollbackType,
ctx: &mut ViewContext<Self>,
) {
let Some(terminal_pane_id) = self.terminal_pane_id else {
return;
};
ctx.emit(ShareSessionModalEvent::StartSharing {
terminal_pane_id,
scrollback_type,
source: self.open_source,
});
}
pub fn open(
&mut self,
terminal_pane_id: TerminalPaneId,
open_source: SharedSessionActionSource,
model: Arc<FairMutex<TerminalModel>>,
terminal_view_id: EntityId,
ctx: &mut ViewContext<Self>,
) {
self.terminal_pane_id = Some(terminal_pane_id);
self.open_source = open_source;
self.modal.update(ctx, |modal, ctx| {
modal.body().update(ctx, |modal, ctx| {
modal.open(open_source, model, terminal_view_id, ctx);
});
});
ctx.notify();
}
pub fn open_denied(&mut self, terminal_pane_id: TerminalPaneId, ctx: &mut ViewContext<Self>) {
self.terminal_pane_id = Some(terminal_pane_id);
self.open_source = SharedSessionActionSource::NonUser;
self.is_denied_modal_open = true;
ctx.notify();
}
fn handle_body_event(&mut self, event: &BodyEvent, ctx: &mut ViewContext<Self>) {
match event {
BodyEvent::Close => self.close(ctx),
BodyEvent::StartSharing { scrollback_type } => {
self.start_sharing(*scrollback_type, ctx)
}
}
}
fn handle_denied_body_event(&mut self, event: &DeniedBodyEvent, ctx: &mut ViewContext<Self>) {
match event {
DeniedBodyEvent::Upgrade => {
self.close(ctx);
ctx.emit(ShareSessionModalEvent::Upgrade)
}
}
}
#[cfg(test)]
pub fn terminal_pane_id(&self) -> Option<TerminalPaneId> {
self.terminal_pane_id
}
}
impl Entity for ShareSessionModal {
type Event = ShareSessionModalEvent;
}
impl View for ShareSessionModal {
fn ui_name() -> &'static str {
"ShareSessionModal"
}
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
if self.is_denied_modal_open {
ChildView::new(&self.denied_modal).finish()
} else {
ChildView::new(&self.modal).finish()
}
}
}
impl TypedActionView for ShareSessionModal {
type Action = ShareSessionModalAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
ShareSessionModalAction::Cancel => self.close(ctx),
}
}
}
@@ -0,0 +1,72 @@
use warp_core::ui::appearance::Appearance;
use warpui::{
fonts::Weight,
ui_components::components::{Coords, UiComponentStyles},
};
pub const MODAL_WIDTH: f32 = 460.;
pub const MODAL_HEIGHT: f32 = 300.;
pub const DENIED_MODAL_WIDTH: f32 = 355.;
pub const MODAL_PADDING: f32 = 24.;
pub const MODAL_MARGIN: f32 = 16.;
pub const BUTTON_GAP: f32 = 4.;
const TEXT_FONT_SIZE: f32 = 14.;
const HEADER_FONT_SIZE: f32 = 16.;
pub fn modal_header_styles() -> UiComponentStyles {
UiComponentStyles {
padding: Some(Coords {
top: 0.,
bottom: 0.,
left: MODAL_PADDING,
right: MODAL_PADDING,
}),
font_size: Some(HEADER_FONT_SIZE),
font_weight: Some(Weight::Bold),
..Default::default()
}
}
pub fn modal_body_styles() -> UiComponentStyles {
UiComponentStyles {
padding: Some(Coords {
top: 0.,
bottom: MODAL_PADDING,
left: MODAL_PADDING,
right: MODAL_PADDING,
}),
..Default::default()
}
}
pub fn button_styles() -> UiComponentStyles {
UiComponentStyles {
font_size: Some(TEXT_FONT_SIZE),
font_weight: Some(Weight::Bold),
height: Some(40.),
..Default::default()
}
}
pub fn radio_button_styles() -> UiComponentStyles {
UiComponentStyles {
padding: Some(Coords {
top: 16.,
..Default::default()
}),
..Default::default()
}
}
pub fn subheader_styles(appearance: &Appearance) -> UiComponentStyles {
UiComponentStyles {
font_size: Some(TEXT_FONT_SIZE),
font_color: Some(
appearance
.theme()
.sub_text_color(appearance.theme().background())
.into(),
),
..Default::default()
}
}
@@ -0,0 +1,495 @@
use std::cell::Cell;
use std::rc::Rc;
use input_classifier::InputType;
use session_sharing_protocol::common::{
CLIAgentSessionState, InputMode, InputType as ProtocolInputType, SelectedAgentModel,
SelectedConversation, ServerConversationToken, UniversalDeveloperInputContextUpdate,
};
use warp_core::features::FeatureFlag;
use warpui::{AppContext, ModelHandle, SingletonEntity, WeakViewHandle};
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewEntryOrigin};
use crate::ai::blocklist::{BlocklistAIContextModel, BlocklistAIHistoryModel, InputConfig};
use crate::ai::llms::{LLMId, LLMPreferences};
use crate::terminal::cli_agent_sessions::{
CLIAgentInputEntrypoint, CLIAgentInputState, CLIAgentRichInputCloseReason, CLIAgentSession,
CLIAgentSessionContext, CLIAgentSessionStatus, CLIAgentSessionsModel,
};
use crate::terminal::CLIAgent;
use crate::terminal::TerminalView;
/// Handles updating the local LLM preferences when a selected agent model update is received.
/// This function is shared between the viewer and sharer to ensure consistent behavior.
pub(crate) fn apply_selected_agent_model_update(
terminal_view_id: warpui::EntityId,
selected_model: &SelectedAgentModel,
_guard: &ActiveRemoteUpdate,
ctx: &mut AppContext,
) {
let model_id = LLMId::from(selected_model.model_id().to_owned());
// Check if this is already our current model - if so, skip the update to avoid loops
let llm_prefs = LLMPreferences::as_ref(ctx);
let current_model_id = llm_prefs
.get_active_base_model(ctx, Some(terminal_view_id))
.id
.clone();
if current_model_id == model_id {
return;
}
// Check if the model is available to the viewer. If not, skip the update.
// This handles cases where the viewer and sharer have different model permissions.
let model_is_available = llm_prefs
.get_base_llm_choices_for_agent_mode()
.any(|info| info.id == model_id);
if !model_is_available {
log::warn!("Skipping shared-session model update - {model_id} is unknown");
return;
}
log::info!("Selecting base agent model {model_id} (from session sharing update)");
// Update the local LLMPreferences to match the selected model
LLMPreferences::handle(ctx).update(ctx, |prefs, ctx| {
prefs.update_preferred_agent_mode_llm(&model_id, terminal_view_id, ctx);
});
}
/// Handles updating the local input mode when an input mode update is received.
/// This function is shared between the viewer and sharer to ensure consistent behavior.
pub(crate) fn apply_input_mode_update(
weak_view_handle: &WeakViewHandle<TerminalView>,
input_mode: &InputMode,
_guard: &ActiveRemoteUpdate,
ctx: &mut AppContext,
) {
let Some(view) = weak_view_handle.upgrade(ctx) else {
return;
};
// When AgentView is enabled, we only apply input mode updates when in an active agent view.
// Outside of agent view, input mode changes are not relevant.
if FeatureFlag::AgentView.is_enabled() {
let agent_view_controller = view.as_ref(ctx).agent_view_controller().clone();
if !agent_view_controller.as_ref(ctx).is_active() {
return;
}
}
let client_input_type = match input_mode.input_type {
ProtocolInputType::Shell => InputType::Shell,
ProtocolInputType::AI => InputType::AI,
};
let new_config = InputConfig {
input_type: client_input_type,
is_locked: input_mode.is_locked,
};
// Skip update if nothing would change
let current_config = view.as_ref(ctx).input_config(ctx);
if current_config == new_config {
return;
}
view.update(ctx, |terminal_view, ctx| {
terminal_view.apply_external_input_mode_update(new_config, ctx);
});
}
/// Handles updating the local auto-approve setting when an update is received.
/// This function is shared between the viewer and sharer to ensure consistent behavior.
pub(crate) fn apply_auto_approve_agent_actions_update(
weak_view_handle: &WeakViewHandle<TerminalView>,
auto_approve: bool,
_guard: &ActiveRemoteUpdate,
ctx: &mut AppContext,
) {
let Some(view) = weak_view_handle.upgrade(ctx) else {
return;
};
view.update(ctx, |view, ctx| {
let ai_context_model = view.ai_context_model().clone();
ai_context_model.update(ctx, |context_model, ctx| {
let current_mode = context_model.pending_query_autoexecute_override(ctx);
let is_on = current_mode.is_autoexecute_any_action();
// Skip if we're already in the desired state to avoid feedback loops.
if is_on == auto_approve {
return;
}
context_model.toggle_pending_query_autoexecute(ctx);
});
});
}
/// Handles updating the local selected conversation when a selected conversation update is received.
/// This function is shared between the viewer and sharer to ensure consistent behavior.
pub(crate) fn apply_selected_conversation_update(
weak_view_handle: &WeakViewHandle<TerminalView>,
selected_conversation: &SelectedConversation,
_guard: &ActiveRemoteUpdate,
ctx: &mut AppContext,
) {
let Some(view) = weak_view_handle.upgrade(ctx) else {
return;
};
// In shared ambient agent sessions, we can temporarily receive "none/new" selected_conversation
// updates (e.g. before a server conversation token exists).
//
// If we already have an active local conversation selected (typically created/selected by the
// incoming shared-session init event), treating these updates as authoritative can create an
// extra empty conversation on the viewer.
//
// To avoid that, ignore "none/new" updates once there is already an active *empty* conversation.
if view.as_ref(ctx).is_shared_ambient_agent_session()
&& matches!(
selected_conversation,
SelectedConversation::NewConversation | SelectedConversation::NoConversation
)
{
let active_conversation_id = if FeatureFlag::AgentView.is_enabled() {
view.as_ref(ctx)
.agent_view_controller()
.as_ref(ctx)
.agent_view_state()
.active_conversation_id()
} else {
view.as_ref(ctx)
.ai_context_model()
.as_ref(ctx)
.selected_conversation_id(ctx)
};
let history_model = BlocklistAIHistoryModel::handle(ctx);
let has_empty_active_conversation = active_conversation_id
.as_ref()
.and_then(|conversation_id| history_model.as_ref(ctx).conversation(conversation_id))
.is_some_and(|c| c.exchange_count() == 0);
if has_empty_active_conversation {
return;
}
}
match selected_conversation {
SelectedConversation::ExistingConversation(server_conversation_token) => {
// Convert server token to local conversation ID using the AI controller
let ai_controller = view.as_ref(ctx).ai_controller().clone();
let conversation_id = ai_controller.update(ctx, |controller, ctx| {
controller.find_existing_conversation_by_server_token(
&server_conversation_token.as_uuid().to_string(),
ctx,
)
});
if let Some(conversation_id) = conversation_id {
// Update the context model with the selected conversation
view.update(ctx, |view, ctx| {
view.ai_context_model().update(ctx, |context_model, ctx| {
// Only update if different to avoid feedback loop
if context_model.selected_conversation_id(ctx) != Some(conversation_id) {
context_model.set_pending_query_state_for_existing_conversation(
conversation_id,
AgentViewEntryOrigin::SharedSessionSelection,
ctx,
);
}
});
});
}
}
SelectedConversation::NewConversation => {
// Start new conversation in agent view
let agent_view_controller = view.as_ref(ctx).agent_view_controller().clone();
view.update(ctx, |view, ctx| {
view.ai_context_model().update(ctx, |context_model, ctx| {
if FeatureFlag::AgentView.is_enabled() {
// Check if we're already in an empty agent view to avoid feedback loop.
let agent_view_state = agent_view_controller.as_ref(ctx).agent_view_state();
if let Some(conversation_id) = agent_view_state.active_conversation_id() {
let history_model = BlocklistAIHistoryModel::handle(ctx);
let is_empty = history_model
.as_ref(ctx)
.conversation(&conversation_id)
.is_none_or(|c| c.exchange_count() == 0);
if is_empty {
// Already in an empty agent view - no need to start another new one
return;
}
}
} else {
// Check if state is already None to avoid feedback loop
if context_model.selected_conversation_id(ctx).is_none() {
return;
}
}
context_model.set_pending_query_state_for_new_conversation(
AgentViewEntryOrigin::SharedSessionSelection,
ctx,
);
});
});
}
SelectedConversation::NoConversation => {
let agent_view_controller = view.as_ref(ctx).agent_view_controller().clone();
view.update(ctx, |view, ctx| {
view.ai_context_model().update(ctx, |context_model, ctx| {
if FeatureFlag::AgentView.is_enabled() {
// Only exit if currently in agent view to avoid feedback loop
if agent_view_controller.as_ref(ctx).is_active() {
agent_view_controller.update(ctx, |controller, ctx| {
controller.exit_agent_view(ctx);
});
}
} else {
// For non-agent view users, we treat NoConversation the same as new conversation.
if context_model.selected_conversation_id(ctx).is_some() {
context_model.set_pending_query_state_for_new_conversation(
AgentViewEntryOrigin::SharedSessionSelection,
ctx,
);
}
}
});
});
}
}
}
/// Build a selected_conversation update based on the current view state.
/// Routes to the appropriate implementation based on whether AgentView is enabled.
/// Returns None if the update should not be sent (e.g., selected conversation has no server token yet).
pub(crate) fn build_selected_conversation_update(
agent_view_controller: &ModelHandle<AgentViewController>,
context_model: &ModelHandle<BlocklistAIContextModel>,
ctx: &mut AppContext,
) -> Option<UniversalDeveloperInputContextUpdate> {
if FeatureFlag::AgentView.is_enabled() {
build_selected_conversation_update_agent_view_enabled(
agent_view_controller,
&BlocklistAIHistoryModel::handle(ctx),
ctx,
)
} else {
build_selected_conversation_update_agent_view_disabled(
context_model,
&BlocklistAIHistoryModel::handle(ctx),
ctx,
)
}
}
fn build_selected_conversation_update_agent_view_disabled(
ai_context_model: &ModelHandle<BlocklistAIContextModel>,
history_model: &ModelHandle<BlocklistAIHistoryModel>,
ctx: &mut AppContext,
) -> Option<UniversalDeveloperInputContextUpdate> {
let selected_conversation_id = ai_context_model.as_ref(ctx).selected_conversation_id(ctx);
let server_token_opt: Option<ServerConversationToken> =
selected_conversation_id.and_then(|conversation_id| {
history_model
.as_ref(ctx)
.conversation(&conversation_id)
.and_then(|conversation| conversation.server_conversation_token().cloned())
.and_then(|token| token.try_into().ok())
});
// Only send update if starting new (None) or token is present
let should_send = selected_conversation_id.is_none() || server_token_opt.is_some();
if !should_send {
return None;
}
Some(UniversalDeveloperInputContextUpdate {
selected_conversation: Some(SelectedConversation::new(server_token_opt)),
..Default::default()
})
}
fn build_selected_conversation_update_agent_view_enabled(
agent_view_controller: &ModelHandle<AgentViewController>,
history_model: &ModelHandle<BlocklistAIHistoryModel>,
ctx: &mut AppContext,
) -> Option<UniversalDeveloperInputContextUpdate> {
let agent_view_state = agent_view_controller.as_ref(ctx).agent_view_state();
let selected_conversation = if !agent_view_state.is_active() {
SelectedConversation::NoConversation
} else if let Some(conversation_id) = agent_view_state.active_conversation_id() {
let conversation = history_model.as_ref(ctx).conversation(&conversation_id);
let server_token_opt = conversation
.and_then(|c| c.server_conversation_token().cloned())
.and_then(|token| token.try_into().ok());
if let Some(server_token) = server_token_opt {
SelectedConversation::ExistingConversation(server_token)
} else {
// If the conversation has content but no token yet, skip this update. Otherwise we'd send
// NewConversation now and ExistingConversation moments later when the token
// arrives, causing the second update to sometimes be overwritten by an echo of the first update
// (and leading to a weird state where the viewer sends a query and is then briefly entered into an empty agent view).
let is_empty = conversation.is_none_or(|c| c.exchange_count() == 0);
if is_empty {
SelectedConversation::NewConversation
} else {
return None;
}
}
} else {
SelectedConversation::NewConversation
};
Some(UniversalDeveloperInputContextUpdate {
selected_conversation: Some(selected_conversation),
..Default::default()
})
}
/// Applies CLI agent session + rich-input state from the remote side.
/// Creates/removes the session and opens/closes rich input based on
/// the given `CLIAgentSessionState`.
pub(crate) fn apply_cli_agent_state_update(
weak_view_handle: &WeakViewHandle<TerminalView>,
cli_agent_session: &CLIAgentSessionState,
_guard: &ActiveRemoteUpdate,
ctx: &mut AppContext,
) {
let Some(view) = weak_view_handle.upgrade(ctx) else {
return;
};
let view_id = view.id();
match cli_agent_session {
CLIAgentSessionState::Active {
cli_agent,
is_rich_input_open,
} => {
let agent = CLIAgent::from_serialized_name(cli_agent);
// Create the agent session if it does not exist.
let already_exists = CLIAgentSessionsModel::as_ref(ctx)
.session(view_id)
.is_some_and(|s| s.agent == agent);
if !already_exists {
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions_model, ctx| {
sessions_model.set_session(
view_id,
CLIAgentSession {
agent,
status: CLIAgentSessionStatus::InProgress,
session_context: CLIAgentSessionContext::default(),
input_state: CLIAgentInputState::Closed,
listener: None,
plugin_version: None,
remote_host: None,
draft_text: None,
custom_command_prefix: None,
// Viewer input is managed by the sync protocol,
// not local status-change auto-toggle.
should_auto_toggle_input: false,
},
ctx,
);
});
view.update(ctx, |view, ctx| {
view.apply_cli_agent_footer_visibility(true, ctx);
});
}
// Update the rich input state.
let currently_open = CLIAgentSessionsModel::as_ref(ctx).is_input_open(view_id);
if currently_open != *is_rich_input_open {
view.update(ctx, |view, ctx| {
if *is_rich_input_open {
view.open_cli_agent_rich_input(
CLIAgentInputEntrypoint::SharedSessionSync,
ctx,
);
} else {
view.close_cli_agent_rich_input(CLIAgentRichInputCloseReason::Other, ctx);
}
});
}
}
CLIAgentSessionState::Inactive => {
// Session cleanup is handled by BlockCompleted events on the
// viewer side, so no explicit teardown is needed here.
}
}
}
// ---------------------------------------------------------------------------
// Echo-suppression for remote session-sharing context updates.
//
// When a participant (viewer or sharer) receives a
// `UniversalDeveloperInputContextUpdate` from the remote side, the `apply_*`
// helpers above update local state which fires model events. Those events are
// observed by broadcast subscribers that would normally send the value *back*
// over the network, creating an echo loop.
//
// To prevent this, each side creates a `RemoteUpdateGuard` and:
// 1. Clones it into every broadcast subscriber, which calls
// `guard.should_broadcast()` and skips when `false`.
// 2. Wraps incoming `apply_*` calls with `guard.start_remote_update()`,
// which returns an `ActiveRemoteUpdate` RAII token that suppresses
// broadcasts for the duration of the synchronous update.
//
// When adding a **new** field to `UniversalDeveloperInputContextUpdate`:
// - Check `guard.should_broadcast()` in the new broadcast subscriber.
// - Ensure the new `apply_*` call sits inside the existing
// `ActiveRemoteUpdate` scope in the incoming handler.
// ---------------------------------------------------------------------------
/// Shared guard that tracks whether we are currently applying a remote
/// session-sharing context update.
#[derive(Clone)]
pub(crate) struct RemoteUpdateGuard {
inner: Rc<Cell<bool>>,
}
impl RemoteUpdateGuard {
/// Creates a new guard, initially not suppressing broadcasts.
pub(crate) fn new() -> Self {
Self {
inner: Rc::new(Cell::new(false)),
}
}
/// Returns `true` when a context update originated locally and should be
/// broadcast to the remote side. Returns `false` when we are in the middle
/// of applying a remote update (i.e. the echo should be suppressed).
pub(crate) fn should_broadcast(&self) -> bool {
!self.inner.get()
}
/// Returns an RAII token that suppresses outgoing broadcasts until dropped.
/// Wrap all `apply_*` calls for incoming remote updates in this so that
/// the synchronous event dispatch sees the guard as active.
pub(crate) fn start_remote_update(&self) -> ActiveRemoteUpdate {
debug_assert!(
!self.inner.get(),
"RemoteUpdateGuard::start_remote_update called while already active"
);
self.inner.set(true);
ActiveRemoteUpdate {
inner: self.inner.clone(),
}
}
}
/// RAII token that suppresses outgoing broadcasts while held.
pub(crate) struct ActiveRemoteUpdate {
inner: Rc<Cell<bool>>,
}
impl Drop for ActiveRemoteUpdate {
fn drop(&mut self) {
self.inner.set(false);
}
}
@@ -0,0 +1,2 @@
//! The sharer is the client that initiates the shared session.
pub(crate) mod network;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,717 @@
use std::sync::Arc;
use async_channel::Sender;
use futures_util::stream::AbortHandle;
use instant::Instant;
use parking_lot::FairMutex;
use session_sharing_protocol::{
common::{
ActivePrompt, OrderedTerminalEvent, OrderedTerminalEventType, ParticipantId, Selection,
SessionId,
},
sharer::{DownstreamMessage, ReconnectToken, UpstreamMessage},
};
use warpui::{App, ModelHandle};
use websocket::{Message, WebsocketMessage as _};
use crate::{
auth::{auth_manager::AuthManager, AuthStateProvider},
editor::ReplicaId,
server::{
server_api::ServerApiProvider, telemetry::context_provider::AppTelemetryContextProvider,
},
terminal::{
shared_session::{SharedSessionScrollbackType, MAX_BYTES_SHAREABLE},
TerminalModel,
},
test_util::assert_eventually,
};
use super::{Network, PtyBytesBatchStatus, Stage};
fn is_upstream_message_pty_bytes_read(
message: UpstreamMessage,
expected_event_no: usize,
expected_bytes: Vec<u8>,
) -> bool {
let compressed_bytes = lz4_flex::block::compress_prepend_size(&expected_bytes);
matches!(message, UpstreamMessage::OrderedTerminalEvent(OrderedTerminalEvent {
event_no,
event_type: OrderedTerminalEventType::PtyBytesRead { bytes },
}) if event_no == expected_event_no && bytes == compressed_bytes)
}
fn is_upstream_message_command_executed(
message: &UpstreamMessage,
expected_event_no: usize,
) -> bool {
matches!(message, UpstreamMessage::OrderedTerminalEvent(OrderedTerminalEvent {
event_no,
event_type: OrderedTerminalEventType::CommandExecutionStarted { .. },
}) if *event_no == expected_event_no)
}
fn create_network(
app: &mut App,
session_initialized: bool,
) -> (ModelHandle<Network>, Sender<OrderedTerminalEventType>) {
let (ordered_events_tx, ordered_events_rx) = async_channel::unbounded();
let scrollback_type = SharedSessionScrollbackType::None;
let active_prompt = ActivePrompt::default();
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
let network = app.add_model(|ctx| {
Network::new_for_test(
terminal_model,
ordered_events_rx,
scrollback_type,
active_prompt,
Selection::None,
ReplicaId::random(),
ctx,
)
});
if session_initialized {
network.update(app, |network, _| {
network.stage = Stage::StartedSuccessfully;
});
}
(network, ordered_events_tx)
}
#[test]
fn test_send_ordered_terminal_event_message_advances_event_no() {
App::test((), |mut app| async move {
let network = create_network(&mut app, true).0;
// Make sure the event no starts at 0.
network.read(&app, |network, _ctx| {
assert_eq!(usize::from(network.event_no), 0);
});
// Try to send an ordered terminal event message to the server.
let event = OrderedTerminalEventType::PtyBytesRead { bytes: "a".into() };
network.update(&mut app, |network, _| {
network.send_ordered_terminal_event_message(event);
});
// The event no should be 1 now.
network.read(&app, |network, _ctx| {
assert_eq!(usize::from(network.event_no), 1);
});
});
}
#[test]
fn test_send_ordered_terminal_event_message_max_reached() {
App::test((), |mut app| async move {
let network = create_network(&mut app, true).0;
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
// Make sure the ws_proxy_tx is open.
let ws_proxy_tx = network.read(&app, |network, _ctx| network.ws_proxy_tx.clone());
assert!(!ws_proxy_tx.is_closed());
// Try to send an ordered terminal event that would exceed the max bytes allowed limit.
let overflow_event = OrderedTerminalEventType::PtyBytesRead {
bytes: "a".repeat(MAX_BYTES_SHAREABLE + 1).into(),
};
network.update(&mut app, |network, _| {
network.send_ordered_terminal_event_message(overflow_event);
});
// Make sure the item we put on the ws_proxy_tx was correct.
assert_eq!(ws_proxy_rx.len(), 1);
let item = ws_proxy_rx.recv().await;
assert!(matches!(item.unwrap(), UpstreamMessage::EndSession { .. }));
// Make sure the ws_proxy_tx is closed and nothing was sent.
assert!(ws_proxy_tx.is_closed());
});
}
#[test]
fn test_send_pty_read_event_while_batching() {
App::test((), |mut app| async move {
let network = create_network(&mut app, true).0;
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
let init_time = Instant::now();
// Set the batch status to batching.
network.update(&mut app, |network, _ctx| {
network.pty_bytes_batch_status = PtyBytesBatchStatus::Batching {
accumulated: "a".into(),
abort_handle: AbortHandle::new_pair().0,
};
});
// Try to send a PtyBytesRead message to the server.
network.update(&mut app, |network, _| {
network.send_pty_bytes_read_message();
});
// Make sure the item we put on the ws_proxy_tx was correct.
let item = ws_proxy_rx.recv().await;
assert!(is_upstream_message_pty_bytes_read(
item.unwrap(),
0,
"a".into()
));
// The batch status should be NotBatching now and the last_sent_at should be updated.
network.read(&app, |network, _ctx| {
assert!(matches!(network.pty_bytes_batch_status, PtyBytesBatchStatus::NotBatching { last_sent_at } if last_sent_at > init_time ));
});
});
}
#[test]
fn test_send_pty_read_event_while_not_batching() {
App::test((), |mut app| async move {
let network = create_network(&mut app, true).0;
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
let init_time = Instant::now();
// Set the batch status to not batching.
network.update(&mut app, |network, _ctx| {
network.pty_bytes_batch_status = PtyBytesBatchStatus::NotBatching {
last_sent_at: init_time,
}
});
// Try to send a PtyBytesRead message to the server.
network.update(&mut app, |network, _| {
network.send_pty_bytes_read_message();
});
// Make sure we didn't try to send anything to the server..
assert_eq!(ws_proxy_rx.len(), 0);
// The batch status should be unchanged.
network.read(&app, |network, _ctx| {
assert!(matches!(network.pty_bytes_batch_status, PtyBytesBatchStatus::NotBatching { last_sent_at } if last_sent_at == init_time));
});
});
}
#[test]
fn test_handle_pty_read_event_while_batching() {
App::test((), |mut app| async move {
let (network, ordered_events_tx) = create_network(&mut app, true);
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
let init_time = Instant::now();
// Set the batch status to batching.
network.update(&mut app, |network, _ctx| {
network.pty_bytes_batch_status = PtyBytesBatchStatus::Batching {
accumulated: "a".into(),
abort_handle: AbortHandle::new_pair().0,
};
});
// Send a PtyBytesRead event to the Network model.
let event = OrderedTerminalEventType::PtyBytesRead { bytes: "a".into() };
ordered_events_tx
.try_send(event)
.expect("Can send event over ordered_events_tx");
// The batching status should reflect the accumulated bytes.
assert_eventually!(
network.read(&app, |network, _ctx| {
matches!(&network.pty_bytes_batch_status, PtyBytesBatchStatus::Batching { accumulated, .. } if accumulated == b"aa" )
}), "Batching status should reflect accumulated bytes"
);
// Technically, we didn't start a task to send the event to the server after a timer. So let's do it manually.
network.update(&mut app, |network, _| {
network.send_pty_bytes_read_message();
});
// Eventually, the accumulated event should be sent to the server.
assert_eq!(ws_proxy_rx.len(), 1);
let item = ws_proxy_rx.recv().await;
assert!(is_upstream_message_pty_bytes_read(
item.unwrap(),
0,
"aa".into()
));
// The batching status should be reset.
network.read(&app, |network, _ctx| {
assert!(matches!(network.pty_bytes_batch_status, PtyBytesBatchStatus::NotBatching { last_sent_at } if last_sent_at > init_time));
});
})
}
#[test]
fn test_handle_pty_read_event_while_not_batching() {
App::test((), |mut app| async move {
let (network, ordered_events_tx) = create_network(&mut app, true);
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
let init_time = Instant::now();
// Set the batch status to not batching.
network.update(&mut app, |network, _ctx| {
network.pty_bytes_batch_status = PtyBytesBatchStatus::NotBatching {
last_sent_at: init_time,
}
});
// Send a PtyBytesRead event to the Network model.
let event = OrderedTerminalEventType::PtyBytesRead { bytes: "a".into() };
ordered_events_tx
.try_send(event)
.expect("Can send event over ordered_events_tx");
assert_eventually!(
network.read(&app, |network, _ctx| {
matches!(&network.pty_bytes_batch_status, PtyBytesBatchStatus::Batching { accumulated, .. } if accumulated == b"a" )
}),
"Batching status should be batching"
);
// When the timer is done, the accumulated event should be sent to the server.
assert_eventually!(
ws_proxy_rx.len() == 1,
"Accumulated event should be sent to the server"
);
let item = ws_proxy_rx.recv().await;
assert!(is_upstream_message_pty_bytes_read(
item.unwrap(),
0,
"a".into()
));
// The batching status should be reset.
network.read(&app, |network, _ctx| {
assert!(matches!(network.pty_bytes_batch_status, PtyBytesBatchStatus::NotBatching { last_sent_at } if last_sent_at > init_time));
});
});
}
#[test]
fn test_handle_non_pty_read_event_while_batching() {
App::test((), |mut app| async move {
let (network, ordered_events_tx) = create_network(&mut app, true);
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
let init_time = Instant::now();
// Set the batch status to batching.
network.update(&mut app, |network, _ctx| {
network.pty_bytes_batch_status = PtyBytesBatchStatus::Batching {
accumulated: "a".into(),
abort_handle: AbortHandle::new_pair().0,
};
});
// Send a non PtyBytesRead event to the Network model.
let event = OrderedTerminalEventType::CommandExecutionStarted {
participant_id: Default::default(),
ai_metadata: None,
};
ordered_events_tx
.try_send(event)
.expect("Can send event over ordered_events_tx");
assert_eventually!(
ws_proxy_rx.len() == 2,
"Two messages should be sent to the server; got {}",
ws_proxy_rx.len()
);
// Make sure that we flush the PtyBytesRead message first.
let item = ws_proxy_rx.recv().await;
assert!(is_upstream_message_pty_bytes_read(
item.unwrap(),
0,
"a".into()
));
// And that the non PtyBytesRead message follows suit.
let item = ws_proxy_rx.recv().await;
assert!(is_upstream_message_command_executed(&item.unwrap(), 1));
// The batching status should be reset.
network.read(&app, |network, _ctx| {
assert!(matches!(network.pty_bytes_batch_status, PtyBytesBatchStatus::NotBatching { last_sent_at } if last_sent_at > init_time));
})
})
}
#[test]
fn test_handle_non_pty_read_event_while_not_batching() {
App::test((), |mut app| async move {
let (network, ordered_events_tx) = create_network(&mut app, true);
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
let init_time = Instant::now();
// Set the batch status to not batching.
network.update(&mut app, |network, _ctx| {
network.pty_bytes_batch_status = PtyBytesBatchStatus::NotBatching {
last_sent_at: init_time,
}
});
// Send a non PtyBytesRead event to the Network model.
let event = OrderedTerminalEventType::CommandExecutionStarted {
participant_id: Default::default(),
ai_metadata: None,
};
ordered_events_tx
.try_send(event)
.expect("Can send event over ordered_events_tx");
assert_eventually!(
ws_proxy_rx.len() == 1,
"One message should be sent to the server; got {}",
ws_proxy_rx.len()
);
let item = ws_proxy_rx.recv().await;
assert!(is_upstream_message_command_executed(&item.unwrap(), 0));
// The batching status should be unchanged.
network.read(&app, |network, _ctx| {
assert!(matches!(network.pty_bytes_batch_status, PtyBytesBatchStatus::NotBatching { last_sent_at } if last_sent_at == init_time));
})
});
}
#[test]
fn test_ignore_duplicate_prompt_updates() {
App::test((), |mut app| async move {
let (network, _) = create_network(&mut app, true);
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
assert_eq!(ws_proxy_rx.len(), 0);
// First prompt update should go through.
network.update(&mut app, |network, _ctx| {
network.send_active_prompt_update_if_changed(ActivePrompt::WarpPrompt(
"test warp prompt".to_owned(),
));
});
assert_eq!(ws_proxy_rx.len(), 1);
// Duplicate prompt updates should be ignored.
network.update(&mut app, |network, _ctx| {
network.send_active_prompt_update_if_changed(ActivePrompt::WarpPrompt(
"test warp prompt".to_owned(),
));
});
assert_eq!(ws_proxy_rx.len(), 1);
network.update(&mut app, |network, _ctx| {
network.send_active_prompt_update_if_changed(ActivePrompt::WarpPrompt(
"test warp prompt".to_owned(),
));
});
assert_eq!(ws_proxy_rx.len(), 1);
// Different prompt should go through.
network.update(&mut app, |network, _ctx| {
network.send_active_prompt_update_if_changed(ActivePrompt::WarpPrompt(
"different warp prompt".to_owned(),
));
});
assert_eq!(ws_proxy_rx.len(), 2);
});
}
#[test]
fn test_selection_updates_throttled_and_duplicates_ignored() {
App::test((), |mut app| async move {
let (network, _) = create_network(&mut app, true);
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
assert_eq!(ws_proxy_rx.len(), 0);
// Rapid fire selection updates. Only the last should be sent up the websocket due to throttling.
network.update(&mut app, |network, _ctx| {
for i in 0..5 {
network.send_presence_selection_if_changed(Selection::Blocks {
block_ids: vec![format!("block{i}").to_string().into()],
});
}
});
// Only the very first and the last updates should go through, but not any of the intermediate ones.
assert_eventually!(
ws_proxy_rx.len() == 2,
"Selection updates should be throttled"
);
// Last sent block ID should be block4, and duplicate selection updates should be ignored.
network.update(&mut app, |network, _ctx| {
network.send_presence_selection_if_changed(Selection::Blocks {
block_ids: vec!["block4".to_string().into()],
});
});
assert_eventually!(
ws_proxy_rx.len() == 2,
"Duplicate selection updates should be ignored"
);
// Different selection update should go through.
network.update(&mut app, |network, _ctx| {
network.send_presence_selection_if_changed(Selection::None);
});
assert_eventually!(
ws_proxy_rx.len() == 3,
"Different selection updates should go through"
);
});
}
#[test]
fn test_messages_are_buffered_before_session_initialized() {
App::test((), |mut app| async move {
let (network, _) = create_network(&mut app, false);
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
// The network should start in the BeforeStarted state with no events.
assert_eq!(ws_proxy_rx.len(), 0);
network.read(&app, |network, _| {
assert!(matches!(&network.stage, Stage::BeforeStarted));
assert_eq!(network.unacked_terminal_events.len(), 0);
});
// Try to send a message to the server.
let event_type = OrderedTerminalEventType::CommandExecutionStarted {
participant_id: Default::default(),
ai_metadata: None,
};
let event = OrderedTerminalEvent {
event_no: 0,
event_type,
};
let message = UpstreamMessage::OrderedTerminalEvent(event);
network.update(&mut app, |network, _ctx| {
network.send_message_to_server(message)
});
// The message should not be sent to the server but should instead be buffered.
assert_eq!(ws_proxy_rx.len(), 0);
network.read(&app, |network, _| {
assert!(matches!(&network.stage, Stage::BeforeStarted));
assert!(is_upstream_message_command_executed(
&UpstreamMessage::OrderedTerminalEvent(
network.unacked_terminal_events.get(&0).unwrap().clone()
),
0
));
});
// Simulate receiving the SessionInitialized message from the server.
network.update(&mut app, |network, ctx| {
let downstream_message = DownstreamMessage::SessionInitialized {
session_id: SessionId::new(),
session_secret: Default::default(),
reconnect_token: ReconnectToken::new(),
sharer_id: ParticipantId::new(),
sharer_firebase_uid: "mock_firebase_uid".to_string(),
};
let serialized = downstream_message.to_json().unwrap();
network.process_websocket_message(Message::new(serialized), ctx);
});
// The message should be flushed to the server and the stage should be advanced.
// We should also re-send the active prompt.
assert_eq!(ws_proxy_rx.len(), 2);
let item = ws_proxy_rx.recv().await;
assert!(is_upstream_message_command_executed(&item.unwrap(), 0));
let item = ws_proxy_rx.recv().await;
matches!(item.unwrap(), UpstreamMessage::UpdateActivePrompt(_));
network.read(&app, |network, _| {
assert!(matches!(&network.stage, Stage::StartedSuccessfully));
});
});
}
#[test]
fn test_messages_are_buffered_while_reconnecting() {
App::test((), |mut app| async move {
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
app.add_singleton_model(AppTelemetryContextProvider::new_context_provider);
app.add_singleton_model(AuthManager::new_for_test);
let (network, _) = create_network(&mut app, false);
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
// The network should start in the BeforeStarted state with no events.
assert_eq!(ws_proxy_rx.len(), 0);
network.read(&app, |network, _| {
assert!(matches!(&network.stage, Stage::BeforeStarted));
assert_eq!(network.unacked_terminal_events.len(), 0);
});
// Simulate receiving the SessionInitialized message from the server.
network.update(&mut app, |network, ctx| {
let downstream_message = DownstreamMessage::SessionInitialized {
session_id: SessionId::new(),
session_secret: Default::default(),
reconnect_token: ReconnectToken::new(),
sharer_id: ParticipantId::new(),
sharer_firebase_uid: "mock_firebase_uid".to_string(),
};
let serialized = downstream_message.to_json().unwrap();
network.process_websocket_message(Message::new(serialized), ctx);
});
// We should have sent the latest prompt on connection.
assert_eq!(ws_proxy_rx.len(), 1);
let item = ws_proxy_rx.recv().await;
matches!(item.unwrap(), UpstreamMessage::UpdateActivePrompt(_));
// Simulate reconnecting to the server after server disconnects. Nothing we need to do in this test to disconnect first.
network.update(&mut app, |network, ctx| {
network.reconnect_websocket(ctx);
});
network.read(&app, |network, _| {
assert!(matches!(&network.stage, Stage::Reconnecting { .. }));
});
// Try to send a message to the server.
let event_type = OrderedTerminalEventType::CommandExecutionStarted {
participant_id: Default::default(),
ai_metadata: None,
};
let event = OrderedTerminalEvent {
event_no: 0,
event_type,
};
let message = UpstreamMessage::OrderedTerminalEvent(event);
network.update(&mut app, |network, _ctx| {
network.send_message_to_server(message)
});
// The message should not be sent to the server but should instead be stored.
assert_eq!(ws_proxy_rx.len(), 0);
network.read(&app, |network, _| {
assert!(matches!(&network.stage, Stage::Reconnecting { .. }));
assert_eq!(network.unacked_terminal_events.len(), 1);
assert!(is_upstream_message_command_executed(
&UpstreamMessage::OrderedTerminalEvent(
network.unacked_terminal_events.get(&0).unwrap().clone()
),
0
));
});
// Simulate receiving the SessionReconnected message from the server.
network.update(&mut app, |network, ctx| {
let downstream_message = DownstreamMessage::SessionReconnected {
last_received_event_no: None,
participant_list: Default::default(),
};
let serialized = downstream_message.to_json().unwrap();
network.process_websocket_message(Message::new(serialized), ctx);
});
// The message should be flushed to the server and the stage should be advanced.
// We should also re-send the active prompt.
assert_eq!(ws_proxy_rx.len(), 2);
let item = ws_proxy_rx.recv().await;
assert!(is_upstream_message_command_executed(&item.unwrap(), 0));
let item = ws_proxy_rx.recv().await;
matches!(item.unwrap(), UpstreamMessage::UpdateActivePrompt(_));
network.read(&app, |network, _| {
assert!(matches!(&network.stage, Stage::StartedSuccessfully));
});
});
}
#[test]
fn test_events_are_saved_on_send_and_removed_on_ack() {
App::test((), |mut app| async move {
let (network, _) = create_network(&mut app, false);
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
// Simulate receiving the SessionInitialized message from the server.
network.update(&mut app, |network, ctx| {
let downstream_message = DownstreamMessage::SessionInitialized {
session_id: SessionId::new(),
session_secret: Default::default(),
reconnect_token: ReconnectToken::new(),
sharer_id: ParticipantId::new(),
sharer_firebase_uid: "mock_firebase_uid".to_string(),
};
let serialized = downstream_message.to_json().unwrap();
network.process_websocket_message(Message::new(serialized), ctx);
});
// We should have sent the latest prompt on connection.
assert_eq!(ws_proxy_rx.len(), 1);
let item = ws_proxy_rx.recv().await;
matches!(item.unwrap(), UpstreamMessage::UpdateActivePrompt(_));
// Try to send a couple messages to the server.
let event_type = OrderedTerminalEventType::CommandExecutionStarted {
participant_id: Default::default(),
ai_metadata: None,
};
let event = OrderedTerminalEvent {
event_no: 0,
event_type,
};
let message = UpstreamMessage::OrderedTerminalEvent(event);
network.update(&mut app, |network, _ctx| {
network.send_message_to_server(message)
});
let event_type = OrderedTerminalEventType::CommandExecutionStarted {
participant_id: Default::default(),
ai_metadata: None,
};
let event = OrderedTerminalEvent {
event_no: 1,
event_type,
};
let message = UpstreamMessage::OrderedTerminalEvent(event);
network.update(&mut app, |network, _ctx| {
network.send_message_to_server(message)
});
// The messages should be both sent and stored.
assert_eq!(ws_proxy_rx.len(), 2);
let item = ws_proxy_rx.recv().await;
assert!(is_upstream_message_command_executed(&item.unwrap(), 0));
let item = ws_proxy_rx.recv().await;
assert!(is_upstream_message_command_executed(&item.unwrap(), 1));
network.read(&app, |network, _| {
assert_eq!(network.unacked_terminal_events.len(), 2);
assert!(is_upstream_message_command_executed(
&UpstreamMessage::OrderedTerminalEvent(
network.unacked_terminal_events.get(&0).unwrap().clone()
),
0
));
assert!(is_upstream_message_command_executed(
&UpstreamMessage::OrderedTerminalEvent(
network.unacked_terminal_events.get(&1).unwrap().clone()
),
1
));
});
// Simulate receiving the EventsProcessedAck message from the server.
network.update(
&mut app,
|network, ctx: &mut warpui::ModelContext<'_, Network>| {
let downstream_message = DownstreamMessage::EventsProcessedAck {
latest_processed_event_no: 1,
};
let serialized = downstream_message.to_json().unwrap();
network.process_websocket_message(Message::new(serialized), ctx);
},
);
// Both messages should be removed from the stored events to free up memory.
network.read(&app, |network, _| {
assert_eq!(network.unacked_terminal_events.len(), 0);
});
});
}
@@ -0,0 +1,333 @@
use crate::ai::agent::AIAgentActionId;
use crate::ai::blocklist::block::cli_controller::LongRunningCommandControlState;
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
use crate::features::FeatureFlag;
use crate::terminal::model::block::AgentInteractionMetadata;
use parking_lot::FairMutex;
use session_sharing_protocol::common::{
OrderedTerminalEvent, OrderedTerminalEventType, Scrollback, WindowSize,
};
use std::io::{sink, Sink};
use std::sync::Arc;
use warpui::{Entity, ModelContext, SingletonEntity, WeakViewHandle};
use crate::terminal::event_listener::ChannelEventListener;
use crate::terminal::model::ansi::{self};
use crate::terminal::shared_session::ai_agent::decode_agent_response_event;
use crate::terminal::shared_session::{decode_scrollback, SharedSessionStatus};
use crate::terminal::{TerminalModel, TerminalView};
use std::collections::HashMap;
/// If we end up buffering more than this many events,
/// this is an indication that we're too far ahead and
/// could indicate an issue.
const TOO_MANY_BUFFERED_EVENTS: usize = 50;
/// The event loop is used to process a stream of events
/// originating from the sender.
pub struct EventLoop {
terminal_model: Arc<FairMutex<TerminalModel>>,
/// We need a reference to the view in the event loop
/// to ensure that any events which require updating the
/// view and model happen in lockstep. For example,
/// resize requires updating the view and model.
/// If we just dispatched an event, we could potentially
/// have other [`OrderedTerminalEvent`]s race which would
/// break the invariant of the event loop.
#[allow(dead_code)]
terminal_view: WeakViewHandle<TerminalView>,
parser: ansi::Processor,
/// We use a sink as a no-op writer to swallow any writes when the ansi handler needs
/// to write back to the PTY after reading (e.g. to identify itself).
/// We assume that the sharer will perform these write-backs.
sink: Sink,
channel_event_listener: ChannelEventListener,
/// The next event number we need from the server.
next_event_no: usize,
/// The latest event no of the session the viewer needs to catch up to, at the time of joining.
catching_up_to_event_no: Option<usize>,
/// A buffer to maintain events we receive from the server that are unordered.
buffer: HashMap<usize, OrderedTerminalEventType>,
}
impl EventLoop {
#[allow(clippy::too_many_arguments)]
pub fn new(
terminal_model: Arc<FairMutex<TerminalModel>>,
terminal_view: WeakViewHandle<TerminalView>,
channel_event_listener: ChannelEventListener,
window_size: WindowSize,
scrollback: Scrollback,
catching_up_to_event_no: Option<usize>,
ctx: &mut ModelContext<Self>,
) -> Self {
let scrollback_blocks = decode_scrollback(&scrollback);
let is_alt_screen_active = scrollback.is_alt_screen_active;
terminal_model
.lock()
.load_shared_session_scrollback(scrollback_blocks.as_slice(), is_alt_screen_active);
// When we load scrollback, we might not actually complete a block (e.g. shared session started
// without any scrollback except active block). In this case, we want to make sure the input
// is aware of what the latest block ID is.
if let Some(terminal_view) = terminal_view.upgrade(ctx) {
terminal_view.update(ctx, |terminal_view, ctx| {
terminal_view.input().update(ctx, |input, ctx| {
input.refresh_deferred_remote_operations(ctx);
});
});
}
if catching_up_to_event_no.is_none() {
terminal_model
.lock()
.set_shared_session_status(SharedSessionStatus::ActiveViewer {
role: Default::default(),
});
}
let mut event_loop = Self {
terminal_model,
terminal_view,
parser: ansi::Processor::new(),
sink: sink(),
channel_event_listener,
// Eventually once we have pagination, the server might need to tell us this.
next_event_no: 0,
buffer: HashMap::new(),
catching_up_to_event_no,
};
// Respect the sharer's window size.
event_loop.process_resize_event(window_size, ctx);
event_loop
}
fn process_resize_event(&mut self, new_window_size: WindowSize, ctx: &mut ModelContext<Self>) {
if let Some(view) = self.terminal_view.upgrade(ctx) {
view.update(ctx, |view, ctx| {
view.resize_from_sharer_update(new_window_size, ctx);
});
}
}
/// Returns None if we haven't received any events yet.
pub fn last_received_event_no(&self) -> Option<usize> {
if self.next_event_no == 0 {
return None;
}
Some(self.next_event_no - 1)
}
pub fn process_ordered_terminal_event(
&mut self,
event: OrderedTerminalEvent,
ctx: &mut ModelContext<Self>,
) {
// Add the event to the buffer.
self.buffer.insert(event.event_no, event.event_type);
// If we get too far ahead, let's log a warning for better debugging.
if self.buffer.len() >= TOO_MANY_BUFFERED_EVENTS {
log::warn!(
"Viewer is more than {TOO_MANY_BUFFERED_EVENTS} events ahead of next_event_no"
);
}
// Flush out as many contiguous events as we can.
while let Some(next_event) = self.buffer.remove(&self.next_event_no) {
match next_event {
OrderedTerminalEventType::PtyBytesRead { bytes } => {
let mut model = self.terminal_model.lock();
let decompressed = lz4_flex::block::decompress_size_prepended(&bytes)
.expect("Should be able to decompress the PtyBytesRead event");
self.parser
.parse_bytes(&mut *model, &decompressed, &mut self.sink);
}
OrderedTerminalEventType::CommandExecutionStarted {
participant_id,
ai_metadata,
} => {
// When a non-agent command starts, clear the loading state and input buffer.
// We don't clear for agent commands because the viewer may be typing a follow-up.
if ai_metadata.is_none() {
if let Some(view) = self.terminal_view.upgrade(ctx) {
view.update(ctx, |view, ctx| {
view.input().update(ctx, |input, ctx| {
input.unfreeze_and_clear_agent_input(ctx);
});
});
}
}
// If we have AI metadata, map the tool_call_id back to the owning conversation
let reconstructed_ai_metadata = ai_metadata.and_then(|ai_metadata| {
let action_id: AIAgentActionId = ai_metadata.tool_call_id.into();
// Try to map the action back to its owning conversation.
let Some(conversation_id) =
self.terminal_view.upgrade(ctx).and_then(|view| {
view.read(ctx, |view, app| {
let terminal_view_id = view.id();
let history = BlocklistAIHistoryModel::as_ref(app);
// Try to map the action back to its owning conversation.
history
.conversation_id_for_action(&action_id, terminal_view_id)
// Fallback to active conversation if no exact match is found.
.or_else(|| {
history.active_conversation_id(terminal_view_id)
})
})
})
else {
// If we can't find the conversation ID, we can't reconstruct the AI metadata.
return None;
};
Some(AgentInteractionMetadata::new(
Some(action_id),
conversation_id,
None,
// If the sharer started this as an agent-monitored long-running command,
// reflect that in the viewer's metadata so the command can be rendered as an agent long-running command.
// Further state will be inferred from the sharer's agent events.
ai_metadata.is_agent_monitored.then_some(
LongRunningCommandControlState::Agent {
is_blocked: false,
should_hide_responses: false,
},
),
false,
true,
))
});
self.terminal_model
.lock()
.start_command_execution_for_shared_session(
participant_id,
reconstructed_ai_metadata.clone(),
);
// Notify the action model that the action is now executing on the sharer's side
// This allows the viewer's UI to show the command as running rather than queued
// (which is essential for long running commands to be expandable in the UI).
if let Some(ai_metadata) = reconstructed_ai_metadata {
if let Some(view) = self.terminal_view.upgrade(ctx) {
if let Some(action_id) = ai_metadata.requested_command_action_id() {
view.update(ctx, |view, ctx| {
view.ai_controller().update(ctx, |controller, ctx| {
controller
.mark_action_as_remotely_executing_in_shared_session(
action_id,
*ai_metadata.conversation_id(),
ctx,
);
});
});
}
}
}
}
OrderedTerminalEventType::Resize { window_size } => {
self.process_resize_event(window_size, ctx)
}
OrderedTerminalEventType::CommandExecutionFinished { .. } => (),
OrderedTerminalEventType::AgentResponseEvent {
response_initiator,
response_event,
forked_from_conversation_token,
} => {
if FeatureFlag::AgentSharedSessions.is_enabled() {
match decode_agent_response_event(&response_event) {
Ok(resp) => {
if let Some(view) = self.terminal_view.upgrade(ctx) {
let event_clone = resp.clone();
let forked_from_token = forked_from_conversation_token.clone();
view.update(ctx, move |view, ctx| {
view.ai_controller().update(ctx, |c, ctx| {
// Set the participant who initiated this response
if let Some(response_initiator) = response_initiator {
c.set_current_response_initiator(
response_initiator,
);
}
// For forked conversations, update the viewer's conversation
// to use the new server token (only sent once per fork).
if let Some(forked_from) = forked_from_token {
c.link_forked_conversation_token(
&forked_from,
&event_clone,
ctx,
);
}
c.handle_shared_session_response_event(
event_clone.clone(),
ctx,
);
});
});
}
}
Err(err) => {
log::warn!("Failed to decode agent response event: {err}");
}
}
}
}
OrderedTerminalEventType::AgentConversationReplayStarted => {
self.terminal_model
.lock()
.set_is_receiving_agent_conversation_replay(true);
}
OrderedTerminalEventType::AgentConversationReplayEnded => {
self.terminal_model
.lock()
.set_is_receiving_agent_conversation_replay(false);
}
}
if Some(self.next_event_no) == self.catching_up_to_event_no {
if let Some(view) = self.terminal_view.upgrade(ctx) {
// TODO (suraj): reconsider how we query the role here.
if let Some(presence_manager) =
view.read(ctx, |view, _| view.shared_session_presence_manager())
{
// Role is set to the presence manager's role to stay as up-to-date as possible.
// This avoids a race condition if a viewer gets a new role before catching up,
// by ensuring we're not overwritting the new role.
if let Some(role) = presence_manager.as_ref(ctx).role() {
self.terminal_model.lock().set_shared_session_status(
SharedSessionStatus::ActiveViewer { role },
);
}
}
}
}
self.channel_event_listener.send_wakeup_event();
self.next_event_no += 1;
}
}
}
impl Entity for EventLoop {
type Event = ();
}
#[cfg(test)]
#[path = "event_loop_test.rs"]
mod tests;
@@ -0,0 +1,255 @@
use crate::ai::blocklist::agent_view::AgentViewState;
use crate::terminal::model::block::SerializedBlock;
use crate::terminal::shared_session::tests::terminal_model_for_viewer;
use crate::terminal::TerminalView;
use crate::terminal::{
event_listener::ChannelEventListener, shared_session::viewer::event_loop::EventLoop,
};
use crate::test_util::add_window_with_terminal;
use crate::test_util::terminal::initialize_app_for_terminal_view;
use parking_lot::FairMutex;
use session_sharing_protocol::common::{
OrderedTerminalEvent, OrderedTerminalEventType, Scrollback, ScrollbackBlock, WindowSize,
};
use std::sync::Arc;
use warpui::units::Lines;
use warpui::{App, ViewHandle};
fn ordered_terminal_event_from_bytes(
bytes: impl Into<Vec<u8>>,
event_no: usize,
) -> OrderedTerminalEvent {
let compressed = lz4_flex::block::compress_prepend_size(&bytes.into());
OrderedTerminalEvent {
event_no,
event_type: OrderedTerminalEventType::PtyBytesRead { bytes: compressed },
}
}
fn terminal_view(app: &mut App) -> ViewHandle<TerminalView> {
initialize_app_for_terminal_view(app);
add_window_with_terminal(app, None)
}
#[test]
fn test_terminal_model_is_correct() {
App::test((), |mut app| async move {
let channel_event_proxy = ChannelEventListener::new_for_test();
let model = Arc::new(FairMutex::new(terminal_model_for_viewer(
channel_event_proxy.clone(),
)));
let terminal_view = terminal_view(&mut app);
let event_loop = app.add_model(|ctx| {
EventLoop::new(
model.clone(),
terminal_view.downgrade(),
channel_event_proxy.clone(),
WindowSize {
num_rows: 0,
num_cols: 0,
},
Scrollback {
blocks: vec![],
is_alt_screen_active: false,
},
None,
ctx,
)
});
// Before we receive any events, the block list only contains hidden blocks.
assert!(model
.lock()
.block_list()
.blocks()
.iter()
.all(|block| block.height(&AgentViewState::Inactive) == Lines::zero()));
// Load shared session scrollback.
let scrollback = &[
SerializedBlock::new_for_test("block1".into(), "block1".into()),
SerializedBlock::new_active_block_for_test(),
];
{
let mut model = model.lock();
model.load_shared_session_scrollback(scrollback, false);
// A hidden block, a completed scrollback block, then the active block.
assert_eq!(model.block_list().blocks().len(), 3);
assert_eq!(
model.block_list().blocks()[0].height(&AgentViewState::Inactive),
Lines::zero()
);
assert_ne!(
model.block_list().blocks()[1].height(&AgentViewState::Inactive),
Lines::zero()
);
assert_eq!(
model.block_list().blocks()[2].height(&AgentViewState::Inactive),
Lines::zero()
);
}
// Write some PTY events after starting active block.
model.lock().start_command_execution();
event_loop.update(&mut app, |event_loop, ctx| {
event_loop
.process_ordered_terminal_event(ordered_terminal_event_from_bytes("a", 0), ctx);
});
let model = model.lock();
// After writing bytes, active block should no longer have height 0.
assert_eq!(model.block_list().blocks().len(), 3);
assert_eq!(
model.block_list().blocks()[0].height(&AgentViewState::Inactive),
Lines::zero()
);
assert_ne!(
model.block_list().blocks()[1].height(&AgentViewState::Inactive),
Lines::zero()
);
assert_ne!(
model.block_list().blocks()[2].height(&AgentViewState::Inactive),
Lines::zero()
);
})
}
#[test]
fn test_out_of_order_buffering() {
App::test((), |mut app| async move {
let channel_event_proxy = ChannelEventListener::new_for_test();
let model = Arc::new(FairMutex::new(terminal_model_for_viewer(
channel_event_proxy.clone(),
)));
let terminal_view = terminal_view(&mut app);
let active_block: SerializedBlock = model.lock().block_list().active_block().into();
let event_loop = app.add_model(|ctx| {
EventLoop::new(
model.clone(),
terminal_view.downgrade(),
channel_event_proxy.clone(),
WindowSize {
num_rows: 0,
num_cols: 0,
},
Scrollback {
blocks: vec![ScrollbackBlock {
raw: serde_json::to_vec(&active_block).unwrap(),
}],
is_alt_screen_active: false,
},
None,
ctx,
)
});
// Simulate the real event flow: CommandExecutionStarted (event_no 0) arrives first,
// then PTY bytes (event_no 1-3) potentially in out-of-order sequence.
event_loop.update(&mut app, |event_loop, ctx| {
// First: sharer sends CommandExecutionStarted when user executes a command
event_loop.process_ordered_terminal_event(
OrderedTerminalEvent {
event_no: 0,
event_type: OrderedTerminalEventType::CommandExecutionStarted {
participant_id: Default::default(),
ai_metadata: None,
},
},
ctx,
);
// Then: PTY bytes arrive (potentially out of order)
event_loop
.process_ordered_terminal_event(ordered_terminal_event_from_bytes("c", 3), ctx);
event_loop
.process_ordered_terminal_event(ordered_terminal_event_from_bytes("b", 2), ctx);
event_loop
.process_ordered_terminal_event(ordered_terminal_event_from_bytes("a", 1), ctx);
});
// Ensure the events were applied in the right order.
let command_grid = model
.lock()
.block_list()
.active_block()
.command_to_string()
.trim()
.to_string();
assert_eq!(command_grid, "abc");
})
}
#[test]
fn test_pty_bytes_buffered_before_command_execution_started() {
App::test((), |mut app| async move {
let channel_event_proxy = ChannelEventListener::new_for_test();
let model = Arc::new(FairMutex::new(terminal_model_for_viewer(
channel_event_proxy.clone(),
)));
let terminal_view = terminal_view(&mut app);
let active_block: SerializedBlock = model.lock().block_list().active_block().into();
let event_loop = app.add_model(|ctx| {
EventLoop::new(
model.clone(),
terminal_view.downgrade(),
channel_event_proxy.clone(),
WindowSize {
num_rows: 0,
num_cols: 0,
},
Scrollback {
blocks: vec![ScrollbackBlock {
raw: serde_json::to_vec(&active_block).unwrap(),
}],
is_alt_screen_active: false,
},
None,
ctx,
)
});
// Edge case: PTY bytes arrive BEFORE CommandExecutionStarted.
// The event loop should buffer the PTY bytes until CommandExecutionStarted arrives,
// then process them in order.
event_loop.update(&mut app, |event_loop, ctx| {
// PTY bytes arrive first (event_no 0-2, out of order)
event_loop
.process_ordered_terminal_event(ordered_terminal_event_from_bytes("c", 2), ctx);
event_loop
.process_ordered_terminal_event(ordered_terminal_event_from_bytes("a", 0), ctx);
// CommandExecutionStarted arrives later (event_no 3)
event_loop.process_ordered_terminal_event(
OrderedTerminalEvent {
event_no: 3,
event_type: OrderedTerminalEventType::CommandExecutionStarted {
participant_id: Default::default(),
ai_metadata: None,
},
},
ctx,
);
// More PTY bytes arrive after CommandExecutionStarted (event_no 4)
event_loop
.process_ordered_terminal_event(ordered_terminal_event_from_bytes("b", 1), ctx);
});
// Ensure the buffering worked correctly and bytes were applied in the right order.
// Note: The first two bytes (0, 2) arrive before CommandExecutionStarted,
// but since the block isn't started until event 3, they should be buffered.
// After CommandExecutionStarted, the block is started and we process in order: 0, 1, 2.
let command_grid = model
.lock()
.block_list()
.active_block()
.command_to_string()
.trim()
.to_string();
assert_eq!(command_grid, "abc");
})
}
@@ -0,0 +1,26 @@
use crate::terminal::HistoryEntry;
use warpui::Entity;
/// Responsible for managing the history of a shared session for a viewer.
#[derive(Default)]
pub struct SharedSessionHistoryModel {
entries: Vec<HistoryEntry>,
}
impl SharedSessionHistoryModel {
pub fn new() -> Self {
Self::default()
}
pub fn entries(&self) -> impl Iterator<Item = &HistoryEntry> {
self.entries.iter()
}
pub fn push(&mut self, entry: HistoryEntry) {
self.entries.push(entry);
}
}
impl Entity for SharedSessionHistoryModel {
type Event = ();
}
@@ -0,0 +1,10 @@
//! The viewer is a client that joins a shared session.
mod event_loop;
pub(crate) mod history_model;
mod network;
pub(crate) mod terminal_manager;
pub(crate) use terminal_manager::TerminalManager;
#[cfg(test)]
#[path = "mod_test.rs"]
mod tests;
@@ -0,0 +1,40 @@
use settings::Setting;
use warpui::{App, SingletonEntity};
use crate::{
terminal::{
safe_mode_settings::SafeModeSettings, shared_session::SharedSessionStatus, TerminalModel,
},
test_util::settings::initialize_settings_for_tests,
};
#[test]
fn test_viewer_secret_obfuscation_disabled() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
app.update(|ctx| {
SafeModeSettings::handle(ctx).update(ctx, |settings, ctx| {
settings
.safe_mode_enabled
.set_value(true, ctx)
.expect("Can update safe mode setting");
});
});
let mut model = TerminalModel::mock(None, None);
model.set_shared_session_status(SharedSessionStatus::ActiveViewer {
role: Default::default(),
});
model.simulate_block("echo 1.1.1.1", "");
for block in model.block_list().blocks() {
assert_eq!(
block
.prompt_and_command_grid()
.grid_handler()
.num_secrets_obfuscated(),
0
);
}
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,136 @@
use async_channel::Sender;
use async_io::Timer;
use instant::Instant;
use session_sharing_protocol::viewer::UpstreamMessage;
use std::{sync::Arc, time::Duration};
use parking_lot::FairMutex;
use warpui::{App, ModelHandle};
use crate::{
terminal::{event_listener::ChannelEventListener, TerminalModel},
test_util::{add_window_with_terminal, terminal::initialize_app_for_terminal_view},
};
use super::{Network, PtyBytesBatchStatus, Stage};
fn create_network(app: &mut App) -> (ModelHandle<Network>, Sender<Vec<u8>>) {
initialize_app_for_terminal_view(app);
let terminal_view = add_window_with_terminal(app, None).downgrade();
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
let channel_event_proxy = ChannelEventListener::new_for_test();
let (write_to_pty_events_tx, write_to_pty_events_rx) = async_channel::unbounded();
let network = app.add_model(|ctx| {
Network::new_for_test(
channel_event_proxy,
terminal_view,
terminal_model,
write_to_pty_events_rx,
ctx,
)
});
network.update(app, |network, _| {
network.stage = Stage::JoinedSuccessfully;
});
(network, write_to_pty_events_tx)
}
#[test]
fn test_send_pty_write_event_advances_event_no() {
App::test((), |mut app| async move {
let (network, _) = create_network(&mut app);
// Event number should start at 0.
network.read(&app, |network, _ctx| {
assert_eq!(network.write_to_pty_event_no.as_usize(), 0);
});
// Try to send a write to pty event message to the server.
network.update(&mut app, |network, ctx| {
let abort_handle = ctx.spawn_abortable(
Timer::after(Duration::from_millis(1)),
move |_, _, _| {},
|_, _| {},
);
network.pty_bytes_batch_status = PtyBytesBatchStatus::Batching {
accumulated: "a".into(),
abort_handle,
};
});
network.update(&mut app, |network, _| {
network.send_write_to_pty();
});
// Event number is advanced to 1.
network.read(&app, |network, _ctx| {
assert_eq!(network.write_to_pty_event_no.as_usize(), 1);
});
});
}
#[test]
fn test_send_pty_write_event_while_batching() {
App::test((), |mut app| async move {
let (network, tx) = create_network(&mut app);
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
let init_time = Instant::now();
// Reset batching status.
network.update(&mut app, |network, _ctx| {
network.pty_bytes_batch_status = PtyBytesBatchStatus::NotBatching {
last_sent_at: init_time,
};
});
// Try to send write to pty events.
tx.try_send("a".into())
.expect("Can send event over write_to_pty_tx");
tx.try_send("b".into())
.expect("Can send event over write_to_pty_tx");
// Ensure the accumulated event is sent to the server, and the item in ws_proxy_tx is correct.
let item = ws_proxy_rx.recv().await;
assert!(
matches!(item.unwrap(), UpstreamMessage::WriteToPty { bytes, .. } if bytes == b"ab")
);
// The batch status should be updated.
network.read(&app, |network, _ctx| {
assert!(matches!(network.pty_bytes_batch_status, PtyBytesBatchStatus::NotBatching { last_sent_at } if last_sent_at > init_time));
});
});
}
#[test]
fn test_send_pty_write_event_while_not_batching() {
App::test((), |mut app| async move {
let (network, _) = create_network(&mut app);
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
let init_time = Instant::now();
// Set batch status to not batching.
network.update(&mut app, |network, _ctx| {
network.pty_bytes_batch_status = PtyBytesBatchStatus::NotBatching {
last_sent_at: init_time,
};
});
// Try to send write to pty message to server.
network.update(&mut app, |network, _| {
network.send_write_to_pty();
});
// Make sure we didn't try to send anything to the server.
assert_eq!(ws_proxy_rx.len(), 0);
// The batch status should be unchanged.
network.read(&app, |network, _ctx| {
assert!(matches!(network.pty_bytes_batch_status, PtyBytesBatchStatus::NotBatching { last_sent_at } if last_sent_at == init_time));
});
});
}
File diff suppressed because it is too large Load Diff