461 lines
15 KiB
Rust
461 lines
15 KiB
Rust
use byte_unit::Byte;
|
|
use galaxyui::{id, keymap::ContextPredicate, AppContext};
|
|
use instant::Duration;
|
|
use serde::{Deserialize, Serialize};
|
|
use session_sharing_protocol::common::{Role, Scrollback, ScrollbackBlock, SessionId};
|
|
use session_sharing_protocol::sharer::SessionSourceType;
|
|
use galaxyui::keymap::ContextPredicate;
|
|
use galaxyui::{id, AppContext};
|
|
|
|
use super::model::block::SerializedBlock;
|
|
use super::model::terminal_model::BlockIndex;
|
|
use super::{GridType, TerminalModel};
|
|
use crate::channel::{Channel, ChannelState};
|
|
use crate::editor::{InteractionState, ReplicaId};
|
|
use crate::features::FeatureFlag;
|
|
|
|
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);
|
|
|
|
/// `SessionSourceType` paired with the orchestrator `task_id` that rides
|
|
/// on the `source_task_id` sidecar.
|
|
#[derive(Debug, Clone)]
|
|
pub struct SharedSessionSource {
|
|
pub source_type: SessionSourceType,
|
|
pub source_task_id: Option<String>,
|
|
}
|
|
|
|
impl SharedSessionSource {
|
|
pub fn user(source_task_id: Option<String>) -> Self {
|
|
Self {
|
|
source_type: SessionSourceType::User,
|
|
source_task_id,
|
|
}
|
|
}
|
|
|
|
pub fn ambient_agent(task_id: Option<String>) -> Self {
|
|
Self {
|
|
source_type: SessionSourceType::AmbientAgent {
|
|
task_id: task_id.clone(),
|
|
},
|
|
source_task_id: task_id,
|
|
}
|
|
}
|
|
|
|
/// Sidecar first, then `AmbientAgent.task_id` for legacy producers.
|
|
pub fn orchestrator_task_id(&self) -> Option<&str> {
|
|
self.source_task_id.as_deref().or(match &self.source_type {
|
|
SessionSourceType::AmbientAgent { task_id } => task_id.as_deref(),
|
|
SessionSourceType::User => None,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Default for SharedSessionSource {
|
|
fn default() -> Self {
|
|
Self::user(None)
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
Yes { source: SharedSessionSource },
|
|
#[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` encodes what kind of shared session will be created once
|
|
/// the session finishes bootstrapping.
|
|
SharePendingPreBootstrap { source: SharedSessionSource },
|
|
|
|
/// 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 included for the prompt when it is scrollback-eligible.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum SharedSessionScrollbackType {
|
|
/// Do not include any scrollback in this shared session.
|
|
/// The active block can still be 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, and neither will hidden active blocks.
|
|
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 galaxyui::SingletonEntity;
|
|
|
|
use crate::workspaces::user_workspaces::UserWorkspaces;
|
|
|
|
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_tests.rs"]
|
|
mod tests;
|