first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _};
|
||||
use base64::engine::general_purpose::STANDARD_NO_PAD;
|
||||
use base64::Engine as _;
|
||||
use prost::Message;
|
||||
use warp_multi_agent_api::ResponseEvent;
|
||||
|
||||
|
||||
@@ -8,9 +8,8 @@ use galaxyui::{
|
||||
WindowId,
|
||||
};
|
||||
|
||||
use crate::terminal::TerminalView;
|
||||
|
||||
use super::SharedSessionActionSource;
|
||||
use crate::terminal::TerminalView;
|
||||
|
||||
struct SharedSessionState {
|
||||
session_id: SessionId,
|
||||
@@ -82,6 +81,25 @@ impl Manager {
|
||||
view_handle
|
||||
}
|
||||
|
||||
pub fn shared_view_by_session_id(
|
||||
&self,
|
||||
session_id: &SessionId,
|
||||
ctx: &AppContext,
|
||||
) -> Option<ViewHandle<TerminalView>> {
|
||||
let weak_handle = self
|
||||
.shared
|
||||
.values()
|
||||
.find(|state| state.session_id == *session_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,
|
||||
|
||||
@@ -4,17 +4,15 @@ 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 crate::{
|
||||
channel::{Channel, ChannelState},
|
||||
editor::{InteractionState, ReplicaId},
|
||||
features::FeatureFlag,
|
||||
};
|
||||
|
||||
use super::{
|
||||
model::{block::SerializedBlock, terminal_model::BlockIndex},
|
||||
GridType, TerminalModel,
|
||||
};
|
||||
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;
|
||||
@@ -44,6 +42,46 @@ pub const COPY_LINK_TEXT: &str = "Sharing link copied";
|
||||
/// 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.
|
||||
@@ -51,9 +89,8 @@ const SELECTION_THROTTLE_PERIOD: Duration = Duration::from_millis(20);
|
||||
/// 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 },
|
||||
/// This session should be shared automatically once bootstrapped.
|
||||
Yes { source: SharedSessionSource },
|
||||
#[default]
|
||||
No,
|
||||
}
|
||||
@@ -78,9 +115,9 @@ pub enum SharedSessionStatus {
|
||||
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 `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
|
||||
@@ -173,11 +210,11 @@ impl SharedSessionStatus {
|
||||
/// 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.
|
||||
/// 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.
|
||||
/// Note the active block is still sent as part of scrollback for the prompt.
|
||||
/// 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.
|
||||
@@ -195,7 +232,7 @@ impl SharedSessionScrollbackType {
|
||||
/// 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.
|
||||
/// _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
|
||||
@@ -248,9 +285,10 @@ impl SharedSessionScrollbackType {
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub fn max_session_size(ctx: &AppContext) -> Byte {
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
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)
|
||||
@@ -418,5 +456,5 @@ pub(crate) fn decode_scrollback(scrollback: &Scrollback) -> Vec<SerializedBlock>
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_test.rs"]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
+56
-12
@@ -1,22 +1,23 @@
|
||||
use super::{decode_scrollback, SharedSessionScrollbackType};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use session_sharing_protocol::common::{Scrollback, ScrollbackBlock};
|
||||
use url::Url;
|
||||
use warpui::r#async::executor::Background;
|
||||
use warpui::units::Lines;
|
||||
|
||||
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::event_listener::ChannelEventListener;
|
||||
use crate::terminal::model::block::SerializedBlock;
|
||||
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 galaxyui::r#async::executor::Background;
|
||||
use galaxyui::units::Lines;
|
||||
use serde_json::Value;
|
||||
use session_sharing_protocol::common::{Scrollback, ScrollbackBlock};
|
||||
use std::sync::Arc;
|
||||
use url::Url;
|
||||
use crate::uri::web_intent_parser::maybe_rewrite_web_url_to_intent;
|
||||
|
||||
pub const MAX_BYTES_SHAREABLE: usize = 5000;
|
||||
|
||||
@@ -244,7 +245,7 @@ fn test_loading_scrollback() {
|
||||
];
|
||||
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);
|
||||
model.load_shared_session_scrollback(scrollback_blocks);
|
||||
|
||||
// 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.
|
||||
@@ -300,6 +301,48 @@ fn test_loading_scrollback() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_loading_scrollback_with_completed_last_block_creates_active_block() {
|
||||
let scrollback_blocks = &[
|
||||
SerializedBlock::new_for_test("block1".into(), "block1".into()),
|
||||
SerializedBlock::new_for_test("block2".into(), "block2".into()),
|
||||
];
|
||||
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);
|
||||
|
||||
// 4 blocks: first is the bootstrap block, the next two are completed scrollback blocks.
|
||||
// Since no active block was serialized, restore creates a fresh active block.
|
||||
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(2.into())
|
||||
.unwrap()
|
||||
.command_to_string(),
|
||||
"block2"
|
||||
);
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_loading_scrollback_in_alt_screen() {
|
||||
let scrollback_blocks = &[
|
||||
@@ -309,7 +352,8 @@ fn test_loading_scrollback_in_alt_screen() {
|
||||
];
|
||||
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);
|
||||
model.load_shared_session_scrollback(scrollback_blocks);
|
||||
model.enter_alt_screen(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.
|
||||
@@ -1,7 +1,13 @@
|
||||
// Session sharing now relies on the server sending protocol-level ping frames that the client responds to,
|
||||
// and the server initiates disconnect if the client is unresponsive.
|
||||
// This module is no longer used, but we keep the code around for now.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::stream::AbortHandle;
|
||||
use galaxyui::r#async::Timer;
|
||||
use galaxyui::{Entity, ModelContext};
|
||||
use std::time::Duration;
|
||||
|
||||
const DEFAULT_PING_FREQUENCY: Duration = Duration::from_secs(5);
|
||||
const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
@@ -45,9 +51,22 @@ impl Heartbeat {
|
||||
/// Starts the periodic ping and the idle timeout tracker.
|
||||
pub fn start(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.reset_idle_timeout(ctx);
|
||||
// Abort any existing ping timer so we don't accumulate chains across reconnects.
|
||||
if let Some(handle) = self.periodic_ping_abort_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
self.periodic_ping(ctx);
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) {
|
||||
if let Some(handle) = self.idle_timeout_abort_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
if let Some(handle) = self.periodic_ping_abort_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use super::{Event, Heartbeat};
|
||||
use std::time::Duration;
|
||||
|
||||
use galaxyui::r#async::Timer;
|
||||
use galaxyui::App;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{Event, Heartbeat};
|
||||
|
||||
#[test]
|
||||
#[ignore = "Flakes in CI"]
|
||||
|
||||
@@ -1,30 +1,29 @@
|
||||
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 galaxyui::r#async::{SpawnedFutureHandle, Timer};
|
||||
use galaxyui::{
|
||||
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 galaxyui::{FocusContext, ViewHandle};
|
||||
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 galaxyui::accessibility::AccessibilityContent;
|
||||
use galaxyui::elements::{
|
||||
Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
|
||||
Fill, Flex, Hoverable, MainAxisAlignment, MouseStateHandle, OffsetPositioning, ParentAnchor,
|
||||
ParentElement, ParentOffsetBounds, Radius, Stack,
|
||||
};
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::r#async::{SpawnedFutureHandle, Timer};
|
||||
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use galaxyui::{
|
||||
AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use super::render_util::non_hoverable_participant_avatar;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields};
|
||||
use crate::pane_group::{PaneHeaderAction, PaneHeaderCustomAction};
|
||||
use crate::terminal::view::TerminalAction;
|
||||
use crate::ui_components::buttons::icon_button;
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum HoveredElement {
|
||||
|
||||
@@ -1,34 +1,29 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
iter,
|
||||
};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::iter;
|
||||
|
||||
use asset_cache::AssetCacheExt as _;
|
||||
use futures::future::BoxFuture;
|
||||
use futures_util::future::join_all;
|
||||
use itertools::{Either, Itertools};
|
||||
use pathfinder_color::ColorU;
|
||||
use rand::Rng;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use session_sharing_protocol::common::Viewer;
|
||||
use session_sharing_protocol::common::{
|
||||
InputReplicaId, ParticipantInfo, ParticipantList, ParticipantPresenceUpdate, PresenceUpdate,
|
||||
Role, RoleRequestId, Selection,
|
||||
InputReplicaId, ParticipantId, ParticipantInfo, ParticipantList, ParticipantPresenceUpdate,
|
||||
PresenceUpdate, Role, RoleRequestId, Selection,
|
||||
};
|
||||
use warpui::assets::asset_cache::{AssetCache, AssetState};
|
||||
use warpui::image_cache::ImageType;
|
||||
use warpui::r#async::SpawnedFutureHandle;
|
||||
use warpui::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use asset_cache::AssetCacheExt as _;
|
||||
use galaxyui::{
|
||||
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,
|
||||
};
|
||||
use crate::auth::UserUid;
|
||||
use crate::editor::{CursorColors, PeerSelectionData};
|
||||
use crate::terminal::model::block::BlockId;
|
||||
use crate::terminal::model::blocks::BlockList;
|
||||
use crate::terminal::model::terminal_model::BlockIndex;
|
||||
use crate::util::color::coloru_with_opacity;
|
||||
|
||||
/// Selections have 25% opacity.
|
||||
pub fn text_selection_color(participant_color: ColorU) -> ColorU {
|
||||
@@ -97,7 +92,7 @@ const PRESET_COLORS: &[ColorU] = &[
|
||||
];
|
||||
|
||||
/// Helper struct containing participant info and anything else necessary for rendering
|
||||
/// for an present participant.
|
||||
/// for a present participant.
|
||||
#[derive(Clone)]
|
||||
pub struct Participant {
|
||||
pub info: ParticipantInfo,
|
||||
@@ -335,11 +330,6 @@ impl PresenceManager {
|
||||
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> {
|
||||
@@ -770,6 +760,33 @@ impl PresenceManager {
|
||||
pub fn present_viewer_id_for_uid(&self, viewer_uid: UserUid) -> Option<&ParticipantId> {
|
||||
self.present_viewer_ids_for_uid(viewer_uid).next()
|
||||
}
|
||||
|
||||
/// Returns the only distinct present viewer UID. Multiple present viewers
|
||||
/// with the same UID count as one user.
|
||||
pub fn single_distinct_present_viewer_uid(&self) -> Option<&str> {
|
||||
Self::single_distinct_uid(
|
||||
self.get_present_viewers()
|
||||
.map(|v| v.info.profile_data.firebase_uid.as_str()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Like `single_distinct_present_viewer_uid`, but reads directly from a
|
||||
/// participant list before the presence manager finishes processing it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn single_distinct_present_viewer_uid_from_viewers<'a>(
|
||||
viewers: impl Iterator<Item = &'a Viewer>,
|
||||
) -> Option<&'a str> {
|
||||
Self::single_distinct_uid(
|
||||
viewers
|
||||
.filter(|v| v.is_present)
|
||||
.map(|v| v.info.profile_data.firebase_uid.as_str()),
|
||||
)
|
||||
}
|
||||
|
||||
fn single_distinct_uid<'a>(mut uids: impl Iterator<Item = &'a str>) -> Option<&'a str> {
|
||||
let uid = uids.next()?;
|
||||
uids.all(|other_uid| other_uid == uid).then_some(uid)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Event {
|
||||
@@ -781,5 +798,5 @@ impl Entity for PresenceManager {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "presence_manager_test.rs"]
|
||||
#[path = "presence_manager_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
+59
-8
@@ -1,9 +1,3 @@
|
||||
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;
|
||||
|
||||
@@ -14,6 +8,56 @@ use session_sharing_protocol::common::{
|
||||
ParticipantId, ParticipantInfo, ParticipantList, ProfileData, Role, Selection, Sharer, Viewer,
|
||||
};
|
||||
|
||||
use crate::auth::UserUid;
|
||||
use crate::terminal::model::ansi::{
|
||||
CommandFinishedValue, CompletionMetadata, Handler, PrecmdValue, PromptMetadata,
|
||||
};
|
||||
use crate::terminal::model::blocks::BlockList;
|
||||
use crate::terminal::model::test_utils::TestBlockListBuilder;
|
||||
use crate::terminal::shared_session::presence_manager::{PresenceManager, PRESET_COLORS};
|
||||
|
||||
fn viewer_with_uid(uid: &str, is_present: bool) -> Viewer {
|
||||
Viewer {
|
||||
info: ParticipantInfo {
|
||||
profile_data: ProfileData {
|
||||
firebase_uid: uid.to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
role: Role::Reader,
|
||||
is_present,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_distinct_present_viewer_uid_filters_absent_duplicates() {
|
||||
let viewers = [
|
||||
viewer_with_uid("same", true),
|
||||
viewer_with_uid("same", true),
|
||||
viewer_with_uid("other", false),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
PresenceManager::single_distinct_present_viewer_uid_from_viewers(viewers.iter()),
|
||||
Some("same")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_distinct_present_viewer_uid_returns_none_for_zero_or_multiple_uids() {
|
||||
assert_eq!(
|
||||
PresenceManager::single_distinct_present_viewer_uid_from_viewers([].iter()),
|
||||
None
|
||||
);
|
||||
|
||||
let viewers = [viewer_with_uid("one", true), viewer_with_uid("two", true)];
|
||||
assert_eq!(
|
||||
PresenceManager::single_distinct_present_viewer_uid_from_viewers(viewers.iter()),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_choosing_preset_colors() {
|
||||
App::test((), |mut app| async move {
|
||||
@@ -267,11 +311,18 @@ fn block_list_for_test(max_block_index: usize) -> BlockList {
|
||||
|
||||
// Block 0 already exists as part of creating the blocklist
|
||||
for i in 1..max_block_index {
|
||||
block_list.command_finished(CommandFinishedValue {
|
||||
let completion_metadata = CompletionMetadata {
|
||||
exit_code: ExitCode::from(0),
|
||||
next_block_id: i.to_string().into(),
|
||||
};
|
||||
block_list.command_finished(CommandFinishedValue {
|
||||
completion_metadata: completion_metadata.clone(),
|
||||
session_id: None,
|
||||
});
|
||||
block_list.precmd_with_completion_metadata(PrecmdValue {
|
||||
completion_metadata,
|
||||
prompt_metadata: PromptMetadata::default(),
|
||||
});
|
||||
block_list.precmd(Default::default());
|
||||
}
|
||||
block_list
|
||||
}
|
||||
@@ -1,21 +1,16 @@
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
ui_components::avatar::{Avatar, AvatarContent},
|
||||
};
|
||||
use galaxyui::{elements::CornerRadius, fonts::Weight};
|
||||
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
ChildAnchor, Fill, Hoverable, MouseStateHandle, OffsetPositioning, ParentAnchor,
|
||||
ParentElement, ParentOffsetBounds, Radius, Stack,
|
||||
},
|
||||
ui_components::components::{UiComponent, UiComponentStyles},
|
||||
AppContext, Element, SingletonEntity,
|
||||
};
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use galaxyui::elements::{
|
||||
ChildAnchor, CornerRadius, Fill, Hoverable, MouseStateHandle, OffsetPositioning, ParentAnchor,
|
||||
ParentElement, ParentOffsetBounds, Radius, Stack,
|
||||
};
|
||||
use galaxyui::fonts::Weight;
|
||||
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use galaxyui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
use super::presence_manager::{Participant, MUTED_AVATAR_BORDER_COLOR, MUTED_PARTICIPANT_COLOR};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::ui_components::avatar::{Avatar, AvatarContent};
|
||||
|
||||
pub fn shared_session_indicator_color(appearance: &Appearance) -> ColorU {
|
||||
appearance.theme().terminal_colors().normal.red.into()
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use api::response_event::stream_finished as stream_finished_event;
|
||||
use api::{client_action as api_client_action, response_event as api_response_event};
|
||||
use warp_multi_agent_api::{self as api, ResponseEvent};
|
||||
|
||||
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};
|
||||
use crate::ai::agent::{AIAgentExchange, MessageId};
|
||||
|
||||
// Reconstructs all response events from conversations for use in session sharing.
|
||||
// These messages are used to replay conversations as if they were happening live.
|
||||
@@ -132,8 +132,16 @@ pub fn reconstruct_response_events_from_conversations(
|
||||
));
|
||||
}
|
||||
|
||||
// Finish this exchange
|
||||
events.push(create_finished_event_from_conversation(conversation));
|
||||
// Finish this exchange — but ONLY if it actually finished. If an
|
||||
// exchange is still in-flight when the scrollback is built, emitting a
|
||||
// synthetic Finished here corrupts the late-joining viewer's stream:
|
||||
// the viewer clears `current_response_id` and then drops every live
|
||||
// ClientAction that arrives for the same in-flight stream. Skipping
|
||||
// the synthetic Finished lets the live wire's real Finished close the
|
||||
// stream naturally for the viewer.
|
||||
if exchange.output_status.is_finished() {
|
||||
events.push(create_finished_event_from_conversation(conversation));
|
||||
}
|
||||
}
|
||||
|
||||
events
|
||||
@@ -158,7 +166,9 @@ fn create_finished_event_from_conversation(conversation: &AIConversation) -> Res
|
||||
let usage_metadata = Some(
|
||||
api_response_event::stream_finished::ConversationUsageMetadata {
|
||||
context_window_usage: conversation.context_window_usage(),
|
||||
credits_spent: conversation.credits_spent(),
|
||||
total_input_tokens: 0,
|
||||
credits_spent: conversation.inference_credits_spent(),
|
||||
platform_credits_spent: conversation.platform_credits_spent(),
|
||||
summarized: conversation.was_summarized(),
|
||||
#[allow(deprecated)]
|
||||
token_usage: conversation
|
||||
@@ -177,6 +187,16 @@ fn create_finished_event_from_conversation(conversation: &AIConversation) -> Res
|
||||
.iter()
|
||||
.filter_map(|u| u.to_proto_byok_usage())
|
||||
.collect(),
|
||||
custom_endpoint_token_usage: conversation
|
||||
.token_usage()
|
||||
.iter()
|
||||
.filter_map(|u| u.to_proto_custom_endpoint_usage())
|
||||
.collect(),
|
||||
context_window_segments: conversation
|
||||
.context_window_segments()
|
||||
.iter()
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
use session_sharing_protocol::common::{ParticipantId, Role, RoleRequestId};
|
||||
use galaxyui::elements::Empty;
|
||||
use galaxyui::presenter::ChildView;
|
||||
use galaxyui::{
|
||||
ui_components::components::{Coords, UiComponentStyles},
|
||||
AppContext, Element, Entity, View, ViewContext, ViewHandle,
|
||||
};
|
||||
use session_sharing_protocol::common::{ParticipantId, Role, RoleRequestId};
|
||||
use galaxyui::ui_components::components::{Coords, UiComponentStyles};
|
||||
use galaxyui::{AppContext, Element, Entity, View, ViewContext, ViewHandle};
|
||||
|
||||
use crate::modal::Modal;
|
||||
use crate::pane_group::TerminalPaneId;
|
||||
|
||||
@@ -8,10 +8,9 @@ use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use galaxyui::ui_components::text::Span;
|
||||
use galaxyui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
|
||||
|
||||
use super::{MODAL_PADDING, TEXT_FONT_SIZE};
|
||||
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.;
|
||||
|
||||
|
||||
@@ -1,28 +1,23 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use session_sharing_protocol::common::{ParticipantId, Role, RoleRequestId};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use warpui::elements::{
|
||||
ConstrainedBox, 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::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
|
||||
|
||||
use super::{BODY_PADDING, HEADER_FONT_SIZE, MODAL_PADDING, TEXT_FONT_SIZE};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::terminal::shared_session::render_util::{
|
||||
non_hoverable_participant_avatar, ParticipantAvatarParams,
|
||||
};
|
||||
use crate::{appearance::Appearance, ui_components::blended_colors};
|
||||
use galaxyui::elements::{
|
||||
ConstrainedBox, Container, Flex, MainAxisAlignment, MouseStateHandle, ParentElement, Text,
|
||||
};
|
||||
use galaxyui::fonts::Properties;
|
||||
use galaxyui::{
|
||||
elements::CrossAxisAlignment,
|
||||
fonts::Weight,
|
||||
platform::Cursor,
|
||||
ui_components::{
|
||||
button::ButtonVariant,
|
||||
components::{UiComponent, UiComponentStyles},
|
||||
},
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
use session_sharing_protocol::common::{ParticipantId, Role, RoleRequestId};
|
||||
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
|
||||
use super::{BODY_PADDING, HEADER_FONT_SIZE, MODAL_PADDING, TEXT_FONT_SIZE};
|
||||
use crate::ui_components::blended_colors;
|
||||
|
||||
pub const BUTTON_HEIGHT: f32 = 32.;
|
||||
pub const BUTTON_WIDTH: f32 = 75.;
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
use crate::{appearance::Appearance, ui_components::blended_colors};
|
||||
use galaxyui::elements::{
|
||||
Container, Flex, MainAxisAlignment, MouseStateHandle, ParentElement, Text,
|
||||
};
|
||||
use galaxyui::{
|
||||
elements::CrossAxisAlignment,
|
||||
fonts::Weight,
|
||||
platform::Cursor,
|
||||
ui_components::{
|
||||
button::ButtonVariant,
|
||||
components::{UiComponent, UiComponentStyles},
|
||||
},
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
use session_sharing_protocol::common::Role;
|
||||
use galaxyui::elements::{
|
||||
Container, CrossAxisAlignment, Flex, MainAxisAlignment, MouseStateHandle, ParentElement, Text,
|
||||
};
|
||||
use galaxyui::fonts::Weight;
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::ui_components::button::ButtonVariant;
|
||||
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use galaxyui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
|
||||
|
||||
use super::{BODY_PADDING, HEADER_FONT_SIZE, MODAL_PADDING, TEXT_FONT_SIZE};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::ui_components::blended_colors;
|
||||
|
||||
pub const BUTTON_HEIGHT: f32 = 40.;
|
||||
pub const BUTTON_WIDTH: f32 = 352.;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use crate::terminal::model::{blocks::BlockList, index::Point, terminal_model::WithinBlock};
|
||||
use session_sharing_protocol::common::BlockPoint;
|
||||
|
||||
use crate::terminal::model::blocks::BlockList;
|
||||
use crate::terminal::model::index::Point;
|
||||
use crate::terminal::model::terminal_model::WithinBlock;
|
||||
|
||||
impl WithinBlock<Point> {
|
||||
/// Converts an un-transformed block point
|
||||
/// to a transformed [`WithinBlock<Point>`].
|
||||
@@ -53,5 +56,5 @@ impl WithinBlock<Point> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "selections_test.rs"]
|
||||
#[path = "selections_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
+13
-20
@@ -1,19 +1,15 @@
|
||||
use galaxy_core::semantic_selection::SemanticSelection;
|
||||
use galaxyui::text::SelectionType;
|
||||
use galaxyui::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 galaxyui::text::SelectionType;
|
||||
use crate::terminal::block_filter::BlockFilterQuery;
|
||||
use crate::terminal::event_listener::ChannelEventListener;
|
||||
use crate::terminal::model::block::SerializedBlock;
|
||||
use crate::terminal::model::blocks::BlockListPoint;
|
||||
use crate::terminal::model::index::{Point, Side};
|
||||
use crate::terminal::model::terminal_model::WithinBlock;
|
||||
use crate::terminal::shared_session::tests::terminal_model_for_viewer;
|
||||
use crate::terminal::{GridType, SizeInfo, SizeUpdate, SizeUpdateReason, TerminalModel};
|
||||
|
||||
/// Creates a [`SelectionType::Simple`], left-to-right text selection
|
||||
/// from `start` to `end` in the `model`'s blocklist.
|
||||
@@ -43,13 +39,10 @@ fn create_sharer_and_viewer_models_with_same_block(
|
||||
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,
|
||||
);
|
||||
viewer_model.load_shared_session_scrollback(&[
|
||||
serialized_block,
|
||||
SerializedBlock::new_active_block_for_test(),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
viewer_model
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use settings::{macros::define_settings_group, Setting, SupportedPlatforms, SyncToCloud};
|
||||
use settings::macros::define_settings_group;
|
||||
use settings::{RespectUserSyncSetting, Setting, SupportedPlatforms, SyncToCloud};
|
||||
|
||||
define_settings_group!(SharedSessionSettings, settings: [
|
||||
onboarding_block_shown: SessionSharingOnboardingBlockShown {
|
||||
|
||||
@@ -1,35 +1,30 @@
|
||||
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 galaxy_core::features::FeatureFlag;
|
||||
|
||||
use std::default::Default;
|
||||
use std::sync::Arc;
|
||||
|
||||
use byte_unit::Byte;
|
||||
use parking_lot::FairMutex;
|
||||
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::elements::{
|
||||
Container, Flex, MainAxisSize, MouseStateHandle, ParentElement, Shrinkable, Text,
|
||||
};
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::ui_components::button::ButtonVariant;
|
||||
use galaxyui::ui_components::components::UiComponent;
|
||||
use galaxyui::ui_components::radio_buttons::{
|
||||
RadioButtonItem, RadioButtonLayout, RadioButtonStateHandle,
|
||||
};
|
||||
use warpui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
|
||||
|
||||
use super::style::{self, BUTTON_GAP, MODAL_MARGIN};
|
||||
use galaxyui::{
|
||||
platform::Cursor, AppContext, Element, Entity, SingletonEntity, TypedActionView, View,
|
||||
ViewContext,
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::terminal::shared_session::ai_agent::encode_agent_response_event;
|
||||
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::{
|
||||
max_session_size, SharedSessionActionSource, SharedSessionScrollbackType,
|
||||
};
|
||||
use crate::terminal::TerminalModel;
|
||||
|
||||
#[derive(Default)]
|
||||
struct ButtonMouseStateHandles {
|
||||
@@ -86,7 +81,7 @@ impl Body {
|
||||
ctx: &ViewContext<Self>,
|
||||
) -> Byte {
|
||||
let conversations: Vec<_> = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.all_live_conversations_for_terminal_view(terminal_view_id)
|
||||
.all_live_conversations_for_terminal_surface(terminal_view_id)
|
||||
.filter(|conv| conv.exchange_count() > 0)
|
||||
.cloned()
|
||||
.collect();
|
||||
@@ -126,7 +121,7 @@ impl Body {
|
||||
// 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)
|
||||
.all_live_conversations_for_terminal_surface(terminal_view_id)
|
||||
.any(|conv| conv.exchange_count() > 0)
|
||||
} else {
|
||||
false
|
||||
@@ -407,5 +402,5 @@ impl TypedActionView for Body {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "body_test.rs"]
|
||||
#[path = "body_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
+6
-7
@@ -4,14 +4,13 @@ use parking_lot::FairMutex;
|
||||
|
||||
use galaxyui::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;
|
||||
use crate::terminal::shared_session::{
|
||||
SharedSessionActionSource, SharedSessionScrollbackType, MAX_BYTES_SHAREABLE,
|
||||
};
|
||||
use crate::terminal::TerminalModel;
|
||||
use crate::test_util::add_window_with_terminal;
|
||||
use crate::test_util::terminal::initialize_app_for_terminal_view;
|
||||
|
||||
#[test]
|
||||
fn test_open_modal_from_non_block() {
|
||||
@@ -1,13 +1,11 @@
|
||||
use crate::appearance::Appearance;
|
||||
use galaxyui::elements::{Container, Flex, MainAxisSize, MouseStateHandle, ParentElement};
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::ui_components::button::ButtonVariant;
|
||||
use galaxyui::ui_components::components::UiComponent;
|
||||
use galaxyui::{
|
||||
platform::Cursor, AppContext, Element, Entity, SingletonEntity, TypedActionView, View,
|
||||
ViewContext,
|
||||
};
|
||||
use galaxyui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
|
||||
|
||||
use super::style::{self, MODAL_PADDING};
|
||||
use crate::appearance::Appearance;
|
||||
|
||||
const SESSION_BUILD_FREE_PLAN_SUBHEADER: &str = "Galaxy'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";
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
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;
|
||||
|
||||
@@ -13,13 +6,17 @@ use galaxyui::keymap::FixedBinding;
|
||||
use galaxyui::EntityId;
|
||||
use parking_lot::FairMutex;
|
||||
use style::{DENIED_MODAL_WIDTH, MODAL_HEIGHT, MODAL_WIDTH};
|
||||
|
||||
use galaxyui::presenter::ChildView;
|
||||
use galaxyui::ui_components::components::UiComponentStyles;
|
||||
use galaxyui::AppContext;
|
||||
use galaxyui::SingletonEntity;
|
||||
use galaxyui::ViewHandle;
|
||||
use galaxyui::{Element, Entity, TypedActionView, View, ViewContext};
|
||||
use galaxyui::{
|
||||
AppContext, Element, Entity, EntityId, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use crate::modal::{Modal, ModalEvent};
|
||||
use crate::pane_group::TerminalPaneId;
|
||||
use crate::terminal::TerminalModel;
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
mod body;
|
||||
mod denied_body;
|
||||
@@ -29,7 +26,6 @@ use body::Body;
|
||||
use denied_body::{DeniedBody, DeniedBodyEvent};
|
||||
|
||||
use self::body::BodyEvent;
|
||||
|
||||
use super::{SharedSessionActionSource, SharedSessionScrollbackType};
|
||||
|
||||
const MODAL_HEADER: &str = "Share session";
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxyui::{
|
||||
fonts::Weight,
|
||||
ui_components::components::{Coords, UiComponentStyles},
|
||||
};
|
||||
use galaxyui::fonts::Weight;
|
||||
use galaxyui::ui_components::components::{Coords, UiComponentStyles};
|
||||
|
||||
pub const MODAL_WIDTH: f32 = 460.;
|
||||
pub const MODAL_HEIGHT: f32 = 300.;
|
||||
|
||||
@@ -16,8 +16,7 @@ use crate::terminal::cli_agent_sessions::{
|
||||
CLIAgentInputEntrypoint, CLIAgentInputState, CLIAgentRichInputCloseReason, CLIAgentSession,
|
||||
CLIAgentSessionContext, CLIAgentSessionStatus, CLIAgentSessionsModel,
|
||||
};
|
||||
use crate::terminal::CLIAgent;
|
||||
use crate::terminal::TerminalView;
|
||||
use crate::terminal::{CLIAgent, 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.
|
||||
@@ -42,7 +41,7 @@ pub(crate) fn apply_selected_agent_model_update(
|
||||
// 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()
|
||||
.get_base_llm_choices_for_agent_mode(ctx)
|
||||
.any(|info| info.id == model_id);
|
||||
if !model_is_available {
|
||||
log::warn!("Skipping shared-session model update - {model_id} is unknown");
|
||||
@@ -210,8 +209,11 @@ pub(crate) fn apply_selected_conversation_update(
|
||||
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() {
|
||||
if let Some(conversation_id) = agent_view_controller
|
||||
.as_ref(ctx)
|
||||
.agent_view_state()
|
||||
.active_conversation_id()
|
||||
{
|
||||
let history_model = BlocklistAIHistoryModel::handle(ctx);
|
||||
let is_empty = history_model
|
||||
.as_ref(ctx)
|
||||
@@ -316,11 +318,13 @@ fn build_selected_conversation_update_agent_view_enabled(
|
||||
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() {
|
||||
let agent_view_controller = agent_view_controller.as_ref(ctx);
|
||||
let selected_conversation = if !agent_view_controller.is_active() {
|
||||
SelectedConversation::NoConversation
|
||||
} else if let Some(conversation_id) = agent_view_state.active_conversation_id() {
|
||||
} else if let Some(conversation_id) = agent_view_controller
|
||||
.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())
|
||||
@@ -389,6 +393,7 @@ pub(crate) fn apply_cli_agent_state_update(
|
||||
remote_host: None,
|
||||
draft_text: None,
|
||||
custom_command_prefix: None,
|
||||
received_rich_notification: false,
|
||||
// Viewer input is managed by the sync protocol,
|
||||
// not local status-change auto-toggle.
|
||||
should_auto_toggle_input: false,
|
||||
@@ -402,11 +407,21 @@ pub(crate) fn apply_cli_agent_state_update(
|
||||
});
|
||||
}
|
||||
|
||||
// For cloud agent sessions with non-Oz harnesses, auto-open rich
|
||||
// input when creating a new CLI agent session so the viewer gets the
|
||||
// composer immediately (byte-sharing has roundtrip lag without it).
|
||||
let effective_rich_input_open =
|
||||
if !already_exists && view.as_ref(ctx).is_shared_ambient_agent_session() {
|
||||
true
|
||||
} else {
|
||||
*is_rich_input_open
|
||||
};
|
||||
|
||||
// Update the rich input state.
|
||||
let currently_open = CLIAgentSessionsModel::as_ref(ctx).is_input_open(view_id);
|
||||
if currently_open != *is_rich_input_open {
|
||||
if currently_open != effective_rich_input_open {
|
||||
view.update(ctx, |view, ctx| {
|
||||
if *is_rich_input_open {
|
||||
if effective_rich_input_open {
|
||||
view.open_cli_agent_rich_input(
|
||||
CLIAgentInputEntrypoint::SharedSessionSync,
|
||||
ctx,
|
||||
@@ -416,6 +431,10 @@ pub(crate) fn apply_cli_agent_state_update(
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.sync_agent_view_for_shared_third_party_viewer(ctx);
|
||||
});
|
||||
}
|
||||
CLIAgentSessionState::Inactive => {
|
||||
// Session cleanup is handled by BlockCompleted events on the
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+149
-27
@@ -3,32 +3,32 @@ use std::sync::Arc;
|
||||
use async_channel::Sender;
|
||||
use futures_util::stream::AbortHandle;
|
||||
use instant::Instant;
|
||||
|
||||
use galaxyui::{App, ModelHandle};
|
||||
use parking_lot::FairMutex;
|
||||
use session_sharing_protocol::{
|
||||
common::{
|
||||
ActivePrompt, OrderedTerminalEvent, OrderedTerminalEventType, ParticipantId, Selection,
|
||||
SessionId,
|
||||
},
|
||||
sharer::{DownstreamMessage, ReconnectToken, UpstreamMessage},
|
||||
use session_sharing_protocol::common::{
|
||||
ActivePrompt, OrderedTerminalEvent, OrderedTerminalEventType, ParticipantId, Selection,
|
||||
SessionId,
|
||||
};
|
||||
use session_sharing_protocol::sharer::{
|
||||
DownstreamMessage, FailedToInitializeSessionReason, QuotaType, ReconnectToken, UpstreamMessage,
|
||||
};
|
||||
use galaxy_server_client::iap::IapManager;
|
||||
use galaxyui::{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::{
|
||||
startup_max_attempts, Network, PtyBytesBatchStatus, Stage, StartupFailure, StartupRetryState,
|
||||
AMBIENT_CREATE_SESSION_MAX_ATTEMPTS,
|
||||
};
|
||||
|
||||
use super::{Network, PtyBytesBatchStatus, Stage};
|
||||
use crate::auth::auth_manager::AuthManager;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::editor::ReplicaId;
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::server::telemetry::context_provider::AppTelemetryContextProvider;
|
||||
use crate::terminal::shared_session::{
|
||||
SharedSessionScrollbackType, SharedSessionSource, MAX_BYTES_SHAREABLE,
|
||||
};
|
||||
use crate::terminal::TerminalModel;
|
||||
use crate::test_util::assert_eventually;
|
||||
|
||||
fn is_upstream_message_pty_bytes_read(
|
||||
message: UpstreamMessage,
|
||||
@@ -42,6 +42,117 @@ fn is_upstream_message_pty_bytes_read(
|
||||
}) if event_no == expected_event_no && bytes == compressed_bytes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_startup_max_attempts_only_retries_ambient_agent_sources() {
|
||||
assert_eq!(
|
||||
startup_max_attempts(&SharedSessionSource::ambient_agent(Some(
|
||||
"task-id".to_string()
|
||||
))),
|
||||
AMBIENT_CREATE_SESSION_MAX_ATTEMPTS
|
||||
);
|
||||
assert_eq!(startup_max_attempts(&SharedSessionSource::user(None)), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_startup_failure_retryability() {
|
||||
assert!(StartupFailure::Transport.is_retryable());
|
||||
assert!(StartupFailure::InitializeSend.is_retryable());
|
||||
assert!(StartupFailure::WebsocketClosedBeforeStarted.is_retryable());
|
||||
assert!(StartupFailure::WebsocketError.is_retryable());
|
||||
assert!(StartupFailure::Timeout.is_retryable());
|
||||
assert!(
|
||||
StartupFailure::ServerRejected(FailedToInitializeSessionReason::InternalServerError {
|
||||
details: "transient".to_string(),
|
||||
})
|
||||
.is_retryable()
|
||||
);
|
||||
|
||||
assert!(!StartupFailure::ServerRejected(
|
||||
FailedToInitializeSessionReason::ScrollbackTooLarge {}
|
||||
)
|
||||
.is_retryable());
|
||||
assert!(!StartupFailure::ServerRejected(
|
||||
FailedToInitializeSessionReason::NoUserQuotaRemaining {
|
||||
quota_type: QuotaType::SessionsCreated,
|
||||
}
|
||||
)
|
||||
.is_retryable());
|
||||
assert!(
|
||||
!StartupFailure::ServerRejected(FailedToInitializeSessionReason::UserNotFound)
|
||||
.is_retryable()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_retry_startup_failure_respects_attempt_budget() {
|
||||
App::test((), |mut app| async move {
|
||||
let network = create_network(&mut app, false).0;
|
||||
|
||||
network.update(&mut app, |network, _| {
|
||||
network.stage = Stage::BeforeStarted {
|
||||
startup_retry: StartupRetryState {
|
||||
current_attempt: 1,
|
||||
max_attempts: AMBIENT_CREATE_SESSION_MAX_ATTEMPTS,
|
||||
timeout_abort_handle: None,
|
||||
transport_abort_handle: None,
|
||||
},
|
||||
};
|
||||
assert!(network.should_retry_startup_failure(&StartupFailure::Timeout));
|
||||
|
||||
network.stage = Stage::BeforeStarted {
|
||||
startup_retry: StartupRetryState {
|
||||
current_attempt: AMBIENT_CREATE_SESSION_MAX_ATTEMPTS,
|
||||
max_attempts: AMBIENT_CREATE_SESSION_MAX_ATTEMPTS,
|
||||
timeout_abort_handle: None,
|
||||
transport_abort_handle: None,
|
||||
},
|
||||
};
|
||||
assert!(!network.should_retry_startup_failure(&StartupFailure::Timeout));
|
||||
|
||||
let mut startup_retry = StartupRetryState::new(1);
|
||||
startup_retry.current_attempt = 1;
|
||||
network.stage = Stage::BeforeStarted { startup_retry };
|
||||
assert!(
|
||||
!network.should_retry_startup_failure(&StartupFailure::ServerRejected(
|
||||
FailedToInitializeSessionReason::InternalServerError {
|
||||
details: "transient".to_string(),
|
||||
}
|
||||
))
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_startup_attempt_stale_filtering() {
|
||||
App::test((), |mut app| async move {
|
||||
let network = create_network(&mut app, false).0;
|
||||
|
||||
network.update(&mut app, |network, _| {
|
||||
network.stage = Stage::BeforeStarted {
|
||||
startup_retry: StartupRetryState {
|
||||
current_attempt: 1,
|
||||
max_attempts: AMBIENT_CREATE_SESSION_MAX_ATTEMPTS,
|
||||
timeout_abort_handle: None,
|
||||
transport_abort_handle: None,
|
||||
},
|
||||
};
|
||||
assert!(!network.should_ignore_startup_attempt_websocket_callback(1));
|
||||
assert!(network.should_ignore_startup_attempt_websocket_callback(0));
|
||||
network.stage = Stage::StartedSuccessfully {
|
||||
startup_attempt: Some(1),
|
||||
};
|
||||
assert!(!network.should_ignore_startup_attempt_websocket_callback(1));
|
||||
assert!(network.should_ignore_startup_attempt_websocket_callback(0));
|
||||
|
||||
network.stage = Stage::StartedSuccessfully {
|
||||
startup_attempt: None,
|
||||
};
|
||||
assert!(!network.should_ignore_startup_attempt_websocket_callback(0));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn is_upstream_message_command_executed(
|
||||
message: &UpstreamMessage,
|
||||
expected_event_no: usize,
|
||||
@@ -75,7 +186,9 @@ fn create_network(
|
||||
|
||||
if session_initialized {
|
||||
network.update(app, |network, _| {
|
||||
network.stage = Stage::StartedSuccessfully;
|
||||
network.stage = Stage::StartedSuccessfully {
|
||||
startup_attempt: None,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -473,7 +586,7 @@ fn test_messages_are_buffered_before_session_initialized() {
|
||||
// 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!(matches!(&network.stage, Stage::BeforeStarted { .. }));
|
||||
assert_eq!(network.unacked_terminal_events.len(), 0);
|
||||
});
|
||||
|
||||
@@ -494,7 +607,7 @@ fn test_messages_are_buffered_before_session_initialized() {
|
||||
// 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!(matches!(&network.stage, Stage::BeforeStarted { .. }));
|
||||
assert!(is_upstream_message_command_executed(
|
||||
&UpstreamMessage::OrderedTerminalEvent(
|
||||
network.unacked_terminal_events.get(&0).unwrap().clone()
|
||||
@@ -525,7 +638,7 @@ fn test_messages_are_buffered_before_session_initialized() {
|
||||
matches!(item.unwrap(), UpstreamMessage::UpdateActivePrompt(_));
|
||||
|
||||
network.read(&app, |network, _| {
|
||||
assert!(matches!(&network.stage, Stage::StartedSuccessfully));
|
||||
assert!(matches!(&network.stage, Stage::StartedSuccessfully { .. }));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -534,6 +647,15 @@ fn test_messages_are_buffered_before_session_initialized() {
|
||||
fn test_messages_are_buffered_while_reconnecting() {
|
||||
App::test((), |mut app| async move {
|
||||
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
|
||||
// Disabled (`None`) IapManager so the reconnect path, which reads the
|
||||
// singleton, doesn't panic; inert no-op in tests.
|
||||
app.add_singleton_model(|ctx| {
|
||||
IapManager::new(
|
||||
None,
|
||||
Box::new(|_| futures::FutureExt::boxed(futures::future::ready(None::<String>))),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
app.add_singleton_model(AppTelemetryContextProvider::new_context_provider);
|
||||
app.add_singleton_model(AuthManager::new_for_test);
|
||||
@@ -543,7 +665,7 @@ fn test_messages_are_buffered_while_reconnecting() {
|
||||
// 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!(matches!(&network.stage, Stage::BeforeStarted { .. }));
|
||||
assert_eq!(network.unacked_terminal_events.len(), 0);
|
||||
});
|
||||
|
||||
@@ -620,7 +742,7 @@ fn test_messages_are_buffered_while_reconnecting() {
|
||||
matches!(item.unwrap(), UpstreamMessage::UpdateActivePrompt(_));
|
||||
|
||||
network.read(&app, |network, _| {
|
||||
assert!(matches!(&network.stage, Stage::StartedSuccessfully));
|
||||
assert!(matches!(&network.stage, Stage::StartedSuccessfully { .. }));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,29 +1,41 @@
|
||||
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 galaxyui::{Entity, ModelContext, SingletonEntity, WeakViewHandle};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{sink, Sink};
|
||||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::FairMutex;
|
||||
use session_sharing_protocol::common::{
|
||||
OrderedTerminalEvent, OrderedTerminalEventType, Scrollback, WindowSize,
|
||||
};
|
||||
use std::io::{sink, Sink};
|
||||
use std::sync::Arc;
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity, WeakViewHandle};
|
||||
|
||||
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::event_listener::ChannelEventListener;
|
||||
use crate::terminal::model::ansi::{self};
|
||||
use crate::terminal::model::block::AgentInteractionMetadata;
|
||||
use crate::terminal::shared_session::ai_agent::decode_agent_response_event;
|
||||
use crate::terminal::shared_session::shared_handlers::RemoteUpdateGuard;
|
||||
use crate::terminal::shared_session::{decode_scrollback, SharedSessionStatus};
|
||||
use crate::terminal::view::ambient_agent::is_cloud_agent_pre_first_exchange;
|
||||
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;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum SharedSessionInitialLoadMode {
|
||||
/// Replace the viewer's placeholder block list with the scrollback snapshot from the session
|
||||
/// being joined.
|
||||
ReplaceFromSessionScrollback,
|
||||
/// Add only the new blocks from a follow-up session while preserving the existing shared
|
||||
/// ambient-agent transcript.
|
||||
AppendFollowupScrollback,
|
||||
}
|
||||
|
||||
/// The event loop is used to process a stream of events
|
||||
/// originating from the sender.
|
||||
pub struct EventLoop {
|
||||
@@ -47,6 +59,7 @@ pub struct EventLoop {
|
||||
sink: Sink,
|
||||
|
||||
channel_event_listener: ChannelEventListener,
|
||||
remote_update_guard: RemoteUpdateGuard,
|
||||
|
||||
/// The next event number we need from the server.
|
||||
next_event_no: usize,
|
||||
@@ -56,6 +69,8 @@ pub struct EventLoop {
|
||||
|
||||
/// A buffer to maintain events we receive from the server that are unordered.
|
||||
buffer: HashMap<usize, OrderedTerminalEventType>,
|
||||
|
||||
should_suppress_existing_agent_conversation_replay: bool,
|
||||
}
|
||||
|
||||
impl EventLoop {
|
||||
@@ -67,13 +82,27 @@ impl EventLoop {
|
||||
window_size: WindowSize,
|
||||
scrollback: Scrollback,
|
||||
catching_up_to_event_no: Option<usize>,
|
||||
load_mode: SharedSessionInitialLoadMode,
|
||||
remote_update_guard: RemoteUpdateGuard,
|
||||
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);
|
||||
{
|
||||
let mut terminal_model = terminal_model.lock();
|
||||
match load_mode {
|
||||
SharedSessionInitialLoadMode::ReplaceFromSessionScrollback => {
|
||||
terminal_model.load_shared_session_scrollback(scrollback_blocks.as_slice());
|
||||
}
|
||||
SharedSessionInitialLoadMode::AppendFollowupScrollback => {
|
||||
terminal_model
|
||||
.append_followup_shared_session_scrollback(scrollback_blocks.as_slice());
|
||||
}
|
||||
}
|
||||
if is_alt_screen_active {
|
||||
terminal_model.enter_alt_screen(true);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -100,10 +129,15 @@ impl EventLoop {
|
||||
parser: ansi::Processor::new(),
|
||||
sink: sink(),
|
||||
channel_event_listener,
|
||||
remote_update_guard,
|
||||
// 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,
|
||||
should_suppress_existing_agent_conversation_replay: matches!(
|
||||
load_mode,
|
||||
SharedSessionInitialLoadMode::AppendFollowupScrollback
|
||||
),
|
||||
};
|
||||
|
||||
// Respect the sharer's window size.
|
||||
@@ -145,6 +179,7 @@ impl EventLoop {
|
||||
|
||||
// Flush out as many contiguous events as we can.
|
||||
while let Some(next_event) = self.buffer.remove(&self.next_event_no) {
|
||||
let _active_remote_update = self.remote_update_guard.start_remote_update();
|
||||
match next_event {
|
||||
OrderedTerminalEventType::PtyBytesRead { bytes } => {
|
||||
let mut model = self.terminal_model.lock();
|
||||
@@ -162,8 +197,35 @@ impl EventLoop {
|
||||
if ai_metadata.is_none() {
|
||||
if let Some(view) = self.terminal_view.upgrade(ctx) {
|
||||
view.update(ctx, |view, ctx| {
|
||||
// Skip during cloud setup: clearing on every setup command would
|
||||
// wipe a follow-up the viewer is composing. Mirrors the
|
||||
// `InputUpdated` guard.
|
||||
let skip_clear_during_setup =
|
||||
FeatureFlag::CloudModeSetupV2.is_enabled() && {
|
||||
let model = view.model.lock();
|
||||
is_cloud_agent_pre_first_exchange(
|
||||
view.ambient_agent_view_model(),
|
||||
view.agent_view_controller(),
|
||||
&model,
|
||||
ctx,
|
||||
)
|
||||
};
|
||||
if skip_clear_during_setup || view.has_queued_command_in_flight(ctx)
|
||||
{
|
||||
return;
|
||||
}
|
||||
view.input().update(ctx, |input, ctx| {
|
||||
input.unfreeze_and_clear_agent_input(ctx);
|
||||
// Restore frozen visual state. Then also reinitialize the
|
||||
// buffer here: for shell commands the block transition will
|
||||
// reset the CRDT with a new block ID shortly after, so the
|
||||
// brief CRDT inconsistency is harmless. This pre-emptive
|
||||
// clear gives the viewer an empty buffer while the command
|
||||
// runs rather than showing the command text.
|
||||
input.unfreeze_agent_input(false, ctx);
|
||||
let editor = input.editor().clone();
|
||||
editor.update(ctx, |editor, ctx| {
|
||||
editor.reinitialize_buffer(None, ctx);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -242,7 +304,10 @@ impl EventLoop {
|
||||
OrderedTerminalEventType::Resize { window_size } => {
|
||||
self.process_resize_event(window_size, ctx)
|
||||
}
|
||||
OrderedTerminalEventType::CommandExecutionFinished { .. } => (),
|
||||
OrderedTerminalEventType::CommandExecutionFinished { .. } => {
|
||||
// Queue advancement waits for block completion so input cleanup can observe
|
||||
// the in-flight queued command and preserve any local draft.
|
||||
}
|
||||
OrderedTerminalEventType::AgentResponseEvent {
|
||||
response_initiator,
|
||||
response_event,
|
||||
@@ -291,11 +356,44 @@ impl EventLoop {
|
||||
self.terminal_model
|
||||
.lock()
|
||||
.set_is_receiving_agent_conversation_replay(true);
|
||||
if let Some(view) = self.terminal_view.upgrade(ctx) {
|
||||
let should_suppress_existing_replay =
|
||||
self.should_suppress_existing_agent_conversation_replay;
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.ai_controller().update(ctx, |controller, _| {
|
||||
controller.set_should_suppress_existing_agent_conversation_replay(
|
||||
should_suppress_existing_replay,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
OrderedTerminalEventType::AgentConversationReplayEnded => {
|
||||
self.terminal_model
|
||||
.lock()
|
||||
.set_is_receiving_agent_conversation_replay(false);
|
||||
if let Some(view) = self.terminal_view.upgrade(ctx) {
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.ai_controller().update(ctx, |controller, _| {
|
||||
controller
|
||||
.set_should_suppress_existing_agent_conversation_replay(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
OrderedTerminalEventType::CloudModeSetupPhaseEnded => {
|
||||
// Canonical setup-complete signal from the sharer. Legacy
|
||||
// AppendedExchange-driven teardowns remain idempotently as
|
||||
// a fallback for pre-feature sharers.
|
||||
if let Some(view) = self.terminal_view.upgrade(ctx) {
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.tear_down_cloud_mode_setup_phase(ctx);
|
||||
// A promptless handoff run never fires a first turn,
|
||||
// so this is the only point a prompt queued during
|
||||
// setup can be auto-sent.
|
||||
view.maybe_drain_queue_after_promptless_setup(ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,7 +405,7 @@ impl EventLoop {
|
||||
{
|
||||
// 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.
|
||||
// by ensuring we're not overwriting the new role.
|
||||
if let Some(role) = presence_manager.as_ref(ctx).role() {
|
||||
self.terminal_model.lock().set_shared_session_status(
|
||||
SharedSessionStatus::ActiveViewer { role },
|
||||
@@ -329,5 +427,5 @@ impl Entity for EventLoop {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "event_loop_test.rs"]
|
||||
#[path = "event_loop_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
use crate::terminal::HistoryEntry;
|
||||
use galaxyui::Entity;
|
||||
|
||||
use crate::terminal::HistoryEntry;
|
||||
|
||||
/// Responsible for managing the history of a shared session for a viewer.
|
||||
#[derive(Default)]
|
||||
pub struct SharedSessionHistoryModel {
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
mod event_loop;
|
||||
pub(crate) mod history_model;
|
||||
mod network;
|
||||
pub(crate) mod orchestration_viewer_model;
|
||||
pub(crate) mod terminal_manager;
|
||||
pub(crate) use terminal_manager::TerminalManager;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_test.rs"]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
+4
-6
@@ -1,12 +1,10 @@
|
||||
use galaxyui::{App, SingletonEntity};
|
||||
use settings::Setting;
|
||||
|
||||
use crate::{
|
||||
terminal::{
|
||||
safe_mode_settings::SafeModeSettings, shared_session::SharedSessionStatus, TerminalModel,
|
||||
},
|
||||
test_util::settings::initialize_settings_for_tests,
|
||||
};
|
||||
use crate::terminal::safe_mode_settings::SafeModeSettings;
|
||||
use crate::terminal::shared_session::SharedSessionStatus;
|
||||
use crate::terminal::TerminalModel;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
|
||||
#[test]
|
||||
fn test_viewer_secret_obfuscation_disabled() {
|
||||
@@ -2,62 +2,56 @@
|
||||
//! connect to and communicate with the shared session.
|
||||
//! Adheres to the [`session-sharing-protocol`].
|
||||
|
||||
use std::pin::pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::bail;
|
||||
use async_channel::Receiver;
|
||||
use galaxyui::r#async::{SpawnedFutureHandle, Timer};
|
||||
use futures_util::stream::AbortHandle;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use instant::Instant;
|
||||
use std::{pin::pin, sync::Arc};
|
||||
|
||||
use futures_util::{stream::AbortHandle, SinkExt, StreamExt};
|
||||
|
||||
use parking_lot::FairMutex;
|
||||
use session_sharing_protocol::{
|
||||
common::{
|
||||
ActivePrompt, ActivePromptUpdate, AddGuestsResponse, AgentAttachment,
|
||||
AgentPromptFailureReason, AgentPromptRequest, AgentPromptRequestId,
|
||||
CommandExecutionFailureReason, ControlAction, ControlActionFailureReason, FeatureSupport,
|
||||
InputOperationId, InputOperationSeqNo, InputUpdate, LinkAccessLevelUpdateResponse,
|
||||
ParticipantId, ParticipantList, ParticipantPresenceUpdate, RemoveGuestResponse, Role,
|
||||
RoleRequestId, RoleRequestResponse, Selection, SelectionUpdate, ServerConversationToken,
|
||||
SessionId, TeamAccessLevelUpdateResponse, TeamAclData, TelemetryContext,
|
||||
UniversalDeveloperInputContext, UniversalDeveloperInputContextUpdate,
|
||||
UpdatePendingUserRoleResponse, UserID, WindowSize, WriteToPtyFailureReason,
|
||||
WriteToPtyRequestId, WriteToPtySeqNo,
|
||||
},
|
||||
sharer::SessionSourceType,
|
||||
viewer::{
|
||||
DownstreamMessage, InitPayload, RoleUpdatedReason, SessionEndedReason, UpstreamMessage,
|
||||
ViewerRemovedReason,
|
||||
},
|
||||
use session_sharing_protocol::common::{
|
||||
ActivePrompt, ActivePromptUpdate, AddGuestsResponse, AgentAttachment, AgentPromptFailureReason,
|
||||
AgentPromptRequest, AgentPromptRequestId, CommandExecutionFailureReason, ControlAction,
|
||||
ControlActionFailureReason, FeatureSupport, InputOperationId, InputOperationSeqNo, InputUpdate,
|
||||
LinkAccessLevelUpdateResponse, ParticipantId, ParticipantList, ParticipantPresenceUpdate,
|
||||
RemoveGuestResponse, Role, RoleRequestId, RoleRequestResponse, Selection, SelectionUpdate,
|
||||
ServerConversationToken, SessionId, TeamAccessLevelUpdateResponse, TeamAclData,
|
||||
TelemetryContext, UniversalDeveloperInputContext, UniversalDeveloperInputContextUpdate,
|
||||
UpdatePendingUserRoleResponse, UserID, WindowSize, WriteToPtyFailureReason,
|
||||
WriteToPtyRequestId, WriteToPtySeqNo,
|
||||
};
|
||||
use session_sharing_protocol::viewer::{
|
||||
DownstreamMessage, InitPayload, RoleUpdatedReason, SessionEndedReason, UpstreamMessage,
|
||||
ViewerRemovedReason,
|
||||
};
|
||||
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_server_client::iap::IapManager;
|
||||
use galaxyui::r#async::{SpawnedFutureHandle, Timer};
|
||||
use galaxyui::{
|
||||
Entity, ModelContext, ModelHandle, RequestState, RetryOption, SingletonEntity, WeakViewHandle,
|
||||
};
|
||||
use std::time::Duration;
|
||||
use websocket::{Message, Sink, Stream, WebsocketMessage as _};
|
||||
|
||||
use crate::{
|
||||
auth::{auth_state::AuthState, AuthStateProvider, UserUid},
|
||||
editor::{CrdtOperation, ReplicaId},
|
||||
server::{
|
||||
server_api::{auth::AuthClient, ServerApiProvider},
|
||||
telemetry::telemetry_context,
|
||||
},
|
||||
terminal::{
|
||||
event_listener::ChannelEventListener,
|
||||
model::block::BlockId,
|
||||
shared_session::{
|
||||
connect_endpoint,
|
||||
network::heartbeat::{Event as HeartbeatEvent, Heartbeat},
|
||||
viewer::event_loop::EventLoop,
|
||||
EventNumber, SELECTION_THROTTLE_PERIOD,
|
||||
},
|
||||
TerminalModel, TerminalView,
|
||||
},
|
||||
throttle::throttle,
|
||||
use crate::auth::auth_state::AuthState;
|
||||
use crate::auth::{AuthStateProvider, UserUid};
|
||||
use crate::editor::{CrdtOperation, ReplicaId};
|
||||
use crate::server::server_api::auth::AuthClient;
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::server::telemetry::telemetry_context;
|
||||
use crate::terminal::event_listener::ChannelEventListener;
|
||||
use crate::terminal::model::block::BlockId;
|
||||
use crate::terminal::shared_session::shared_handlers::RemoteUpdateGuard;
|
||||
use crate::terminal::shared_session::viewer::event_loop::{
|
||||
EventLoop, SharedSessionInitialLoadMode,
|
||||
};
|
||||
use crate::terminal::shared_session::{
|
||||
connect_endpoint, EventNumber, SharedSessionSource, SELECTION_THROTTLE_PERIOD,
|
||||
};
|
||||
use crate::terminal::{TerminalModel, TerminalView};
|
||||
use crate::throttle::throttle;
|
||||
|
||||
/// The amount of time we will wait to batch consecutive write to pty requests before sending an event to the server.
|
||||
const PTY_WRITES_BATCH_THRESHOLD: Duration = if cfg!(test) {
|
||||
@@ -113,8 +107,6 @@ struct CachedLatestState {
|
||||
/// The network interface to allow communication to and from the
|
||||
/// cloud-backed shared session.
|
||||
pub struct Network {
|
||||
heartbeat: ModelHandle<Heartbeat>,
|
||||
|
||||
session_id: SessionId,
|
||||
/// [`None`] until the viewer receives the successful join ack.
|
||||
event_loop: Option<ModelHandle<EventLoop>>,
|
||||
@@ -123,6 +115,8 @@ pub struct Network {
|
||||
|
||||
channel_event_proxy: ChannelEventListener,
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
initial_load_mode: SharedSessionInitialLoadMode,
|
||||
remote_update_guard: RemoteUpdateGuard,
|
||||
|
||||
stage: Stage,
|
||||
|
||||
@@ -149,25 +143,27 @@ pub struct Network {
|
||||
/// The next event number to use when sending a write to pty request to the server.
|
||||
write_to_pty_event_no: WriteToPtySeqNo,
|
||||
pty_bytes_batch_status: PtyBytesBatchStatus,
|
||||
|
||||
/// Input updates buffered while disconnected, to be flushed on reconnect.
|
||||
pending_input_updates: Vec<InputUpdate>,
|
||||
}
|
||||
|
||||
impl Network {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
session_id: SessionId,
|
||||
channel_event_proxy: ChannelEventListener,
|
||||
terminal_view: WeakViewHandle<TerminalView>,
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
write_to_pty_events_rx: Receiver<Vec<u8>>,
|
||||
initial_load_mode: SharedSessionInitialLoadMode,
|
||||
remote_update_guard: RemoteUpdateGuard,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let (ws_proxy_tx, ws_proxy_rx) = async_channel::unbounded();
|
||||
let (selection_throttled_tx, selection_rx) = async_channel::unbounded();
|
||||
let selection_throttled_rx = throttle(SELECTION_THROTTLE_PERIOD, selection_rx);
|
||||
let heartbeat = ctx.add_model(|_| Heartbeat::default());
|
||||
ctx.subscribe_to_model(&heartbeat, Self::handle_heartbeat_event);
|
||||
|
||||
let model = Network {
|
||||
heartbeat,
|
||||
session_id,
|
||||
event_loop: None,
|
||||
ws_proxy_tx,
|
||||
@@ -175,6 +171,8 @@ impl Network {
|
||||
ws_proxy_rx: ws_proxy_rx.clone(),
|
||||
channel_event_proxy,
|
||||
terminal_model,
|
||||
initial_load_mode,
|
||||
remote_update_guard,
|
||||
terminal_view,
|
||||
stage: Stage::BeforeJoined,
|
||||
id: None,
|
||||
@@ -189,6 +187,7 @@ impl Network {
|
||||
pty_bytes_batch_status: PtyBytesBatchStatus::NotBatching {
|
||||
last_sent_at: Instant::now(),
|
||||
},
|
||||
pending_input_updates: Vec::new(),
|
||||
};
|
||||
|
||||
model.start_write_to_pty_events_listener(write_to_pty_events_rx, ctx);
|
||||
@@ -214,6 +213,7 @@ impl Network {
|
||||
terminal_view: WeakViewHandle<TerminalView>,
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
write_to_pty_events_rx: Receiver<Vec<u8>>,
|
||||
remote_update_guard: RemoteUpdateGuard,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
use session_sharing_protocol::common::SessionId;
|
||||
@@ -221,22 +221,20 @@ impl Network {
|
||||
let (ws_proxy_tx, ws_proxy_rx) = async_channel::unbounded();
|
||||
let (selection_throttled_tx, selection_rx) = async_channel::unbounded();
|
||||
let selection_throttled_rx = throttle(SELECTION_THROTTLE_PERIOD, selection_rx);
|
||||
let heartbeat = ctx.add_model(|_| Heartbeat::default());
|
||||
ctx.subscribe_to_model(&heartbeat, Self::handle_heartbeat_event);
|
||||
|
||||
let session_id = SessionId::new();
|
||||
let viewer_id = ParticipantId::new();
|
||||
let viewer_firebase_uid = UserUid::new("mock_firebase_uid");
|
||||
let active_prompt = ActivePrompt::WarpPrompt("test warp prompt".to_owned());
|
||||
|
||||
let model = Network {
|
||||
heartbeat,
|
||||
session_id,
|
||||
event_loop: None,
|
||||
ws_proxy_tx,
|
||||
ws_proxy_rx,
|
||||
channel_event_proxy,
|
||||
terminal_model,
|
||||
initial_load_mode: SharedSessionInitialLoadMode::ReplaceFromSessionScrollback,
|
||||
remote_update_guard,
|
||||
terminal_view,
|
||||
stage: Stage::BeforeJoined,
|
||||
id: Some(viewer_id.clone()),
|
||||
@@ -251,6 +249,7 @@ impl Network {
|
||||
pty_bytes_batch_status: PtyBytesBatchStatus::NotBatching {
|
||||
last_sent_at: Instant::now(),
|
||||
},
|
||||
pending_input_updates: Vec::new(),
|
||||
};
|
||||
|
||||
ctx.emit(NetworkEvent::JoinedSuccessfully {
|
||||
@@ -260,7 +259,7 @@ impl Network {
|
||||
participant_list: Default::default(),
|
||||
input_replica_id: ReplicaId::random(),
|
||||
universal_developer_input_context: None,
|
||||
source_type: SessionSourceType::default(),
|
||||
source: SharedSessionSource::default(),
|
||||
});
|
||||
|
||||
model.start_write_to_pty_events_listener(write_to_pty_events_rx, ctx);
|
||||
@@ -278,21 +277,6 @@ impl Network {
|
||||
model
|
||||
}
|
||||
|
||||
/// We need to ensure we're maintaining a heartbeat with the server.
|
||||
/// This helps us detect if the server has gone away silently and helps
|
||||
/// the server detect if we (the client) have disconnected quietly.
|
||||
fn handle_heartbeat_event(&mut self, event: &HeartbeatEvent, ctx: &mut ModelContext<Self>) {
|
||||
match event {
|
||||
HeartbeatEvent::Ping => {
|
||||
self.send_message_to_server(UpstreamMessage::Ping { data: vec![] });
|
||||
}
|
||||
HeartbeatEvent::Idle => {
|
||||
log::info!("Viewer reconnecting: heartbeat idle timeout");
|
||||
self.reconnect_websocket(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_user_id(
|
||||
auth_client: Arc<dyn AuthClient>,
|
||||
auth_state: &AuthState,
|
||||
@@ -312,12 +296,15 @@ impl Network {
|
||||
session_id: SessionId,
|
||||
auth_client: Arc<dyn AuthClient>,
|
||||
auth_state: Arc<AuthState>,
|
||||
iap_headers: Vec<(&'static str, String)>,
|
||||
) -> anyhow::Result<((impl Sink, impl Stream), UserID)> {
|
||||
let Some(join_endpoint) = connect_endpoint(format!("/sessions/join/{session_id}")) else {
|
||||
bail!("This channel does not support session-sharing.");
|
||||
};
|
||||
let user_id = Self::get_user_id(auth_client, &auth_state).await?;
|
||||
let socket = websocket::WebSocket::connect(join_endpoint, None /* protocols */).await?;
|
||||
let socket =
|
||||
websocket::WebSocket::connect_with_headers(&join_endpoint, None::<&str>, iap_headers)
|
||||
.await?;
|
||||
anyhow::Ok(((socket.split().await), user_id))
|
||||
}
|
||||
|
||||
@@ -328,18 +315,11 @@ impl Network {
|
||||
stream: impl Stream,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.heartbeat.update(ctx, |heartbeat, ctx| {
|
||||
heartbeat.start(ctx);
|
||||
});
|
||||
|
||||
// Receive messages from the server.
|
||||
ctx.spawn_stream_local(
|
||||
stream,
|
||||
|network, item, ctx| match item {
|
||||
Ok(message) => {
|
||||
network.heartbeat.update(ctx, |heartbeat, ctx| {
|
||||
heartbeat.reset_idle_timeout(ctx);
|
||||
});
|
||||
network.process_websocket_message(message, ctx);
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -348,13 +328,13 @@ impl Network {
|
||||
},
|
||||
|network, ctx| {
|
||||
log::info!("Websocket to session sharing server ended");
|
||||
// Close our current websocket proxy, because we may try to reconnect and that will create a new websocket proxy.
|
||||
// This must be done before trying to reconnect.
|
||||
network.close();
|
||||
if matches!(network.stage, Stage::JoinedSuccessfully) {
|
||||
// The connection may have timed out or the server restarted.
|
||||
log::info!("Viewer reconnecting: websocket closed by server");
|
||||
network.reconnect_websocket(ctx);
|
||||
} else if !matches!(network.stage, Stage::Reconnecting { .. }) {
|
||||
// Not reconnecting — clean up the proxy channel.
|
||||
network.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -389,10 +369,20 @@ impl Network {
|
||||
) {
|
||||
let auth_client = ServerApiProvider::as_ref(ctx).get_auth_client();
|
||||
let auth_state = AuthStateProvider::as_ref(ctx).get().clone();
|
||||
let iap_headers: Vec<(&'static str, String)> = IapManager::as_ref(ctx)
|
||||
.iap_state()
|
||||
.and_then(|state| state.proxy_auth_header())
|
||||
.into_iter()
|
||||
.collect();
|
||||
// Open a websocket to the server to join the session.
|
||||
ctx.spawn(
|
||||
Self::connect_websocket_and_get_user_id(session_id, auth_client, auth_state.clone()),
|
||||
|network, conn, ctx| match conn {
|
||||
Self::connect_websocket_and_get_user_id(
|
||||
session_id,
|
||||
auth_client,
|
||||
auth_state.clone(),
|
||||
iap_headers,
|
||||
),
|
||||
move |network, conn, ctx| match conn {
|
||||
Ok(((sink, stream), user_id)) => {
|
||||
let initialize_message = UpstreamMessage::Initialize(InitPayload {
|
||||
viewer_id: network.id.clone(),
|
||||
@@ -414,7 +404,13 @@ impl Network {
|
||||
network.on_websocket_connected(ws_proxy_rx, sink, stream, ctx)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to join shared session: {e}");
|
||||
log::error!(
|
||||
"viewer Network::start_websocket: WS connect FAILED for \
|
||||
session_id={session_id}: {e:#}; emitting FailedToJoin (no automatic retry)"
|
||||
);
|
||||
IapManager::handle(ctx).update(ctx, |manager, ctx| {
|
||||
manager.check_ws_connect_error(&e, ctx);
|
||||
});
|
||||
ctx.emit(NetworkEvent::FailedToJoin {
|
||||
reason: FailedToJoinReason::FailedToConnectToServer,
|
||||
});
|
||||
@@ -432,6 +428,11 @@ impl Network {
|
||||
if matches!(self.stage, Stage::Finished | Stage::Reconnecting { .. }) {
|
||||
return;
|
||||
}
|
||||
// Close the old connection before reconnecting so the server sees
|
||||
// WebsocketClosed immediately rather than waiting for its own idle
|
||||
// timer. Stage is guaranteed not Reconnecting here, so close() will
|
||||
// not abort any in-progress reconnect handle.
|
||||
self.close();
|
||||
let Some(event_loop) = self.event_loop.clone() else {
|
||||
log::error!("Cannot reconnect to server as viewer when event loop does not exist");
|
||||
return;
|
||||
@@ -439,10 +440,23 @@ impl Network {
|
||||
let session_id = self.session_id;
|
||||
let auth_client = ServerApiProvider::as_ref(ctx).get_auth_client();
|
||||
let auth_state = AuthStateProvider::as_ref(ctx).get().clone();
|
||||
let iap_state = IapManager::as_ref(ctx).iap_state();
|
||||
let abort_handle = ctx.spawn_with_retry_on_error(
|
||||
move || {
|
||||
log::info!("Attempting to reconnect to session sharing server as viewer");
|
||||
Self::connect_websocket_and_get_user_id(session_id, auth_client.clone(), auth_state.clone())
|
||||
// Re-read the IAP header each attempt so a refresh that landed
|
||||
// since the last try is picked up (staging only).
|
||||
let iap_headers: Vec<(&'static str, String)> = iap_state
|
||||
.as_ref()
|
||||
.and_then(|state| state.proxy_auth_header())
|
||||
.into_iter()
|
||||
.collect();
|
||||
Self::connect_websocket_and_get_user_id(
|
||||
session_id,
|
||||
auth_client.clone(),
|
||||
auth_state.clone(),
|
||||
iap_headers,
|
||||
)
|
||||
},
|
||||
RECONNECT_RETRY_STRATEGY,
|
||||
move |network, conn, ctx| match conn {
|
||||
@@ -472,6 +486,9 @@ impl Network {
|
||||
network.on_websocket_connected(ws_proxy_rx, sink, stream, ctx)
|
||||
}
|
||||
RequestState::RequestFailedRetryPending(e) => {
|
||||
IapManager::handle(ctx).update(ctx, |manager, ctx| {
|
||||
manager.check_ws_connect_error(&e, ctx);
|
||||
});
|
||||
log::warn!("Failed to reconnect to shared session as viewer, will retry: {e}");
|
||||
}
|
||||
RequestState::RequestFailed(e) => {
|
||||
@@ -489,10 +506,10 @@ impl Network {
|
||||
|
||||
/// Fetches the new user id and reconnectes to the websocket.
|
||||
pub fn reauthenticate_viewer(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
let server_api = ServerApiProvider::as_ref(ctx).get();
|
||||
let auth_client = ServerApiProvider::as_ref(ctx).get_auth_client();
|
||||
let auth_state = AuthStateProvider::as_ref(ctx).get().clone();
|
||||
ctx.spawn(
|
||||
async move { Self::get_user_id(server_api, &auth_state).await },
|
||||
async move { Self::get_user_id(auth_client, &auth_state).await },
|
||||
|network, res, ctx| match res {
|
||||
Ok(user_id) => {
|
||||
let message = UpstreamMessage::Reauthenticated { user_id };
|
||||
@@ -508,10 +525,11 @@ impl Network {
|
||||
}
|
||||
|
||||
fn process_websocket_message(&mut self, message: Message, ctx: &mut ModelContext<Self>) {
|
||||
let Some(msg) = message
|
||||
.text()
|
||||
.and_then(|t| DownstreamMessage::from_json(t).ok())
|
||||
else {
|
||||
// Ignore non-text frames (e.g. ping frames sent by the server).
|
||||
let Some(text) = message.text() else {
|
||||
return;
|
||||
};
|
||||
let Some(msg) = DownstreamMessage::from_json(text).ok() else {
|
||||
log::warn!("Got unexpected message from shared session viewer websocket");
|
||||
return;
|
||||
};
|
||||
@@ -529,15 +547,19 @@ impl Network {
|
||||
// We use the more detailed source type here,
|
||||
// ignoring the legacy source_type field (which was kept around for backwards compatibility).
|
||||
detailed_source_type: source_type,
|
||||
source_task_id,
|
||||
..
|
||||
} => {
|
||||
let source = SharedSessionSource {
|
||||
source_type,
|
||||
source_task_id,
|
||||
};
|
||||
if matches!(self.stage, Stage::JoinedSuccessfully) {
|
||||
log::warn!(
|
||||
"Received unexpected JoinedSuccessfully message when we've already joined"
|
||||
);
|
||||
return;
|
||||
}
|
||||
log::info!("Successfully joined shared session.");
|
||||
self.id = Some(viewer_id.clone());
|
||||
self.stage = Stage::JoinedSuccessfully;
|
||||
|
||||
@@ -555,6 +577,8 @@ impl Network {
|
||||
window_size,
|
||||
*scrollback,
|
||||
latest_event_no,
|
||||
self.initial_load_mode,
|
||||
self.remote_update_guard.clone(),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
@@ -566,7 +590,7 @@ impl Network {
|
||||
participant_list: Box::new(*participant_list),
|
||||
input_replica_id: input_replica_id.into(),
|
||||
universal_developer_input_context,
|
||||
source_type,
|
||||
source,
|
||||
});
|
||||
}
|
||||
DownstreamMessage::RejoinedSuccessfully { participant_list } => {
|
||||
@@ -576,6 +600,7 @@ impl Network {
|
||||
}
|
||||
log::info!("Successfully reconnected to shared session as viewer.");
|
||||
self.stage = Stage::JoinedSuccessfully;
|
||||
self.flush_pending_input_updates_to_server();
|
||||
// Events where we only care about the latest value were dropped before we reconnected.
|
||||
self.send_latest_state_to_server();
|
||||
ctx.emit(NetworkEvent::ReconnectedSuccessfully);
|
||||
@@ -615,7 +640,13 @@ impl Network {
|
||||
));
|
||||
}
|
||||
DownstreamMessage::FailedToJoin { reason } => {
|
||||
log::warn!("Failed to join shared session: {reason:?}");
|
||||
log::warn!(
|
||||
"viewer Network: server replied FailedToJoin for \
|
||||
session_id={} reason={reason:?} stage={:?} (no automatic retry on initial \
|
||||
join failure)",
|
||||
self.session_id,
|
||||
std::mem::discriminant(&self.stage),
|
||||
);
|
||||
|
||||
if let Stage::Reconnecting { abort_handle } = &self.stage {
|
||||
abort_handle.abort();
|
||||
@@ -817,6 +848,8 @@ impl Network {
|
||||
// with are monotonically increasing.
|
||||
if block_id != &self.next_buffer_seq_no.0 {
|
||||
self.next_buffer_seq_no = (block_id.to_owned(), InputOperationSeqNo::zero());
|
||||
// Clear buffered ops for the old block since they're now stale.
|
||||
self.pending_input_updates.clear();
|
||||
}
|
||||
|
||||
let operations = operations
|
||||
@@ -838,7 +871,20 @@ impl Network {
|
||||
};
|
||||
self.next_buffer_seq_no.1.advance();
|
||||
|
||||
self.send_message_to_server(UpstreamMessage::UpdateInput(InputUpdate { id, ops }));
|
||||
let update = InputUpdate { id, ops };
|
||||
if matches!(self.stage, Stage::JoinedSuccessfully) {
|
||||
if let Err(e) = self
|
||||
.ws_proxy_tx
|
||||
.try_send(UpstreamMessage::UpdateInput(update))
|
||||
{
|
||||
log::warn!(
|
||||
"Failed to send input update over ws_proxy channel in viewer network: {e}"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Not connected; buffer the update to be flushed on reconnect.
|
||||
self.pending_input_updates.push(update);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_write_to_pty(&mut self) {
|
||||
@@ -939,6 +985,21 @@ impl Network {
|
||||
self.send_message_to_server(UpstreamMessage::ReportTerminalSize { window_size });
|
||||
}
|
||||
|
||||
/// Sends all input updates buffered during disconnection to the server, then clears the buffer.
|
||||
fn flush_pending_input_updates_to_server(&mut self) {
|
||||
for update in self.pending_input_updates.drain(..) {
|
||||
if let Err(e) = self
|
||||
.ws_proxy_tx
|
||||
.try_send(UpstreamMessage::UpdateInput(update))
|
||||
{
|
||||
log::warn!(
|
||||
"Failed to send pending input update over ws_proxy channel in viewer network: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send everything in `self.cached_latest_state` to the server.
|
||||
/// This is needed when we reconnect to the server, since all values were dropped before we were connected.
|
||||
fn send_latest_state_to_server(&mut self) {
|
||||
@@ -1109,7 +1170,7 @@ pub enum NetworkEvent {
|
||||
participant_list: Box<ParticipantList>,
|
||||
input_replica_id: ReplicaId,
|
||||
universal_developer_input_context: Option<UniversalDeveloperInputContext>,
|
||||
source_type: SessionSourceType,
|
||||
source: SharedSessionSource,
|
||||
},
|
||||
FailedToJoin {
|
||||
reason: FailedToJoinReason,
|
||||
@@ -1188,5 +1249,5 @@ impl Drop for Network {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "network_test.rs"]
|
||||
#[path = "network_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
+10
-9
@@ -1,19 +1,19 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
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 session_sharing_protocol::viewer::UpstreamMessage;
|
||||
use galaxyui::{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};
|
||||
use crate::terminal::event_listener::ChannelEventListener;
|
||||
use crate::terminal::shared_session::shared_handlers::RemoteUpdateGuard;
|
||||
use crate::terminal::TerminalModel;
|
||||
use crate::test_util::add_window_with_terminal;
|
||||
use crate::test_util::terminal::initialize_app_for_terminal_view;
|
||||
|
||||
fn create_network(app: &mut App) -> (ModelHandle<Network>, Sender<Vec<u8>>) {
|
||||
initialize_app_for_terminal_view(app);
|
||||
@@ -28,6 +28,7 @@ fn create_network(app: &mut App) -> (ModelHandle<Network>, Sender<Vec<u8>>) {
|
||||
terminal_view,
|
||||
terminal_model,
|
||||
write_to_pty_events_rx,
|
||||
RemoteUpdateGuard::new(),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
@@ -0,0 +1,822 @@
|
||||
//! Drives the orchestration pill bar in shared session viewers.
|
||||
//!
|
||||
//! After the viewer joins a parent ambient-agent session, this model
|
||||
//! discovers and tracks the parent's direct children using one of two
|
||||
//! delivery paths, gated on [`FeatureFlag::OrchestrationViewerStreamer`]:
|
||||
//!
|
||||
//! 1. **Streamer-driven (flag ON, default).** Registers as a viewer-mode
|
||||
//! consumer on [`OrchestrationEventStreamer`], which opens an ancestor
|
||||
//! SSE (seeded by a one-shot REST snapshot) and broadcasts
|
||||
//! `ChildSpawned`/`ChildStatusChanged` events.
|
||||
//! 2. **Legacy REST polling (flag OFF).** Periodically polls
|
||||
//! `GET /agent/runs?ancestor_run_id=` and reconciles the full child
|
||||
//! list each cycle.
|
||||
//!
|
||||
//! Each viewer pane has its own model with its own placeholder
|
||||
//! conversations; the streamer (when on) is a shared singleton.
|
||||
//! Pill clicks navigate via `SwapPaneToConversation`.
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use session_sharing_protocol::common::SessionId;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use warpui::r#async::{SpawnedFutureHandle, Timer};
|
||||
use warpui::{Entity, EntityId, ModelContext, SingletonEntity, WeakViewHandle};
|
||||
|
||||
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
|
||||
use crate::ai::ambient_agents::{AmbientAgentTask, AmbientAgentTaskId, AmbientAgentTaskState};
|
||||
use crate::ai::blocklist::history_model::BlocklistAIHistoryEvent;
|
||||
use crate::ai::blocklist::orchestration_event_streamer::{
|
||||
OrchestrationEventStreamer, OrchestrationEventStreamerEvent,
|
||||
};
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::server::server_api::ai::TaskListFilter;
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::terminal::{Event as TerminalViewEvent, TerminalView};
|
||||
|
||||
/// Max child runs per legacy `?ancestor_run_id=` page (polling path).
|
||||
const CHILD_DISCOVERY_FETCH_LIMIT: i32 = 100;
|
||||
/// Polling cadence (legacy path) while any child is non-terminal.
|
||||
const STATUS_POLL_INTERVAL: Duration = Duration::from_secs(5);
|
||||
/// Slower polling cadence (legacy path) once every known child is terminal.
|
||||
const STATUS_POLL_INTERVAL_IDLE: Duration = Duration::from_secs(30);
|
||||
/// Refetch cadence for children whose claim-time `session_id` is not yet known.
|
||||
const PENDING_SESSION_ID_POLL_INTERVAL: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Per-child orchestration metadata, keyed by `AmbientAgentTaskId`.
|
||||
struct ChildAgentEntry {
|
||||
conversation_id: AIConversationId,
|
||||
/// `None` until execution has been claimed.
|
||||
session_id: Option<SessionId>,
|
||||
/// Polling path uses this to dedupe status writes.
|
||||
last_state: AmbientAgentTaskState,
|
||||
/// True once `EnsureSharedSessionViewerChildPane` has been emitted.
|
||||
pane_materialization_requested: bool,
|
||||
}
|
||||
|
||||
/// Owns child discovery + status tracking for a shared session viewer of
|
||||
/// an orchestrated session.
|
||||
pub struct OrchestrationViewerModel {
|
||||
parent_task_id: AmbientAgentTaskId,
|
||||
terminal_view_id: EntityId,
|
||||
terminal_view: WeakViewHandle<TerminalView>,
|
||||
/// Placeholder conversations materialized for direct children.
|
||||
children: HashMap<AmbientAgentTaskId, ChildAgentEntry>,
|
||||
/// Secondary index keyed by stringified `run_id`, used by the streamer
|
||||
/// path's broadcast event handler. Kept in sync with `children`.
|
||||
children_by_run_id: HashMap<String, AmbientAgentTaskId>,
|
||||
/// (Polling path.) `None` on the streamer path.
|
||||
polling_handle: Option<SpawnedFutureHandle>,
|
||||
/// (Polling path.) Bumped before each fetch so stale responses can
|
||||
/// be dropped.
|
||||
fetch_generation: u64,
|
||||
/// Set when the most recent fetch returned no children; resumed by
|
||||
/// the next orchestrator `AppendedExchange`.
|
||||
idle_due_to_no_children: bool,
|
||||
/// (Streamer path.) Periodic timer fetching the claim-time
|
||||
/// `session_id` for not-yet-claimed children.
|
||||
pending_session_id_poll_handle: Option<SpawnedFutureHandle>,
|
||||
/// Test-only: counts `spawn_task_metadata_fetch` invocations.
|
||||
#[cfg(test)]
|
||||
metadata_fetch_dispatch_count: usize,
|
||||
}
|
||||
|
||||
impl Entity for OrchestrationViewerModel {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl OrchestrationViewerModel {
|
||||
/// Returns the orchestrator's `AmbientAgentTaskId`.
|
||||
pub fn parent_task_id(&self) -> AmbientAgentTaskId {
|
||||
self.parent_task_id
|
||||
}
|
||||
/// Builds a viewer model attached to the given parent shared session.
|
||||
/// See the module docs for the two delivery paths.
|
||||
pub fn new(
|
||||
parent_task_id: AmbientAgentTaskId,
|
||||
terminal_view_id: EntityId,
|
||||
terminal_view: WeakViewHandle<TerminalView>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
if FeatureFlag::OrchestrationViewerStreamer.is_enabled() {
|
||||
// Streamer-driven path. Subscribe to broadcast events filtered
|
||||
// on `parent_task_id`; the streamer handles SSE open/teardown,
|
||||
// cold-start seed, and cursor persistence on our behalf.
|
||||
let streamer = OrchestrationEventStreamer::handle(ctx);
|
||||
ctx.subscribe_to_model(&streamer, move |me, _, event, ctx| {
|
||||
me.handle_streamer_event(event, ctx);
|
||||
});
|
||||
ctx.subscribe_to_model(
|
||||
&BlocklistAIHistoryModel::handle(ctx),
|
||||
|me, _, event, ctx| {
|
||||
me.handle_history_event(event, ctx);
|
||||
},
|
||||
);
|
||||
|
||||
let model = Self {
|
||||
parent_task_id,
|
||||
terminal_view_id,
|
||||
terminal_view,
|
||||
children: HashMap::new(),
|
||||
children_by_run_id: HashMap::new(),
|
||||
polling_handle: None,
|
||||
fetch_generation: 0,
|
||||
idle_due_to_no_children: false,
|
||||
pending_session_id_poll_handle: None,
|
||||
#[cfg(test)]
|
||||
metadata_fetch_dispatch_count: 0,
|
||||
};
|
||||
model.register_viewer_mode_consumer_if_possible(ctx);
|
||||
return model;
|
||||
}
|
||||
|
||||
// Legacy polling path. Kick to fast cadence on `AppendedExchange` so
|
||||
// follow-up input that spawns new children surfaces without waiting
|
||||
// for the next 30s idle poll.
|
||||
ctx.subscribe_to_model(
|
||||
&BlocklistAIHistoryModel::handle(ctx),
|
||||
|me, _, event, ctx| {
|
||||
me.maybe_kick_polling(event, ctx);
|
||||
me.maybe_backfill_parent_agent_ids(event, ctx);
|
||||
},
|
||||
);
|
||||
|
||||
let mut model = Self {
|
||||
parent_task_id,
|
||||
terminal_view_id,
|
||||
terminal_view,
|
||||
children: HashMap::new(),
|
||||
children_by_run_id: HashMap::new(),
|
||||
polling_handle: None,
|
||||
fetch_generation: 0,
|
||||
idle_due_to_no_children: false,
|
||||
pending_session_id_poll_handle: None,
|
||||
#[cfg(test)]
|
||||
metadata_fetch_dispatch_count: 0,
|
||||
};
|
||||
|
||||
// Each fetch reschedules itself via its response callback.
|
||||
model.fetch_children(ctx);
|
||||
model
|
||||
}
|
||||
|
||||
// ---- Streamer-driven path (FeatureFlag::OrchestrationViewerStreamer on)
|
||||
|
||||
fn handle_history_event(
|
||||
&mut self,
|
||||
event: &BlocklistAIHistoryEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// Stamp `parent_agent_id` on any tracked children once the parent
|
||||
// placeholder receives its server token. Children registered before
|
||||
// the parent run_id was known would otherwise stay with
|
||||
// `parent_agent_id = None` and break parent-conversation lookups.
|
||||
self.maybe_backfill_parent_agent_ids(event, ctx);
|
||||
|
||||
match event {
|
||||
BlocklistAIHistoryEvent::SetActiveConversation {
|
||||
terminal_surface_id,
|
||||
..
|
||||
}
|
||||
| BlocklistAIHistoryEvent::ConversationServerTokenAssigned {
|
||||
terminal_surface_id,
|
||||
..
|
||||
} if *terminal_surface_id == self.terminal_view_id => {
|
||||
self.register_viewer_mode_consumer_if_possible(ctx);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers this model as a viewer-mode consumer once the active
|
||||
/// conversation is the orchestrator placeholder (identified by
|
||||
/// `is_viewing_shared_session() && parent_conversation_id().is_none()`).
|
||||
/// Defers if the placeholder hasn't been stamped yet; re-runs from
|
||||
/// history events that may flip the placeholder state.
|
||||
fn register_viewer_mode_consumer_if_possible(&self, ctx: &mut ModelContext<Self>) {
|
||||
let Some(parent_conversation_id) =
|
||||
BlocklistAIHistoryModel::as_ref(ctx).active_conversation_id(self.terminal_view_id)
|
||||
else {
|
||||
log::debug!(
|
||||
"[orch-viewer] no active conversation yet for terminal_view_id={:?} \
|
||||
parent_task_id={}; registration deferred",
|
||||
self.terminal_view_id,
|
||||
self.parent_task_id,
|
||||
);
|
||||
return;
|
||||
};
|
||||
let (is_viewing_shared_session, has_parent_conv) = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&parent_conversation_id)
|
||||
.map(|conversation| {
|
||||
(
|
||||
conversation.is_viewing_shared_session(),
|
||||
conversation.parent_conversation_id().is_some(),
|
||||
)
|
||||
})
|
||||
.unwrap_or((false, false));
|
||||
let is_parent_placeholder = is_viewing_shared_session && !has_parent_conv;
|
||||
if !is_parent_placeholder {
|
||||
log::debug!(
|
||||
"[orch-viewer] active conversation {parent_conversation_id:?} for \
|
||||
terminal_view_id={:?} is not the parent placeholder yet \
|
||||
(is_viewing_shared_session={is_viewing_shared_session}, \
|
||||
has_parent_conv={has_parent_conv}); registration deferred",
|
||||
self.terminal_view_id,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let parent_task_id = self.parent_task_id;
|
||||
let consumer_id = ctx.model_id();
|
||||
OrchestrationEventStreamer::handle(ctx).update(ctx, move |streamer, ctx| {
|
||||
streamer.register_viewer_mode_consumer(
|
||||
parent_task_id,
|
||||
parent_conversation_id,
|
||||
consumer_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Routes broadcast events from the streamer, filtered on this model's
|
||||
/// `parent_task_id`.
|
||||
fn handle_streamer_event(
|
||||
&mut self,
|
||||
event: &OrchestrationEventStreamerEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
OrchestrationEventStreamerEvent::ChildSpawned {
|
||||
parent_task_id,
|
||||
run_id,
|
||||
} if *parent_task_id == self.parent_task_id => {
|
||||
self.handle_child_spawned(run_id.clone(), ctx);
|
||||
}
|
||||
OrchestrationEventStreamerEvent::ChildStatusChanged {
|
||||
parent_task_id,
|
||||
run_id,
|
||||
status,
|
||||
} if *parent_task_id == self.parent_task_id => {
|
||||
self.handle_child_status_changed(run_id, status.clone(), ctx);
|
||||
}
|
||||
// Other orchestrators (or non-viewer-mode variants) are ignored.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// First observation of a child `run_id`. Fetches pill metadata and
|
||||
/// dispatches to `register_child`. Dropped events are retried on the
|
||||
/// next status change for the same `run_id`.
|
||||
fn handle_child_spawned(&mut self, run_id: String, ctx: &mut ModelContext<Self>) {
|
||||
let Ok(task_id) = run_id.parse::<AmbientAgentTaskId>() else {
|
||||
log::warn!("[orch-viewer] ChildSpawned with malformed run_id={run_id:?}; dropping");
|
||||
return;
|
||||
};
|
||||
if self.children.contains_key(&task_id) {
|
||||
// Already materialized (e.g. re-registered after reconnect).
|
||||
return;
|
||||
}
|
||||
|
||||
self.spawn_task_metadata_fetch(task_id, "ChildSpawned", ctx);
|
||||
}
|
||||
|
||||
/// Writes the new status through `BlocklistAIHistoryModel`. If the
|
||||
/// entry hasn't been fully materialized yet (no `session_id` or no
|
||||
/// pane), also kicks a metadata refetch so the claim-time
|
||||
/// `session_id` eventually lands.
|
||||
fn handle_child_status_changed(
|
||||
&mut self,
|
||||
run_id: &str,
|
||||
status: ConversationStatus,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(task_id) = self.children_by_run_id.get(run_id).copied() else {
|
||||
// No placeholder yet; the ChildSpawned handler will create one.
|
||||
return;
|
||||
};
|
||||
let Some(entry) = self.children.get(&task_id) else {
|
||||
return;
|
||||
};
|
||||
let conversation_id = entry.conversation_id;
|
||||
let needs_metadata_refetch =
|
||||
entry.session_id.is_none() || !entry.pane_materialization_requested;
|
||||
let terminal_view_id = self.terminal_view_id;
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||
history.update_conversation_status(terminal_view_id, conversation_id, status, ctx);
|
||||
});
|
||||
|
||||
if needs_metadata_refetch {
|
||||
self.spawn_task_metadata_fetch(task_id, "ChildStatusChanged", ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches a single task's metadata and routes the response through
|
||||
/// `register_child`. The `trigger` label is logged on failure to
|
||||
/// distinguish the caller.
|
||||
fn spawn_task_metadata_fetch(
|
||||
&mut self,
|
||||
task_id: AmbientAgentTaskId,
|
||||
trigger: &'static str,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.metadata_fetch_dispatch_count += 1;
|
||||
}
|
||||
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
|
||||
let parent_task_id = self.parent_task_id;
|
||||
ctx.spawn(
|
||||
async move { ai_client.get_ambient_agent_task(&task_id).await },
|
||||
move |me, result, ctx| {
|
||||
let task = match result {
|
||||
Ok(task) => task,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"[orch-viewer] failed to fetch pill metadata for \
|
||||
child task_id={task_id} parent_task_id={parent_task_id} \
|
||||
trigger={trigger}: {err:#}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
me.register_child(task, ctx);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Shared child registration (used by both paths) -----------------
|
||||
|
||||
/// Creates the local placeholder conversation for a child task,
|
||||
/// records it in the per-pane map, and emits
|
||||
/// `EnsureSharedSessionViewerChildPane` if a session id is already
|
||||
/// known. Idempotent: a second call for the same `task_id` updates
|
||||
/// status / session-id only.
|
||||
fn register_child(&mut self, task: AmbientAgentTask, ctx: &mut ModelContext<Self>) {
|
||||
// The server-side ancestor endpoint includes the parent itself in
|
||||
// the response; skip it.
|
||||
if task.task_id == self.parent_task_id {
|
||||
return;
|
||||
}
|
||||
|
||||
let task_id = task.task_id;
|
||||
let session_id = task
|
||||
.session_id
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse::<SessionId>().ok());
|
||||
let new_state = task.state.clone();
|
||||
let conversation_status = conversation_status_from_state(&new_state);
|
||||
|
||||
if let Some(entry) = self.children.get_mut(&task_id) {
|
||||
// Existing child: update status if it changed and fill in
|
||||
// session id once it becomes available. (Polling path replays
|
||||
// every cycle; streamer path can also re-register on reconnect.)
|
||||
if entry.last_state != new_state {
|
||||
let conversation_id = entry.conversation_id;
|
||||
let terminal_view_id = self.terminal_view_id;
|
||||
let status_for_update = conversation_status.clone();
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||
history.update_conversation_status(
|
||||
terminal_view_id,
|
||||
conversation_id,
|
||||
status_for_update,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
entry.last_state = new_state;
|
||||
}
|
||||
let was_missing_session_id = entry.session_id.is_none();
|
||||
if entry.session_id.is_none() {
|
||||
entry.session_id = session_id;
|
||||
}
|
||||
if was_missing_session_id
|
||||
&& entry.session_id.is_some()
|
||||
&& !entry.pane_materialization_requested
|
||||
{
|
||||
let conversation_id = entry.conversation_id;
|
||||
let sid = entry.session_id.expect("session_id checked just above");
|
||||
entry.pane_materialization_requested = true;
|
||||
self.request_child_pane_materialization(conversation_id, sid, ctx);
|
||||
}
|
||||
// Re-arm the session_id timer; no-op once all children are materialized.
|
||||
self.maybe_schedule_pending_session_id_poll(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
// New child: register under the orchestrator's local conversation.
|
||||
// Without an active parent conversation, `start_new_child_conversation`
|
||||
// would lose the parent linkage. Drop and try again next cycle/event.
|
||||
let Some(parent_conversation_id) = self.find_parent_conversation_id(ctx) else {
|
||||
log::warn!(
|
||||
"[orch-viewer] no active parent conversation for terminal_view_id={:?} \
|
||||
parent_task_id={}; deferring child registration for task_id={task_id}",
|
||||
self.terminal_view_id,
|
||||
self.parent_task_id,
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let name = task.display_name().to_string();
|
||||
// Trim to stay in sync with `display_name()`, which also trims;
|
||||
// the descriptive title flows through `set_fallback_display_title`
|
||||
// so `AIConversation::title()` keeps surfacing it.
|
||||
let fallback_title = task.title.trim().to_string();
|
||||
let harness = task
|
||||
.agent_config_snapshot
|
||||
.as_ref()
|
||||
.and_then(|c| c.harness.as_ref())
|
||||
.map(|h| h.harness_type);
|
||||
let terminal_view_id = self.terminal_view_id;
|
||||
let status_for_initial = conversation_status.clone();
|
||||
|
||||
let conversation_id = BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||
let conversation_id = history.start_new_child_conversation(
|
||||
terminal_view_id,
|
||||
name,
|
||||
parent_conversation_id,
|
||||
harness,
|
||||
ctx,
|
||||
);
|
||||
// Suppress server-side status reporting (viewer-side); also
|
||||
// disambiguates viewer-spawned children downstream.
|
||||
history.set_viewing_shared_session_for_conversation(conversation_id, true);
|
||||
if let Some(conversation) = history.conversation_mut(&conversation_id) {
|
||||
if !fallback_title.is_empty() {
|
||||
conversation.set_fallback_display_title(fallback_title);
|
||||
}
|
||||
}
|
||||
// Stamp run_id/task_id and populate the agent_id index so
|
||||
// transcript references resolve to this child.
|
||||
history.assign_run_id_for_conversation(
|
||||
conversation_id,
|
||||
task_id.to_string(),
|
||||
Some(task_id),
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
);
|
||||
history.update_conversation_status(
|
||||
terminal_view_id,
|
||||
conversation_id,
|
||||
status_for_initial,
|
||||
ctx,
|
||||
);
|
||||
conversation_id
|
||||
});
|
||||
|
||||
let pane_materialization_requested = session_id.is_some();
|
||||
self.children.insert(
|
||||
task_id,
|
||||
ChildAgentEntry {
|
||||
conversation_id,
|
||||
session_id,
|
||||
last_state: new_state.clone(),
|
||||
pane_materialization_requested,
|
||||
},
|
||||
);
|
||||
self.children_by_run_id.insert(task_id.to_string(), task_id);
|
||||
log::info!(
|
||||
"[orch-viewer] registered child placeholder task_id={task_id} \
|
||||
parent_task_id={} conversation_id={conversation_id:?} \
|
||||
session_id={session_id:?} initial_state={new_state:?}",
|
||||
self.parent_task_id,
|
||||
);
|
||||
|
||||
if let Some(sid) = session_id {
|
||||
self.request_child_pane_materialization(conversation_id, sid, ctx);
|
||||
}
|
||||
|
||||
// Streamer path only: arm the session_id refetch timer.
|
||||
self.maybe_schedule_pending_session_id_poll(ctx);
|
||||
}
|
||||
|
||||
// ---- Pending-session_id polling (streamer path) -------------------
|
||||
|
||||
/// True iff at least one tracked child is still pending materialization.
|
||||
fn has_pending_session_id_children(&self) -> bool {
|
||||
self.children
|
||||
.values()
|
||||
.any(|entry| entry.session_id.is_none() || !entry.pane_materialization_requested)
|
||||
}
|
||||
|
||||
/// Schedules the next session_id refetch tick on the streamer path.
|
||||
/// Safe to call unconditionally — bails when not needed.
|
||||
fn maybe_schedule_pending_session_id_poll(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if !FeatureFlag::OrchestrationViewerStreamer.is_enabled() {
|
||||
return;
|
||||
}
|
||||
if self.pending_session_id_poll_handle.is_some() {
|
||||
return;
|
||||
}
|
||||
if !self.has_pending_session_id_children() {
|
||||
return;
|
||||
}
|
||||
let handle = ctx.spawn(
|
||||
async {
|
||||
Timer::after(PENDING_SESSION_ID_POLL_INTERVAL).await;
|
||||
},
|
||||
|me, _, ctx| {
|
||||
me.pending_session_id_poll_handle = None;
|
||||
me.run_pending_session_id_poll(ctx);
|
||||
},
|
||||
);
|
||||
self.pending_session_id_poll_handle = Some(handle);
|
||||
}
|
||||
|
||||
/// Body of the session_id timer tick. Refetches metadata for every
|
||||
/// child still missing a `session_id`/pane, then reschedules until
|
||||
/// the pending set is empty.
|
||||
fn run_pending_session_id_poll(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
let pending: Vec<AmbientAgentTaskId> = self
|
||||
.children
|
||||
.iter()
|
||||
.filter(|(_, entry)| {
|
||||
entry.session_id.is_none() || !entry.pane_materialization_requested
|
||||
})
|
||||
.map(|(task_id, _)| *task_id)
|
||||
.collect();
|
||||
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
for task_id in pending {
|
||||
self.spawn_task_metadata_fetch(task_id, "PendingSessionIdPoll", ctx);
|
||||
}
|
||||
|
||||
self.maybe_schedule_pending_session_id_poll(ctx);
|
||||
}
|
||||
|
||||
// ---- Legacy polling path (FeatureFlag::OrchestrationViewerStreamer off)
|
||||
|
||||
/// Schedules the next poll: fast cadence while any child is
|
||||
/// non-terminal, slow once all are terminal. Skipped while
|
||||
/// [`Self::idle_due_to_no_children`] is set; [`Self::maybe_kick_polling`]
|
||||
/// resumes on the next orchestrator `AppendedExchange`.
|
||||
fn schedule_next_poll(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
// `SpawnedFutureHandle` doesn't abort on drop, so abort
|
||||
// explicitly to avoid stacking parallel timer chains.
|
||||
if let Some(prior) = self.polling_handle.take() {
|
||||
prior.abort();
|
||||
}
|
||||
|
||||
// Stay idle until an `AppendedExchange` on the orchestrator wakes
|
||||
// us up. `apply_children_fetch` is responsible for setting this
|
||||
// flag when an empty descendant list comes back.
|
||||
if self.idle_due_to_no_children {
|
||||
return;
|
||||
}
|
||||
|
||||
let all_terminal = !self.children.is_empty()
|
||||
&& self
|
||||
.children
|
||||
.values()
|
||||
.all(|child| child.last_state.is_terminal());
|
||||
let interval = if all_terminal {
|
||||
STATUS_POLL_INTERVAL_IDLE
|
||||
} else {
|
||||
STATUS_POLL_INTERVAL
|
||||
};
|
||||
|
||||
let handle = ctx.spawn(
|
||||
async move {
|
||||
Timer::after(interval).await;
|
||||
},
|
||||
|me, _, ctx| me.fetch_children(ctx),
|
||||
);
|
||||
self.polling_handle = Some(handle);
|
||||
}
|
||||
|
||||
/// Tightens polling on `AppendedExchange` during the idle→active
|
||||
/// transition, and resumes from `idle_due_to_no_children` on an
|
||||
/// orchestrator-scoped exchange. The idle-resume check runs first
|
||||
/// because it would otherwise be conflated with the
|
||||
/// "fetch in flight" state by the `polling_handle.is_none()` guard.
|
||||
fn maybe_kick_polling(
|
||||
&mut self,
|
||||
event: &BlocklistAIHistoryEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let BlocklistAIHistoryEvent::AppendedExchange {
|
||||
conversation_id, ..
|
||||
} = event
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let conversation_id = *conversation_id;
|
||||
let is_orchestrator = self.find_parent_conversation_id(ctx) == Some(conversation_id);
|
||||
|
||||
// Resume from idle-due-to-no-children. Only orchestrator-scoped
|
||||
// exchanges count: child events are ignored because we have no
|
||||
// tracked children to update yet, and an unrelated conversation's
|
||||
// exchange does not imply this orchestrator just spawned a child.
|
||||
if self.idle_due_to_no_children {
|
||||
if is_orchestrator {
|
||||
self.idle_due_to_no_children = false;
|
||||
if let Some(prior) = self.polling_handle.take() {
|
||||
prior.abort();
|
||||
}
|
||||
self.fetch_children(ctx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let all_terminal = !self.children.is_empty()
|
||||
&& self
|
||||
.children
|
||||
.values()
|
||||
.all(|child| child.last_state.is_terminal());
|
||||
if !all_terminal {
|
||||
return;
|
||||
}
|
||||
// `polling_handle = None` here means a kick fetch is already in
|
||||
// flight (the idle-due-to-no-children case is handled above);
|
||||
// skipping prevents pile-up when exchanges arrive in bursts.
|
||||
if self.polling_handle.is_none() {
|
||||
return;
|
||||
}
|
||||
let is_tracked_child = self
|
||||
.children
|
||||
.values()
|
||||
.any(|child| child.conversation_id == conversation_id);
|
||||
if !is_orchestrator && !is_tracked_child {
|
||||
return;
|
||||
}
|
||||
if let Some(prior) = self.polling_handle.take() {
|
||||
prior.abort();
|
||||
}
|
||||
self.fetch_children(ctx);
|
||||
}
|
||||
|
||||
/// Backfills `parent_agent_id` on viewer-created children once the
|
||||
/// orchestrator receives its server token / run id. First-poll
|
||||
/// children are created with `parent_agent_id = None` because the
|
||||
/// orchestrator hasn't been identified yet; this fixes them up so
|
||||
/// `parent_conversation_id` resolution works.
|
||||
fn maybe_backfill_parent_agent_ids(
|
||||
&mut self,
|
||||
event: &BlocklistAIHistoryEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let BlocklistAIHistoryEvent::ConversationServerTokenAssigned {
|
||||
conversation_id, ..
|
||||
} = event
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let conversation_id = *conversation_id;
|
||||
if self.find_parent_conversation_id(ctx) != Some(conversation_id) {
|
||||
return;
|
||||
}
|
||||
let history_handle = BlocklistAIHistoryModel::handle(ctx);
|
||||
let parent_agent_id = history_handle
|
||||
.as_ref(ctx)
|
||||
.conversation(&conversation_id)
|
||||
.and_then(|c| c.orchestration_agent_id());
|
||||
let Some(parent_agent_id) = parent_agent_id else {
|
||||
return;
|
||||
};
|
||||
let child_conversation_ids: Vec<AIConversationId> = self
|
||||
.children
|
||||
.values()
|
||||
.map(|child| child.conversation_id)
|
||||
.collect();
|
||||
history_handle.update(ctx, |history, _ctx| {
|
||||
for child_id in child_conversation_ids {
|
||||
let Some(child) = history.conversation_mut(&child_id) else {
|
||||
continue;
|
||||
};
|
||||
if child.parent_agent_id().is_some() {
|
||||
continue;
|
||||
}
|
||||
child.set_parent_agent_id(parent_agent_id.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Issues a `GET /agent/runs?ancestor_run_id={parent_task_id}` request
|
||||
/// and routes the response into [`Self::apply_children_fetch`]. Errors
|
||||
/// are logged and ignored; the next poll retries.
|
||||
fn fetch_children(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
// Bump generation BEFORE dispatch so any in-flight stale fetch
|
||||
// is invalidated when its response callback compares.
|
||||
self.fetch_generation = self.fetch_generation.wrapping_add(1);
|
||||
let fetch_generation = self.fetch_generation;
|
||||
|
||||
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
|
||||
let filter = TaskListFilter {
|
||||
ancestor_run_id: Some(self.parent_task_id.to_string()),
|
||||
..TaskListFilter::default()
|
||||
};
|
||||
let parent_task_id = self.parent_task_id;
|
||||
|
||||
ctx.spawn(
|
||||
async move {
|
||||
ai_client
|
||||
.list_ambient_agent_tasks(CHILD_DISCOVERY_FETCH_LIMIT, filter)
|
||||
.await
|
||||
},
|
||||
move |me, result, ctx| {
|
||||
// Stale fetch: a newer one's already in flight (or applied).
|
||||
// The newer fetch owns rescheduling.
|
||||
if me.fetch_generation != fetch_generation {
|
||||
return;
|
||||
}
|
||||
match result {
|
||||
Ok(tasks) => me.apply_children_fetch(tasks, ctx),
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"OrchestrationViewerModel: failed to fetch children for {parent_task_id}: {err:#}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Always reschedule (even on error) so transient failures
|
||||
// don't break the polling loop.
|
||||
me.schedule_next_poll(ctx);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Consumes a children list response, registering new children and
|
||||
/// updating statuses / session ids on existing ones. Each child goes
|
||||
/// through [`Self::register_child`] which is shared with the streamer
|
||||
/// path. Also manages the polling-path `idle_due_to_no_children` flag
|
||||
/// so an empty descendant list parks the timer chain until the next
|
||||
/// orchestrator `AppendedExchange` resumes it.
|
||||
fn apply_children_fetch(&mut self, tasks: Vec<AmbientAgentTask>, ctx: &mut ModelContext<Self>) {
|
||||
for task in tasks {
|
||||
self.register_child(task, ctx);
|
||||
}
|
||||
|
||||
// Polling-cost mitigation: if no children are tracked after this
|
||||
// fetch, stop scheduling timers. The resume signal is an
|
||||
// `AppendedExchange` on the orchestrator (see
|
||||
// `maybe_kick_polling`). `schedule_next_poll` honours this flag
|
||||
// and bails before spawning a new timer.
|
||||
if self.children.is_empty() {
|
||||
self.idle_due_to_no_children = true;
|
||||
if let Some(prior) = self.polling_handle.take() {
|
||||
prior.abort();
|
||||
}
|
||||
} else {
|
||||
self.idle_due_to_no_children = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Shared helpers ------------------------------------------------
|
||||
|
||||
/// Resolves the orchestrator's local conversation id via the view's
|
||||
/// active conversation, which `on_shared_init` sets on first join.
|
||||
fn find_parent_conversation_id(&self, ctx: &ModelContext<Self>) -> Option<AIConversationId> {
|
||||
BlocklistAIHistoryModel::as_ref(ctx).active_conversation_id(self.terminal_view_id)
|
||||
}
|
||||
|
||||
/// Tells the parent's `TerminalView` to materialize a hidden
|
||||
/// shared-session viewer pane for this child.
|
||||
fn request_child_pane_materialization(
|
||||
&self,
|
||||
conversation_id: AIConversationId,
|
||||
session_id: SessionId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(view) = self.terminal_view.upgrade(ctx) else {
|
||||
log::warn!(
|
||||
"[orch-viewer] cannot request child pane materialization for conv={conversation_id:?}: \
|
||||
parent terminal view is gone"
|
||||
);
|
||||
return;
|
||||
};
|
||||
view.update(ctx, |_view, ctx| {
|
||||
ctx.emit(TerminalViewEvent::EnsureSharedSessionViewerChildPane {
|
||||
conversation_id,
|
||||
session_id,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a server-side run state to the [`ConversationStatus`] used by the
|
||||
/// pill bar and the conversation list. Working states (queued/pending/claimed/
|
||||
/// in-progress) all collapse to [`ConversationStatus::InProgress`] so the
|
||||
/// pill badge stays in the loading spinner until the run terminates.
|
||||
fn conversation_status_from_state(state: &AmbientAgentTaskState) -> ConversationStatus {
|
||||
match state {
|
||||
AmbientAgentTaskState::Queued
|
||||
| AmbientAgentTaskState::Pending
|
||||
| AmbientAgentTaskState::Claimed
|
||||
| AmbientAgentTaskState::InProgress => ConversationStatus::InProgress,
|
||||
AmbientAgentTaskState::Succeeded => ConversationStatus::Success,
|
||||
AmbientAgentTaskState::Failed | AmbientAgentTaskState::Error => ConversationStatus::Error,
|
||||
AmbientAgentTaskState::Blocked => ConversationStatus::Blocked {
|
||||
blocked_action: String::new(),
|
||||
},
|
||||
AmbientAgentTaskState::Cancelled => ConversationStatus::Cancelled,
|
||||
// The `Unknown` variant is a forward-compat catch-all for server
|
||||
// states the client doesn't recognize yet. The rest of the codebase
|
||||
// (`is_terminal`, `is_failure_like`, `Display`, `status_icon_and_color`)
|
||||
// consistently treats it as a terminal error, so we follow suit.
|
||||
AmbientAgentTaskState::Unknown => ConversationStatus::Error,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "orchestration_viewer_model_tests.rs"]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,293 @@
|
||||
//! Regression tests for the viewer `TerminalManager`'s `on_view_detached`
|
||||
//! discriminator and the OVM-teardown helper.
|
||||
//!
|
||||
//! Before the fix, closing a viewer pane (tab close / split-pane close) did
|
||||
//! not flow through any of the network-event paths
|
||||
//! (`SessionEnded` / `ViewerRemoved` / `FailedToReconnect`), so the
|
||||
//! orchestration viewer model — and its viewer-mode registration on the
|
||||
//! shared [`OrchestrationEventStreamer`] — leaked until the app exited.
|
||||
//! `TerminalManager::on_view_detached` now tears down the OVM on
|
||||
//! `DetachType::Closed`, while deliberately preserving it on
|
||||
//! `HiddenForClose` (undo-close grace window) and `Moved`.
|
||||
|
||||
use async_broadcast::broadcast;
|
||||
use warpui::App;
|
||||
|
||||
use super::*;
|
||||
use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer;
|
||||
use crate::ai::blocklist::QueuedQueryModel;
|
||||
// Bring the `TerminalManager` trait into scope (named under a different alias
|
||||
// since the local `TerminalManager` struct shadows it) so the trait method
|
||||
// `on_view_detached` is callable on the struct.
|
||||
use crate::terminal::TerminalManager as _;
|
||||
use crate::test_util::add_window_with_terminal;
|
||||
use crate::test_util::terminal::initialize_app_for_terminal_view;
|
||||
use crate::workspace::ToastStack;
|
||||
|
||||
/// Stub UUID used for the orchestrator's `AmbientAgentTaskId`; opaque to
|
||||
/// the manager.
|
||||
const PARENT_TASK_ID: &str = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
fn task_id(s: &str) -> AmbientAgentTaskId {
|
||||
s.parse().expect("hardcoded task id parses")
|
||||
}
|
||||
|
||||
/// Constructs a viewer `TerminalManager` whose `orchestration_viewer_model`
|
||||
/// slot is populated with a real OVM registered against the
|
||||
/// [`OrchestrationEventStreamer`]. The returned `parent_task_id` is the one
|
||||
/// used to register the OVM, so callers can look it up via
|
||||
/// [`OrchestrationEventStreamer::viewer_mode_consumer_count_for_test`].
|
||||
///
|
||||
/// Deliberately bypasses `TerminalManager::new_internal` / `new_deferred`
|
||||
/// (which would create a whole ambient-agent view stack with a real
|
||||
/// `TerminalView::new` instead of `TerminalView::new_for_test`); the
|
||||
/// `on_view_detached` path only depends on a small subset of the manager's
|
||||
/// fields, so a struct-literal construction keeps the test focused.
|
||||
fn build_manager_with_registered_ovm(app: &mut App) -> (TerminalManager, AmbientAgentTaskId) {
|
||||
let parent = task_id(PARENT_TASK_ID);
|
||||
|
||||
let terminal_view = add_window_with_terminal(app, None);
|
||||
let terminal_view_id = terminal_view.id();
|
||||
|
||||
// Set up the orchestrator placeholder conversation in the shape the
|
||||
// viewer model requires (is_viewing_shared_session == true, no parent
|
||||
// conversation id, marked active for the view).
|
||||
let history = BlocklistAIHistoryModel::handle(app);
|
||||
history.update(app, |history, ctx| {
|
||||
let id = history.start_new_conversation(terminal_view_id, false, true, false, ctx);
|
||||
history.set_viewing_shared_session_for_conversation(id, true);
|
||||
history.set_active_conversation_id(id, terminal_view_id, ctx);
|
||||
});
|
||||
|
||||
// The OVM registers with the streamer on construction (streamer flag
|
||||
// is expected to be ON in the calling test).
|
||||
let ovm_handle = app.add_model(|ctx| {
|
||||
OrchestrationViewerModel::new(parent, terminal_view_id, terminal_view.downgrade(), ctx)
|
||||
});
|
||||
|
||||
// Build the minimal field values the `TerminalManager` struct needs.
|
||||
// The network-side fields are left in their `Idle` / `None` defaults
|
||||
// so `on_view_detached` short-circuits the live-session teardown
|
||||
// branches and only the OVM-teardown branch is exercised.
|
||||
let (wakeups_tx, _wakeups_rx) = async_channel::unbounded();
|
||||
let (events_tx, events_rx) = async_channel::unbounded();
|
||||
let (pty_reads_tx, pty_reads_rx) = broadcast(8);
|
||||
let inactive_pty_reads_rx = pty_reads_rx.deactivate();
|
||||
let channel_event_proxy = ChannelEventListener::new(wakeups_tx, events_tx, pty_reads_tx);
|
||||
|
||||
let model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
|
||||
let sessions = app.add_model(|_| Sessions::new_for_test());
|
||||
let model_events =
|
||||
app.add_model(|ctx| ModelEventDispatcher::new(events_rx, sessions.clone(), ctx));
|
||||
let prompt_type =
|
||||
app.add_model(|_| PromptType::new_static(vec![], false, WarpPromptSeparator::None));
|
||||
|
||||
let manager = TerminalManager {
|
||||
model,
|
||||
view: terminal_view,
|
||||
_model_events: model_events,
|
||||
_inactive_pty_reads_rx: inactive_pty_reads_rx,
|
||||
network_state: NetworkState::Idle,
|
||||
network_resources: NetworkResources {
|
||||
prompt_type,
|
||||
channel_event_proxy,
|
||||
},
|
||||
current_network: Arc::new(FairMutex::new(None)),
|
||||
viewer_remote_update_guard: RemoteUpdateGuard::new(),
|
||||
outbound_handlers_registered: false,
|
||||
orchestration_viewer_model: Arc::new(FairMutex::new(Some(ovm_handle))),
|
||||
enable_orchestration_polling: true,
|
||||
};
|
||||
(manager, parent)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_execution_request_failed_clears_queued_command_in_flight() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app_for_terminal_view(&mut app);
|
||||
app.add_singleton_model(|_| ToastStack);
|
||||
|
||||
let terminal = add_window_with_terminal(&mut app, None);
|
||||
let terminal_view_id = terminal.id();
|
||||
let conversation_id =
|
||||
BlocklistAIHistoryModel::handle(&app).update(&mut app, |history, ctx| {
|
||||
let id = history.start_new_conversation(terminal_view_id, false, false, false, ctx);
|
||||
history.set_active_conversation_id(id, terminal_view_id, ctx);
|
||||
id
|
||||
});
|
||||
QueuedQueryModel::handle(&app).update(&mut app, |model, _ctx| {
|
||||
model.arm_command_in_flight(conversation_id);
|
||||
});
|
||||
|
||||
terminal.update(&mut app, |view, ctx| {
|
||||
TerminalManager::handle_command_execution_request_failed(
|
||||
view,
|
||||
&CommandExecutionFailureReason::StaleBuffer,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
QueuedQueryModel::handle(&app).read(&app, |model, _ctx| {
|
||||
assert!(!model.has_command_in_flight(conversation_id));
|
||||
});
|
||||
});
|
||||
}
|
||||
#[test]
|
||||
fn on_view_detached_closed_clears_orchestration_viewer_model_slot() {
|
||||
// Regression: closing a viewer pane must drop the OVM and release its
|
||||
// streamer registration so the ancestor SSE can be torn down.
|
||||
App::test((), |mut app| async move {
|
||||
let _streamer = FeatureFlag::OrchestrationViewerStreamer.override_enabled(true);
|
||||
|
||||
initialize_app_for_terminal_view(&mut app);
|
||||
|
||||
let (manager, parent) = build_manager_with_registered_ovm(&mut app);
|
||||
let slot = manager.orchestration_viewer_model.clone();
|
||||
|
||||
// Sanity: OVM registered with the streamer.
|
||||
let streamer = OrchestrationEventStreamer::handle(&app);
|
||||
streamer.read(&app, |me, _| {
|
||||
assert_eq!(
|
||||
me.viewer_mode_consumer_count_for_test(parent),
|
||||
1,
|
||||
"pre-detach: OVM should have a viewer-mode registration on the streamer"
|
||||
);
|
||||
});
|
||||
assert!(
|
||||
slot.lock().is_some(),
|
||||
"pre-detach: OVM slot should be populated"
|
||||
);
|
||||
|
||||
app.update(|ctx| manager.on_view_detached(DetachType::Closed, ctx));
|
||||
|
||||
assert!(
|
||||
slot.lock().is_none(),
|
||||
"post-detach (Closed): OVM slot should be cleared"
|
||||
);
|
||||
streamer.read(&app, |me, _| {
|
||||
assert_eq!(
|
||||
me.viewer_mode_consumer_count_for_test(parent),
|
||||
0,
|
||||
"post-detach (Closed): streamer's viewer-mode registration count should drop to 0"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_view_detached_hidden_for_close_keeps_orchestration_viewer_model_alive() {
|
||||
// Negative case: HiddenForClose is part of the undo-close grace
|
||||
// window. OVM (and the ancestor SSE registration) must stay alive so
|
||||
// the pill bar restores seamlessly if the user undoes the close.
|
||||
App::test((), |mut app| async move {
|
||||
let _streamer = FeatureFlag::OrchestrationViewerStreamer.override_enabled(true);
|
||||
|
||||
initialize_app_for_terminal_view(&mut app);
|
||||
|
||||
let (manager, parent) = build_manager_with_registered_ovm(&mut app);
|
||||
let slot = manager.orchestration_viewer_model.clone();
|
||||
|
||||
app.update(|ctx| manager.on_view_detached(DetachType::HiddenForClose, ctx));
|
||||
|
||||
assert!(
|
||||
slot.lock().is_some(),
|
||||
"HiddenForClose must NOT clear the OVM slot (undo-close grace window)"
|
||||
);
|
||||
let streamer = OrchestrationEventStreamer::handle(&app);
|
||||
streamer.read(&app, |me, _| {
|
||||
assert_eq!(
|
||||
me.viewer_mode_consumer_count_for_test(parent),
|
||||
1,
|
||||
"HiddenForClose must NOT unregister from the streamer"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_view_detached_moved_keeps_orchestration_viewer_model_alive() {
|
||||
// Negative case: Moved transfers the `TerminalManager` (and its OVM)
|
||||
// to a new pane group. Tearing down the OVM would orphan the pill
|
||||
// bar on the moved pane.
|
||||
App::test((), |mut app| async move {
|
||||
let _streamer = FeatureFlag::OrchestrationViewerStreamer.override_enabled(true);
|
||||
|
||||
initialize_app_for_terminal_view(&mut app);
|
||||
|
||||
let (manager, parent) = build_manager_with_registered_ovm(&mut app);
|
||||
let slot = manager.orchestration_viewer_model.clone();
|
||||
|
||||
app.update(|ctx| manager.on_view_detached(DetachType::Moved, ctx));
|
||||
|
||||
assert!(
|
||||
slot.lock().is_some(),
|
||||
"Moved must NOT clear the OVM slot (the manager is reused in the new pane group)"
|
||||
);
|
||||
let streamer = OrchestrationEventStreamer::handle(&app);
|
||||
streamer.read(&app, |me, _| {
|
||||
assert_eq!(
|
||||
me.viewer_mode_consumer_count_for_test(parent),
|
||||
1,
|
||||
"Moved must NOT unregister from the streamer"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_viewer_session_end_ignores_stale_ambient_end() {
|
||||
// A stale ambient end (the ended network is no longer the current one) must
|
||||
// be ignored: `handle_viewer_session_end` routes ambient panes through
|
||||
// `end_current_ambient_session`, whose current-network guard bails, so the
|
||||
// helper returns `false` and performs no teardown.
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app_for_terminal_view(&mut app);
|
||||
|
||||
let terminal_view = add_window_with_terminal(&mut app, None);
|
||||
let model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
|
||||
|
||||
let (wakeups_tx, _wakeups_rx) = async_channel::unbounded();
|
||||
let (events_tx, _events_rx) = async_channel::unbounded();
|
||||
let (pty_reads_tx, pty_reads_rx) = broadcast(8);
|
||||
let _inactive_pty_reads_rx = pty_reads_rx.deactivate();
|
||||
let channel_event_proxy = ChannelEventListener::new(wakeups_tx, events_tx, pty_reads_tx);
|
||||
let (_write_to_pty_tx, write_to_pty_rx) = async_channel::unbounded();
|
||||
|
||||
let ended_network = app.add_model(|ctx| {
|
||||
Network::new_for_test(
|
||||
channel_event_proxy,
|
||||
terminal_view.downgrade(),
|
||||
model.clone(),
|
||||
write_to_pty_rx,
|
||||
RemoteUpdateGuard::new(),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
// Empty `current_network` => the ended network is stale.
|
||||
let current_network = Arc::new(FairMutex::new(None));
|
||||
let orchestration_viewer_model = Arc::new(FairMutex::new(None));
|
||||
|
||||
let mut handled = true;
|
||||
app.update(|ctx| {
|
||||
handled = TerminalManager::handle_viewer_session_end(
|
||||
&terminal_view,
|
||||
model.clone(),
|
||||
¤t_network,
|
||||
&ended_network,
|
||||
&orchestration_viewer_model,
|
||||
/* is_ambient_agent */ true,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
assert!(
|
||||
!handled,
|
||||
"a stale ambient end (ended network != current) must be ignored"
|
||||
);
|
||||
assert!(
|
||||
!model.lock().shared_session_status().is_finished_viewer(),
|
||||
"an ignored stale ambient end must not finish the viewer"
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user