diff --git a/crates/chattyness-user-ui/src/components/chat.rs b/crates/chattyness-user-ui/src/components/chat.rs index 1ff1f6e..18fdabd 100644 --- a/crates/chattyness-user-ui/src/components/chat.rs +++ b/crates/chattyness-user-ui/src/components/chat.rs @@ -162,25 +162,24 @@ pub fn ChatInput( return; }; - // Pre-fill with /whisper command - let placeholder = "your message here"; - let whisper_text = format!("/whisper {} {}", target_name, placeholder); + // Pre-fill with /whisper command prefix only (no placeholder text) + // User types their message after the space + // parse_whisper_command already rejects empty messages + let whisper_prefix = format!("/whisper {} ", target_name); if let Some(input) = input_ref.get() { // Set the message - set_message.set(whisper_text.clone()); + set_message.set(whisper_prefix.clone()); + // Don't show hint - user already knows they're whispering set_command_mode.set(CommandMode::None); // Update input value - input.set_value(&whisper_text); + input.set_value(&whisper_prefix); - // Focus the input + // Focus the input and position cursor at end let _ = input.focus(); - - // Select the placeholder text so it gets replaced when typing - let prefix_len = format!("/whisper {} ", target_name).len() as u32; - let total_len = whisper_text.len() as u32; - let _ = input.set_selection_range(prefix_len, total_len); + let len = whisper_prefix.len() as u32; + let _ = input.set_selection_range(len, len); } }); } @@ -230,8 +229,24 @@ pub fn ChatInput( let cmd = value[1..].to_lowercase(); // Show hint for slash commands (don't execute until Enter) - // Match: /s[etting], /i[nventory], /w[hisper], or their full forms with args - if cmd.is_empty() + // Match: /s[etting], /i[nventory], /w[hisper] + // But NOT when whisper command is complete (has name + space for message) + let is_complete_whisper = { + // Check if it's "/w name " or "/whisper name " (name followed by space) + let rest = cmd.strip_prefix("whisper ").or_else(|| cmd.strip_prefix("w ")); + if let Some(after_cmd) = rest { + // If there's content after the command and it contains a space, + // user has typed "name " and is now typing the message + after_cmd.contains(' ') + } else { + false + } + }; + + if is_complete_whisper { + // User is typing the message part, no hint needed + set_command_mode.set(CommandMode::None); + } else if cmd.is_empty() || "setting".starts_with(&cmd) || "inventory".starts_with(&cmd) || "whisper".starts_with(&cmd) diff --git a/crates/chattyness-user-ui/src/components/context_menu.rs b/crates/chattyness-user-ui/src/components/context_menu.rs index 236b16a..c766d95 100644 --- a/crates/chattyness-user-ui/src/components/context_menu.rs +++ b/crates/chattyness-user-ui/src/components/context_menu.rs @@ -24,6 +24,7 @@ pub struct ContextMenuItem { /// Props: /// - `open`: Whether the menu is currently visible /// - `position`: The (x, y) position in client coordinates where the menu should appear +/// - `header`: Optional header text displayed at the top (e.g., username) /// - `items`: The menu items to display /// - `on_select`: Callback when a menu item is selected, receives the action string /// - `on_close`: Callback when the menu should close (click outside, escape, etc.) @@ -35,6 +36,9 @@ pub fn ContextMenu( /// Position (x, y) in client coordinates. #[prop(into)] position: Signal<(f64, f64)>, + /// Optional header text (e.g., username) displayed above the menu items. + #[prop(optional, into)] + header: Option>>, /// Menu items to display. #[prop(into)] items: Signal>, @@ -82,12 +86,40 @@ pub fn ContextMenu( ) }; - // Click outside handler + // Click outside handler - use Effect with cleanup to properly remove handlers #[cfg(feature = "hydrate")] { + use std::cell::RefCell; + use std::rc::Rc; use wasm_bindgen::{JsCast, closure::Closure}; + // Store closures so we can remove them on cleanup + let mousedown_closure: Rc>>> = + Rc::new(RefCell::new(None)); + let keydown_closure: Rc>>> = + Rc::new(RefCell::new(None)); + + let mousedown_closure_clone = mousedown_closure.clone(); + let keydown_closure_clone = keydown_closure.clone(); + Effect::new(move |_| { + let window = web_sys::window().unwrap(); + + // Clean up previous handlers first + if let Some(old_handler) = mousedown_closure_clone.borrow_mut().take() { + let _ = window.remove_event_listener_with_callback( + "mousedown", + old_handler.as_ref().unchecked_ref(), + ); + } + if let Some(old_handler) = keydown_closure_clone.borrow_mut().take() { + let _ = window.remove_event_listener_with_callback( + "keydown", + old_handler.as_ref().unchecked_ref(), + ); + } + + // Only add handlers when menu is open if !open.get() { return; } @@ -100,6 +132,7 @@ pub fn ContextMenu( let menu_el: web_sys::HtmlElement = menu_el.into(); let menu_el_clone = menu_el.clone(); + // Mousedown handler for click-outside detection let handler = Closure::::new(move |ev: web_sys::MouseEvent| { if let Some(target) = ev.target() { @@ -111,9 +144,9 @@ pub fn ContextMenu( } }); - let window = web_sys::window().unwrap(); let _ = window .add_event_listener_with_callback("mousedown", handler.as_ref().unchecked_ref()); + *mousedown_closure_clone.borrow_mut() = Some(handler); // Escape key handler let on_close_esc = on_close.clone(); @@ -129,10 +162,7 @@ pub fn ContextMenu( "keydown", keydown_handler.as_ref().unchecked_ref(), ); - - // Store handlers to clean up (they get cleaned up when Effect reruns) - handler.forget(); - keydown_handler.forget(); + *keydown_closure_clone.borrow_mut() = Some(keydown_handler); }); } @@ -145,6 +175,17 @@ pub fn ContextMenu( role="menu" aria-label="Context menu" > + // Header with username and divider + {move || { + header.and_then(|h| h.get()).map(|header_text| { + view! { +
+
{header_text}
+
+
+ } + }) + }}