diff --git a/crates/shelldeck-ssh/src/workspace_helper/remote.rs b/crates/shelldeck-ssh/src/workspace_helper/remote.rs index 565dcbef..dde7d792 100644 --- a/crates/shelldeck-ssh/src/workspace_helper/remote.rs +++ b/crates/shelldeck-ssh/src/workspace_helper/remote.rs @@ -467,6 +467,32 @@ mod tests { use std::fs; use std::io::Cursor; + // The helper deliberately changes process cwd through a retained directory + // descriptor. Each successful helper journey needs its own process: a test + // mutex cannot protect unrelated Rust test threads from process-wide cwd. + fn isolated_helper_test(test_name: &str) -> bool { + const CHILD: &str = "SHELLDECK_REMOTE_HELPER_TEST_CHILD"; + if std::env::var(CHILD).as_deref() == Ok(test_name) { + return false; + } + let output = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", test_name, "--nocapture"]) + .env(CHILD, test_name) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("running 1 test"), + "child test not selected: {stdout}" + ); + assert!( + output.status.success(), + "isolated helper test failed: {stdout}\n{}", + String::from_utf8_lossy(&output.stderr) + ); + true + } + struct TestRepository { root: PathBuf, git: PathBuf, @@ -518,6 +544,9 @@ mod tests { #[test] fn retained_descriptor_receipt_revalidates_clean_exact_lineage() { + if isolated_helper_test("workspace_helper::remote::tests::retained_descriptor_receipt_revalidates_clean_exact_lineage") { + return; + } let repository = TestRepository::new(); let config = RemoteHelperConfig::for_test(repository.root.clone(), repository.git.clone()); let prepared = prepare_workspace( @@ -577,6 +606,9 @@ mod tests { #[test] fn exchange_refuses_wrong_receipt_without_starting_shell() { + if isolated_helper_test("workspace_helper::remote::tests::exchange_refuses_wrong_receipt_without_starting_shell") { + return; + } let repository = TestRepository::new(); let config = RemoteHelperConfig::for_test(repository.root.clone(), repository.git.clone()); let prepare = super::super::encode_request(&RequestFrame::Prepare( diff --git a/crates/shelldeck-terminal/src/grid.rs b/crates/shelldeck-terminal/src/grid.rs index a93c64aa..fe03b57b 100644 --- a/crates/shelldeck-terminal/src/grid.rs +++ b/crates/shelldeck-terminal/src/grid.rs @@ -662,7 +662,11 @@ impl TerminalGrid { if width == 0 { let row = self.cursor.row; self.ensure_row(row); - let target_col = if self.cursor.col > 0 { + // At the right margin the cursor stays on the last written cell + // until a spacing character resolves the pending wrap. + let target_col = if self.pending_wrap { + self.cursor.col + } else if self.cursor.col > 0 { self.cursor.col - 1 } else { 0 @@ -1678,7 +1682,14 @@ impl TerminalGrid { let rows = self.rows; let cols = self.cols; let max_scrollback = self.max_scrollback; + // These belong to the session/host, rather than the resettable screen. + // Dropping the sender disconnects PTY replies; rewinding the generation + // makes existing command-completion observers miss subsequent events. + let response_tx = self.response_tx.take(); + let prompt_mark_sequence = self.prompt_mark_sequence; *self = Self::with_scrollback(rows, cols, max_scrollback); + self.response_tx = response_tx; + self.prompt_mark_sequence = prompt_mark_sequence; } /// Adjust the maximum number of scrollback lines retained, preserving the diff --git a/crates/shelldeck-terminal/src/parser.rs b/crates/shelldeck-terminal/src/parser.rs index 1cfdb1a5..df2a0014 100644 --- a/crates/shelldeck-terminal/src/parser.rs +++ b/crates/shelldeck-terminal/src/parser.rs @@ -309,7 +309,7 @@ fn parse_osc_color(spec: &str) -> Option<(u8, u8, u8)> { } } else if let Some(rest) = spec.strip_prefix('#') { // Format: #RRGGBB - if rest.len() == 6 { + if rest.len() == 6 && rest.bytes().all(|byte| byte.is_ascii_hexdigit()) { let r = u8::from_str_radix(&rest[0..2], 16).ok()?; let g = u8::from_str_radix(&rest[2..4], 16).ok()?; let b = u8::from_str_radix(&rest[4..6], 16).ok()?; @@ -1343,4 +1343,96 @@ mod tests { // Response is 1-indexed: row 3, col 3. assert_eq!(resp, b"\x1b[3;3R"); } + + // SDTEST-1926 + #[test] + fn malformed_unicode_palette_colors_preserve_state_and_parser_recovery() { + let grid = run(2, 10, b"\x1b]4;1;#123456\x07"); + let mut processor = TerminalProcessor::new(grid.clone()); + let mut parser = vte::Parser::new(); + for color in ["#a€bc", "#abc€", "#é1234", "#xyzxyz"] { + let input = format!("\x1b]4;1;{color}\x07"); + // PTY reads can split UTF-8 and escape sequences anywhere. + for chunk in input.as_bytes().chunks(3) { + processor.process_bytes(&mut parser, chunk); + } + assert_eq!(grid.lock().get_palette_color(1), Some((0x12, 0x34, 0x56))); + } + processor.process_bytes(&mut parser, b"\x1b]4;2;#abcdef\x07OK"); + let grid = grid.lock(); + assert_eq!(grid.get_palette_color(2), Some((0xab, 0xcd, 0xef))); + assert!(row_text(&grid, 0).starts_with("OK")); + } + + // SDTEST-1927 + #[test] + fn terminal_reset_keeps_pty_replies_connected() { + let grid = Arc::new(Mutex::new(TerminalGrid::new(3, 10))); + let (tx, rx) = std::sync::mpsc::channel(); + grid.lock().set_response_tx(tx); + let mut processor = TerminalProcessor::new(grid.clone()); + let mut parser = vte::Parser::new(); + processor.process_bytes(&mut parser, b"old\r\noutput\r\nlast\r\n\x1b[1m"); + assert!(grid.lock().scrollback_len() > 0); + processor.process_bytes(&mut parser, b"\x1bc"); + { + let grid = grid.lock(); + assert_eq!(grid.scrollback_len(), 0); + assert_eq!(row_text(&grid, 0), " "); + assert!(!grid.current_attrs.bold); + } + // Shells and TUIs still need device/status replies after `reset`. + processor.process_bytes(&mut parser, b"\x1b[2;4H\x1b[6n\x1b[c"); + assert_eq!(rx.try_recv().expect("CPR after RIS"), b"\x1b[2;4R"); + assert_eq!(rx.try_recv().expect("DA after RIS"), b"\x1b[?62;22c"); + } + + // SDTEST-1928 + #[test] + fn combining_characters_at_right_margin_attach_before_wrapping() { + for (cols, text, base_col) in [(4, "abce", 3), (4, "ab界", 2), (1, "e", 0)] { + let grid = run(2, cols, text.as_bytes()); + let mut processor = TerminalProcessor::new(grid.clone()); + let mut parser = vte::Parser::new(); + processor.process_bytes(&mut parser, "\u{0301}".as_bytes()); + { + let grid = grid.lock(); + assert_eq!(grid.cells[0][base_col].combining.as_slice(), &['\u{0301}']); + assert_eq!(grid.cursor.row, 0, "combining characters must not wrap"); + for (col, cell) in grid.cells[0].iter().enumerate() { + if col != base_col { + assert!(cell.combining.is_empty(), "accent attached to column {col}"); + } + } + } + processor.process_bytes(&mut parser, b"X"); + let grid = grid.lock(); + assert_eq!(grid.cells[1][0].c, 'X'); + assert!(grid.line_flags[1].soft_wrapped); + } + } + + // SDTEST-1929 + #[test] + fn command_completion_generation_stays_monotonic_across_reset() { + let grid = run(2, 10, b"\x1b]133;C\x07before\x1b]133;D;0\x07"); + let observed = grid.lock().prompt_mark_sequence; + let mut processor = TerminalProcessor::new(grid.clone()); + let mut parser = vte::Parser::new(); + processor.process_bytes(&mut parser, b"\x1bc"); + { + let grid = grid.lock(); + assert!(grid.prompt_mark.is_none()); + assert!(grid.command_output_start.is_none()); + } + processor.process_bytes(&mut parser, b"\x1b]133;C\x07after\x1b]133;D;7\x07"); + let grid = grid.lock(); + assert!( + grid.prompt_mark_sequence > observed, + "completion must reach existing observers" + ); + assert_eq!(grid.prompt_mark, Some(PromptMark::CommandFinished(Some(7)))); + assert!(grid.command_output(10, 100).contains("after")); + assert!(!grid.command_output(10, 100).contains("before")); + } } diff --git a/crates/shelldeck-ui/src/agent_console_view.rs b/crates/shelldeck-ui/src/agent_console_view.rs index f17c43d9..0e3f116f 100644 --- a/crates/shelldeck-ui/src/agent_console_view.rs +++ b/crates/shelldeck-ui/src/agent_console_view.rs @@ -1285,10 +1285,11 @@ impl AgentConsoleView { .border_color(ShellDeckColors::border()) .child(lucide_icon("search", 11.0, ShellDeckColors::text_muted())) .child( - div().flex_1().min_w(px(0.0)).child( + div().flex().flex_col().flex_1().min_w(px(0.0)).child( Input::new(&self.navigator_search) .variant(InputVariant::Bare) .size(InputSize::Sm) + .w_full() .placeholder(t!("fleet.sessions.search").to_string()) .on_change(move |_value, cx| { parent.update(cx, |_this, cx| cx.notify()); @@ -2489,7 +2490,8 @@ impl Render for AgentConsoleView { .flex() .size_full() .min_h(px(0.0)) - .bg(ShellDeckColors::bg_primary()); + .bg(ShellDeckColors::bg_primary()) + .text_color(ShellDeckColors::text_primary()); if wide { root = root.child(self.render_navigator(cx)); } diff --git a/crates/shelldeck-ui/src/fleet_view.rs b/crates/shelldeck-ui/src/fleet_view.rs index 05a49aeb..76f52208 100644 --- a/crates/shelldeck-ui/src/fleet_view.rs +++ b/crates/shelldeck-ui/src/fleet_view.rs @@ -3416,18 +3416,18 @@ impl FleetView { list } - fn render_session_search(&self, cx: &mut Context) -> impl IntoElement { - Input::new(&self.search_state) - .size(InputSize::Sm) + fn render_session_search(&self, compact: bool, cx: &mut Context) -> impl IntoElement { + let mut input = Input::new(&self.search_state).size(InputSize::Sm); + // The desktop session column is 340 px with 10 px padding on each + // side. A definite input width prevents its text viewport from + // resolving a percentage against an intrinsic/min-content width. + if !compact { + input = input.w(px(320.0)); + } + input .placeholder(t!("fleet.sessions.search").to_string()) .clearable(true) - .prefix( - svg() - .path("icons/lucide/search.svg") - .size(px(12.0)) - .flex_shrink_0() - .text_color(ShellDeckColors::text_muted()), - ) + .prefix(lucide_icon("search", 12.0, ShellDeckColors::text_muted())) .on_change({ let entity = cx.entity(); move |value, cx| { @@ -4060,11 +4060,15 @@ impl Render for FleetView { .flex_col() .child( div() + .flex() + .flex_col() + .w_full() + .min_w(px(0.0)) .px(px(10.0)) .py(px(8.0)) .border_b_1() .border_color(ShellDeckColors::border()) - .child(self.render_session_search(cx)), + .child(self.render_session_search(compact, cx)), ) .child(self.render_pane_tabs(cx)) .child(self.render_selected_pane(cx)) @@ -4115,11 +4119,15 @@ impl Render for FleetView { )) .child( div() + .flex() + .flex_col() + .w_full() + .min_w(px(0.0)) .px(px(10.0)) .py(px(8.0)) .border_b_1() .border_color(ShellDeckColors::border()) - .child(self.render_session_search(cx)), + .child(self.render_session_search(compact, cx)), ) .child(self.render_sessions(cx)), ) diff --git a/crates/shelldeck-ui/src/login_form.rs b/crates/shelldeck-ui/src/login_form.rs index 065fb658..cf460b36 100644 --- a/crates/shelldeck-ui/src/login_form.rs +++ b/crates/shelldeck-ui/src/login_form.rs @@ -8,7 +8,7 @@ use crate::scale::px; use gpui::prelude::*; use gpui::*; -use adabraka_ui::components::input::{Input, InputSize, InputState}; +use adabraka_ui::components::input::{Input, InputSize, InputState, InputType}; use crate::overlay::{window_backdrop, InputEscape}; use crate::t; @@ -84,7 +84,7 @@ impl LoginForm { pub fn new(server: String, device: String, cx: &mut Context) -> Self { Self { email_state: cx.new(InputState::new), - password_state: cx.new(InputState::new), + password_state: cx.new(|cx| InputState::new(cx).input_type(InputType::Password)), device, server, busy: false, diff --git a/crates/shelldeck-ui/src/settings.rs b/crates/shelldeck-ui/src/settings.rs index 4ca9dfc3..5fcba7ac 100644 --- a/crates/shelldeck-ui/src/settings.rs +++ b/crates/shelldeck-ui/src/settings.rs @@ -3,7 +3,7 @@ use crate::scale::px; use adabraka_ui::components::icon_button::IconButton; use adabraka_ui::components::icon_source::IconSource; use adabraka_ui::components::input::{Input, InputSize}; -use adabraka_ui::components::input_state::InputState; +use adabraka_ui::components::input_state::{InputState, InputType}; use adabraka_ui::components::select::{Select, SelectOption}; use adabraka_ui::components::toggle::Toggle; use adabraka_ui::prelude::{ @@ -376,7 +376,7 @@ impl SettingsView { state.content = ai_model.into(); state }), - ai_api_key_state: cx.new(InputState::new), + ai_api_key_state: cx.new(|cx| InputState::new(cx).input_type(InputType::Password)), ai_connection_state: AiConnectionState::NotTested, shortcut_capture: None, shortcut_status_before_capture: None, @@ -1489,57 +1489,62 @@ impl SettingsView { t!("settings.ai.api_key.description").as_ref(), div() .flex() - .items_center() + .flex_col() .gap(px(8.0)) .w(px(300.0)) .child( - div().flex_grow().child( - Input::new(&self.ai_api_key_state) - .size(InputSize::Sm) - .password(true) - .placeholder(t!("settings.ai.api_key.placeholder").to_string()), - ), + Input::new(&self.ai_api_key_state) + .w_full() + .size(InputSize::Sm) + .password(true) + .placeholder(t!("settings.ai.api_key.placeholder").to_string()), ) .child( - Button::new( - "ai-api-key-save", - t!("settings.ai.api_key.save").to_string(), - ) - .variant(ButtonVariant::Outline) - .on_click(cx.listener( - move |this, _, _window, cx| { - let value = key_state.read(cx).content().trim().to_string(); - if value.is_empty() { - this.ai_connection_state = AiConnectionState::Failed( - t!("settings.ai.api_key.required").to_string(), - ); - cx.notify(); - return; - } - key_state.update(cx, |state, cx| { - state.reset(cx); - }); - cx.emit(SettingsEvent::AiApiKeyStored { backend, value }); - }, - )), - ) - .child( - Button::new( - "ai-api-key-delete", - t!("settings.ai.api_key.delete").to_string(), - ) - .variant(ButtonVariant::Ghost) - .on_click(cx.listener( - move |_this, _, _window, cx| { - cx.emit(SettingsEvent::AiApiKeyDeleted { - backend: if provider == "openai" { - AiBackend::OpenAi - } else { - AiBackend::Anthropic + div() + .flex() + .items_center() + .gap(px(8.0)) + .child( + Button::new( + "ai-api-key-save", + t!("settings.ai.api_key.save").to_string(), + ) + .variant(ButtonVariant::Outline) + .on_click(cx.listener( + move |this, _, _window, cx| { + let value = key_state.read(cx).content().trim().to_string(); + if value.is_empty() { + this.ai_connection_state = AiConnectionState::Failed( + t!("settings.ai.api_key.required").to_string(), + ); + cx.notify(); + return; + } + key_state.update(cx, |state, cx| { + state.reset(cx); + }); + cx.emit(SettingsEvent::AiApiKeyStored { backend, value }); }, - }); - }, - )), + )), + ) + .child( + Button::new( + "ai-api-key-delete", + t!("settings.ai.api_key.delete").to_string(), + ) + .variant(ButtonVariant::Ghost) + .on_click(cx.listener( + move |_this, _, _window, cx| { + cx.emit(SettingsEvent::AiApiKeyDeleted { + backend: if provider == "openai" { + AiBackend::OpenAi + } else { + AiBackend::Anthropic + }, + }); + }, + )), + ), ), )); } diff --git a/crates/shelldeck-ui/src/sidebar.rs b/crates/shelldeck-ui/src/sidebar.rs index f3ed3a1e..aeecf53e 100644 --- a/crates/shelldeck-ui/src/sidebar.rs +++ b/crates/shelldeck-ui/src/sidebar.rs @@ -53,6 +53,18 @@ fn conn_matches_site_filter(site_filter: Option, conn_site_id: Option bool { + if query.is_empty() { + return true; + } + let q = query.to_lowercase(); + fuzzy_match(conn.display_name(), &q) + || fuzzy_match(&conn.hostname, &q) + || fuzzy_match(&conn.user, &q) + || conn.group.as_deref().is_some_and(|g| fuzzy_match(g, &q)) + || conn.tags.iter().any(|tag| fuzzy_match(tag, &q)) +} + /// Returns indices of matched characters in haystack for a fuzzy needle. fn fuzzy_match_indices(haystack: &str, needle: &str) -> Option> { let haystack_lower: Vec = haystack.to_lowercase().chars().collect(); @@ -828,14 +840,7 @@ impl SidebarView { } fn conn_matches_search(&self, conn: &Connection) -> bool { - if self.search_query.is_empty() { - return true; - } - let q = &self.search_query; - fuzzy_match(conn.display_name(), q) - || fuzzy_match(&conn.hostname, q) - || fuzzy_match(&conn.user, q) - || conn.group.as_deref().is_some_and(|g| fuzzy_match(g, q)) + connection_matches_search(conn, &self.search_query) } fn render_search_bar(&self, cx: &mut Context) -> impl IntoElement { @@ -1352,11 +1357,38 @@ impl Render for SidebarView { #[cfg(test)] mod tests { use super::{ - conn_matches_site_filter, fuzzy_match_indices, rail_scroll_child_index, - sidebar_total_width, RAIL_WIDTH, + conn_matches_site_filter, connection_matches_search, fuzzy_match_indices, + rail_scroll_child_index, sidebar_total_width, RAIL_WIDTH, }; use uuid::Uuid; + // SDTEST-1025 — search admission must agree with case-insensitive highlights + // and must consult every advertised connection field. + #[test] + fn connection_search_matches_all_fields_case_insensitively() { + let mut conn = shelldeck_core::models::Connection::new_manual( + "QA isolated SSH".into(), + "build.exemple.test".into(), + "Déploiement".into(), + ); + conn.group = Some("Production".into()); + conn.tags = vec!["Critical".into()]; + for query in ["QA isolated", "QISS", "BUILD", "DÉP", "PROD", "CRIT", ""] { + assert!( + connection_matches_search(&conn, query), + "missing match: {query}" + ); + } + for query in ["missing", "SSHQ", "dèp"] { + assert!( + !connection_matches_search(&conn, query), + "unexpected match: {query}" + ); + } + conn.alias.clear(); + assert!(connection_matches_search(&conn, "BUILD")); + } + // ── sidebar_total_width ──────────────────────────────────────────── // SDTEST-1210 — the terminal grid is sized against this number. An error diff --git a/crates/shelldeck-ui/src/workspace/mod.rs b/crates/shelldeck-ui/src/workspace/mod.rs index 316d33f8..4f739244 100644 --- a/crates/shelldeck-ui/src/workspace/mod.rs +++ b/crates/shelldeck-ui/src/workspace/mod.rs @@ -736,6 +736,9 @@ pub struct Workspace { /// right-side sheet, toggled by the "Nouvelle demande" button in the list /// header. user_new_request_sheet_open: bool, + /// Claim title focus once the sheet is visible, including after a mode + /// transition. Further renders must preserve the user's chosen field. + user_new_request_needs_focus: bool, /// While `true` the composer sheet plays its slide-out/fade-out animation. /// Cleared (along with `..open`) by a delayed task the close handler spawns. user_new_request_sheet_dismissing: bool, @@ -1530,6 +1533,7 @@ impl Workspace { issue_thread_link_action: None, _issues_poll: None, user_new_request_sheet_open: false, + user_new_request_needs_focus: false, user_new_request_sheet_dismissing: false, confirm_new_request_discard: false, new_request_discard_focus: cx.focus_handle(), diff --git a/crates/shelldeck-ui/src/workspace/modes.rs b/crates/shelldeck-ui/src/workspace/modes.rs index ef81a6e0..0b181b7c 100644 --- a/crates/shelldeck-ui/src/workspace/modes.rs +++ b/crates/shelldeck-ui/src/workspace/modes.rs @@ -252,6 +252,7 @@ impl Workspace { self.issue_selected = None; self.issue_detail = None; self.user_new_request_sheet_open = false; + self.user_new_request_needs_focus = false; self.user_new_request_sheet_dismissing = false; self.confirm_new_request_discard = false; self.new_request_discard_return_focus = None; diff --git a/crates/shelldeck-ui/src/workspace/request_views.rs b/crates/shelldeck-ui/src/workspace/request_views.rs index e92cc3fe..2b617225 100644 --- a/crates/shelldeck-ui/src/workspace/request_views.rs +++ b/crates/shelldeck-ui/src/workspace/request_views.rs @@ -967,11 +967,21 @@ impl Workspace { } pub(super) fn render_user_new_request_sheet( - &self, + &mut self, is_maximized: bool, - window: &Window, + window: &mut Window, cx: &mut Context, ) -> impl IntoElement { + if self.user_new_request_needs_focus + && self.mode_transition.is_none() + && !self.confirm_new_request_discard + { + self.user_new_request_needs_focus = false; + self.issue_title_state + .read(cx) + .focus_handle(cx) + .focus(window); + } let viewport_width = window.viewport_size().width.to_f64() as f32; let (panel_width, compact) = user_request_sheet_layout(viewport_width, self.ui_font_size); // One chip, not four. Four badges plus a 186px site select overflowed @@ -1721,9 +1731,9 @@ impl Workspace { ) .variant(ButtonVariant::Destructive) .icon(IconSource::from("trash-2")) - .on_click(move |_, _, cx| { + .on_click(move |_, window, cx| { discard_entity.update(cx, |this, cx| { - this.discard_new_request_draft(cx); + this.discard_new_request_draft(window, cx); }); }), ), diff --git a/crates/shelldeck-ui/src/workspace/requests.rs b/crates/shelldeck-ui/src/workspace/requests.rs index 4aca6489..861918d6 100644 --- a/crates/shelldeck-ui/src/workspace/requests.rs +++ b/crates/shelldeck-ui/src/workspace/requests.rs @@ -53,7 +53,9 @@ impl Workspace { } self.issue_new_source = "user"; self.reset_new_request_site_to_active(cx); + self.settings_open = false; self.user_new_request_sheet_open = true; + self.user_new_request_needs_focus = true; self.sync_issues_poll(cx); cx.notify(); } @@ -84,6 +86,8 @@ impl Workspace { self.set_mode(AppMode::User, cx); } self.user_new_request_sheet_open = true; + self.settings_open = false; + self.user_new_request_needs_focus = true; self.sync_issues_poll(cx); cx.notify(); } @@ -427,6 +431,7 @@ impl Workspace { cx.notify(); return; } + self.focus_handle.focus(window); self.dismiss_new_request_sheet(cx); } @@ -442,9 +447,14 @@ impl Workspace { cx.notify(); } - pub(super) fn discard_new_request_draft(&mut self, cx: &mut Context) { + pub(super) fn discard_new_request_draft( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { self.confirm_new_request_discard = false; self.new_request_discard_return_focus = None; + self.focus_handle.focus(window); self.dismiss_new_request_sheet(cx); } @@ -473,6 +483,7 @@ impl Workspace { return; } 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_ai_request_id = self.issue_ai_request_id.wrapping_add(1); self.issue_ai_expanded = false; diff --git a/crates/shelldeck-ui/src/workspace/workspaces_render.rs b/crates/shelldeck-ui/src/workspace/workspaces_render.rs index 85695d35..b122933a 100644 --- a/crates/shelldeck-ui/src/workspace/workspaces_render.rs +++ b/crates/shelldeck-ui/src/workspace/workspaces_render.rs @@ -1043,6 +1043,7 @@ impl Render for WorkspaceHubView { .flex_col() .size_full() .bg(ShellDeckColors::bg_primary()) + .text_color(ShellDeckColors::text_primary()) .child(self.render_header(cx)) .child( div() diff --git a/docs/design/reliability-test-tracker.md b/docs/design/reliability-test-tracker.md index a736d31d..52c151d5 100644 --- a/docs/design/reliability-test-tracker.md +++ b/docs/design/reliability-test-tracker.md @@ -30,6 +30,43 @@ risques par frontière observable et impose une vérification avant correction. ## Journal +- **2026-09-30 — Dernier passage, champs secrets.** Le formulaire de connexion + et la clé API démarraient sans masquage : le bouton œil ne configurait pas + le type de l’état. Les deux états sont maintenant initialisés en Password. + Vérification native : masquage initial, révéler/masquer, refus 401, réouverture + et conservation après resize. Les actions de clé API passent sous le champ + pour préserver sa largeur, y compris à 580 px. SDTEST-1934 reste Rouge pour + le harnais automatisé ; aucun vrai secret stocké ni appel fournisseur. + +- **2026-09-30 — Suite de la boucle, SSH et sélection.** Un serveur SSH jetable + sur loopback a validé l'authentification par clé, la sortie du PTY et un tunnel + local → distant avec réponse HTTP puis arrêt. Le clic sur une ligne filtrée + de Combobox sélectionnait le premier hôte non filtré après fermeture du popup : + SDPATCH-046 capture maintenant l'identité rendue, vérifiée dans le JSON avant + toute nouvelle exécution. Le filtre Sidebar ignorait les requêtes majuscules + et les tags : SDTEST-1025 couvre désormais les cinq champs. Une répétition de + la suite a aussi révélé la course de cwd entre SDTEST-1788/1789 ; les parcours + du helper sont isolés dans deux processus de test distincts. + +- **2026-09-30 — Passe visuelle native, REL-003/006.** Sur Xvfb isolé, + parcours User/Support/Dev, terminaux et script locaux, formulaires, réglages, + navigateur local, cockpits et palette. Corrections reproduites : titre de + demande sans focus, focus conservé sur un contrôle masqué après fermeture, + recherche Platform/Agents réduite au contenu intrinsèque, texte hérité noir + sur les surfaces sombres Workspaces/Agents. Les parcours corrigés sont + revalidés en Light/Tokyo Night et fenêtres 1200 × 850 / 820 × 600. + [Preuves et limites](../testing/visual-qa-2026-09-30.md) ; SDTEST-1930..1932 + restent rouges pour l'automatisation GPUI, même si la passe manuelle réussit. + +- **2026-09-30 — REL-010, régressions reproduites puis corrigées.** + SDTEST-1926..1929 échouaient avant correction : une couleur OSC 4 Unicode + provoquait un panic de découpage UTF-8, RIS déconnectait les réponses PTY et + remettait à zéro la génération des fins de commande, et un accent reçu à la + marge droite s'attachait au caractère précédent. Les tests traversent le + véritable parseur VTE et vérifient la reprise, les réponses après reset, + l'observation de la commande suivante et le wrap différé. Les autres lacunes + protocolaires de REL-010 restent à auditer. + - **2026-08-22 — REL-005 terminé.** ShellDeck ne contient plus d'autorité d'exécution ni de compatibilité runtime. Le cockpit natif utilise le contrat partagé et le transport HTTPS authentifié. diff --git a/docs/testing/USE_CASES.md b/docs/testing/USE_CASES.md index b0236fe8..1fc52b39 100644 --- a/docs/testing/USE_CASES.md +++ b/docs/testing/USE_CASES.md @@ -25,6 +25,8 @@ Writing printable bytes into the parser produces glyphs in the grid at the expected cell, advances the cursor left-to-right, and wraps at the right edge when auto-wrap is on. Combining characters attach to the previous cell; wide characters occupy two cells. +At the right margin, a combining character attaches to the last written +glyph without resolving the pending wrap; the next spacing character wraps. ### SDUC-002 — Control chars behave per VT100 @@ -120,6 +122,8 @@ CSI `?25h/l` toggles cursor visibility, observable via a public `ESC c` (RIS) clears the grid, resets attributes, clears scrollback, homes the cursor. Soft reset does the subset per VT220. +Full reset preserves the live session's PTY response channel and monotonic +command-completion generation while clearing its prompt markers and output. ### SDUC-019 — Cursor position report @@ -130,6 +134,8 @@ channel when one is wired. Truncated or invalid escape sequences are dropped without panicking and the parser recovers on the next valid byte. +Malformed OSC palette values, including fragmented non-ASCII color strings, +leave existing palette entries intact. ### SDUC-021 — URL & path detection in scrollback @@ -870,6 +876,10 @@ at a short cadence; background runtime refreshes never erase an in-flight chat. ### SDUC-475 — Coding agents run on an explicit local or SSH target +The session search has a usable text viewport, and inherited navigator/provider +labels follow the active theme in both light and dark appearances +(SDTEST-1931/1932, manual native validation). + Dev mode exposes one provider-neutral agent console for Claude Code, Codex, Automonique ACP, DeepSeek through Jcode, and Jcode's configured/default provider. Every run names a target-valid working directory (a native absolute path locally, @@ -1024,6 +1034,10 @@ Retired 2026-08-22. ShellDeck no longer registers as an execution runtime. ### SDUC-476 — Fleet observation and control use the shared platform boundary +The session search displays its placeholder and query in both the fixed desktop +column and compact full-width view, preserving text across resize and theme +changes (SDTEST-1931, manual native validation). + ShellDeck is a presentation client. It may read scoped fleet state and request typed server-side control, but it never opens a provider process, claims a job, or owns an execution lease locally. The first load is a snapshot; subsequent @@ -1097,6 +1111,11 @@ Workspace polls issues every 15s while User or Support is visible. ### SDUC-228 — User "Mes demandes" view +The New Request title receives focus once the sheet is visible, including after +a mode transition or opening from Settings. Later renders preserve the chosen +field. Clean close and explicit discard return focus to the workspace so +keyboard shortcuts continue working (SDTEST-1930, manual native validation). + `render_user_requests` shows the caller's own issues with expand-to-comment and create composer. The composer exposes a searchable site picker backed by the signed-in account's Manage directory, defaults to the active site when @@ -1435,7 +1454,9 @@ filters. ### SDUC-306 — Sidebar search bar filters connections -`conn_matches_search` matches on alias, hostname, user, and tag. +`conn_matches_search` matches on alias, hostname, user, group, and tags. +Queries are normalized case-insensitively before fuzzy matching; uppercase +letters must not make a visible connection disappear (SDTEST-1025). ### SDUC-307 — Sidebar resize width bounds @@ -1458,7 +1479,9 @@ User/Support with Dev clamped to User; super-admin → persisted mode. Switching between Dev / User / Support hides the Dev surface without destroying terminal sessions (SDUC-023 must not be interrupted). Settings is a closable personal surface available in every authenticated -mode. User/Support expose General, AI, Appearance and About; Dev-capable +mode. The AI API-key field starts masked, supports explicit reveal/hide, and +keeps its full text viewport with Save/Delete below it at compact and desktop +widths. User/Support expose General, AI, Appearance and About; Dev-capable accounts additionally expose Terminal and Editor. The shared General tab also applies that capability boundary to its SSH-session controls: reconnecting terminal sessions on startup and automatically attaching tmux are absent for a @@ -1490,12 +1513,17 @@ validates via `port_forward::validate_port`. ### SDUC-314 — Port forward form connection picker Picker shows only connected (or connectable) hosts; disabled when -none. +none. Filtered mouse rows retain their original connection identity even if +outside-click dismissal clears the query before selection. Keyboard selection +uses the same identity, and the committed label shows the actual chosen host +rather than stale search text (SDPATCH-046, SDTEST-1933). ### SDUC-315 — Login form flows Email + password is the one primary path and submit stays disabled while either -field is empty. Password recovery opens the active Manage origin's public +field is empty. Newly opened password fields start masked; explicit reveal/hide +is retained across repaint, and failed sign-in does not change that choice. +Password recovery opens the active Manage origin's public `/manage/forgot-password` page. SSO, Google, GitHub, and browser-password login are collapsed under Other methods by default; expanding it preserves their exact provider routing, with browser password emitting `StartOidc(None)`. @@ -1913,6 +1941,8 @@ under every autonomy policy. A full plan may advance step by step only after a deduplicated `OSC 133;D` completion event; missing shell integration is framed by ShellDeck, non-zero exit stops the sequence, output is bounded, and Ctrl+C remains the stop path. +The completion generation stays monotonic across a terminal reset, so an +already attached observer can still detect the next command's completion. ### SDUC-432 — Requests and Support tickets accept image evidence through every desktop path @@ -2821,6 +2851,9 @@ host or filesystem authority. ### SDUC-489 — Manual and external-task intake share one workspace lifecycle +Workspace onboarding inherits the active primary text color so checkout labels +remain readable in dark and light themes (SDTEST-1932, manual native validation). + Manual creation and issue, pull-request, or task-prefilled creation enter the same validated launcher and produce the same resumable local workspace record. Local UUIDs are never treated as Platform identities: a durable project, @@ -3123,6 +3156,19 @@ an unavailable adapter refuses before any effect. ## Change log +- **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). +- **2026-09-30**: Amended SDUC-228/475/476/489 after native X11 visual QA: + request-sheet opening/closing retains keyboard focus, cockpit searches keep a + visible text viewport, and onboarding/navigator labels inherit theme text. + SDTEST-1930..1932 record the remaining automated GPUI gaps; dated screenshots + and verified journeys are in `visual-qa-2026-09-30.md`. +- **2026-09-30**: Amended SDUC-001/018/020/431 with SDTEST-1926 through + SDTEST-1929: malformed Unicode palette colors are ignored safely, accents + remain attached at the right margin, and full reset retains PTY replies and + command-completion generations without retaining old output. + - **2026-09-10**: Amended SDUC-414 with SDTEST-1925: assistant answers are laid out at the thread's definite content width, so Markdown tables no longer collapse to one glyph per column, and compact user bubbles are sized from diff --git a/docs/testing/screenshots/2026-09-30/agents-before.png b/docs/testing/screenshots/2026-09-30/agents-before.png new file mode 100644 index 00000000..ac50fcb0 Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/agents-before.png differ diff --git a/docs/testing/screenshots/2026-09-30/agents-dark-after.png b/docs/testing/screenshots/2026-09-30/agents-dark-after.png new file mode 100644 index 00000000..d5e5e83b Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/agents-dark-after.png differ diff --git a/docs/testing/screenshots/2026-09-30/agents-light-after.png b/docs/testing/screenshots/2026-09-30/agents-light-after.png new file mode 100644 index 00000000..931555f1 Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/agents-light-after.png differ diff --git a/docs/testing/screenshots/2026-09-30/api-key-after.png b/docs/testing/screenshots/2026-09-30/api-key-after.png new file mode 100644 index 00000000..0d374eb2 Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/api-key-after.png differ diff --git a/docs/testing/screenshots/2026-09-30/api-key-before.png b/docs/testing/screenshots/2026-09-30/api-key-before.png new file mode 100644 index 00000000..c8eee2ed Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/api-key-before.png differ diff --git a/docs/testing/screenshots/2026-09-30/api-key-compact-after.png b/docs/testing/screenshots/2026-09-30/api-key-compact-after.png new file mode 100644 index 00000000..d5673e61 Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/api-key-compact-after.png differ diff --git a/docs/testing/screenshots/2026-09-30/local-script-result.png b/docs/testing/screenshots/2026-09-30/local-script-result.png new file mode 100644 index 00000000..22adf050 Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/local-script-result.png differ diff --git a/docs/testing/screenshots/2026-09-30/password-masked-after.png b/docs/testing/screenshots/2026-09-30/password-masked-after.png new file mode 100644 index 00000000..8012aeb9 Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/password-masked-after.png differ diff --git a/docs/testing/screenshots/2026-09-30/password-retry-after.png b/docs/testing/screenshots/2026-09-30/password-retry-after.png new file mode 100644 index 00000000..082254a5 Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/password-retry-after.png differ diff --git a/docs/testing/screenshots/2026-09-30/picker-keyboard-after.png b/docs/testing/screenshots/2026-09-30/picker-keyboard-after.png new file mode 100644 index 00000000..93d8cad4 Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/picker-keyboard-after.png differ diff --git a/docs/testing/screenshots/2026-09-30/picker-mouse-after.png b/docs/testing/screenshots/2026-09-30/picker-mouse-after.png new file mode 100644 index 00000000..a94e7301 Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/picker-mouse-after.png differ diff --git a/docs/testing/screenshots/2026-09-30/platform-before.png b/docs/testing/screenshots/2026-09-30/platform-before.png new file mode 100644 index 00000000..c4cecc95 Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/platform-before.png differ diff --git a/docs/testing/screenshots/2026-09-30/platform-dark-after.png b/docs/testing/screenshots/2026-09-30/platform-dark-after.png new file mode 100644 index 00000000..3ab7a1ad Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/platform-dark-after.png differ diff --git a/docs/testing/screenshots/2026-09-30/platform-light-after.png b/docs/testing/screenshots/2026-09-30/platform-light-after.png new file mode 100644 index 00000000..fc4718eb Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/platform-light-after.png differ diff --git a/docs/testing/screenshots/2026-09-30/platform-small-after.png b/docs/testing/screenshots/2026-09-30/platform-small-after.png new file mode 100644 index 00000000..3b9da78c Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/platform-small-after.png differ diff --git a/docs/testing/screenshots/2026-09-30/remote-script-result.png b/docs/testing/screenshots/2026-09-30/remote-script-result.png new file mode 100644 index 00000000..336b3ea5 Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/remote-script-result.png differ diff --git a/docs/testing/screenshots/2026-09-30/request-after.png b/docs/testing/screenshots/2026-09-30/request-after.png new file mode 100644 index 00000000..17c6e66a Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/request-after.png differ diff --git a/docs/testing/screenshots/2026-09-30/request-before.png b/docs/testing/screenshots/2026-09-30/request-before.png new file mode 100644 index 00000000..f3152198 Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/request-before.png differ diff --git a/docs/testing/screenshots/2026-09-30/request-settings-after.png b/docs/testing/screenshots/2026-09-30/request-settings-after.png new file mode 100644 index 00000000..fc5bc97f Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/request-settings-after.png differ diff --git a/docs/testing/screenshots/2026-09-30/request-small-after.png b/docs/testing/screenshots/2026-09-30/request-small-after.png new file mode 100644 index 00000000..a6604ecd Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/request-small-after.png differ diff --git a/docs/testing/screenshots/2026-09-30/ssh-output.png b/docs/testing/screenshots/2026-09-30/ssh-output.png new file mode 100644 index 00000000..67db6e36 Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/ssh-output.png differ diff --git a/docs/testing/screenshots/2026-09-30/ssh-tunnel-active.png b/docs/testing/screenshots/2026-09-30/ssh-tunnel-active.png new file mode 100644 index 00000000..c003057b Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/ssh-tunnel-active.png differ diff --git a/docs/testing/screenshots/2026-09-30/workspaces-before.png b/docs/testing/screenshots/2026-09-30/workspaces-before.png new file mode 100644 index 00000000..1d17dcdc Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/workspaces-before.png differ diff --git a/docs/testing/screenshots/2026-09-30/workspaces-dark-after.png b/docs/testing/screenshots/2026-09-30/workspaces-dark-after.png new file mode 100644 index 00000000..8544a779 Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/workspaces-dark-after.png differ diff --git a/docs/testing/screenshots/2026-09-30/workspaces-light-after.png b/docs/testing/screenshots/2026-09-30/workspaces-light-after.png new file mode 100644 index 00000000..b5e44556 Binary files /dev/null and b/docs/testing/screenshots/2026-09-30/workspaces-light-after.png differ diff --git a/docs/testing/tests-ssh.md b/docs/testing/tests-ssh.md index 87da0649..70c16350 100644 --- a/docs/testing/tests-ssh.md +++ b/docs/testing/tests-ssh.md @@ -67,8 +67,8 @@ they pin the transport `session.rs` composes with `new_with_jump`. | ID | Location | SDUC | Status | Notes | |---|---|---|---|---| | SDTEST-1787 | `workspace_helper.rs::protocol_round_trips_exact_coordinates_and_receipt` + protocol rejection tests | SDUC-491 | Green | The 8-KiB framed codec retains exact operation/workspace coordinates and opaque receipt bytes, and refuses unknown, oversize, trailing, invalid-code, or diagnostic-channel input. | -| SDTEST-1788 | `workspace_helper::remote::retained_descriptor_receipt_revalidates_clean_exact_lineage` | SDUC-491 | Green | A real clean Git repository is opened beneath the configured root, bound to directory identity/HEAD/branch, and a later untracked file makes resume fail closed. | -| SDTEST-1789 | `workspace_helper::remote::root_walk_refuses_symlink_components_and_outside_paths` | SDUC-491 | Green | Component-by-component `openat(O_NOFOLLOW)` refuses a symlink and a path outside every administrator-fixed root. | +| SDTEST-1788 | `workspace_helper::remote::retained_descriptor_receipt_revalidates_clean_exact_lineage` | SDUC-491 | Green | Revalidated 2026-09-30 in an isolated child test process because successful helper exchanges change process cwd. A real clean Git repository is opened beneath the configured root, bound to directory identity/HEAD/branch, and a later untracked file makes resume fail closed. | +| SDTEST-1789 | `workspace_helper::remote::root_walk_refuses_symlink_components_and_outside_paths` | SDUC-491 | Green | Component-by-component `openat(O_NOFOLLOW)` refuses a symlink and a path outside every administrator-fixed root. | Native helper exchanges now run in isolated child test processes (2026-09-30) to prevent cwd races with SDTEST-1788. | SDTEST-1790 | `workspace_helper::remote::exchange_refuses_wrong_receipt_without_starting_shell` | SDUC-491 | Green | A second frame with the wrong opaque token receives only the bounded stale-receipt code; the exchange never reaches shell resume. | --- diff --git a/docs/testing/tests-terminal.md b/docs/testing/tests-terminal.md index 829a6050..bfc003a4 100644 --- a/docs/testing/tests-terminal.md +++ b/docs/testing/tests-terminal.md @@ -46,6 +46,10 @@ cross-platform coverage. | SDTEST-724 | `parser.rs::dec_special_graphics_charset` | SDUC-015 | Green | | SDTEST-725 | `parser.rs::partial_and_malformed_sequences_do_not_panic` | SDUC-020 | Green | | SDTEST-726 | `parser.rs::cpr_response_sent_when_channel_present` | SDUC-019 | Green | +| SDTEST-1926 | `parser.rs::malformed_unicode_palette_colors_preserve_state_and_parser_recovery` | SDUC-014, SDUC-020 | Green | Fragmented OSC 4 values containing non-ASCII or invalid hex preserve the existing palette without a UTF-8 slicing panic; subsequent valid colors and printable text still parse. | +| SDTEST-1927 | `parser.rs::terminal_reset_keeps_pty_replies_connected` | SDUC-018, SDUC-019 | Green | RIS clears screen, scrollback and attributes while retaining the host response channel; subsequent cursor-position and device-attribute queries receive exact replies. | +| SDTEST-1928 | `parser.rs::combining_characters_at_right_margin_attach_before_wrapping` | SDUC-001 | Green | A separately received accent attaches to the final glyph before delayed wrapping, including a wide glyph and a one-column terminal; the next spacing character still wraps. | +| SDTEST-1929 | `parser.rs::command_completion_generation_stays_monotonic_across_reset` | SDUC-018, SDUC-431 | Green | RIS clears previous command output and prompt markers while preserving the session's completion generation, so a new OSC 133 completion remains visible to existing observers. | ### Gaps diff --git a/docs/testing/tests-ui-and-app.md b/docs/testing/tests-ui-and-app.md index 5c16625d..3e0134ff 100644 --- a/docs/testing/tests-ui-and-app.md +++ b/docs/testing/tests-ui-and-app.md @@ -52,7 +52,7 @@ Existing: **0 tests.** | SDTEST-1022 | `sidebar.rs::no_match_returns_none` | SDUC-301 | Green | Added 2026-07-09. Also covers double-sided case-insensitivity (unlike `fuzzy_match`, this fn lowercases the needle too). | | SDTEST-1023 | `sidebar.rs::no_filter_matches_every_connection` | SDUC-302 | Green | Added 2026-07-09. | | SDTEST-1024 | `sidebar.rs::filter_matches_bound_site_and_all_unbound_connections` | SDUC-302 | Green | Added 2026-07-09. Test hits the extracted pure fn `conn_matches_site_filter(Option, Option) -> bool` so no GPUI `Context` needed. The method still exists and delegates. | -| SDTEST-1025 | *to write* — conn_matches_search: alias, hostname, user, tag match | SDUC-306 | **Red / P1** | | +| SDTEST-1025 | `sidebar::tests::connection_search_matches_all_fields_case_insensitively` | SDUC-306 | Green | Added 2026-09-30 after native uppercase-search failure. Alias, hostname, user, group and tags accept mixed/uppercase fuzzy queries; empty query, alias fallback, ordered subsequences and accent distinctions retain their behavior. The missing tag branch failed the regression before correction. | | SDTEST-1026 | *to write* — set_width clamps within [MIN, MAX] | SDUC-307 | **Red / P1** | | | SDTEST-1027 | *to write* — toggle_collapsed toggles state and preserves other state | SDUC-308 | **Red / P2** | | @@ -645,3 +645,18 @@ builds fails, the release + manifest jobs are skipped entirely". `shelldeck_core::util::executable_on_path`, whose contracts (multi-dir PATH walk, PATHEXT extensions, unix `+x` check — a stricter superset) are pinned by SDTEST-1591 in `tests-core.md`. IDs stay reserved per the sticky-ID rule. + +## Native visual regression follow-up — 2026-09-30 + +These render/focus regressions were reproduced and rechecked in the running +Linux app. They remain Red for **automated GPUI coverage**, not for an unresolved +observed defect. Evidence and repeatable journeys live in +[the visual QA record](visual-qa-2026-09-30.md). + +| ID | Location | SDUC | Status | Notes | +|---|---|---|---|---| +| SDTEST-1930 | *to write* — request-sheet focus lifecycle | SDUC-228 | **Red / P1** | Manual X11 pass: immediate typing after opening from the palette, User Settings, and Support ticket conversion reaches the title. Escape opens the dirty-draft guard; Continue restores the caret; body focus survives repaint/resize; clean close and explicit discard restore workspace shortcuts. An automated native focus harness is still missing. | +| SDTEST-1931 | *to write* — cockpit search viewports | SDUC-475, SDUC-476 | **Red / P1** | Manual X11 pass: Agents search accepts typing and filtering; Platform placeholder and entered query stay visible at 1200 × 850 and 820 × 600, across compact/desktop transitions and Light/Tokyo Night themes. Production session-data filtering was not exercised against a live Platform. | +| 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. | diff --git a/docs/testing/visual-qa-2026-09-30.md b/docs/testing/visual-qa-2026-09-30.md new file mode 100644 index 00000000..2d508c5d --- /dev/null +++ b/docs/testing/visual-qa-2026-09-30.md @@ -0,0 +1,82 @@ +# Native visual QA — 2026-09-30 + +This pass ran the actual debug ShellDeck binary on a private Xvfb X11 display, +with a temporary XDG profile, a private D-Bus session and the repository's +`manage_stub.py` on loopback. Windows were tested at 1200 × 850 and 820 × 600; +Light and Tokyo Night were both exercised. Secret-field Settings also passed +at 580 × 600, including scrolling to its action buttons. The fixture account is fictional. +Local terminal/script operations were real; Manage data came from the fixture. +The real SSH config was imported read-only. An initial filtered-picker failure +selected the first imported host and attempted DNS lookup, which failed before +an SSH transport was established. That defect was fixed, and saved connection +UUIDs were explicitly checked before subsequent tunnel starts. All established +SSH sessions and successful tunnel traffic used the disposable loopback fixture. + +## Observed regressions and repairs + +| Journey | Before | After | Evidence | +|---|---|---|---| +| Open New Request and type without a mouse click | Title stayed empty; input had no focus | Title takes focus once visible, including after mode changes | [Before](screenshots/2026-09-30/request-before.png), [after](screenshots/2026-09-30/request-after.png) | +| Close/discard a request, then use the palette | Focus remained on a removed control; shortcut did not open | Clean close and explicit discard focus the workspace | Palette was reopened immediately after discard; Settings → palette → New Request also passed | +| Request field focus after repaint/resize | Opening had no defined focus lifecycle | Body/caret remain selected after later renders; Continue restores prior field | [Compact draft](screenshots/2026-09-30/request-small-after.png), [from Settings](screenshots/2026-09-30/request-settings-after.png) | +| Platform session search | Search collapsed to its icon; text also disappeared in the fixed column | Definite desktop text width; full-width compact input retains its query | [Before](screenshots/2026-09-30/platform-before.png), [desktop](screenshots/2026-09-30/platform-dark-after.png), [compact](screenshots/2026-09-30/platform-small-after.png), [Light](screenshots/2026-09-30/platform-light-after.png) | +| Agents navigator | Search text viewport collapsed; headings/session/provider labels were black on dark | Search stretches within its column; root supplies semantic text color | [Before](screenshots/2026-09-30/agents-before.png), [Tokyo Night](screenshots/2026-09-30/agents-dark-after.png), [Light](screenshots/2026-09-30/agents-light-after.png) | +| Filtered connection picker | Closing the popup cleared the filter before a clicked row resolved; row zero selected the wrong host | Mouse rows retain the rendered original identity; keyboard resolves the same identity; stale query is cleared | [Mouse](screenshots/2026-09-30/picker-mouse-after.png), [keyboard](screenshots/2026-09-30/picker-keyboard-after.png) | +| Login password and AI API key | New input states exposed secrets by default; API-key buttons squeezed the input to a few characters | Password input states start masked; explicit reveal/hide survives repaint; API-key actions sit below a full-width field | [Masked login](screenshots/2026-09-30/password-masked-after.png), [401 retry](screenshots/2026-09-30/password-retry-after.png), [key before](screenshots/2026-09-30/api-key-before.png), [after](screenshots/2026-09-30/api-key-after.png), [compact](screenshots/2026-09-30/api-key-compact-after.png) | +| Sidebar connection search | Uppercase queries hid matching rows; tags were not consulted | Case-insensitive alias/host/user/group/tag admission, covered by SDTEST-1025 | Reproduced while searching for the SSH fixture; rechecked after rebuilding | +| Workspace onboarding | Checkout heading inherited black on a dark card | Root supplies active primary text color | [Before](screenshots/2026-09-30/workspaces-before.png), [Tokyo Night](screenshots/2026-09-30/workspaces-dark-after.png), [Light](screenshots/2026-09-30/workspaces-light-after.png) | + +Four independent terminal protocol regressions were reproduced with failing +parser tests before fixing them: malformed Unicode OSC palette values could +panic; RIS lost the PTY response channel; RIS reset the shell-completion +sequence; combining accents at the right margin attached to the previous cell. +SDTEST-1926..1929 cover recovery and subsequent observable behavior. + +## Feature coverage + +| Surface | Exercised in the running app | Limits | +|---|---|---| +| User | Home, owner-scoped requests, request detail/comment draft, creation draft, discard/Continue/Escape, site overview, mode switcher | No real request or attachment upload was submitted | +| Support | Home counts, tickets, ownership filters, thread/messages/note rendering, ticket action menu, conversion to a prefilled request | Fixture does not persist mutations; live replies/triage are not claimed | +| Requests focus | Immediate keyboard input, palette launch, launch from User Settings, Support → User conversion, body focus after resize, safe Escape/Continue, palette after discard | Manual native coverage; automated GPUI harness remains a gap | +| Local terminal | Real PTY, input/output, `pwd`, split panes, continued input in selected pane | Imported SSH profiles were not contacted | +| Scripts | Create/save an isolated local `echo ShellDeckQA` script, select/edit, compact layout, execute and observe output plus exit 0 | The same script also executed over the disposable SSH fixture with output and exit 0; [local result](screenshots/2026-09-30/local-script-result.png), [remote result](screenshots/2026-09-30/remote-script-result.png) | +| Connections | Create/save isolated profiles; real key-authenticated SSH to a disposable Paramiko loopback server; `echo` and `pwd` output from its PTY; uppercase search | No established production SSH session; [SSH output](screenshots/2026-09-30/ssh-output.png) | +| Port forwards | Forms at both sizes, SOCKS selection, filtered mouse/keyboard target selection, saved UUID verification, real Local → Remote tunnel start/HTTP traffic/stop on loopback | Remote → Local and SOCKS transport remain untested; [active tunnel](screenshots/2026-09-30/ssh-tunnel-active.png) | +| Login / cloud sync | Logout to welcome, empty-submit disabled, fixture password sign-in, manual cloud sync, 401 error/retry, reopen with a newly masked input | No production authentication, OIDC, or successful OS-keychain persistence; fixture sync returned zero changes | +| Settings | General, Appearance scrolling, theme selection and persistence, companion controls, keyboard opening, palette navigation; fake API-key typing, reveal/hide and preserved state across resize at three sizes | Native tray/global shortcuts and multi-monitor behavior unavailable in Xvfb | +| Platform | Offline/retry presentation, desktop/compact Resources and Sessions, placeholder/query/caret visibility, query preserved across resize/themes | No live projection, attach/control lease, or approval executed | +| Agents | Navigator, search viewport, provider/target header, composer, local inspector tree, light/dark text | No paid/provider run or write-capable agent launched | +| Workspaces | Empty catalog/onboarding, authorized checkout form, compact/wide rendering, light/dark heading | No real worktree/remote checkout created | +| Server sync | Source picker and local directory listing | No rsync or service migration launched | +| File editor | Repository tree, README opening, line numbers/syntax/minimap | File opened read-only; no source file modified through the editor | +| Bext | Cloud disconnected surface; Instance target form and unavailable-instance error | No site creation/configuration/go-live/destruction | +| Recent activity | Persisted local script/connection/ticket/tunnel events, list/filter controls | Remote replay not exercised | +| Monique | Missing-configuration warning through palette | No authenticated conversation or action approval | + +## Automated validation and remaining gaps + +`cargo test --workspace --no-fail-fast` passes 853 unique tests: app 100, +core 403, SSH 46, terminal 88, UI 203, update 13. Six live/infrastructure tests +and two documentation examples remain ignored. `cargo clippy --workspace +--no-deps -- -D warnings`, formatting, Linux debug build and whitespace checks +pass. Vendored dependency dead-code warnings are unchanged. + +SDTEST-1930..1934 remain **Red** for automated native focus/layout/contrast and popup event-order and secret-input +coverage; passing screenshots are not presented as an automated harness. +Production authentication/OIDC/keychain, production SSH/SFTP and remote/SOCKS +forwarding, actual cloud writes, +AI/provider execution, update installation, tray/desktop companion and native +macOS/Windows interaction still need dedicated live environments. Cross-platform +CI covers the existing native core/update and authority-boundary tests. + +A repeated full suite also exposed a pre-existing flaky SSH-helper test: +two successful helper journeys changed the test process cwd concurrently. +Those journeys now run in isolated child test processes; SDTEST-1788/1789 retain +their original authority assertions. The full suite passes with normal parallel +test execution after this change. + +Representative original screenshots are committed above. The larger local +capture set, including intermediate states, remains under +`/tmp/shelldeck-visual-qa/screenshots/`; captures with imported hostnames are +kept local rather than published in this repository. diff --git a/patches/adabraka-ui/PATCHES.md b/patches/adabraka-ui/PATCHES.md index 5e1bbf95..810630d8 100644 --- a/patches/adabraka-ui/PATCHES.md +++ b/patches/adabraka-ui/PATCHES.md @@ -4,8 +4,9 @@ **Upstream**: https://github.com/Augani/adabraka-ui **Last synced**: 2026-07-07 (v0.3.0 → v0.3.9) -Total markers in code: **148** -(sum of the per-entry `Markers` lists below; SDPATCH-008 is an adapter and +Total markers in code: **179** +(the previous total of 148 was stale: 175 markers preceded SDPATCH-046; +SDPATCH-046 adds four markers. The per-entry `Markers` lists remain the inventory; SDPATCH-008 is an adapter and carries no marker of its own — see its entry). ## Patches @@ -1091,3 +1092,14 @@ carries no marker of its own — see its entry). ## Retired patches *(empty for now)* + +### SDPATCH-046 — Filtered combobox clicks preserve the displayed item + +- **Files / symbols**: `src/components/combobox.rs` — `ComboboxState::select_item`, `Combobox::select_item`, `Combobox::combobox_confirm`, popup row mouse handler. +- **Markers** (4): + - `// ShellDeck patch: SDPATCH-046 — committed single-select rows show` + - `// ShellDeck patch: SDPATCH-046 — popup mouse-down-out may clear the filter` + - `// ShellDeck patch: SDPATCH-046 — keyboard focus is a filtered` + - `// ShellDeck patch: SDPATCH-046 — capture the rendered` +- **Why**: On X11 the outside mouse-down closes the deferred dropdown and clears its search before a row handler resolves its filtered position. A click on the sole matching SSH connection consequently chose the first unfiltered host. Mouse rows now capture the original index, keyboard focus resolves to that same identity, and committed single selections clear stale query/focus state so the actual selected label is visible. +- **Validation**: SDTEST-1933 records native filtered mouse/keyboard selection and saved target checks. Automated GPUI event-order coverage remains a gap. No upstream version or dependency entry changed. diff --git a/patches/adabraka-ui/src/components/combobox.rs b/patches/adabraka-ui/src/components/combobox.rs index 23cb8544..038b9a23 100644 --- a/patches/adabraka-ui/src/components/combobox.rs +++ b/patches/adabraka-ui/src/components/combobox.rs @@ -76,6 +76,10 @@ impl ComboboxState { } else { self.selected = vec![item]; self.is_open = false; + // ShellDeck patch: SDPATCH-046 — committed single-select rows show + // their selected label rather than the obsolete search query. + self.search_text.clear(); + self.focused_index = None; } } @@ -273,21 +277,19 @@ impl Combobox { cx.emit(ComboboxEvent::Change); } - /// Select an item - fn select_item(&mut self, index: usize, window: &mut Window, cx: &mut Context) { - let filtered = self.filtered_items(cx); - if let Some(&(original_idx, _)) = filtered.get(index) { - if let Some(item) = self.items.get(original_idx).cloned() { - self.state.update(cx, |state, cx| { - state.select_item(item.clone(), self.multi_select); - cx.notify(); // Trigger re-render - }); - - cx.emit(ComboboxEvent::Change); - - if let Some(ref callback) = self.on_select { - (callback)(&item, window, cx); - } + /// Select an item by its stable index in the unfiltered item collection. + // ShellDeck patch: SDPATCH-046 — popup mouse-down-out may clear the filter + // before the row handler runs. Never resolve a rendered row against that + // changed filter, or row zero silently becomes the first unrelated item. + fn select_item(&mut self, original_idx: usize, window: &mut Window, cx: &mut Context) { + if let Some(item) = self.items.get(original_idx).cloned() { + self.state.update(cx, |state, cx| { + state.select_item(item.clone(), self.multi_select); + cx.notify(); + }); + cx.emit(ComboboxEvent::Change); + if let Some(ref callback) = self.on_select { + (callback)(&item, window, cx); } } } @@ -346,7 +348,12 @@ impl Combobox { let state = self.state.read(cx); if state.is_open { if let Some(idx) = state.focused_index { - self.select_item(idx, window, cx); + // ShellDeck patch: SDPATCH-046 — keyboard focus is a filtered + // position; resolve it to the same original index as mouse rows. + let original_idx = self.filtered_items(cx).get(idx).map(|(index, _)| *index); + if let Some(original_idx) = original_idx { + self.select_item(original_idx, window, cx); + } } } else { self.toggle_dropdown(window, cx); @@ -626,8 +633,10 @@ impl Render for Combobox { .cursor(CursorStyle::PointingHand) .text_size(px(14.0)) .font_family(theme.tokens.font_family.clone()) + // ShellDeck patch: SDPATCH-046 — capture the rendered + // row identity before outside-click closes the filter. .on_mouse_down(MouseButton::Left, cx.listener(move |this, _, window, cx| { - this.select_item(display_idx, window, cx); + this.select_item(_original_idx, window, cx); })) .child(item_text) .when(is_selected, |div| {