From 3ece6fa97fc2013f78b74bfc2c67d2a6774ffadb Mon Sep 17 00:00:00 2001 From: reviewer Date: Sat, 17 Oct 2026 18:46:28 +0800 Subject: [PATCH 1/4] [CTX-1302] fix(ui): retain sole workspace beside pinned panel --- crates/bitty-terminal/src/chrome_keys.rs | 28 ++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/bitty-terminal/src/chrome_keys.rs b/crates/bitty-terminal/src/chrome_keys.rs index 2a01314f9..a7ee0097b 100644 --- a/crates/bitty-terminal/src/chrome_keys.rs +++ b/crates/bitty-terminal/src/chrome_keys.rs @@ -1456,8 +1456,9 @@ impl TerminalApp { .zoom .backup_leaf_count(&self.runtime) .map_or(self.runtime.leaf_count() <= 1, |count| count == 1); - let prune_last_tiled_workspace = - close_last_tiled_panel && self.runtime.floated_count() == 0; + let prune_last_tiled_workspace = close_last_tiled_panel + && self.runtime.floated_count() == 0 + && self.runtime.workspace_count() > 1; let zoom_backup_views = if prune_last_tiled_workspace { self.runtime .workspace_seq_at(self.runtime.active_workspace_index()) @@ -3708,6 +3709,29 @@ mod tests { assert!(app.runtime.focused_view().is_some()); } + #[test] + fn close_view_keeps_sole_workspace_with_pinned_panel() { + use bitty_config::ChromeAction; + + // A pinned panel is window-global, so closing the only tiled panel in + // the only workspace must keep that workspace and use the existing + // session-close fallback rather than replacing it. + let mut app = workspace_test_app(); + app.runtime.set_layout(two_pane_layout()); + assert!(app.runtime.set_focus(ViewId::new(1))); + app.apply_chrome_action(ChromeAction::TogglePinned); + assert_eq!(app.runtime.pinned_views(), vec![ViewId::new(1)]); + assert_eq!(app.runtime.workspace_count(), 1); + assert_eq!(app.runtime.layout().leaf_ids(), vec![ViewId::new(2)]); + assert!(app.runtime.set_focus(ViewId::new(2))); + + assert!(!app.apply_chrome_action(ChromeAction::CloseView)); + + assert_eq!(app.runtime.workspace_count(), 1); + assert_eq!(app.runtime.layout().leaf_ids(), vec![ViewId::new(2)]); + assert_eq!(app.runtime.pinned_views(), vec![ViewId::new(1)]); + } + #[test] #[cfg(unix)] fn close_zoomed_multi_pane_closes_only_focused_leaf() { From d408ffd6c5b64fd4ab9f3ab1ebf2c79104ae8d35 Mon Sep 17 00:00:00 2001 From: reviewer Date: Sat, 17 Oct 2026 19:25:46 +0800 Subject: [PATCH 2/4] [CTX-1302] fix(ui): close primary shell in retained workspace --- crates/bitty-runtime/src/runtime/pty.rs | 31 +++++++++++++++++++++ crates/bitty-terminal/src/chrome_keys.rs | 35 ++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/crates/bitty-runtime/src/runtime/pty.rs b/crates/bitty-runtime/src/runtime/pty.rs index 414aea74f..62480b038 100644 --- a/crates/bitty-runtime/src/runtime/pty.rs +++ b/crates/bitty-runtime/src/runtime/pty.rs @@ -478,6 +478,37 @@ impl Runtime { self.pty.is_some() } + /// Tears down the runtime-global primary shell without removing its live + /// layout leaf. The retained leaf becomes session-less and can continue + /// to hold focus while another workspace-global view remains pinned. + /// + /// Unlike [`Runtime::close_pane_session`], this drops the primary PTY, + /// reader, writer, and forwarder while preserving the shared waker used + /// by any remaining pane sessions. + pub fn close_primary_session(&mut self) -> bool { + let had_session = self.pty.is_some() + || self.pty_reader.is_some() + || self.pty_writer.is_some() + || self.pty_forward_rx.is_some() + || self.pty_forward_handle.is_some(); + if !had_session { + return false; + } + self.pty_forward_rx = None; + self.pty_reader = None; + self.pty_writer = None; + let forward_handle = self.pty_forward_handle.take(); + drop(self.pty.take()); + if let Some(handle) = forward_handle { + let _ = super::join_forwarder_with_timeout(handle, super::FORWARDER_JOIN_TIMEOUT); + } + self.pending_input.clear(); + self.kitty_assembler + .discard_stream(super::bell::KittyStreamId::Primary); + self.pending_full_redraw = true; + true + } + /// Whether a PTY reader channel is currently owned (implies [`has_pty`]). /// /// True while either the direct pump reader or the wakeup-forwarder diff --git a/crates/bitty-terminal/src/chrome_keys.rs b/crates/bitty-terminal/src/chrome_keys.rs index a7ee0097b..eb45e2a7f 100644 --- a/crates/bitty-terminal/src/chrome_keys.rs +++ b/crates/bitty-terminal/src/chrome_keys.rs @@ -1540,6 +1540,12 @@ impl TerminalApp { "warning: keymap close_view could not prune empty workspace — ignoring" ); } + } else if self.runtime.is_primary_view(&focused) { + if self.runtime.close_primary_session() { + eprintln!( + "bitty: keymap close_view tore down primary shell {focused:?} (workspace kept for float)" + ); + } } else if self.runtime.close_pane_session(&focused) { eprintln!( "bitty: keymap close_view tore down last tiled panel shell {focused:?} (workspace kept for float)" @@ -3732,6 +3738,35 @@ mod tests { assert_eq!(app.runtime.pinned_views(), vec![ViewId::new(1)]); } + #[test] + #[cfg(unix)] + fn close_view_tears_down_primary_with_sole_workspace_pinned_panel() { + require_pty!(); + use bitty_config::ChromeAction; + + let mut app = close_confirm_test_app(CloseConfirmMode::WhenBusy); + app.runtime.set_layout(two_pane_layout()); + app.runtime + .spawn_shell_with_args("/bin/sh", &[]) + .expect("primary shell spawns"); + let primary = app.runtime.primary_view().expect("primary owner"); + assert!(app.runtime.has_pty()); + + let pinned = ViewId::new(2); + assert!(app.runtime.set_focus(pinned)); + app.apply_chrome_action(ChromeAction::TogglePinned); + assert_eq!(app.runtime.pinned_views(), vec![pinned]); + assert_eq!(app.runtime.focused_view(), Some(primary)); + + assert!(!app.apply_chrome_action(ChromeAction::CloseView)); + + assert!(!app.runtime.has_pty(), "primary shell must be torn down"); + assert_eq!(app.runtime.primary_view(), Some(primary)); + assert_eq!(app.runtime.layout().leaf_ids(), vec![primary]); + assert_eq!(app.runtime.pinned_views(), vec![pinned]); + assert_eq!(app.runtime.workspace_count(), 1); + } + #[test] #[cfg(unix)] fn close_zoomed_multi_pane_closes_only_focused_leaf() { From 490ef43d8ad196c22ef206c7e39cc8d2257d6a35 Mon Sep 17 00:00:00 2001 From: reviewer Date: Sat, 17 Oct 2026 19:47:28 +0800 Subject: [PATCH 3/4] [CTX-1302] fix(runtime): clear primary state after shell close --- crates/bitty-runtime/src/runtime/pty.rs | 3 +++ crates/bitty-terminal/src/chrome_keys.rs | 18 +++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/bitty-runtime/src/runtime/pty.rs b/crates/bitty-runtime/src/runtime/pty.rs index 62480b038..9480d5e97 100644 --- a/crates/bitty-runtime/src/runtime/pty.rs +++ b/crates/bitty-runtime/src/runtime/pty.rs @@ -503,6 +503,9 @@ impl Runtime { let _ = super::join_forwarder_with_timeout(handle, super::FORWARDER_JOIN_TIMEOUT); } self.pending_input.clear(); + self.primary_view = None; + self.primary_spawn = None; + self.session_primary_cwd = None; self.kitty_assembler .discard_stream(super::bell::KittyStreamId::Primary); self.pending_full_redraw = true; diff --git a/crates/bitty-terminal/src/chrome_keys.rs b/crates/bitty-terminal/src/chrome_keys.rs index eb45e2a7f..e9af0f0d1 100644 --- a/crates/bitty-terminal/src/chrome_keys.rs +++ b/crates/bitty-terminal/src/chrome_keys.rs @@ -3761,10 +3761,26 @@ mod tests { assert!(!app.apply_chrome_action(ChromeAction::CloseView)); assert!(!app.runtime.has_pty(), "primary shell must be torn down"); - assert_eq!(app.runtime.primary_view(), Some(primary)); + assert!(!app.runtime.has_pty_reader()); + assert!(!app.runtime.has_pty_writer()); + assert_eq!(app.runtime.primary_view(), None); + assert_eq!(app.runtime.primary_spawn_recipe(), None); assert_eq!(app.runtime.layout().leaf_ids(), vec![primary]); assert_eq!(app.runtime.pinned_views(), vec![pinned]); assert_eq!(app.runtime.workspace_count(), 1); + assert!(!app.runtime.workspace_is_occupied(0)); + assert!( + !app.runtime.close_primary_session(), + "teardown is idempotent" + ); + + app.runtime.workspace_new().expect("empty workspace opens"); + let fresh = app.runtime.focused_view().expect("fresh focus"); + assert!(!app.runtime.has_pane_session(&fresh)); + assert!( + !app.runtime.has_pty(), + "closed primary recipe must not replay" + ); } #[test] From 95e98a664bb67aae3c7420e409ade4709b5bb547 Mon Sep 17 00:00:00 2001 From: reviewer Date: Sat, 17 Oct 2026 19:59:01 +0800 Subject: [PATCH 4/4] [CTX-1302] fix(runtime): clear primary view bindings on close --- crates/bitty-runtime/src/runtime/pty.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/bitty-runtime/src/runtime/pty.rs b/crates/bitty-runtime/src/runtime/pty.rs index 9480d5e97..0f78af2ee 100644 --- a/crates/bitty-runtime/src/runtime/pty.rs +++ b/crates/bitty-runtime/src/runtime/pty.rs @@ -503,11 +503,15 @@ impl Runtime { let _ = super::join_forwarder_with_timeout(handle, super::FORWARDER_JOIN_TIMEOUT); } self.pending_input.clear(); + if let Some(view) = self.primary_view { + self.drop_view_bindings_for(view); + } self.primary_view = None; self.primary_spawn = None; self.session_primary_cwd = None; self.kitty_assembler .discard_stream(super::bell::KittyStreamId::Primary); + self.sync_mode_caches_to_focus(); self.pending_full_redraw = true; true }