diff --git a/assets/main.css b/assets/main.css index 57ce435..2bdd4ce 100644 --- a/assets/main.css +++ b/assets/main.css @@ -1884,6 +1884,44 @@ h6 { color: var(--accent); } +/* -- Chat Action Bar -- */ +.chat-action-bar { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 24px 0; + background-color: var(--bg-sidebar); +} + +.chat-action-btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + background: none; + border: 1px solid transparent; + border-radius: 6px; + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + transition: all 0.15s ease; +} + +.chat-action-btn:hover:not(:disabled) { + color: var(--text-primary); + background-color: var(--bg-card); + border-color: var(--border-secondary); +} + +.chat-action-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.chat-action-label { + font-family: 'Inter', sans-serif; +} + /* -- Chat Input Bar -- */ .chat-input-bar { display: flex; diff --git a/src/components/chat_action_bar.rs b/src/components/chat_action_bar.rs new file mode 100644 index 0000000..09e9d48 --- /dev/null +++ b/src/components/chat_action_bar.rs @@ -0,0 +1,65 @@ +use dioxus::prelude::*; +use dioxus_free_icons::icons::fa_solid_icons::{FaCopy, FaPenToSquare, FaShareNodes}; + +/// Action bar displayed above the chat input with copy, share, and edit buttons. +/// +/// Only visible when there is at least one message in the conversation. +/// +/// # Arguments +/// +/// * `on_copy` - Copies the last assistant response to the clipboard +/// * `on_share` - Copies the full conversation as text to the clipboard +/// * `on_edit` - Places the last user message back in the input for editing +/// * `has_messages` - Whether any messages exist (hides the bar when empty) +/// * `has_assistant_message` - Whether an assistant message exists (disables copy if not) +/// * `has_user_message` - Whether a user message exists (disables edit if not) +#[component] +pub fn ChatActionBar( + on_copy: EventHandler<()>, + on_share: EventHandler<()>, + on_edit: EventHandler<()>, + has_messages: bool, + has_assistant_message: bool, + has_user_message: bool, +) -> Element { + if !has_messages { + return rsx! {}; + } + + rsx! { + div { class: "chat-action-bar", + button { + class: "chat-action-btn", + disabled: !has_assistant_message, + title: "Copy last response", + onclick: move |_| on_copy.call(()), + dioxus_free_icons::Icon { + icon: FaCopy, + width: 14, height: 14, + } + span { class: "chat-action-label", "Copy" } + } + button { + class: "chat-action-btn", + title: "Copy conversation", + onclick: move |_| on_share.call(()), + dioxus_free_icons::Icon { + icon: FaShareNodes, + width: 14, height: 14, + } + span { class: "chat-action-label", "Share" } + } + button { + class: "chat-action-btn", + disabled: !has_user_message, + title: "Edit last message", + onclick: move |_| on_edit.call(()), + dioxus_free_icons::Icon { + icon: FaPenToSquare, + width: 14, height: 14, + } + span { class: "chat-action-label", "Edit" } + } + } + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index 93577e5..3018046 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,6 +1,7 @@ mod app_shell; mod article_detail; mod card; +mod chat_action_bar; mod chat_bubble; mod chat_input_bar; mod chat_message_list; @@ -20,6 +21,7 @@ mod tool_card; pub use app_shell::*; pub use article_detail::*; pub use card::*; +pub use chat_action_bar::*; pub use chat_bubble::*; pub use chat_input_bar::*; pub use chat_message_list::*; diff --git a/src/pages/chat.rs b/src/pages/chat.rs index 5bd50b2..cc59c32 100644 --- a/src/pages/chat.rs +++ b/src/pages/chat.rs @@ -1,4 +1,6 @@ -use crate::components::{ChatInputBar, ChatMessageList, ChatModelSelector, ChatSidebar}; +use crate::components::{ + ChatActionBar, ChatInputBar, ChatMessageList, ChatModelSelector, ChatSidebar, +}; use crate::infrastructure::chat::{ chat_complete, create_chat_session, delete_chat_session, list_chat_messages, list_chat_sessions, rename_chat_session, save_chat_message, @@ -16,7 +18,7 @@ pub fn ChatPage() -> Element { // ---- Signals ---- let mut active_session_id: Signal> = use_signal(|| None); let mut messages: Signal> = use_signal(Vec::new); - let input_text: Signal = use_signal(String::new); + let mut input_text: Signal = use_signal(String::new); let mut is_streaming: Signal = use_signal(|| false); let mut streaming_content: Signal = use_signal(String::new); let mut selected_model: Signal = use_signal(String::new); @@ -202,6 +204,77 @@ pub fn ChatPage() -> Element { }); }; + // ---- Action bar state ---- + let has_messages = !messages.read().is_empty(); + let has_assistant_message = messages + .read() + .iter() + .any(|m| m.role == ChatRole::Assistant); + let has_user_message = messages.read().iter().any(|m| m.role == ChatRole::User); + + // Copy last assistant response to clipboard + let on_copy = move |_: ()| { + #[cfg(feature = "web")] + { + let last_assistant = messages + .read() + .iter() + .rev() + .find(|m| m.role == ChatRole::Assistant) + .map(|m| m.content.clone()); + if let Some(text) = last_assistant { + if let Some(window) = web_sys::window() { + let clipboard = window.navigator().clipboard(); + let _ = clipboard.write_text(&text); + } + } + } + }; + + // Copy full conversation as text to clipboard + let on_share = move |_: ()| { + #[cfg(feature = "web")] + { + let text: String = messages + .read() + .iter() + .filter(|m| m.role != ChatRole::System) + .map(|m| { + let label = match m.role { + ChatRole::User => "You", + ChatRole::Assistant => "Assistant", + ChatRole::System => "System", + }; + format!("{label}:\n{}\n", m.content) + }) + .collect::>() + .join("\n"); + if let Some(window) = web_sys::window() { + let clipboard = window.navigator().clipboard(); + let _ = clipboard.write_text(&text); + } + } + }; + + // Edit last user message: remove it and place text back in input + let on_edit = move |_: ()| { + let last_user = messages + .read() + .iter() + .rev() + .find(|m| m.role == ChatRole::User) + .map(|m| m.content.clone()); + if let Some(text) = last_user { + // Remove the last user message (and any assistant reply after it) + let mut msgs = messages.read().clone(); + if let Some(pos) = msgs.iter().rposition(|m| m.role == ChatRole::User) { + msgs.truncate(pos); + messages.set(msgs); + } + input_text.set(text); + } + }; + // Scroll to bottom when messages or streaming content changes let msg_count = messages.read().len(); let stream_len = streaming_content.read().len(); @@ -244,6 +317,14 @@ pub fn ChatPage() -> Element { streaming_content: streaming_content.read().clone(), is_streaming: *is_streaming.read(), } + ChatActionBar { + on_copy: on_copy, + on_share: on_share, + on_edit: on_edit, + has_messages: has_messages, + has_assistant_message: has_assistant_message, + has_user_message: has_user_message, + } ChatInputBar { input_text: input_text, on_send: on_send,