first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+2 -1
View File
@@ -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;
+20 -2
View File
@@ -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,
+59 -21
View File
@@ -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;
@@ -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;
@@ -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
}
+9 -14
View File
@@ -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;
@@ -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
+2 -1
View File
@@ -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;
@@ -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
@@ -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;
@@ -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() {
+158 -97
View File
@@ -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;
@@ -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(),
&current_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"
);
});
}