diff --git a/crates/shelldeck-core/src/agent_session.rs b/crates/shelldeck-core/src/agent_session.rs index 71c4c520..4b4e8389 100644 --- a/crates/shelldeck-core/src/agent_session.rs +++ b/crates/shelldeck-core/src/agent_session.rs @@ -117,6 +117,9 @@ pub struct AgentMessage { #[serde(default)] pub sequence: u64, pub role: AgentMessageRole, + /// Original author survives later provider changes in the same session. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, pub text: String, pub at_ms: i64, } @@ -430,6 +433,7 @@ impl AgentSession { id: Uuid::new_v4(), sequence, role, + provider: (role == AgentMessageRole::Agent).then_some(self.context.provider), text: truncate_utf8(&text, MAX_AGENT_MESSAGE_BYTES), at_ms: now_ms, }, @@ -924,6 +928,52 @@ mod tests { } } + // SDTEST-1941 — earlier replies retain their author across provider changes + // and durable reload; older files without author metadata remain readable. + #[test] + fn provider_switch_preserves_reply_authorship_and_legacy_history() { + let mut session = + AgentSession::new("Providers", context(AgentProvider::Claude), 10).unwrap(); + session.begin_run("first", 11).unwrap(); + session.apply_stream_event(AgentStreamEvent::TextDelta("Claude".into()), 12, true); + session.apply_stream_event(AgentStreamEvent::TextDelta(" reply".into()), 13, true); + session + .finish(AgentSessionStatus::Completed, 14, true) + .unwrap(); + session + .set_context(context(AgentProvider::Codex), 15) + .unwrap(); + session.begin_run("second", 16).unwrap(); + session.apply_stream_event(AgentStreamEvent::Text("Codex reply".into()), 17, true); + session + .finish(AgentSessionStatus::Completed, 18, true) + .unwrap(); + let history = serde_json::to_value(&session).unwrap(); + let restored: AgentSession = serde_json::from_value(history.clone()).unwrap(); + let replies = restored + .messages + .iter() + .filter(|m| m.role == AgentMessageRole::Agent) + .map(|m| (m.provider, m.text.as_str())) + .collect::>(); + assert_eq!( + replies, + [ + (Some(AgentProvider::Claude), "Claude reply"), + (Some(AgentProvider::Codex), "Codex reply") + ] + ); + let mut legacy = history; + for message in legacy["messages"].as_array_mut().unwrap() { + message.as_object_mut().unwrap().remove("provider"); + } + let legacy: AgentSession = serde_json::from_value(legacy).unwrap(); + assert_eq!(legacy.messages.len(), 4); + assert_eq!(legacy.messages[1].text, "Claude reply"); + assert_eq!(legacy.messages[3].text, "Codex reply"); + assert!(legacy.messages.iter().all(|m| m.provider.is_none())); + } + // SDTEST-1879 — SDUC-499 #[test] fn sdtest_1879_collection_runs_bounded_sessions_concurrently_and_tracks_attention() { diff --git a/crates/shelldeck-ssh/src/tunnel.rs b/crates/shelldeck-ssh/src/tunnel.rs index c614d1de..5ef85313 100644 --- a/crates/shelldeck-ssh/src/tunnel.rs +++ b/crates/shelldeck-ssh/src/tunnel.rs @@ -183,6 +183,7 @@ impl TunnelManager { pub async fn start_remote_forward( &mut self, handle: SharedHandle, + remote_host: String, remote_port: u16, local_host: String, local_port: u16, @@ -191,7 +192,7 @@ impl TunnelManager { // Request remote forwarding from the server. { let h = handle.lock().await; - h.tcpip_forward("0.0.0.0", remote_port as u32) + h.tcpip_forward(&remote_host, remote_port as u32) .await .map_err(|e| SshError::Tunnel(format!("Remote forward request failed: {}", e)))?; } @@ -277,6 +278,20 @@ impl TunnelManager { connections.abort_all(); while connections.join_next().await.is_some() {} + // Release the exact server-side listener even when the caller + // retains its SSH session after stopping this tunnel. + let cancel = async { + handle + .lock() + .await + .cancel_tcpip_forward(&remote_host, remote_port as u32) + .await + }; + match tokio::time::timeout(std::time::Duration::from_secs(5), cancel).await { + Ok(Ok(())) => {} + Ok(Err(error)) => tracing::warn!(%error, "Remote forward cancellation failed"), + Err(_) => tracing::warn!("Remote forward cancellation timed out"), + } *status_clone.lock() = TunnelStatus::Stopped; }); @@ -810,7 +825,7 @@ mod tests { /// hands its own [`server::Handle`] back to the test so the test can play /// the remote side and open a forwarded-tcpip channel on demand. struct RemoteForwardServer { - forward_requests: mpsc::UnboundedSender<(String, u32)>, + forward_requests: mpsc::UnboundedSender<(bool, String, u32)>, server_handles: mpsc::UnboundedSender, } @@ -827,16 +842,30 @@ mod tests { port: &mut u32, session: &mut Session, ) -> Result { - let _ = self.forward_requests.send((address.to_owned(), *port)); + let _ = self + .forward_requests + .send((true, address.to_owned(), *port)); let _ = self.server_handles.send(session.handle()); Ok(true) } + + async fn cancel_tcpip_forward( + &mut self, + address: &str, + port: u32, + _session: &mut Session, + ) -> Result { + let _ = self + .forward_requests + .send((false, address.to_owned(), port)); + Ok(true) + } } #[allow(clippy::type_complexity)] async fn start_remote_forward_server() -> ( SharedHandle, - mpsc::UnboundedReceiver<(String, u32)>, + mpsc::UnboundedReceiver<(bool, String, u32)>, mpsc::UnboundedReceiver, mpsc::UnboundedReceiver, JoinHandle<()>, @@ -1075,14 +1104,14 @@ mod tests { let (local_port, echo_task) = start_local_echo_target().await; let mut manager = TunnelManager::new(); - // Unlike the local and SOCKS forwards, `start_remote_forward` does not - // keep the client handle: its channels are opened by the server, not by - // the tunnel. Production holds it through the owning `SshSession`, so - // the test has to hold it too or the transport closes underneath us. + // Keep the owning SSH session alive beyond tunnel stop, so receiving + // cancel-tcpip-forward proves explicit listener cleanup rather than + // incidental cleanup from disconnecting the transport. let session_handle = handle.clone(); let id = manager .start_remote_forward( handle, + "127.0.0.1".to_owned(), 8443, "127.0.0.1".to_owned(), local_port, @@ -1091,14 +1120,14 @@ mod tests { .await .expect("start remote forward"); - // The server must be asked to listen on the *remote* port, on every - // interface — this is what makes it a reverse forward. + // Honor the configured remote interface instead of exposing the + // listener on every interface regardless of the saved profile. assert_eq!( timeout(Duration::from_secs(2), forward_requests.recv()) .await .expect("tcpip-forward request timed out") .expect("tcpip-forward request channel closed"), - ("0.0.0.0".to_owned(), 8443), + (true, "127.0.0.1".to_owned(), 8443), ); // Play the remote side: a client hit the forwarded port, so the server @@ -1108,7 +1137,7 @@ mod tests { .expect("server handle timed out") .expect("server handle channel closed"); let channel = server_handle - .channel_open_forwarded_tcpip("0.0.0.0", 8443, "203.0.113.7", 54321) + .channel_open_forwarded_tcpip("127.0.0.1", 8443, "203.0.113.7", 54321) .await .expect("open forwarded-tcpip channel"); @@ -1136,6 +1165,14 @@ mod tests { manager.get_tunnel(&id).expect("tunnel handle").stop(); wait_until_stopped(&manager, id).await; + assert_eq!( + timeout(Duration::from_secs(2), forward_requests.recv()) + .await + .unwrap() + .unwrap(), + (false, "127.0.0.1".to_owned(), 8443), + "stop must cancel the exact listener while the session remains connected" + ); let mut one = [0_u8; 1]; assert_eq!( diff --git a/crates/shelldeck-ui/src/agent_console_view.rs b/crates/shelldeck-ui/src/agent_console_view.rs index 92c4cae6..44b4b01c 100644 --- a/crates/shelldeck-ui/src/agent_console_view.rs +++ b/crates/shelldeck-ui/src/agent_console_view.rs @@ -2328,8 +2328,8 @@ impl Render for AgentConsoleView { .py(px(16.0)); if !has_content { let subscription_setup = crate::cli_subscription_setup::render_subscription_setup( - context.provider.binary(), - matches!(context.target, AgentTarget::Ssh { .. }), + self.provider.binary(), + self.selected_connection.is_some(), ); let mut empty_state = div() .flex() @@ -2380,7 +2380,11 @@ impl Render for AgentConsoleView { }; let label = match message.role { AgentMessageRole::User => t!("agents.role.you").to_string(), - AgentMessageRole::Agent => context.provider.display_name().to_string(), + AgentMessageRole::Agent => message + .provider + .unwrap_or(context.provider) + .display_name() + .to_string(), AgentMessageRole::Error => t!("agents.activity.title").to_string(), }; timeline_view = timeline_view.child( diff --git a/crates/shelldeck-ui/src/workspace/account.rs b/crates/shelldeck-ui/src/workspace/account.rs index 591fe531..ff64719a 100644 --- a/crates/shelldeck-ui/src/workspace/account.rs +++ b/crates/shelldeck-ui/src/workspace/account.rs @@ -21,12 +21,16 @@ impl Workspace { } let base = self.account_base_url(); let token = self.app_config.cloud_sync.token.clone(); + let request = self.manage_requests.begin(ManageRead::Account); cx.spawn(async move |this, cx: &mut AsyncApp| { let result = cx .background_executor() .spawn(async move { cloud_account::whoami(&base, &token) }) .await; let _ = this.update(cx, |ws, cx| { + if !ws.accept_manage_result(request, result.as_ref().err(), cx) { + return; + } match result { Ok(info) => { ws.account_status = AccountStatus::Ok; @@ -35,6 +39,7 @@ impl Workspace { ws.app_config.account = Some(refreshed); let _ = ws.app_config.save(); } + ws.sync_settings_config(cx); // Stash the full whoami — the User "Mes informations" // tab renders every field (device label, created_at, // last_seen_at, …) that `AccountInfo` doesn't persist. @@ -111,6 +116,7 @@ impl Workspace { /// Open the password + OIDC login modal. pub fn show_login_form(&mut self, cx: &mut Context) { + self.manage_requests.begin(ManageRead::Login); let server = self.account_base_url(); let device = cloud_account::device_name(); let form = cx.new(|form_cx| LoginForm::new(server, device, form_cx)); @@ -128,6 +134,7 @@ impl Workspace { this.open_forgot_password(cx); } LoginFormEvent::Cancel => { + this.manage_requests.begin(ManageRead::Login); this.login_form = None; this._login_form_sub = None; cx.notify(); @@ -217,6 +224,7 @@ impl Workspace { ) { let base = self.account_base_url(); let device = cloud_account::device_name(); + let request = self.manage_requests.begin(ManageRead::Login); if let Some(form) = &self.login_form { form.update(cx, |f, cx| { f.set_busy(true); @@ -230,21 +238,26 @@ impl Workspace { async move { cloud_account::login_password(&base, &email, &password, &device) }, ) .await; - let _ = this.update(cx, |ws, cx| match result { - Ok((token, account)) => ws.apply_login(token, account, cx), - Err(e) => { - let msg = crate::i18n::login_error_message(&e); - // Une seule fois, et à l'endroit où l'on regarde : sous les - // champs. La bulle en bas à droite répétait mot pour mot le - // message déjà affiché dans la modale. - if let Some(form) = &ws.login_form { - form.update(cx, |f, cx| { - f.set_busy(false); - f.set_error(msg); - cx.notify(); - }); - } else { - ws.show_toast(msg, ToastLevel::Error, cx); + let _ = this.update(cx, |ws, cx| { + if !ws.manage_requests.is_current(request) { + return; + } + match result { + Ok((token, account)) => ws.apply_login(token, account, cx), + Err(e) => { + let msg = crate::i18n::login_error_message(&e); + // Une seule fois, et à l'endroit où l'on regarde : sous les + // champs. La bulle en bas à droite répétait mot pour mot le + // message déjà affiché dans la modale. + if let Some(form) = &ws.login_form { + form.update(cx, |f, cx| { + f.set_busy(false); + f.set_error(msg); + cx.notify(); + }); + } else { + ws.show_toast(msg, ToastLevel::Error, cx); + } } } }); @@ -255,6 +268,7 @@ impl Workspace { /// Start the browser device-authorize flow: bind a loopback listener, open /// the system browser, and wait (background) for the token redirect. pub(super) fn start_oidc_login(&mut self, provider: Option, cx: &mut Context) { + let request = self.manage_requests.begin(ManageRead::Login); let base = self.account_base_url(); let device = cloud_account::device_name(); @@ -326,17 +340,22 @@ impl Workspace { )) }) .await; - let _ = this.update(cx, |ws, cx| match outcome { - Ok((token, account)) => ws.apply_login(token, account, cx), - Err(e) => ws.show_toast( - t!( - "toast.browser_login_failed", - error = crate::i18n::api_error_message(&e) - ) - .to_string(), - ToastLevel::Error, - cx, - ), + let _ = this.update(cx, |ws, cx| { + if !ws.manage_requests.is_current(request) { + return; + } + match outcome { + Ok((token, account)) => ws.apply_login(token, account, cx), + Err(e) => ws.show_toast( + t!( + "toast.browser_login_failed", + error = crate::i18n::api_error_message(&e) + ) + .to_string(), + ToastLevel::Error, + cx, + ), + } }); }) .detach(); @@ -350,6 +369,7 @@ impl Workspace { account: AccountInfo, cx: &mut Context, ) { + self.manage_requests.change_session(); if let Err(error) = shelldeck_core::config::keychain::store_manage_token(&token) { tracing::error!("Failed to store AI Operations token in OS keychain: {error}"); } @@ -383,46 +403,56 @@ impl Workspace { // subset, but "Mes informations" needs the richer payload. self.check_account_on_startup(cx); let cfg = self.app_config.cloud_sync.clone(); + let request = self.manage_requests.begin(ManageRead::Sync); + let login_session = self.manage_requests.session(); let name = account.display_name(); let splash_started_at = std::time::Instant::now(); cx.spawn(async move |this, cx: &mut AsyncApp| { let result = cx .background_executor() .spawn(async move { - shelldeck_core::config::cloud_sync::sync_now(&cfg, shelldeck_core::VERSION) + shelldeck_core::config::cloud_sync::fetch_sync(&cfg, shelldeck_core::VERSION) }) .await; if let Some(remaining) = post_login_splash_remaining(splash_started_at.elapsed()) { cx.background_executor().timer(remaining).await; } - let _ = this.update(cx, |ws, cx| match result { - Ok(_stats) => { - ws.reload_connections_after_sync(cx); - let n = ws - .connections - .iter() - .filter(|c| c.source == ConnectionSource::CloudSync) - .count(); - ws.show_toast( - t!("toast.login_synced", name = name.as_str(), count = n).to_string(), - ToastLevel::Success, - cx, - ); + let _ = this.update(cx, |ws, cx| { + if !ws.accept_manage_result(request, result.as_ref().err(), cx) { + return; } - Err(e) => { - ws.show_toast( - t!( - "toast.login_sync_failed", - name = name.as_str(), - error = crate::i18n::api_error_message(&e) - ) - .to_string(), - ToastLevel::Warning, - cx, - ); + let result = result.and_then(|payload| ws.apply_cloud_profiles(payload, cx)); + match result { + Ok(_stats) => { + let n = ws + .connections + .iter() + .filter(|c| c.source == ConnectionSource::CloudSync) + .count(); + ws.show_toast( + t!("toast.login_synced", name = name.as_str(), count = n).to_string(), + ToastLevel::Success, + cx, + ); + } + Err(e) => { + ws.show_toast( + t!( + "toast.login_sync_failed", + name = name.as_str(), + error = crate::i18n::api_error_message(&e) + ) + .to_string(), + ToastLevel::Warning, + cx, + ); + } } }); let _ = this.update(cx, |ws, cx| { + if !ws.manage_requests.is_current(login_session) { + return; + } if let Some(splash) = &mut ws.post_login_splash { splash.dismissing = true; } @@ -432,6 +462,9 @@ impl Workspace { .timer(std::time::Duration::from_millis(POST_LOGIN_SPLASH_FADE_MS)) .await; let _ = this.update(cx, |ws, cx| { + if !ws.manage_requests.is_current(login_session) { + return; + } ws.post_login_splash = None; ws.maybe_show_onboarding(cx); cx.notify(); @@ -459,6 +492,7 @@ impl Workspace { /// Clear Inklura Manage credentials and stop cloud-backed polls/views. pub(super) fn invalidate_cloud_session(&mut self, cx: &mut Context) { + self.manage_requests.change_session(); self.stop_authenticated_runtime(cx); // Overwrite the persisted workspace after closing sessions. Otherwise // a later launch/account could restore terminals from the old session. @@ -499,7 +533,9 @@ impl Workspace { self.issues_list_owner_scoped = false; self.issues_instances.clear(); self.issues_staff = false; - self.reset_issue_selection(cx); + self.reset_request_session(cx); + self.mention_people.clear(); + self.refresh_mention_directory(cx); self.issue_new_site_id = None; self.rebuild_issue_site_select(cx); self.site_menu_open = false; diff --git a/crates/shelldeck-ui/src/workspace/cloud_sync.rs b/crates/shelldeck-ui/src/workspace/cloud_sync.rs index 454536b7..89f75147 100644 --- a/crates/shelldeck-ui/src/workspace/cloud_sync.rs +++ b/crates/shelldeck-ui/src/workspace/cloud_sync.rs @@ -4,9 +4,9 @@ impl Workspace { /// Pull SSH connection profiles from Inklura Manage on demand. /// /// If Cloud Sync isn't configured, this just explains how to set it up. - /// Otherwise the blocking network fetch + merge runs on a background thread - /// (never the UI thread), and on completion the merged connections are - /// reloaded into the sidebar/dashboard and a toast reports the stats. + /// Otherwise the blocking network fetch runs on a background thread. The + /// current session commits profiles in the completion callback, reloads + /// the sidebar/dashboard, and reports the merge stats. pub fn cloud_sync_now(&mut self, cx: &mut Context) { if !self.signed_in() { return; @@ -45,38 +45,44 @@ impl Workspace { ); } let version = shelldeck_core::VERSION; + let request = self.manage_requests.begin(ManageRead::Sync); cx.spawn(async move |this, cx: &mut AsyncApp| { let result = cx .background_executor() - .spawn(async move { shelldeck_core::config::cloud_sync::sync_now(&cfg, version) }) + .spawn(async move { shelldeck_core::config::cloud_sync::fetch_sync(&cfg, version) }) .await; - let _ = this.update(cx, |ws, cx| match result { - Ok(stats) => { - ws.reload_connections_after_sync(cx); - ws.show_toast( - t!( - "toast.cloud_sync.done", - added = stats.added, - updated = stats.updated, - removed = stats.removed - ) - .to_string(), - ToastLevel::Success, - cx, - ); + let _ = this.update(cx, |ws, cx| { + if !ws.accept_manage_result(request, result.as_ref().err(), cx) { + return; } - Err(e) => { - ws.show_toast( - t!( - "toast.cloud_sync.failed", - error = crate::i18n::api_error_message(&e) - ) - .to_string(), - ToastLevel::Error, - cx, - ); + let result = result.and_then(|payload| ws.apply_cloud_profiles(payload, cx)); + match result { + Ok(stats) => { + ws.show_toast( + t!( + "toast.cloud_sync.done", + added = stats.added, + updated = stats.updated, + removed = stats.removed + ) + .to_string(), + ToastLevel::Success, + cx, + ); + } + Err(e) => { + ws.show_toast( + t!( + "toast.cloud_sync.failed", + error = crate::i18n::api_error_message(&e) + ) + .to_string(), + ToastLevel::Error, + cx, + ); + } } }); }) diff --git a/crates/shelldeck-ui/src/workspace/forwards.rs b/crates/shelldeck-ui/src/workspace/forwards.rs index 3d48c38f..aaa98ec9 100644 --- a/crates/shelldeck-ui/src/workspace/forwards.rs +++ b/crates/shelldeck-ui/src/workspace/forwards.rs @@ -190,6 +190,7 @@ impl Workspace { tunnel_manager .start_remote_forward( shared_handle, + remote_host, remote_port, local_host, local_port, diff --git a/crates/shelldeck-ui/src/workspace/manage_requests.rs b/crates/shelldeck-ui/src/workspace/manage_requests.rs new file mode 100644 index 00000000..f1ae2cb5 --- /dev/null +++ b/crates/shelldeck-ui/src/workspace/manage_requests.rs @@ -0,0 +1,219 @@ +use super::*; +use shelldeck_core::ShellDeckError; + +/// Independent reads may run together; only the newest read of each surface +/// can publish. Session changes invalidate every outstanding read or write. +#[derive(Clone, Copy)] +pub(super) enum ManageRead { + Login, + Account, + Sites, + Issues, + IssueDetail, + Support, + SupportDetail, + Sync, + People, +} + +#[derive(Clone, Copy)] +pub(super) struct ManageRequest { + session: u64, + read: Option<(ManageRead, u64)>, +} + +#[derive(Default)] +pub(super) struct ManageRequests { + session: u64, + reads: [u64; 9], +} + +#[derive(Debug, PartialEq, Eq)] +enum ManageDisposition { + Apply, + Ignore, + Expired, +} + +impl ManageRequests { + pub(super) fn session(&self) -> ManageRequest { + ManageRequest { + session: self.session, + read: None, + } + } + + pub(super) fn current(&self, read: ManageRead) -> ManageRequest { + ManageRequest { + session: self.session, + read: Some((read, self.reads[read as usize])), + } + } + + pub(super) fn begin(&mut self, read: ManageRead) -> ManageRequest { + let serial = &mut self.reads[read as usize]; + *serial = serial.wrapping_add(1); + self.current(read) + } + + pub(super) fn is_current(&self, request: ManageRequest) -> bool { + request.session == self.session + && request + .read + .is_none_or(|(read, serial)| self.reads[read as usize] == serial) + } + + pub(super) fn change_session(&mut self) { + self.session = self.session.wrapping_add(1); + } + + fn disposition( + &self, + request: ManageRequest, + error: Option<&ShellDeckError>, + ) -> ManageDisposition { + if !self.is_current(request) { + return ManageDisposition::Ignore; + } + if error.is_some_and(|error| { + cloud_account::classify_api_error(error) == cloud_account::ApiFailure::AuthRejected + }) { + ManageDisposition::Expired + } else { + ManageDisposition::Apply + } + } +} + +impl Workspace { + /// A 403 is a permission failure, not a revoked login. Ignore errors from + /// old sessions before considering 401, so an old token cannot log out a + /// newly signed-in account or produce repeated expiry notifications. + pub(super) fn accept_manage_result( + &mut self, + request: ManageRequest, + error: Option<&ShellDeckError>, + cx: &mut Context, + ) -> bool { + match self.manage_requests.disposition(request, error) { + ManageDisposition::Apply => true, + ManageDisposition::Ignore => false, + ManageDisposition::Expired => { + self.invalidate_cloud_session(cx); + self.account_status = AccountStatus::Rejected; + self.show_toast( + t!("toast.session.expired").to_string(), + ToastLevel::Warning, + cx, + ); + false + } + } + } + + /// Fetching happens in the background. Commit only after checking the + /// session on the UI thread, where logout and connection edits are ordered. + pub(super) fn apply_cloud_profiles( + &mut self, + payload: shelldeck_core::config::cloud_sync::SyncPayload, + cx: &mut Context, + ) -> shelldeck_core::Result { + let mut store = ConnectionStore::load()?; + let stats = + shelldeck_core::config::cloud_sync::merge_profiles(&mut store, &payload.connections); + if stats.changed() { + store.save()?; + } + self.reload_connections_after_sync(cx); + Ok(stats) + } +} + +#[cfg(test)] +mod tests { + use super::{ManageDisposition, ManageRead, ManageRequests}; + use shelldeck_core::ShellDeckError; + + // SDTEST-1937 — the later selected request must survive out-of-order reads, + // while a list refresh must not accidentally cancel its detail request. + #[test] + fn newer_detail_survives_delayed_response_and_independent_list_refresh() { + let mut requests = ManageRequests::default(); + let old = requests.begin(ManageRead::IssueDetail); + let old_write_detail = requests.current(ManageRead::IssueDetail); + let write_session = requests.session(); + let new = requests.begin(ManageRead::IssueDetail); + let list = requests.begin(ManageRead::Issues); + let mut displayed = None; + for (request, id) in [(new, "selected"), (old, "previous")] { + if requests.is_current(request) { + displayed = Some(id); + } + } + assert_eq!(displayed, Some("selected")); + assert!(requests.is_current(write_session)); + assert!(!requests.is_current(old_write_detail)); + assert!(requests.is_current(list)); + let obsolete_list = list; + requests.begin(ManageRead::Issues); + assert!(!requests.is_current(obsolete_list)); + assert!(requests.is_current(new)); + } + + // SDTEST-1938 — old reads/writes must not restore data or reject a new + // account, including a re-login that uses the same bearer token. + #[test] + fn logout_and_relogin_retire_all_previous_reads_and_writes() { + let mut requests = ManageRequests::default(); + let old_read = requests.begin(ManageRead::Account); + let old_write = requests.session(); + requests.change_session(); + let logged_out = requests.session(); + requests.change_session(); + let new_read = requests.begin(ManageRead::Account); + let new_write = requests.session(); + let mut accepted = Vec::new(); + for (request, label) in [ + (old_read, "old identity"), + (old_write, "old write"), + (logged_out, "logged out"), + (new_read, "new identity"), + (new_write, "new write"), + ] { + if requests.is_current(request) { + accepted.push(label); + } + } + assert_eq!(accepted, ["new identity", "new write"]); + } + + // SDTEST-1939 — a permission error stays local; the first current 401 + // ends the session, making concurrent rejections and old successes inert. + #[test] + fn permission_failures_keep_session_but_current_revocation_expires_once() { + let mut requests = ManageRequests::default(); + let request = requests.begin(ManageRead::Support); + let concurrent = requests.begin(ManageRead::Issues); + let forbidden = ShellDeckError::Connection("staff only (403)".into()); + let rejected = ShellDeckError::Connection("session token rejected (401)".into()); + assert_eq!( + requests.disposition(request, Some(&forbidden)), + ManageDisposition::Apply + ); + assert_eq!( + requests.disposition(request, Some(&rejected)), + ManageDisposition::Expired + ); + requests.change_session(); + assert_eq!( + requests.disposition(concurrent, Some(&rejected)), + ManageDisposition::Ignore + ); + assert_eq!( + requests.disposition(request, None), + ManageDisposition::Ignore + ); + let new = requests.begin(ManageRead::Support); + assert_eq!(requests.disposition(new, None), ManageDisposition::Apply); + } +} diff --git a/crates/shelldeck-ui/src/workspace/mentions.rs b/crates/shelldeck-ui/src/workspace/mentions.rs index ba41e9df..cc59a13e 100644 --- a/crates/shelldeck-ui/src/workspace/mentions.rs +++ b/crates/shelldeck-ui/src/workspace/mentions.rs @@ -19,7 +19,7 @@ use shelldeck_core::config::manage_directory; use shelldeck_core::models::script::ScriptTarget; use std::rc::Rc; -use super::Workspace; +use super::{ManageRead, Workspace}; use crate::t; impl Workspace { @@ -558,6 +558,7 @@ impl Workspace { let base = self.app_config.cloud_sync.base_url.clone(); let token = self.app_config.cloud_sync.token.clone(); let site = self.app_config.cloud_sync.active_site_id.clone(); + let request = self.manage_requests.begin(ManageRead::People); cx.spawn(async move |this, cx| { let result = cx .background_executor() @@ -565,16 +566,21 @@ impl Workspace { async move { manage_directory::fetch_people(&base, &token, site.as_deref()) }, ) .await; - let _ = this.update(cx, |workspace, cx| match result { - Ok(people) => { - let changed = workspace.mention_people != people; - workspace.mention_people = people; - if changed { - workspace.refresh_mention_directory(cx); - } + let _ = this.update(cx, |workspace, cx| { + if !workspace.accept_manage_result(request, result.as_ref().err(), cx) { + return; } - Err(error) => { - tracing::debug!(%error, "mentionable people unavailable"); + match result { + Ok(people) => { + let changed = workspace.mention_people != people; + workspace.mention_people = people; + if changed { + workspace.refresh_mention_directory(cx); + } + } + Err(error) => { + tracing::debug!(%error, "mentionable people unavailable"); + } } }); }) diff --git a/crates/shelldeck-ui/src/workspace/mod.rs b/crates/shelldeck-ui/src/workspace/mod.rs index 4f739244..e5533f99 100644 --- a/crates/shelldeck-ui/src/workspace/mod.rs +++ b/crates/shelldeck-ui/src/workspace/mod.rs @@ -123,6 +123,8 @@ mod fleet; pub(crate) mod platform_attention; pub use platform_attention::PlatformAttentionNotification; mod forwards; +mod manage_requests; +use manage_requests::{ManageRead, ManageRequests}; mod mentions; mod menu; mod modes; @@ -565,6 +567,7 @@ pub struct Workspace { /// bits `AccountInfo` persists. Refreshed by `check_account_on_startup` /// and set on login. Cleared on logout. last_whoami: Option, + manage_requests: ManageRequests, /// Which User-mode home tab is showing (Sites / Demandes / Infos). user_home_tab: UserHomeTab, /// Cached Inklura Manage sites directory + areas (fetched after sign-in). @@ -1478,6 +1481,7 @@ impl Workspace { _login_form_sub: None, _onboarding_sub: None, last_whoami: None, + manage_requests: ManageRequests::default(), user_home_tab: UserHomeTab::Home, site_directory: None, site_menu_open: false, diff --git a/crates/shelldeck-ui/src/workspace/modes.rs b/crates/shelldeck-ui/src/workspace/modes.rs index 0b181b7c..17fd102f 100644 --- a/crates/shelldeck-ui/src/workspace/modes.rs +++ b/crates/shelldeck-ui/src/workspace/modes.rs @@ -249,6 +249,10 @@ impl Workspace { /// land on an empty state. Any new issue-selection field added to /// `Workspace` must be reset here too. pub(super) fn reset_issue_selection(&mut self, cx: &mut Context) { + self.issue_attachment_generation = self.issue_attachment_generation.wrapping_add(1); + self.issue_attachment_busy = false; + self.manage_requests.begin(ManageRead::IssueDetail); + self.manage_requests.begin(ManageRead::SupportDetail); self.issue_selected = None; self.issue_detail = None; self.user_new_request_sheet_open = false; diff --git a/crates/shelldeck-ui/src/workspace/requests.rs b/crates/shelldeck-ui/src/workspace/requests.rs index 861918d6..b5313686 100644 --- a/crates/shelldeck-ui/src/workspace/requests.rs +++ b/crates/shelldeck-ui/src/workspace/requests.rs @@ -205,11 +205,10 @@ impl Workspace { }) .await; let _ = this.update(cx, |ws, cx| { - ws.issue_attachment_busy = false; if generation != ws.issue_attachment_generation { - cx.notify(); return; } + ws.issue_attachment_busy = false; for result in loaded { match result { Ok(draft) => ws.add_attachment_draft(target, draft, cx), @@ -298,11 +297,10 @@ impl Workspace { .spawn(async move { issues::download_issue_image_url(&url) }) .await; let _ = this.update(cx, |ws, cx| { - ws.issue_attachment_busy = false; if generation != ws.issue_attachment_generation { - cx.notify(); return; } + ws.issue_attachment_busy = false; match result.and_then(|upload| { AttachmentDraft::from_bytes(upload.filename, upload.bytes) .map_err(shelldeck_core::ShellDeckError::Connection) @@ -345,11 +343,10 @@ impl Workspace { .spawn(async { capture_region() }) .await; let _ = this.update(cx, |ws, cx| { - ws.issue_attachment_busy = false; if generation != ws.issue_attachment_generation { - cx.notify(); return; } + ws.issue_attachment_busy = false; match result { Ok(draft) => ws.open_issue_capture_annotator(target, draft, cx), Err(error) => ws.show_toast( @@ -475,6 +472,23 @@ impl Workspace { self.rebuild_issue_site_select(cx); } + /// Drafts survive navigation within an account, but never a sign-out. + pub(super) fn reset_request_session(&mut self, cx: &mut Context) { + self.issue_attachment_generation = self.issue_attachment_generation.wrapping_add(1); + self.issue_ai_request_id = self.issue_ai_request_id.wrapping_add(1); + self.issue_attachment_busy = false; + self.issue_ai_loading = false; + self.issue_ai_error = None; + self.issue_ai_expanded = false; + self.issue_capture_annotator = None; + self.issue_thread_link_action = None; + self.issue_comment_attachments.clear(); + self.issue_comment_attachments_open = false; + Self::reset_input(&self.issue_comment_state.clone(), cx); + self.reset_issue_selection(cx); + self.reset_new_request_draft(cx); + } + /// Close the "Nouvelle demande" sheet after a clean close or an explicit /// discard. Plays the exit animation first, then clears the buffers only /// once the panel has left the viewport. @@ -485,17 +499,22 @@ impl Workspace { self.user_new_request_sheet_dismissing = true; self.user_new_request_needs_focus = false; self.issue_attachment_generation = self.issue_attachment_generation.wrapping_add(1); + self.issue_attachment_busy = false; self.issue_ai_request_id = self.issue_ai_request_id.wrapping_add(1); self.issue_ai_expanded = false; self.issue_ai_loading = false; self.issue_ai_error = None; self.issue_capture_annotator = None; cx.notify(); + let request = self.manage_requests.session(); cx.spawn(async move |this, cx: &mut AsyncApp| { cx.background_executor() .timer(std::time::Duration::from_millis(SHEET_ANIM_MS)) .await; let _ = this.update(cx, |ws, cx| { + if !ws.manage_requests.is_current(request) { + return; + } ws.user_new_request_sheet_open = false; ws.user_new_request_sheet_dismissing = false; ws.reset_new_request_draft(cx); @@ -512,7 +531,9 @@ impl Workspace { return; } self.user_issue_detail_dismissing = true; + let request = self.manage_requests.begin(ManageRead::IssueDetail); self.issue_attachment_generation = self.issue_attachment_generation.wrapping_add(1); + self.issue_attachment_busy = false; self.issue_capture_annotator = None; self.issue_thread_link_action = None; cx.notify(); @@ -521,6 +542,9 @@ impl Workspace { .timer(std::time::Duration::from_millis(SHEET_ANIM_MS)) .await; let _ = this.update(cx, |ws, cx| { + if !ws.manage_requests.is_current(request) { + return; + } ws.issue_selected = None; ws.issue_detail = None; ws.user_issue_detail_dismissing = false; @@ -790,34 +814,40 @@ impl Workspace { // Support mode retains its triage filters and broader staff scope. let filter = issue_list_filter_for_mode(self.effective_mode(), &self.issues_filter); let owner_scoped = filter.mine; + let request = self.manage_requests.begin(ManageRead::Issues); cx.spawn(async move |this, cx: &mut AsyncApp| { let result = cx .background_executor() .spawn(async move { issues::list_issues(&base, &token, &filter) }) .await; - let _ = this.update(cx, |ws, cx| match result { - Ok(list) => { - ws.issues_list = list.issues.clone(); - ws.issues_counts = list.counts; - ws.issues_list_owner_scoped = owner_scoped; - ws.issues_staff = list.staff; - ws.issues_instances = list.instances.clone(); - // Fixture de phase de test uniquement : son interrupteur - // reste coupé en utilisation normale. Même activée, elle - // n'affecte que la mémoire et n'est jamais envoyée à Manage. - Self::inject_thread_showcase(&mut ws.issues_list, ws.issues_staff); - ws.push_issues_to_support(cx); - cx.notify(); + let _ = this.update(cx, |ws, cx| { + if !ws.accept_manage_result(request, result.as_ref().err(), cx) { + return; + } + match result { + Ok(list) => { + ws.issues_list = list.issues.clone(); + ws.issues_counts = list.counts; + ws.issues_list_owner_scoped = owner_scoped; + ws.issues_staff = list.staff; + ws.issues_instances = list.instances.clone(); + // Fixture de phase de test uniquement : son interrupteur + // reste coupé en utilisation normale. Même activée, elle + // n'affecte que la mémoire et n'est jamais envoyée à Manage. + Self::inject_thread_showcase(&mut ws.issues_list, ws.issues_staff); + ws.push_issues_to_support(cx); + cx.notify(); + } + Err(e) => ws.show_toast( + t!( + "toast.issue.list_failed", + error = crate::i18n::api_error_message(&e) + ) + .to_string(), + ToastLevel::Error, + cx, + ), } - Err(e) => ws.show_toast( - t!( - "toast.issue.list_failed", - error = crate::i18n::api_error_message(&e) - ) - .to_string(), - ToastLevel::Error, - cx, - ), }); }) .detach(); @@ -890,6 +920,8 @@ impl Workspace { } pub fn select_issue(&mut self, id: String, cx: &mut Context) { + self.user_issue_detail_dismissing = false; + let request = self.manage_requests.begin(ManageRead::IssueDetail); // La fixture n'existe pas côté Manage : demander son détail renvoie 404 // et un toast erroné. On prend la version qu'on a déjà en mémoire. if id == Self::FAKE_SHOWCASE_ID { @@ -906,6 +938,7 @@ impl Workspace { return; }; if self.issue_selected.as_deref() != Some(id.as_str()) { + self.issue_attachment_busy = false; self.issue_attachment_generation = self.issue_attachment_generation.wrapping_add(1); self.issue_comment_attachments.clear(); Self::reset_input(&self.issue_attachment_url_state.clone(), cx); @@ -928,24 +961,29 @@ impl Workspace { .background_executor() .spawn(async move { issues::get_issue(&base, &token, &id) }) .await; - let _ = this.update(cx, |ws, cx| match result { - Ok(iss) => { - ws.issue_detail = Some(iss); - if pin_user_thread { - crate::follow_scroll::pin_to_latest(&ws.user_issue_thread_scroll); + let _ = this.update(cx, |ws, cx| { + if !ws.accept_manage_result(request, result.as_ref().err(), cx) { + return; + } + match result { + Ok(iss) => { + ws.issue_detail = Some(iss); + if pin_user_thread { + crate::follow_scroll::pin_to_latest(&ws.user_issue_thread_scroll); + } + ws.push_issues_to_support(cx); + cx.notify(); } - ws.push_issues_to_support(cx); - cx.notify(); + Err(e) => ws.show_toast( + t!( + "toast.issue.detail_failed", + error = crate::i18n::api_error_message(&e) + ) + .to_string(), + ToastLevel::Error, + cx, + ), } - Err(e) => ws.show_toast( - t!( - "toast.issue.detail_failed", - error = crate::i18n::api_error_message(&e) - ) - .to_string(), - ToastLevel::Error, - cx, - ), }); }) .detach(); @@ -989,6 +1027,8 @@ impl Workspace { self.issue_attachment_busy = true; self.issue_attachment_generation = self.issue_attachment_generation.wrapping_add(1); cx.notify(); + let request = self.manage_requests.session(); + let attachment_generation = self.issue_attachment_generation; cx.spawn(async move |this, cx: &mut AsyncApp| { let result = cx .background_executor() @@ -1023,7 +1063,16 @@ impl Workspace { }) .await; let _ = this.update(cx, |ws, cx| { - ws.issue_attachment_busy = false; + let error = result + .as_ref() + .err() + .or_else(|| result.as_ref().ok().and_then(|(_, error)| error.as_ref())); + if !ws.accept_manage_result(request, error, cx) { + return; + } + if ws.issue_attachment_generation == attachment_generation { + ws.issue_attachment_busy = false; + } match result { Ok((iss, attachment_error)) => { let preserve_attachments = attachment_error.is_some(); @@ -1042,6 +1091,12 @@ impl Workspace { .with_action(ActivityAction::OpenIssue), cx, ); + ws.upsert_issue_in_list(iss.clone()); + if ws.issue_attachment_generation != attachment_generation { + ws.push_issues_to_support(cx); + cx.notify(); + return; + } // Success: close the composer sheet, clear its buffers, // and pop the detail sheet on the newly-created request. ws.user_new_request_sheet_open = false; @@ -1056,7 +1111,6 @@ impl Workspace { ws.issue_ai_expanded = false; ws.issue_ai_loading = false; ws.issue_ai_error = None; - ws.upsert_issue_in_list(iss.clone()); ws.issue_detail = Some(iss.clone()); ws.issue_selected = Some(iss.id.clone()); ws.push_issues_to_support(cx); @@ -1114,6 +1168,9 @@ impl Workspace { self.issue_attachment_generation = self.issue_attachment_generation.wrapping_add(1); let sent_issue_id = id.clone(); cx.notify(); + let request = self.manage_requests.session(); + let attachment_generation = self.issue_attachment_generation; + let detail_request = self.manage_requests.current(ManageRead::IssueDetail); cx.spawn(async move |this, cx: &mut AsyncApp| { let result = cx .background_executor() @@ -1132,17 +1189,25 @@ impl Workspace { }) .await; let _ = this.update(cx, |ws, cx| { - ws.issue_attachment_busy = false; + if !ws.accept_manage_result(request, result.as_ref().err(), cx) { + return; + } + if ws.issue_attachment_generation == attachment_generation { + ws.issue_attachment_busy = false; + } match result { Ok(iss) => { ws.upsert_issue_in_list(iss.clone()); - ws.issue_detail = Some(iss); - crate::follow_scroll::pin_to_latest(&ws.user_issue_thread_scroll); - if let Some((id, title)) = ws - .issue_detail - .as_ref() - .map(|detail| (detail.id.clone(), detail.title.clone())) + if ws.manage_requests.is_current(detail_request) + && ws.issue_selected.as_deref() == Some(iss.id.as_str()) { + ws.issue_detail = Some(iss.clone()); + } + if ws.manage_requests.is_current(detail_request) { + crate::follow_scroll::pin_to_latest(&ws.user_issue_thread_scroll); + } + { + let (id, title) = (iss.id.clone(), iss.title.clone()); ws.add_activity_entry( ActivityEntry::new( ActivityKind::Issue, @@ -1158,11 +1223,16 @@ impl Workspace { ws.support.update(cx, |view, cx| { view.clear_issue_draft_after_send(&sent_issue_id, cx); }); - Self::reset_input(&ws.issue_comment_state.clone(), cx); - Self::reset_input(&ws.issue_attachment_url_state.clone(), cx); - ws.issue_attachment_url_open = false; - ws.issue_comment_attachments.clear(); - ws.issue_comment_attachments_open = false; + if ws.manage_requests.is_current(detail_request) + && ws.issue_selected.as_deref() == Some(sent_issue_id.as_str()) + && ws.issue_attachment_generation == attachment_generation + { + Self::reset_input(&ws.issue_comment_state.clone(), cx); + Self::reset_input(&ws.issue_attachment_url_state.clone(), cx); + ws.issue_attachment_url_open = false; + ws.issue_comment_attachments.clear(); + ws.issue_comment_attachments_open = false; + } cx.notify(); } Err(e) => { @@ -1196,42 +1266,51 @@ impl Workspace { let Some((base, token)) = self.manage_base_token() else { return; }; + let request = self.manage_requests.session(); + let detail_request = self.manage_requests.current(ManageRead::IssueDetail); cx.spawn(async move |this, cx: &mut AsyncApp| { let result = cx .background_executor() .spawn(async move { f(base, token) }) .await; - let _ = this.update(cx, |ws, cx| match result { - Ok(iss) => { - ws.upsert_issue_in_list(iss.clone()); - ws.issue_detail = Some(iss); - if let Some((id, title)) = ws - .issue_detail - .as_ref() - .map(|detail| (detail.id.clone(), detail.title.clone())) - { - ws.add_activity_entry( - ActivityEntry::new( - ActivityKind::Issue, - t!("activity.issue.updated", title = title.as_str()).to_string(), - ) - .with_target(id, title) - .with_action(ActivityAction::OpenIssue), - cx, - ); + let _ = this.update(cx, |ws, cx| { + if !ws.accept_manage_result(request, result.as_ref().err(), cx) { + return; + } + match result { + Ok(iss) => { + ws.upsert_issue_in_list(iss.clone()); + if ws.manage_requests.is_current(detail_request) + && ws.issue_selected.as_deref() == Some(iss.id.as_str()) + { + ws.issue_detail = Some(iss.clone()); + } + { + let (id, title) = (iss.id.clone(), iss.title.clone()); + ws.add_activity_entry( + ActivityEntry::new( + ActivityKind::Issue, + t!("activity.issue.updated", title = title.as_str()) + .to_string(), + ) + .with_target(id, title) + .with_action(ActivityAction::OpenIssue), + cx, + ); + } + ws.push_issues_to_support(cx); + cx.notify(); } - ws.push_issues_to_support(cx); - cx.notify(); + Err(e) => ws.show_toast( + t!( + "toast.issue.staff_failed", + error = crate::i18n::api_error_message(&e) + ) + .to_string(), + ToastLevel::Error, + cx, + ), } - Err(e) => ws.show_toast( - t!( - "toast.issue.staff_failed", - error = crate::i18n::api_error_message(&e) - ) - .to_string(), - ToastLevel::Error, - cx, - ), }); }) .detach(); @@ -1297,6 +1376,7 @@ impl Workspace { ToastLevel::Info, cx, ); + let request = self.manage_requests.session(); cx.spawn(async move |this, cx: &mut AsyncApp| { let result = cx .background_executor() @@ -1315,38 +1395,46 @@ impl Workspace { }) }) .await; - let _ = this.update(cx, |ws, cx| match result { - Ok(issue) => { - ws.upsert_issue_in_list(issue.clone()); - ws.issue_detail = Some(issue.clone()); - ws.push_issues_to_support(cx); - ws.add_activity_entry( - ActivityEntry::new( - ActivityKind::Issue, - t!("activity.issue.updated", title = issue.title.as_str()).to_string(), - ) - .with_target(issue.id, issue.title) - .with_action(ActivityAction::OpenIssue), - cx, - ); - ws.show_toast( - t!("toast.ai.triage_applied", count = change_count).to_string(), - ToastLevel::Success, - cx, - ); - cx.notify(); + let _ = this.update(cx, |ws, cx| { + if !ws.accept_manage_result(request, result.as_ref().err(), cx) { + return; } - Err(error) => { - ws.show_toast( - t!( - "toast.ai.triage_failed", - error = crate::i18n::api_error_message(&error) - ) - .to_string(), - ToastLevel::Error, - cx, - ); - ws.refresh_issues(cx); + match result { + Ok(issue) => { + ws.upsert_issue_in_list(issue.clone()); + if ws.issue_selected.as_deref() == Some(issue.id.as_str()) { + ws.issue_detail = Some(issue.clone()); + } + ws.push_issues_to_support(cx); + ws.add_activity_entry( + ActivityEntry::new( + ActivityKind::Issue, + t!("activity.issue.updated", title = issue.title.as_str()) + .to_string(), + ) + .with_target(issue.id, issue.title) + .with_action(ActivityAction::OpenIssue), + cx, + ); + ws.show_toast( + t!("toast.ai.triage_applied", count = change_count).to_string(), + ToastLevel::Success, + cx, + ); + cx.notify(); + } + Err(error) => { + ws.show_toast( + t!( + "toast.ai.triage_failed", + error = crate::i18n::api_error_message(&error) + ) + .to_string(), + ToastLevel::Error, + cx, + ); + ws.refresh_issues(cx); + } } }); }) @@ -1405,6 +1493,7 @@ impl Workspace { ToastLevel::Info, cx, ); + let request = self.manage_requests.session(); cx.spawn(async move |this, cx: &mut AsyncApp| { let result = cx .background_executor() @@ -1427,38 +1516,46 @@ impl Workspace { }) }) .await; - let _ = this.update(cx, |workspace, cx| match result { - Ok(ticket) => { - let label = ticket.subject.clone(); - let id = ticket.id.clone(); - workspace.support.update(cx, |view, cx| { - view.set_detail(ticket, cx); - }); - workspace.add_activity_entry( - ActivityEntry::new( - ActivityKind::Support, - t!("activity.support.updated", subject = label.as_str()).to_string(), + let _ = this.update(cx, |workspace, cx| { + if !workspace.accept_manage_result(request, result.as_ref().err(), cx) { + return; + } + match result { + Ok(ticket) => { + let label = ticket.subject.clone(); + let id = ticket.id.clone(); + workspace.support.update(cx, |view, cx| { + if view.selected_id().as_deref() == Some(ticket.id.as_str()) { + view.set_detail(ticket, cx); + } + }); + workspace.add_activity_entry( + ActivityEntry::new( + ActivityKind::Support, + t!("activity.support.updated", subject = label.as_str()) + .to_string(), + ) + .with_target(id, label) + .with_action(ActivityAction::OpenTicket), + cx, + ); + workspace.show_toast( + t!("toast.ai.triage_applied", count = change_count).to_string(), + ToastLevel::Success, + cx, + ); + workspace.refresh_support(cx); + } + Err(error) => workspace.show_toast( + t!( + "toast.ai.triage_failed", + error = crate::i18n::api_error_message(&error) ) - .with_target(id, label) - .with_action(ActivityAction::OpenTicket), - cx, - ); - workspace.show_toast( - t!("toast.ai.triage_applied", count = change_count).to_string(), - ToastLevel::Success, + .to_string(), + ToastLevel::Error, cx, - ); - workspace.refresh_support(cx); + ), } - Err(error) => workspace.show_toast( - t!( - "toast.ai.triage_failed", - error = crate::i18n::api_error_message(&error) - ) - .to_string(), - ToastLevel::Error, - cx, - ), }); }) .detach(); @@ -1472,40 +1569,47 @@ impl Workspace { return; }; let deleted_id = id.clone(); + let request = self.manage_requests.session(); cx.spawn(async move |this, cx: &mut AsyncApp| { let result = cx .background_executor() .spawn(async move { issues::delete_issue(&base, &token, &id) }) .await; - let _ = this.update(cx, |ws, cx| match result { - Ok(_) => { - ws.add_activity( - t!("activity.issue.deleted", id = deleted_id.as_str()).to_string(), - ActivityKind::Issue, - cx, - ); - ws.remove_issue_from_list(&deleted_id); - if ws.issue_selected.as_deref() == Some(deleted_id.as_str()) { - ws.issue_selected = None; - ws.issue_detail = None; + let _ = this.update(cx, |ws, cx| { + if !ws.accept_manage_result(request, result.as_ref().err(), cx) { + return; + } + match result { + Ok(_) => { + ws.add_activity( + t!("activity.issue.deleted", id = deleted_id.as_str()).to_string(), + ActivityKind::Issue, + cx, + ); + ws.remove_issue_from_list(&deleted_id); + if ws.issue_selected.as_deref() == Some(deleted_id.as_str()) { + ws.manage_requests.begin(ManageRead::IssueDetail); + ws.issue_selected = None; + ws.issue_detail = None; + } + ws.push_issues_to_support(cx); + ws.show_toast( + t!("toast.issue.deleted").to_string(), + ToastLevel::Success, + cx, + ); + cx.notify(); } - ws.push_issues_to_support(cx); - ws.show_toast( - t!("toast.issue.deleted").to_string(), - ToastLevel::Success, + Err(e) => ws.show_toast( + t!( + "toast.issue.delete_failed", + error = crate::i18n::api_error_message(&e) + ) + .to_string(), + ToastLevel::Error, cx, - ); - cx.notify(); + ), } - Err(e) => ws.show_toast( - t!( - "toast.issue.delete_failed", - error = crate::i18n::api_error_message(&e) - ) - .to_string(), - ToastLevel::Error, - cx, - ), }); }) .detach(); @@ -1520,6 +1624,7 @@ impl Workspace { let Some((base, token)) = self.manage_base_token() else { return; }; + let request = self.manage_requests.session(); cx.spawn(async move |this, cx: &mut AsyncApp| { let result = cx .background_executor() @@ -1527,29 +1632,34 @@ impl Workspace { issues::delete_issue_attachment(&base, &token, &id, &attachment_id) }) .await; - let _ = this.update(cx, |ws, cx| match result { - Ok(issue) => { - ws.upsert_issue_in_list(issue.clone()); - if ws.issue_selected.as_deref() == Some(issue.id.as_str()) { - ws.issue_detail = Some(issue); + let _ = this.update(cx, |ws, cx| { + if !ws.accept_manage_result(request, result.as_ref().err(), cx) { + return; + } + match result { + Ok(issue) => { + ws.upsert_issue_in_list(issue.clone()); + if ws.issue_selected.as_deref() == Some(issue.id.as_str()) { + ws.issue_detail = Some(issue); + } + ws.push_issues_to_support(cx); + ws.show_toast( + t!("toast.issue.attachment_deleted").to_string(), + ToastLevel::Success, + cx, + ); + cx.notify(); } - ws.push_issues_to_support(cx); - ws.show_toast( - t!("toast.issue.attachment_deleted").to_string(), - ToastLevel::Success, + Err(error) => ws.show_toast( + t!( + "toast.issue.attachment_delete_failed", + error = crate::i18n::api_error_message(&error) + ) + .to_string(), + ToastLevel::Error, cx, - ); - cx.notify(); + ), } - Err(error) => ws.show_toast( - t!( - "toast.issue.attachment_delete_failed", - error = crate::i18n::api_error_message(&error) - ) - .to_string(), - ToastLevel::Error, - cx, - ), }); }) .detach(); diff --git a/crates/shelldeck-ui/src/workspace/sites.rs b/crates/shelldeck-ui/src/workspace/sites.rs index f22a821a..d679d167 100644 --- a/crates/shelldeck-ui/src/workspace/sites.rs +++ b/crates/shelldeck-ui/src/workspace/sites.rs @@ -100,27 +100,33 @@ impl Workspace { } let base = self.account_base_url(); let token = self.app_config.cloud_sync.token.clone(); + let request = self.manage_requests.begin(ManageRead::Sites); cx.spawn(async move |this, cx: &mut AsyncApp| { let result = cx .background_executor() .spawn(async move { manage_sites::fetch_sites(&base, &token) }) .await; - let _ = this.update(cx, |ws, cx| match result { - Ok(payload) => { - tracing::info!( - "Loaded {} manage sites, {} areas", - payload.sites.len(), - payload.areas.len() - ); - ws.site_directory = Some(payload); - ws.rebuild_issue_site_select(cx); - ws.refresh_command_palette(cx); - // Server may have just delivered the Monique config (super-admin). - ws.update_monique_availability(cx); - ws.sync_monique_poll(cx); - cx.notify(); + let _ = this.update(cx, |ws, cx| { + if !ws.accept_manage_result(request, result.as_ref().err(), cx) { + return; + } + match result { + Ok(payload) => { + tracing::info!( + "Loaded {} manage sites, {} areas", + payload.sites.len(), + payload.areas.len() + ); + ws.site_directory = Some(payload); + ws.rebuild_issue_site_select(cx); + ws.refresh_command_palette(cx); + // Server may have just delivered the Monique config (super-admin). + ws.update_monique_availability(cx); + ws.sync_monique_poll(cx); + cx.notify(); + } + Err(e) => tracing::warn!("Failed to load manage sites: {}", e), } - Err(e) => tracing::warn!("Failed to load manage sites: {}", e), }); }) .detach(); diff --git a/crates/shelldeck-ui/src/workspace/support.rs b/crates/shelldeck-ui/src/workspace/support.rs index 216495b8..c71a8b87 100644 --- a/crates/shelldeck-ui/src/workspace/support.rs +++ b/crates/shelldeck-ui/src/workspace/support.rs @@ -252,6 +252,7 @@ impl Workspace { v.set_loading(true); cx.notify(); }); + let request = self.manage_requests.begin(ManageRead::Support); cx.spawn(async move |this, cx: &mut AsyncApp| { let (list, agents) = cx .background_executor() @@ -266,6 +267,9 @@ impl Workspace { }) .await; let _ = this.update(cx, |ws, cx| { + if !ws.accept_manage_result(request, list.as_ref().err(), cx) { + return; + } ws.support.update(cx, |v, cx| { match list { Ok(mut r) => { @@ -298,6 +302,7 @@ impl Workspace { } pub(super) fn select_support_ticket(&mut self, id: String, cx: &mut Context) { + let request = self.manage_requests.begin(ManageRead::SupportDetail); let base = self.account_base_url(); let token = self.app_config.cloud_sync.token.clone(); self.add_activity_entry( @@ -331,26 +336,33 @@ impl Workspace { .spawn(async move { let detail = manage_support::support_ticket(&base, &token, &id); // Best-effort mark-read; ignore result. - let _ = manage_support::support_read(&base, &token, &id); + if detail.is_ok() { + let _ = manage_support::support_read(&base, &token, &id); + } detail }) .await; - let _ = this.update(cx, |ws, cx| match detail { - Ok(t) => { - ws.support.update(cx, |v, cx| { - v.set_detail(t, cx); - cx.notify(); - }); - // Unread counts drift ≤30 s until the poll runs — an - // eager `refresh_support` here doubled the HTTP round - // trips on every selection. + let _ = this.update(cx, |ws, cx| { + if !ws.accept_manage_result(request, detail.as_ref().err(), cx) { + return; } - Err(e) => { - let msg = crate::i18n::api_error_message(&e); - ws.support.update(cx, |v, cx| { - v.set_error(msg); - cx.notify(); - }); + match detail { + Ok(t) => { + ws.support.update(cx, |v, cx| { + v.set_detail(t, cx); + cx.notify(); + }); + // Unread counts drift ≤30 s until the poll runs — an + // eager `refresh_support` here doubled the HTTP round + // trips on every selection. + } + Err(e) => { + let msg = crate::i18n::api_error_message(&e); + ws.support.update(cx, |v, cx| { + v.set_error(msg); + cx.notify(); + }); + } } }); }) @@ -594,29 +606,40 @@ impl Workspace { v.set_loading(true); cx.notify(); }); + let request = self.manage_requests.session(); + let detail_request = self.manage_requests.current(ManageRead::SupportDetail); cx.spawn(async move |this, cx: &mut AsyncApp| { let result = cx .background_executor() .spawn(async move { f(base, token) }) .await; - let _ = this.update(cx, |ws, cx| match result { - Ok(t) => { - ws.support.update(cx, |v, cx| { - v.set_detail(t, cx); - if let Some(id) = sent_ticket_id.as_deref() { - v.clear_ticket_draft_after_send(id, cx); - } - cx.notify(); - }); - ws.refresh_support(cx); + let _ = this.update(cx, |ws, cx| { + if !ws.accept_manage_result(request, result.as_ref().err(), cx) { + return; } - Err(e) => { - let msg = crate::i18n::api_error_message(&e); - ws.support.update(cx, |v, cx| { - v.set_error(msg.clone()); - cx.notify(); - }); - ws.show_toast(msg, ToastLevel::Error, cx); + match result { + Ok(t) => { + ws.support.update(cx, |v, cx| { + if ws.manage_requests.is_current(detail_request) + && v.selected_id().as_deref() == Some(t.id.as_str()) + { + v.set_detail(t, cx); + } + if let Some(id) = sent_ticket_id.as_deref() { + v.clear_ticket_draft_after_send(id, cx); + } + cx.notify(); + }); + ws.refresh_support(cx); + } + Err(e) => { + let msg = crate::i18n::api_error_message(&e); + ws.support.update(cx, |v, cx| { + v.set_error(msg.clone()); + cx.notify(); + }); + ws.show_toast(msg, ToastLevel::Error, cx); + } } }); }) diff --git a/docs/testing/USE_CASES.md b/docs/testing/USE_CASES.md index 1c1b2554..91e43302 100644 --- a/docs/testing/USE_CASES.md +++ b/docs/testing/USE_CASES.md @@ -276,8 +276,8 @@ increment for both directions. ### SDUC-050 — Remote port forward tunnel `TunnelManager::start_remote_forward` requests remote port binding via -the SSH channel and forwards `ForwardedTcpIpEvent`s back to a local -target. +the SSH channel using the saved remote bind address (loopback by default) +and forwards `ForwardedTcpIpEvent`s back to a local target. ### SDUC-051 — SOCKS forward tunnel @@ -288,7 +288,8 @@ proxies TCP through the SSH session. `stop()` on a tunnel drains and closes cleanly. `stop_all` walks every active tunnel. `cleanup` removes stopped entries so `active_count` -matches `tunnels().len()`. +matches `tunnels().len()`. Reverse forwarding explicitly cancels the exact +server-side bind on stop, even if its owning SSH session remains connected. ### SDUC-053 — Jump-host session @@ -732,6 +733,9 @@ AI Dock, native tray and pinned connections) enforce the same gate in their handlers. Logout closes authenticated auxiliary windows, stops terminals, tunnels and scripts, clears account-scoped caches, and persists an empty terminal workspace so runtime authority cannot cross an account boundary. +Pending Manage reads/writes cannot publish into a later session, even when a +re-login reuses the same token. Request creation/comment drafts, attachment +imports, pending AI request drafts and mention identities clear on sign-out. ### SDUC-153 — Login persists identity, enables cloud sync, toasts profile count @@ -741,8 +745,15 @@ profiles merged. ### SDUC-154 — Startup account check refreshes silently -`check_account_on_startup` runs whoami in the background; on 401/403 -it clears `account` but leaves cloud_sync config alone. +`check_account_on_startup` runs whoami in the background and refreshes the +Workspace and Settings identity together. A current 401 from whoami, sync, +sites, requests or Support invalidates credentials, clears account state and +stops authenticated polls. A 403 remains a permission failure; transient +network/server failures retain the session for retry. Old-session responses +cannot reject or restore the new account. Independent surfaces may refresh +concurrently; the newest read of each surface owns its result, so an older +request/ticket detail cannot replace a newer selection. Successful writes may +update the list cache but cannot replace another selected detail or its draft. --- @@ -938,6 +949,11 @@ authoritative when one is available. ### SDUC-499 — Agent work remains independently identifiable and observable +Each new agent reply retains its original provider attribution across later +provider changes and durable reload. Older history without author metadata +remains readable. Empty-session subscription guidance follows the provider and +local/SSH target currently selected before the first run. + The Dev cockpit retains multiple named provider-neutral agent sessions rather than replacing one global transcript. Up to four run concurrently by default, with a hard bound of eight, and every session retains its own exact execution @@ -3166,6 +3182,14 @@ an unavailable adapter refuses before any effect. ## Change log +- **2026-09-30**: Amended SDUC-050/052/152/154 for configured reverse bind + addresses, explicit remote listener cancellation, account-bound async + results, current-token expiry and request draft cleanup. SDTEST-565 and + SDTEST-1937..1939 pin the protocol and completion-order regressions. + Native evidence and published Linux update validation are recorded in + `feature-followup-qa-2026-09-30.md`. SDUC-499/SDTEST-1941 additionally pin + retained reply authorship when the selected provider changes. + - **2026-09-30**: Amended SDUC-306/314 after loopback SSH/tunnel QA: uppercase/tag connection searches now match, and filtered combobox clicks preserve the displayed host identity (SDTEST-1025, SDPATCH-046/SDTEST-1933). diff --git a/docs/testing/feature-followup-qa-2026-09-30.md b/docs/testing/feature-followup-qa-2026-09-30.md new file mode 100644 index 00000000..3cfa6883 --- /dev/null +++ b/docs/testing/feature-followup-qa-2026-09-30.md @@ -0,0 +1,82 @@ +# Feature follow-up QA — 2026-09-30 + +This continues the [native visual pass](visual-qa-2026-09-30.md) and +[subscription checks](subscription-qa-2026-09-30.md). The published v0.9.10 and +rebuilt debug binaries ran on a private Xvfb X11 display, private D-Bus and +temporary XDG profiles. HOME and provider credential locations stayed unchanged. +Manage data is fictional and served by the repository demo fixture, extended +locally with per-request delay and 401 injection. Established SSH connections +used a disposable, key-authenticated Paramiko loopback server. Signing was not +changed. + +## Reproduced defects and fixes + +| Journey | Published behavior | Rebuilt behavior / evidence | +|---|---|---| +| Select request A, then B while A takes six seconds | B opens first; the late A response replaces the detail cache, making B's sheet disappear | Only the latest detail read may publish. [B before delayed A](screenshots/2026-09-30/followups/detail-newer-before.png), [disappeared sheet](screenshots/2026-09-30/followups/detail-stale-before.png), [retained B after delay](screenshots/2026-09-30/followups/detail-after.png) | +| Revoke the fixture token during requests/sync | Expiry toast appears but User remains signed in and continues polling | Current 401 invalidates the session, stops its polls and returns to welcome. 403 remains a permission error; old-session responses cannot reject a new login. [Before](screenshots/2026-09-30/followups/expired-before.png), [after](screenshots/2026-09-30/followups/expired-after.png) | +| Start a creation draft, revoke the token, then sign in again | Source inspection found draft buffers survived invalidation | Creation/comment drafts, pending request AI/import state and mention identities clear. Re-login using the same fixture token opens an [empty creation sheet](screenshots/2026-09-30/followups/draft-after-relogin.png) | +| Logout while Manage fetches or writes are pending | Completion callbacks lacked account ownership checks; sync could also write old profiles before the callback | Session generations fence identity, sites, lists, details, people, sync and writes. Fetch remains in the background; profile merge/persistence is accepted only by the current foreground session. Settings receives refreshed whoami identity. | +| Reverse forwarding with a loopback remote bind | The saved remote host was ignored; the SSH request always asked for 0.0.0.0 | Saved remote host reaches tcpip-forward. Stop sends cancel-tcpip-forward for that exact host/port while the owning SSH session can remain connected. SDTEST-565 covers traffic, directional counters, existing-channel shutdown and explicit cancellation. | +| Choose another provider/SSH host before the first agent run | Subscription help still described the session's previously committed provider/local host | Setup uses the selected controls immediately, including [target-host login guidance](screenshots/2026-09-30/followups/ssh-subscription-guide-after.png). Opening help never launches login/inference. | +| Run Claude, switch to Codex, run again | Both earlier and newer replies were labeled Codex | New replies retain their original provider in durable history. Legacy files without this metadata still load. SDTEST-1941 covers streamed deltas, context switch and reload; a repeated native Claude → Codex SSH journey keeps [both author labels](screenshots/2026-09-30/followups/provider-history-after.png). | + +## Additional live checks + +**Published Linux archive and update:** a fresh extraction of v0.9.10 opened +successfully. A separate v0.9.9 installation, with its own XDG profile and +updates enabled, detected v0.9.10, downloaded the actual GitHub asset, verified +SHA-256 and replaced its binary. The installed binary matches the fresh +v0.9.10 extraction; the v0.9.9 backup remains present. A real restart logged +`Starting ShellDeck v0.9.10` and rendered the User home. This verifies archive +installation and the in-app Linux updater, not the home-writing installer script. + +**All three SSH tunnels:** native Start controls opened Local → Remote +(127.0.0.1:8899), Remote → Local (remote 127.0.0.1:8895) and SOCKS5 +(127.0.0.1:8896). Curl traversed each tunnel to the fixture API and received the +expected JSON. Native Stop controls closed all three ports; fresh TCP connects +were refused. The SSH server recorded `remote-listen 127.0.0.1 8895` and +`remote-cancel 127.0.0.1 8895`. [All active](screenshots/2026-09-30/followups/tunnels-active.png) +and [stopped with retained byte counts](screenshots/2026-09-30/followups/tunnels-stopped.png). +A deliberately changed disposable server key was also rejected, as expected; +subsequent checks used a new fixture endpoint and stable key. + +**Subscription agents over SSH:** provider status on the fixture target reported +Codex ChatGPT login and Claude.ai/Max. Both native Agents runs used the SSH +target, isolated workdir and Read Only access, returned +`SHELLDECK_SSH_SUBSCRIPTION_OK`, and completed successfully. API-key environment +variables were absent in both app and target. The target is a loopback process +under the same OS account, with existing provider-owned OAuth storage: this is +real SSH transport and agent execution, not a fresh independent remote-host +login. No OAuth token was copied by ShellDeck. Codex's installed MCP workers +emitted shutdown diagnostics after the successful reply; ShellDeck retained +those diagnostics and still reported the successful process completion. + +**Desktop fallback and X11 global shortcuts:** a separate temporary profile +requested `start_hidden=true`, `close_to_tray=true` and both global shortcuts. +With no tray service, the app logged the expected warning and showed its main +window. After closing the Dock, the main close button exited the process +instead of leaving an invisible app. With an owned xmessage window focused, Ctrl+Alt+Space opened the +[standalone command palette](screenshots/2026-09-30/followups/global-palette.png) +and Ctrl+Shift+Space opened the [AI Dock](screenshots/2026-09-30/followups/global-ai-dock.png). +No prompt was submitted. This exercised the private native X11 backend; +it makes no claim about the user's Wayland portal or a physical desktop tray. + +## Validation and limits + +The full workspace suite passes 857 unique tests (app 100, core 404, SSH 46, +terminal 88, UI 206, update 13; seven live tests and two documentation examples +remain ignored). Linux debug build, UI compile check, Clippy over all +targets, formatting and whitespace checks pass. SDTEST-1937..1939 exercise +completion ordering, account transitions (including identical token reuse) and +401/403 handling. SDTEST-1941 exercises provider attribution and legacy history. +The workspace orchestrator remains below 2,000 lines. Cross-platform CI is +checked before merge. + +SDTEST-1940 and the earlier native render/click inventory entries remain Red for +automated GPUI coverage; screenshots do not substitute for such a harness. +Production cloud writes, fresh remote-host subscription login, successful OS +keychain/OIDC, physical multi-monitor/tray integration and native macOS/Windows +interaction still require their dedicated environments. Xvfb has one display, +no window manager, no tray service and no portal; these limitations are not +reported as product bugs or passing physical-desktop tests. diff --git a/docs/testing/screenshots/2026-09-30/followups/claude-ssh-subscription.png b/docs/testing/screenshots/2026-09-30/followups/claude-ssh-subscription.png new file mode 100644 index 00000000..c887be77 Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/followups/claude-ssh-subscription.png differ diff --git a/docs/testing/screenshots/2026-09-30/followups/codex-ssh-subscription-before.png b/docs/testing/screenshots/2026-09-30/followups/codex-ssh-subscription-before.png new file mode 100644 index 00000000..e245a3d1 Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/followups/codex-ssh-subscription-before.png differ diff --git a/docs/testing/screenshots/2026-09-30/followups/detail-after.png b/docs/testing/screenshots/2026-09-30/followups/detail-after.png new file mode 100644 index 00000000..8e53c78a Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/followups/detail-after.png differ diff --git a/docs/testing/screenshots/2026-09-30/followups/detail-newer-before.png b/docs/testing/screenshots/2026-09-30/followups/detail-newer-before.png new file mode 100644 index 00000000..5aff7926 Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/followups/detail-newer-before.png differ diff --git a/docs/testing/screenshots/2026-09-30/followups/detail-stale-before.png b/docs/testing/screenshots/2026-09-30/followups/detail-stale-before.png new file mode 100644 index 00000000..a61f15c4 Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/followups/detail-stale-before.png differ diff --git a/docs/testing/screenshots/2026-09-30/followups/draft-after-relogin.png b/docs/testing/screenshots/2026-09-30/followups/draft-after-relogin.png new file mode 100644 index 00000000..ed9a34c0 Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/followups/draft-after-relogin.png differ diff --git a/docs/testing/screenshots/2026-09-30/followups/expired-after.png b/docs/testing/screenshots/2026-09-30/followups/expired-after.png new file mode 100644 index 00000000..166d4caa Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/followups/expired-after.png differ diff --git a/docs/testing/screenshots/2026-09-30/followups/expired-before.png b/docs/testing/screenshots/2026-09-30/followups/expired-before.png new file mode 100644 index 00000000..c546090e Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/followups/expired-before.png differ diff --git a/docs/testing/screenshots/2026-09-30/followups/global-ai-dock.png b/docs/testing/screenshots/2026-09-30/followups/global-ai-dock.png new file mode 100644 index 00000000..007cf370 Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/followups/global-ai-dock.png differ diff --git a/docs/testing/screenshots/2026-09-30/followups/global-palette.png b/docs/testing/screenshots/2026-09-30/followups/global-palette.png new file mode 100644 index 00000000..17a89cf5 Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/followups/global-palette.png differ diff --git a/docs/testing/screenshots/2026-09-30/followups/provider-history-after.png b/docs/testing/screenshots/2026-09-30/followups/provider-history-after.png new file mode 100644 index 00000000..301cda17 Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/followups/provider-history-after.png differ diff --git a/docs/testing/screenshots/2026-09-30/followups/ssh-subscription-guide-after.png b/docs/testing/screenshots/2026-09-30/followups/ssh-subscription-guide-after.png new file mode 100644 index 00000000..3df27418 Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/followups/ssh-subscription-guide-after.png differ diff --git a/docs/testing/screenshots/2026-09-30/followups/tunnels-active.png b/docs/testing/screenshots/2026-09-30/followups/tunnels-active.png new file mode 100644 index 00000000..a43f5869 Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/followups/tunnels-active.png differ diff --git a/docs/testing/screenshots/2026-09-30/followups/tunnels-stopped.png b/docs/testing/screenshots/2026-09-30/followups/tunnels-stopped.png new file mode 100644 index 00000000..ba87723c Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/followups/tunnels-stopped.png differ diff --git a/docs/testing/tests-core.md b/docs/testing/tests-core.md index 9fcd281f..4f5243b7 100644 --- a/docs/testing/tests-core.md +++ b/docs/testing/tests-core.md @@ -598,3 +598,9 @@ Existing: **0 tests**. ## Retired tests *(none yet)* + +## Provider attribution regression — 2026-09-30 + +| ID | Location | SDUC | Status | Notes | +|---|---|---|---|---| +| SDTEST-1941 | `agent_session.rs::provider_switch_preserves_reply_authorship_and_legacy_history` | SDUC-499 | Green | Claude deltas form one authored reply, a later Codex run retains its own author, and durable reload preserves both. Legacy message JSON without provider metadata remains readable with unchanged content. Native SSH reproduction previously relabeled the Claude reply as Codex after switching providers. | diff --git a/docs/testing/tests-ssh.md b/docs/testing/tests-ssh.md index 70c16350..c9485566 100644 --- a/docs/testing/tests-ssh.md +++ b/docs/testing/tests-ssh.md @@ -106,7 +106,7 @@ Existing: **4 protocol/lifecycle tests.** | SDTEST-562 | `tunnel.rs::local_forward_echoes_tracks_bytes_and_drains_on_stop` | SDUC-049 | Green | Real `russh` direct-tcpip over an in-memory transport; loopback listener forwards bytes both ways. | | SDTEST-563 | `tunnel.rs::port_availability_and_prebound_start_failures_are_reported` | SDUC-049 | Green | Local and SOCKS pre-bound ports synchronously return `SshError::PortInUse` without registering a tunnel. | | SDTEST-564 | `tunnel.rs::local_forward_echoes_tracks_bytes_and_drains_on_stop` | SDUC-052 | Green | Caught detached copy tasks: stop now aborts and joins accepted connections before publishing `Stopped`. | -| SDTEST-565 | `tunnel.rs::remote_forward_requests_the_port_and_routes_connections_to_the_local_target` | SDUC-050, SDUC-052 | Green | Real reverse forward end to end: the in-memory server records `tcpip_forward("0.0.0.0", 8443)`, then plays the remote side by opening a forwarded-tcpip channel back. Bytes reach a loopback echo target and return; counters are directional; stopping the tunnel closes the already-routed connection. | +| SDTEST-565 | `tunnel.rs::remote_forward_requests_the_port_and_routes_connections_to_the_local_target` | SDUC-050, SDUC-052 | Green | Real reverse forward end to end: the in-memory server records the configured `tcpip_forward("127.0.0.1", 8443)`, then plays the remote side by opening a forwarded-tcpip channel back. Bytes reach a loopback echo target and return; counters are directional; stopping the tunnel closes the already-routed connection and sends `cancel_tcpip_forward` for the exact bind while the SSH session remains alive. | | SDTEST-566 | `tunnel.rs::socks5_connect_echoes_and_rejects_bind_and_udp_associate` | SDUC-051 | Green | Raw SOCKS5 no-auth + domain CONNECT reaches direct-tcpip and echoes; BIND/UDP-associate return command-not-supported without opening a channel. | | SDTEST-567 | `tunnel.rs::stop_all_closes_every_listener_and_active_connection` | SDUC-052 | Green | Two active listeners and connections are drained; active count reaches zero. | | SDTEST-568 | local/SOCKS/stop-all tunnel tests | SDUC-052 | Green | `cleanup()` removes stopped handles after task drain. | diff --git a/docs/testing/tests-ui-and-app.md b/docs/testing/tests-ui-and-app.md index 6da761a1..4a2658cd 100644 --- a/docs/testing/tests-ui-and-app.md +++ b/docs/testing/tests-ui-and-app.md @@ -660,4 +660,8 @@ observed defect. Evidence and repeatable journeys live in | SDTEST-1932 | *to write* — workspace and agent inherited theme text | SDUC-475, SDUC-489 | **Red / P1** | Manual X11 screenshots reproduce black inherited text on dark backgrounds, then verify Workspaces onboarding and Agents heading/session/provider labels in Tokyo Night and Light. Automated rendered contrast coverage is still missing. | | SDTEST-1933 | *to write* — filtered combobox row identity across outside-click dismissal | SDUC-314 | **Red / P0** | SDPATCH-046. Native X11 reproduction: click the sole filtered row, then verify its displayed selected label and the saved connection UUID before executing. Outside-click clears the filter before the row handler; selection must keep the original rendered identity. Keyboard selection must choose the same target. Automated GPUI event-order coverage is still missing. | | SDTEST-1934 | *to write* — default secret masking, reveal lifecycle and API-key viewport | SDUC-310, SDUC-315 | **Red / P1** | Native X11 pass: freshly typed login password and API key are masked; eye toggles reveal/hide; login 401 keeps the secret masked and reopening starts masked; API-key value/reveal state survives resize at 1200 × 850, 820 × 600 and 580 × 600. Save/Delete remain reachable below the full-width input. No API key was stored and no provider request was sent. Automated native coverage remains missing. | -| SDTEST-1936 | *to write* — subscription setup visibility, copy command and target-host guidance | SDUC-413, SDUC-475 | **Red / P1** | Manual X11 pass: both CLI choices are fully labeled, show setup without API-key controls, and copy the exact sign-in command. Shared setup is visible in Agents and remains reachable at 1200 × 850 and 580 × 600. The SSH-host instruction branch was source-reviewed; remote login was not exercised. Opening these surfaces does not launch login or inference. Automated rendered/click coverage remains missing. See subscription-qa-2026-09-30.md. | +| SDTEST-1936 | *to write* — subscription setup visibility, copy command and target-host guidance | SDUC-413, SDUC-475 | **Red / P1** | Manual X11 pass: both CLI choices are fully labeled, show setup without API-key controls, and copy the exact sign-in command. Shared setup is visible in Agents and remains reachable at 1200 × 850 and 580 × 600. The SSH-host instruction branch now follows the selected controls before first run; both provider runs passed over the loopback SSH fixture using existing target-host subscription credentials (see feature-followup-qa-2026-09-30.md). Fresh remote-host login remains untested. Opening these surfaces does not launch login or inference. Automated rendered/click coverage remains missing. See subscription-qa-2026-09-30.md. | +| SDTEST-1937 | `workspace/manage_requests.rs::newer_detail_survives_delayed_response_and_independent_list_refresh` | SDUC-154, SDUC-161, SDUC-228 | Green | Replays B-before-A completion; only B publishes. List refresh remains independent. An old write may update its session cache but its captured detail stamp is retired by a new selection. | +| SDTEST-1938 | `workspace/manage_requests.rs::logout_and_relogin_retire_all_previous_reads_and_writes` | SDUC-152, SDUC-154 | Green | Logout/re-login rejects prior account reads, writes and logged-out callbacks, including identical bearer reuse; only the new session can publish. | +| SDTEST-1939 | `workspace/manage_requests.rs::permission_failures_keep_session_but_current_revocation_expires_once` | SDUC-152, SDUC-154 | Green | Current 403 stays local; current 401 expires the session once. Concurrent stale 401 and success cannot reject or repopulate a later session. | +| SDTEST-1940 | *to write* — native async session recovery and account draft cleanup | SDUC-152, SDUC-154, SDUC-228 | **Red / P1** | Manual X11 pass reproduced delayed-detail disappearance and lingering expired session on v0.9.10, then verified the rebuilt app retains the latest detail, returns to welcome on polling 401, and re-login opens an empty creation draft. Automated rendered/click coverage remains missing. See feature-followup-qa-2026-09-30.md. |