From 2f843283e16952cd0ee8618e9295feed632f91af Mon Sep 17 00:00:00 2001 From: Tryanks Date: Mon, 21 Sep 2026 14:24:55 +0800 Subject: [PATCH] Open the project chooser without the keyboard on phones The mobile Threads page "+" button opens the command palette as a project chooser when more than one project exists. The palette always focused its search input, which raised the software keyboard over a list that is meant to be tapped. WindowState now records whether the palette should focus its query: open_palette keeps the keyboard for the search pill, and the new open_palette_without_keyboard leaves the search input unfocused and gives focus to the palette overlay so Escape and Back still close it. start_thread uses the latter on mobile builds only; a narrow desktop window keeps typing into the chooser. project_choice_reuses_drafts_and_focuses_only_on_desktop now asserts the query focus after opening the chooser: focused off-phone, unfocused on a phone, with the palette owning focus in both cases. --- crates/ui/src/palette.rs | 18 +++++++++++++--- crates/ui/src/shell.rs | 39 +++++++++++++++++++++++++++++++---- crates/ui/src/window_state.rs | 14 +++++++++++++ 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/crates/ui/src/palette.rs b/crates/ui/src/palette.rs index e3fc3528..21bfad06 100644 --- a/crates/ui/src/palette.rs +++ b/crates/ui/src/palette.rs @@ -164,16 +164,28 @@ impl CommandPalette { } } - /// Focus the search input when the palette opens. - pub fn focus(&mut self, window: &mut Window, cx: &mut Context) { + /// Reset the palette as it opens. With `focus_query` the search input + /// takes focus; without it the overlay does, so Escape still closes the + /// palette but no software keyboard is raised. + pub fn open(&mut self, focus_query: bool, window: &mut Window, cx: &mut Context) { self.query.update(cx, |state, cx| { state.set_value(String::new(), window, cx); - state.focus(window, cx); + if focus_query { + state.focus(window, cx); + } }); + if !focus_query { + self.focus_handle.focus(window, cx); + } self.selected = 0; self.content_hits.clear(); } + #[cfg(test)] + pub(crate) fn query_focus_handle(&self, cx: &gpui::App) -> FocusHandle { + self.query.read(cx).focus_handle(cx) + } + /// Debounce, then ask the host for content hits. The index, cache and /// session metadata are host-owned; this keeps only the debounce and the /// generation guard that discards an answer a newer query superseded. diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index f1cf6daf..c2d908af 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -1539,14 +1539,22 @@ impl AppShell { /// New thread: one project starts a draft directly, several go through the /// palette, which already owns "new thread in " and can search. + /// On a phone the palette opens without the keyboard: the projects are + /// there to tap, and the search pill still raises it on demand. fn start_thread(&mut self, window: &mut Window, cx: &mut Context) { let Some(attachment) = &self.attachment else { return; }; let projects = attachment.link.store.read(cx).projects(); let Some(project) = projects.first().cloned().filter(|_| projects.len() == 1) else { - self.window_state - .update(cx, |state, cx| state.open_palette(cx)); + let mobile = crate::window_seam::is_mobile(cx); + self.window_state.update(cx, |state, cx| { + if mobile { + state.open_palette_without_keyboard(cx); + } else { + state.open_palette(cx); + } + }); return; }; attachment.link.store.update(cx, |store, cx| { @@ -2395,12 +2403,17 @@ impl Render for AppShell { // first frame, or an inset change that narrowed the content box). self.sync_layout(window, cx); // The palette takes focus on the frame it opens, at either width. - let palette_open = self.window_state.read(cx).palette_open; + let (palette_open, focus_query) = { + let state = self.window_state.read(cx); + (state.palette_open, state.palette_focuses_query) + }; if palette_open && !self.palette_was_open && let Some(attachment) = &self.attachment { - attachment.palette.update(cx, |p, cx| p.focus(window, cx)); + attachment + .palette + .update(cx, |p, cx| p.open(focus_query, window, cx)); } self.palette_was_open = palette_open; let body = if self.compact(cx) { @@ -3548,6 +3561,24 @@ mod tests { draw(cx); if project_count > 1 || !compact { assert!(shell.read_with(cx, |shell, cx| shell.window_state.read(cx).palette_open)); + cx.update(|window, cx| { + let palette = &shell.read(cx).attachment.as_ref().unwrap().palette; + let query = palette.read(cx).query_focus_handle(cx); + // The chooser is tapped through on a phone; a desktop + // window (even a narrow one) types into it. + assert_eq!( + query.is_focused(window), + !(compact && crate::window_seam::is_mobile(cx)), + "project chooser raises the keyboard only off-phone" + ); + assert!( + palette + .read(cx) + .focus_handle(cx) + .contains_focused(window, cx), + "the palette owns focus so Escape closes it" + ); + }); let row = cx .debug_bounds("palette-new-thread-project-1") .expect("project choice"); diff --git a/crates/ui/src/window_state.rs b/crates/ui/src/window_state.rs index 2733a2c5..aedefd6d 100644 --- a/crates/ui/src/window_state.rs +++ b/crates/ui/src/window_state.rs @@ -142,6 +142,10 @@ pub struct WindowState { /// [`Destination::Hosts`], the root the platform's Back gesture falls off. history: Vec, pub palette_open: bool, + /// Whether the palette focuses its search input when it opens. A tap on + /// the search pill wants the keyboard; the mobile "+" button lists + /// projects to tap and must not raise it. + pub palette_focuses_query: bool, pub sidebar_collapsed: bool, pub quit_prompt_epoch: u64, pub quit_prompt_open: bool, @@ -154,6 +158,7 @@ impl WindowState { compact: false, history: vec![Destination::Hosts], palette_open: false, + palette_focuses_query: true, sidebar_collapsed, quit_prompt_epoch: 0, quit_prompt_open: false, @@ -312,6 +317,15 @@ impl WindowState { pub fn open_palette(&mut self, cx: &mut Context) { self.palette_open = true; + self.palette_focuses_query = true; + cx.notify(); + } + + /// Open the palette as a list to tap through, leaving the search input + /// unfocused so no software keyboard comes up. + pub fn open_palette_without_keyboard(&mut self, cx: &mut Context) { + self.palette_open = true; + self.palette_focuses_query = false; cx.notify(); }