diff --git a/.agents/agent-console.md b/.agents/agent-console.md index 68bce00..c172c20 100644 --- a/.agents/agent-console.md +++ b/.agents/agent-console.md @@ -18,6 +18,12 @@ conversation/execution timeline. rows and tabs. Selecting an actually visible session marks it read and restores its context and composer draft; hidden selection is navigation identity only. +- The Agents navigator includes every remote session in the authenticated + Platform directory, including observation-only and stale sessions, without + requiring a local checkout mapping. Reuse Fleet's typed directory and retained + cockpit; refresh it while Agents is visible. Remote selection keeps local + sessions hidden for unread accounting and never attaches or claims control + implicitly. Account reset clears the shared directory. - Treat provider, target, permissions, workdir, and model as one execution context, collapsed into the compact session header by default. - Change execution context through the core context setter so an opaque resume diff --git a/crates/shelldeck-core/locales/en.toml b/crates/shelldeck-core/locales/en.toml index 7f4f152..52a5567 100644 --- a/crates/shelldeck-core/locales/en.toml +++ b/crates/shelldeck-core/locales/en.toml @@ -1167,6 +1167,10 @@ user.monique.recent_activity = "Recent activity" user.github_issue = "GitHub #%{number}" fleet.title = "Platform cockpit" fleet.refresh = "Refresh" +agents.remote.title = "Remote sessions" +agents.remote.loading = "Loading sessions…" +agents.remote.unavailable = "This remote session is no longer available." +agents.remote.cannot_observe = "Observation is unavailable for this session." fleet.metric.resources = "%{count} resources" fleet.metric.sessions = "%{count} sessions" fleet.metric.models = "%{count} models" diff --git a/crates/shelldeck-core/locales/fr.toml b/crates/shelldeck-core/locales/fr.toml index a56f113..9e308b1 100644 --- a/crates/shelldeck-core/locales/fr.toml +++ b/crates/shelldeck-core/locales/fr.toml @@ -1167,6 +1167,10 @@ user.monique.recent_activity = "Activité récente" user.github_issue = "GitHub #%{number}" fleet.title = "Poste de contrôle plateforme" fleet.refresh = "Actualiser" +agents.remote.title = "Sessions distantes" +agents.remote.loading = "Chargement des sessions…" +agents.remote.unavailable = "Cette session distante n’est plus disponible." +agents.remote.cannot_observe = "L’observation n’est pas disponible pour cette session." fleet.metric.resources = "%{count} ressources" fleet.metric.sessions = "%{count} sessions" fleet.metric.models = "%{count} modèles" diff --git a/crates/shelldeck-ui/src/agent_console_view.rs b/crates/shelldeck-ui/src/agent_console_view.rs index 01db59d..80fe95e 100644 --- a/crates/shelldeck-ui/src/agent_console_view.rs +++ b/crates/shelldeck-ui/src/agent_console_view.rs @@ -61,6 +61,8 @@ mod model; mod navigator; #[path = "agent_console_view/progress.rs"] mod progress; +#[path = "agent_console_view/remote_sessions.rs"] +mod remote_sessions; use live_input::PendingLiveInput; #[path = "agent_console_view/review.rs"] mod review; @@ -116,6 +118,8 @@ struct InspectorDiff { #[derive(Debug, Clone)] pub enum AgentConsoleEvent { + RefreshRemoteSessions, + OpenPlatformSession(shelldeck_core::config::platform::ResourceCoordinate), SaveFile(AgentFileSaveRequest), Run(AgentRunRequest), Stop(Uuid), @@ -286,6 +290,7 @@ pub struct AgentConsoleView { model_discovery: model::AgentModelDiscoveryUi, navigator_search: Entity, sessions: AgentSessionCollection, + remote_sessions: remote_sessions::RemoteAgentSessionsUi, session_ui: HashMap, run_sessions: HashMap, foreground_runs: HashSet, @@ -402,6 +407,7 @@ impl AgentConsoleView { model_discovery: model::AgentModelDiscoveryUi::new(), navigator_search: input_state(cx, String::new()), sessions, + remote_sessions: remote_sessions::RemoteAgentSessionsUi::default(), session_ui, run_sessions: HashMap::new(), foreground_runs: HashSet::new(), @@ -704,7 +710,8 @@ impl AgentConsoleView { } pub fn run_requests_focus(&self, run_id: Uuid) -> bool { - self.foreground_runs.contains(&run_id) + !self.remote_sessions.active + && self.foreground_runs.contains(&run_id) && self.run_sessions.get(&run_id).copied() == self.sessions.selected_id() } @@ -763,6 +770,7 @@ impl AgentConsoleView { if self.sessions.select(session_id).is_err() { return false; } + self.set_remote_session_active(false, cx); self.apply_selected_context(cx); self.schedule_library_save(cx); pin_to_latest(&self.scroll); @@ -781,6 +789,7 @@ impl AgentConsoleView { .create(name, context, now_ms()) .map_err(|error| error.to_string())?; self.session_ui.insert(session_id, AgentSessionUi::new(cx)); + self.set_remote_session_active(false, cx); self.apply_selected_context(cx); self.schedule_library_save(cx); pin_to_latest(&self.scroll); @@ -806,7 +815,11 @@ impl AgentConsoleView { } pub fn set_surface_visible(&mut self, visible: bool, cx: &mut Context) { - if self.sessions.set_surface_visible(visible) { + self.remote_sessions.surface_visible = visible; + if self + .sessions + .set_surface_visible(visible && !self.remote_sessions.active) + { cx.notify(); } } @@ -1549,6 +1562,9 @@ impl AgentConsoleView { impl Render for AgentConsoleView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.remote_sessions.active && self.remote_sessions.available { + return self.render_remote_session(cx); + } if self.surface_focus_requested && self.pending_confirm.is_none() && self.workflows.dialog.is_none() @@ -2129,7 +2145,7 @@ impl Render for AgentConsoleView { .absolute() .size_full(), ); - root + root.into_any_element() } } diff --git a/crates/shelldeck-ui/src/agent_console_view/navigator.rs b/crates/shelldeck-ui/src/agent_console_view/navigator.rs index 469b834..b5fd1fb 100644 --- a/crates/shelldeck-ui/src/agent_console_view/navigator.rs +++ b/crates/shelldeck-ui/src/agent_console_view/navigator.rs @@ -11,6 +11,7 @@ impl AgentConsoleView { .map(|session| u64::from(session.unread_count)) .sum::(); let mut body = div().flex().flex_col().gap(px(4.0)).px(px(6.0)).py(px(7.0)); + body = body.child(self.render_remote_session_rows(cx)); if self.project_groups.is_empty() { body = body.child(self.render_session_rows(None, cx)); } else { @@ -41,10 +42,11 @@ impl AgentConsoleView { ); for worktree in &project.worktrees { let worktree_option = worktree.clone(); - let worktree_selected = self - .sessions - .selected() - .is_some_and(|session| session_worktree_matches(session, worktree)); + let worktree_selected = !self.remote_sessions.active + && self + .sessions + .selected() + .is_some_and(|session| session_worktree_matches(session, worktree)); body = body.child( div() .id(("agent-worktree", stable_string_key(&worktree.id))) @@ -264,7 +266,7 @@ impl AgentConsoleView { .filter(|session| include(session)) { let id = session.id; - let selected = self.sessions.selected_id() == Some(id); + let selected = !self.remote_sessions.active && self.sessions.selected_id() == Some(id); let removable = self.sessions.sessions().len() > 1 && !session.status.is_active(); let mut row = div() .id(("agent-nav-session", uuid_key(id))) diff --git a/crates/shelldeck-ui/src/agent_console_view/remote_sessions.rs b/crates/shelldeck-ui/src/agent_console_view/remote_sessions.rs new file mode 100644 index 0000000..1df153c --- /dev/null +++ b/crates/shelldeck-ui/src/agent_console_view/remote_sessions.rs @@ -0,0 +1,378 @@ +//! A navigator over the shared Platform directory, never a second session store. +use super::*; +use crate::fleet_view::FleetView; +use shelldeck_core::config::platform::{FreshnessState, SessionRecord}; + +#[derive(Default)] +pub(super) struct RemoteAgentSessionsUi { + source: Option>, + _subscription: Option, + pub(super) available: bool, + pub(super) active: bool, + pub(super) surface_visible: bool, +} + +fn matching_sessions<'a>(sessions: &'a [SessionRecord], query: &str) -> Vec<&'a SessionRecord> { + let query = query.trim().to_lowercase(); + sessions + .iter() + .filter(|record| { + query.is_empty() + || record + .session + .summary + .as_str() + .to_lowercase() + .contains(&query) + || record + .session + .resource + .id + .as_str() + .to_lowercase() + .contains(&query) + }) + .collect() +} + +impl AgentConsoleView { + pub(crate) fn set_remote_session_source( + &mut self, + source: Entity, + cx: &mut Context, + ) { + self.remote_sessions._subscription = Some(cx.observe(&source, |_, _, cx| cx.notify())); + self.remote_sessions.source = Some(source); + } + + pub(crate) fn set_remote_sessions_available( + &mut self, + available: bool, + cx: &mut Context, + ) { + self.remote_sessions.available = available; + if !available && self.remote_sessions.active { + // Access can disappear before Workspace updates host visibility. + // Do not briefly mark the hidden local conversation as read. + self.remote_sessions.active = false; + self.set_surface_visible(false, cx); + } + cx.notify(); + } + + pub(crate) fn set_remote_session_active(&mut self, active: bool, cx: &mut Context) { + self.remote_sessions.active = active && self.remote_sessions.available; + self.sessions.set_surface_visible( + self.remote_sessions.surface_visible && !self.remote_sessions.active, + ); + cx.notify(); + } + + pub(super) fn render_remote_session_rows(&self, cx: &mut Context) -> AnyElement { + let mut list = div().flex().flex_col().gap(px(2.0)); + if !self.remote_sessions.available { + return list.into_any_element(); + } + let Some(source) = &self.remote_sessions.source else { + return list.into_any_element(); + }; + let source = source.read(cx); + let sessions = matching_sessions( + source.agent_sessions(), + self.navigator_search.read(cx).content(), + ); + list = list.child( + div() + .flex() + .items_center() + .gap(px(6.0)) + .px(px(6.0)) + .pt(px(6.0)) + .pb(px(3.0)) + .child(lucide_icon("cloud", 12.0, ShellDeckColors::text_muted())) + .child( + div() + .flex_1() + .min_w(px(0.0)) + .truncate() + .text_size(px(10.5)) + .font_weight(FontWeight::SEMIBOLD) + .child(t!("agents.remote.title").to_string()), + ) + .child(Badge::new(sessions.len().to_string()).variant(BadgeVariant::Secondary)) + .child( + Button::new("agent-remote-refresh", "") + .variant(ButtonVariant::Ghost) + .size(ButtonSize::Icon) + .icon(IconSource::from("refresh-cw")) + .w(px(26.0)) + .h(px(26.0)) + .disabled(source.agent_sessions_loading()) + .tooltip(t!("fleet.refresh").to_string()) + .on_click(cx.listener(|_, _, _, cx| { + cx.emit(AgentConsoleEvent::RefreshRemoteSessions) + })), + ), + ); + if let Some(error) = source.agent_sessions_error() { + list = list.child( + div() + .min_w(px(0.0)) + .truncate() + .px(px(8.0)) + .py(px(6.0)) + .text_size(px(10.0)) + .text_color(ShellDeckColors::error()) + .child(error.to_owned()), + ); + } else if sessions.is_empty() { + let label = if source.agent_sessions_loading() { + t!("agents.remote.loading") + } else { + t!("fleet.sessions.empty") + }; + list = list.child( + div() + .px(px(8.0)) + .py(px(6.0)) + .text_size(px(10.0)) + .text_color(ShellDeckColors::text_muted()) + .child(label.to_string()), + ); + } + for record in sessions { + let coordinate = record.session.resource.clone(); + let selected = self.remote_sessions.active && source.session_is_open_exact(&coordinate); + let color = match record.session.freshness.state { + FreshnessState::Fresh => ShellDeckColors::success(), + FreshnessState::Stale => ShellDeckColors::warning(), + FreshnessState::Unknown => ShellDeckColors::text_muted(), + }; + // Match the local navigator's rows; two text lines keep remote identity visible. + let mut row = div() + .id(SharedString::from(format!( + "agent-remote-{}:{}:{}", + coordinate.authority.as_str(), + coordinate.kind.as_str(), + coordinate.id.as_str() + ))) + .flex() + .items_center() + .gap(px(7.0)) + .h(px(43.0)) + .px(px(8.0)) + .rounded(px(5.0)) + .cursor_pointer() + .child(div().size(px(6.0)).flex_shrink_0().rounded_full().bg(color)) + .child( + div() + .flex_1() + .min_w(px(0.0)) + .overflow_hidden() + .flex() + .flex_col() + .child( + div() + .truncate() + .text_size(px(11.0)) + .child(record.session.summary.as_str().to_owned()), + ) + .child( + div() + .truncate() + .text_size(px(9.0)) + .text_color(ShellDeckColors::text_muted()) + .child(coordinate.id.as_str().to_owned()), + ), + ) + .on_click(cx.listener(move |_, _, _, cx| { + cx.emit(AgentConsoleEvent::OpenPlatformSession(coordinate.clone())) + })); + if selected { + row = row.bg(ShellDeckColors::selected_bg()); + } else { + row = row.hover(|style| style.bg(ShellDeckColors::hover_bg())); + } + list = list.child(row); + } + list.pb(px(7.0)) + .mb(px(4.0)) + .border_b_1() + .border_color(ShellDeckColors::border()) + .into_any_element() + } + + pub(super) fn render_remote_session(&mut self, cx: &mut Context) -> AnyElement { + self.surface_focus_requested = false; + let mut root = div() + .id("agent-remote-cockpit") + .key_context("AgentConsole") + .on_action(cx.listener(|this, _: &AgentNewChat, _, cx| this.new_session(cx))) + .flex() + .size_full() + .min_w(px(0.0)) + .min_h(px(0.0)) + .bg(ShellDeckColors::bg_primary()) + .text_color(ShellDeckColors::text_primary()) + .child(self.render_navigator(cx)); + if let Some(source) = self.remote_sessions.source.clone() { + root = root.child(source.update(cx, |view, cx| view.render_agent_session(cx))); + } + root.into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::{ + matching_sessions, AgentConsoleView, AgentStreamEvent, FleetView, FreshnessState, + SessionRecord, Uuid, + }; + use automonique_protocol::platform::{Capabilities, CursorTopic, Freshness}; + use automonique_protocol::primitives::{EpochMillis, Revision}; + use gpui::AppContext; + use shelldeck_core::config::platform::{ + PlatformCursor, PlatformSnapshot, PlatformText, PlatformView, ResourceAuthority, + ResourceCoordinate, ResourceId, ResourceKind, ResourceRecord, + }; + + fn session( + id: &str, + summary: &str, + freshness: FreshnessState, + attachable: bool, + ) -> SessionRecord { + SessionRecord { + session: ResourceRecord { + resource: ResourceCoordinate::new( + ResourceAuthority::Automonique, + ResourceKind::Session, + ResourceId::new(id).unwrap(), + ), + freshness: Freshness { + state: freshness, + observed_at: EpochMillis::EPOCH, + revision: Revision::FIRST, + }, + summary: PlatformText::new(summary).unwrap(), + }, + run: None, + attachable, + controllable: false, + } + } + + fn snapshot(sessions: Vec) -> PlatformSnapshot { + let cursor = PlatformCursor { + authority: ResourceAuthority::Automonique, + topic: CursorTopic::new("sessions").unwrap(), + sequence: Revision::FIRST, + }; + PlatformSnapshot { + capabilities: Capabilities::platform_v1(), + resources: Vec::new(), + cursor: cursor.clone(), + sessions, + sessions_cursor: cursor, + view: PlatformView::default(), + } + } + + // SDTEST-2645 / SDUC-559 + #[test] + fn sdtest_2645_remote_search_includes_observation_only_and_stale_sessions_without_checkout_filters( + ) { + let directory = vec![ + session("remote-one", "Bext deploy", FreshnessState::Fresh, true), + session("remote-two", "Équipe support", FreshnessState::Stale, false), + session("remote-three", "Infra", FreshnessState::Unknown, true), + ]; + assert_eq!(matching_sessions(&directory, "").len(), 3); + assert_eq!( + matching_sessions(&directory, " ÉQUIPE ")[0] + .session + .resource + .id + .as_str(), + "remote-two" + ); + assert_eq!( + matching_sessions(&directory, "REMOTE-THREE")[0] + .session + .summary + .as_str(), + "Infra" + ); + assert!(matching_sessions(&directory, "unrelated").is_empty()); + } + + // SDTEST-2646 / SDUC-559 + #[test] + fn sdtest_2646_remote_selection_tracks_shared_directory_and_preserves_hidden_local_attention() { + let cx = gpui::TestAppContext::single(); + cx.update(|cx| { + let source = cx.new(FleetView::new); + let console = cx.new(AgentConsoleView::new); + let remote = session("remote-one", "Remote agent", FreshnessState::Fresh, false); + source.update(cx, |view, cx| { + view.set_snapshot(snapshot(vec![remote.clone()])); + let mut foreign = remote.session.resource.clone(); + foreign.authority = ResourceAuthority::AiOperations; + assert!(!view.select_agent_session(&foreign, cx)); + assert!(view.select_agent_session(&remote.session.resource, cx)); + assert!( + view.attachments().is_empty(), + "selection must not claim or attach a session" + ); + }); + console.update(cx, |view, cx| { + view.set_remote_session_source(source.clone(), cx); + view.set_remote_sessions_available(true, cx); + view.set_surface_visible(true, cx); + view.set_remote_session_active(true, cx); + let local = view.sessions.selected_id().unwrap(); + let run = Uuid::new_v4(); + view.foreground_runs.insert(run); + view.run_sessions.insert(run, local); + assert!(!view.run_requests_focus(run)); + view.sessions + .apply_stream_event( + local, + AgentStreamEvent::Text("background reply".into()), + 10, + ) + .unwrap(); + view.set_surface_visible(true, cx); // Workspace navigation must not consume hidden replies. + assert_eq!(view.sessions.get(local).unwrap().unread_count, 1); + assert!(view.select_session(local, cx)); + assert!(view.run_requests_focus(run)); + assert_eq!(view.sessions.get(local).unwrap().unread_count, 0); + assert!(!view.remote_sessions.active); + view.set_remote_session_active(true, cx); + }); + source.update(cx, |view, cx| { + // A directory refresh must remove vanished sessions from Agents too. + view.set_snapshot(snapshot(Vec::new())); + assert!(view.agent_sessions().is_empty()); + assert!(!view.select_agent_session(&remote.session.resource, cx)); + view.reset(); + cx.notify(); + }); + console.update(cx, |view, cx| { + let local = view.sessions.selected_id().unwrap(); + view.sessions + .apply_stream_event( + local, + AgentStreamEvent::Text("reply before logout".into()), + 20, + ) + .unwrap(); + view.set_remote_sessions_available(false, cx); + view.set_remote_session_active(true, cx); + assert!(!view.remote_sessions.active); + assert!(!view.sessions.surface_visible()); + assert_eq!(view.sessions.get(local).unwrap().unread_count, 1); + }); + }); + } +} diff --git a/crates/shelldeck-ui/src/fleet_view.rs b/crates/shelldeck-ui/src/fleet_view.rs index 76f5220..19e6e4d 100644 --- a/crates/shelldeck-ui/src/fleet_view.rs +++ b/crates/shelldeck-ui/src/fleet_view.rs @@ -40,6 +40,9 @@ use crate::workspace::platform_attention::{ attention_reason_label, platform_attention_state_label, PlatformAttentionPresentation, }; +#[path = "fleet_view/agents.rs"] +mod agents; + /// Bound on the chronological activity rows drawn at once. The full count /// stays visible in the section badge so nothing is silently dropped. const MAX_ATTENTION_ACTIVITY_ROWS: usize = 8; diff --git a/crates/shelldeck-ui/src/fleet_view/agents.rs b/crates/shelldeck-ui/src/fleet_view/agents.rs new file mode 100644 index 0000000..7f8991c --- /dev/null +++ b/crates/shelldeck-ui/src/fleet_view/agents.rs @@ -0,0 +1,105 @@ +//! Reuse the retained Platform cockpit inside Agents without mounting Fleet's navigation. +use super::*; + +impl FleetView { + pub(crate) fn agent_sessions(&self) -> &[SessionRecord] { + self.snapshot + .as_ref() + .map_or(&[], |snapshot| snapshot.sessions.as_slice()) + } + + pub(crate) fn agent_sessions_loading(&self) -> bool { + self.loading + } + + pub(crate) fn agent_sessions_error(&self) -> Option<&str> { + self.error.as_deref() + } + + pub(crate) fn select_agent_session( + &mut self, + coordinate: &ResourceCoordinate, + cx: &mut Context, + ) -> bool { + // Use the full authority/kind/id, including for observation-only sessions. + if self + .agent_sessions() + .iter() + .filter(|record| &record.session.resource == coordinate) + .count() + != 1 + { + return false; + } + self.select_session(coordinate, cx); + cx.notify(); + true + } + + pub(crate) fn render_agent_session(&self, cx: &mut Context) -> AnyElement { + let mut content = div() + .flex_1() + .min_w(px(0.0)) + .min_h(px(0.0)) + .overflow_hidden() + .flex() + .flex_col(); + if let Some(error) = &self.error { + content = content.child( + Alert::error() + .title(t!("fleet.error.title").to_string()) + .description(error.clone()), + ); + } + if let Some((outcome, explanation)) = &self.refusal { + content = content.child( + Alert::error() + .title(t!("fleet.action.refused", outcome = outcome).to_string()) + .description(explanation.clone()), + ); + } + let selected = self.agent_sessions().iter().find(|record| { + self.selected_session.as_deref() == Some(record.session.resource.id.as_str()) + }); + if let Some(record) = selected { + content = content.child(div().flex_shrink_0().child(self.render_session(record, cx))); + if self.cockpit.pane(&record.session.resource).is_some() { + content = content.child(self.render_selected_pane(cx)); + } else { + content = content.child( + div() + .flex_1() + .min_h(px(0.0)) + .flex() + .items_center() + .justify_center() + .p(px(24.0)) + .text_size(px(12.0)) + .text_color(ShellDeckColors::text_muted()) + .child(if record.attachable { + t!("fleet.pane.attach_to_read").to_string() + } else { + t!("agents.remote.cannot_observe").to_string() + }), + ); + } + } else { + content = content.child( + div() + .p(px(24.0)) + .text_size(px(12.0)) + .text_color(ShellDeckColors::text_muted()) + .child(t!("agents.remote.unavailable").to_string()), + ); + } + div() + .flex_1() + .min_w(px(0.0)) + .min_h(px(0.0)) + .flex() + .flex_col() + .child(self.render_action_preview(true, cx)) + .child(content) + .into_any_element() + } +} diff --git a/crates/shelldeck-ui/src/workspace/agents.rs b/crates/shelldeck-ui/src/workspace/agents.rs index 6e0d634..4143bfe 100644 --- a/crates/shelldeck-ui/src/workspace/agents.rs +++ b/crates/shelldeck-ui/src/workspace/agents.rs @@ -145,6 +145,17 @@ impl Workspace { return; } match event { + AgentConsoleEvent::RefreshRemoteSessions => self.refresh_fleet_view(cx), + AgentConsoleEvent::OpenPlatformSession(session) => { + if self.platform_connection().is_some() + && self + .fleet_view + .update(cx, |view, cx| view.select_agent_session(&session, cx)) + { + self.agent_console + .update(cx, |view, cx| view.set_remote_session_active(true, cx)); + } + } AgentConsoleEvent::SaveFile(request) => self.save_agent_file(request, cx), AgentConsoleEvent::Run(request) => self.prepare_agent_run(request, cx), AgentConsoleEvent::Stop(run_id) => self.stop_agent_run(run_id, cx), diff --git a/crates/shelldeck-ui/src/workspace/fleet.rs b/crates/shelldeck-ui/src/workspace/fleet.rs index f4b8de8..bff6905 100644 --- a/crates/shelldeck-ui/src/workspace/fleet.rs +++ b/crates/shelldeck-ui/src/workspace/fleet.rs @@ -181,6 +181,21 @@ fn unavailable_review_capabilities( const FLEET_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10); +fn platform_poll_visible( + connected: bool, + mode: AppMode, + settings_open: bool, + view: ActiveView, +) -> bool { + connected + && mode == AppMode::Dev + && !settings_open + && matches!( + view, + ActiveView::Fleet | ActiveView::Workspaces | ActiveView::Agents + ) +} + fn fleet_retry_delay(consecutive_failures: u32) -> std::time::Duration { let exponent = consecutive_failures.saturating_sub(1).min(2); std::time::Duration::from_secs(30 * (1_u64 << exponent)) @@ -220,10 +235,12 @@ impl Workspace { } pub(super) fn platform_surface_visible(&self) -> bool { - !self.settings_open - && self.platform_connection().is_some() - && self.effective_mode() == AppMode::Dev - && matches!(self.active_view, ActiveView::Fleet | ActiveView::Workspaces) + platform_poll_visible( + self.platform_connection().is_some(), + self.effective_mode(), + self.settings_open, + self.active_view, + ) } pub(super) fn update_fleet_availability(&mut self, cx: &mut Context) { @@ -232,6 +249,8 @@ impl Workspace { sidebar.set_fleet_available(show); cx.notify(); }); + self.agent_console + .update(cx, |view, cx| view.set_remote_sessions_available(show, cx)); } pub(super) fn refresh_fleet_view(&mut self, cx: &mut Context) { @@ -723,6 +742,34 @@ impl Workspace { #[cfg(test)] mod tests { + // SDTEST-2647 / SDUC-559 + #[test] + fn sdtest_2647_remote_directory_refresh_follows_agents_visibility_and_authentication() { + use super::{platform_poll_visible, ActiveView, AppMode}; + for surface in [ + ActiveView::Agents, + ActiveView::Fleet, + ActiveView::Workspaces, + ] { + assert!(platform_poll_visible(true, AppMode::Dev, false, surface)); + assert!(!platform_poll_visible(false, AppMode::Dev, false, surface)); + assert!(!platform_poll_visible(true, AppMode::User, false, surface)); + assert!(!platform_poll_visible( + true, + AppMode::Support, + false, + surface + )); + assert!(!platform_poll_visible(true, AppMode::Dev, true, surface)); + } + assert!(!platform_poll_visible( + true, + AppMode::Dev, + false, + ActiveView::Terminal + )); + } + use super::{ fleet_retry_delay, fleet_should_log_failure, platform_connection_is_current, review_action_for_active_context, review_attention_presence, review_for_active_context, diff --git a/crates/shelldeck-ui/src/workspace/mod.rs b/crates/shelldeck-ui/src/workspace/mod.rs index 640697b..d0006d5 100644 --- a/crates/shelldeck-ui/src/workspace/mod.rs +++ b/crates/shelldeck-ui/src/workspace/mod.rs @@ -1114,6 +1114,9 @@ impl Workspace { let support = cx.new(SupportView::new); let monique_view = cx.new(MoniqueView::new); let fleet_view = cx.new(FleetView::new); + agent_console.update(cx, |view, cx| { + view.set_remote_session_source(fleet_view.clone(), cx) + }); let bext_view = cx.new(BextCloudView::new); ai_assistant.update(cx, |view, cx| view.set_tasks(ai_tasks.clone(), cx)); // The Dock window has no titlebar, so its only account signal is the diff --git a/docs/testing/USE_CASES.md b/docs/testing/USE_CASES.md index 1c2251f..013abb4 100644 --- a/docs/testing/USE_CASES.md +++ b/docs/testing/USE_CASES.md @@ -3223,6 +3223,9 @@ Tmux owns its pane layout across ShellDeck restarts and SSH disconnects. ## Change log +- **2026-10-15** — Added SDUC-559 for remote Platform sessions in the Agents + sidebar, shared retained detail, authenticated refresh and hidden local-session + attention. Covered by SDTEST-2645..2647. - **2026-10-15** — Added SDUC-558 for production `--agents` launch, single-instance presentation handoff and installed launchers; SDTEST-2630..2632 cover offline AppImage/installer contracts. Amended SDUC-557 for public runtime permissions @@ -4365,3 +4368,21 @@ failed dependency deployment. Linux release builds run the native packaged CEF fixture before compression. This feature does not by itself publish a release, change account roles, grant browser authority or add a second application runtime. Usage: [agents-launch.md](../agents-launch.md). + +### SDUC-559 — Remote sessions in the Agents sidebar + +For an authenticated Dev account, Agents lists every remote session returned by +the account's Platform directory alongside its local conversation library. +Sessions do not require a local checkout mapping, attachability or control +permission to appear. Search matches remote summaries and IDs, including stale +and observation-only entries. Loading, empty and network-error states remain +visible, with explicit refresh and the existing periodic Platform refresh while +Agents is foregrounded. Settings, signed-out and non-Dev surfaces stop polling. + +Selecting a full remote resource coordinate keeps the Agents sidebar visible and +opens Fleet's retained session detail. Observation and control use the existing +explicit attach/claim actions and server-issued permissions; selecting a row does +not attach or acquire control. Hidden local sessions keep unread output until +they are selected again. Directory replacement removes vanished entries; account +reset clears the shared directory and remote selection is retired when access is +lost. No email-specific role override or second session store is introduced. diff --git a/docs/testing/tests-ui-and-app.md b/docs/testing/tests-ui-and-app.md index e0aceba..eea064a 100644 --- a/docs/testing/tests-ui-and-app.md +++ b/docs/testing/tests-ui-and-app.md @@ -805,3 +805,11 @@ full shell. Pending signed-out requests do not auto-open a login modal. |---|---|---|---|---| | SDTEST-2642 | `scripts/test-browser-packaging.py::test_sdtest_2642_public_runtime_modes_preserve_executables_and_framework_links` | SDUC-557, SDUC-558 | Green | Runs the actual bundler with private-mode Linux/macOS fixture inputs. Published directory/data modes are 0755/0644, executable helpers retain executable roles, macOS framework symlinks remain links, and source modes stay unchanged. Unix-mode contract explicitly skips on Windows, where inherited installation ACLs apply. Runs in the lightweight Linux packaging CI job; no CEF download, native process or profile is involved. | | SDTEST-2644 | `scripts/test-agents-packaging.py::test_sdtest_2644_appimage_includes_matching_dynamic_nss_modules` | SDUC-557, SDUC-558 | Green | Packages the exact NSS distribution discovered through Chromium's linked libnss3, including its dynamically loaded softokn/freebl/root/database modules. A missing source module or a bundler silently omitting modules refuses before compression. Offline fixture paths contain spaces. The Linux release job additionally enables the real packaged CEF fixture before compression. | + +## Remote sessions in Agents — 2026-10-15 + +| ID | Location | SDUC | Status | Notes | +|---|---|---|---|---| +| SDTEST-2645 | `agent_console_view/remote_sessions.rs::sdtest_2645_remote_search_includes_observation_only_and_stale_sessions_without_checkout_filters` | SDUC-559 | Green | Directory search includes stale, unknown and observation-only sessions without checkout requirements; Unicode summary and case-insensitive ID matching. Passed in the isolated 243-test UI suite. | +| SDTEST-2646 | `agent_console_view/remote_sessions.rs::sdtest_2646_remote_selection_tracks_shared_directory_and_preserves_hidden_local_attention` | SDUC-559 | Green | Full-coordinate selection rejects a foreign authority, makes no attachment, retains unread local output while remote detail is active, and follows directory replacement and access loss. No real network/provider execution. Passed in the isolated 243-test UI suite. | +| SDTEST-2647 | `workspace/fleet.rs::sdtest_2647_remote_directory_refresh_follows_agents_visibility_and_authentication` | SDUC-559 | Green | Agents, Fleet and Workspaces share refresh admission; missing authentication, User/Support modes, Settings and unrelated views suppress polling. Passed in the isolated 243-test UI suite. |