feat(chat): add copy, share, and edit action buttons above input bar
Add ChatActionBar component with three icon buttons above the chat input: - Copy: copies last assistant response to clipboard - Share: copies full conversation text to clipboard - Edit: removes last user message and places it back in input Buttons are disabled when no relevant messages exist. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1884,6 +1884,44 @@ h6 {
|
|||||||
color: var(--accent);
|
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 -- */
|
||||||
.chat-input-bar {
|
.chat-input-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
65
src/components/chat_action_bar.rs
Normal file
65
src/components/chat_action_bar.rs
Normal file
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
mod app_shell;
|
mod app_shell;
|
||||||
mod article_detail;
|
mod article_detail;
|
||||||
mod card;
|
mod card;
|
||||||
|
mod chat_action_bar;
|
||||||
mod chat_bubble;
|
mod chat_bubble;
|
||||||
mod chat_input_bar;
|
mod chat_input_bar;
|
||||||
mod chat_message_list;
|
mod chat_message_list;
|
||||||
@@ -20,6 +21,7 @@ mod tool_card;
|
|||||||
pub use app_shell::*;
|
pub use app_shell::*;
|
||||||
pub use article_detail::*;
|
pub use article_detail::*;
|
||||||
pub use card::*;
|
pub use card::*;
|
||||||
|
pub use chat_action_bar::*;
|
||||||
pub use chat_bubble::*;
|
pub use chat_bubble::*;
|
||||||
pub use chat_input_bar::*;
|
pub use chat_input_bar::*;
|
||||||
pub use chat_message_list::*;
|
pub use chat_message_list::*;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use crate::components::{ChatInputBar, ChatMessageList, ChatModelSelector, ChatSidebar};
|
use crate::components::{
|
||||||
|
ChatActionBar, ChatInputBar, ChatMessageList, ChatModelSelector, ChatSidebar,
|
||||||
|
};
|
||||||
use crate::infrastructure::chat::{
|
use crate::infrastructure::chat::{
|
||||||
chat_complete, create_chat_session, delete_chat_session, list_chat_messages,
|
chat_complete, create_chat_session, delete_chat_session, list_chat_messages,
|
||||||
list_chat_sessions, rename_chat_session, save_chat_message,
|
list_chat_sessions, rename_chat_session, save_chat_message,
|
||||||
@@ -16,7 +18,7 @@ pub fn ChatPage() -> Element {
|
|||||||
// ---- Signals ----
|
// ---- Signals ----
|
||||||
let mut active_session_id: Signal<Option<String>> = use_signal(|| None);
|
let mut active_session_id: Signal<Option<String>> = use_signal(|| None);
|
||||||
let mut messages: Signal<Vec<ChatMessage>> = use_signal(Vec::new);
|
let mut messages: Signal<Vec<ChatMessage>> = use_signal(Vec::new);
|
||||||
let input_text: Signal<String> = use_signal(String::new);
|
let mut input_text: Signal<String> = use_signal(String::new);
|
||||||
let mut is_streaming: Signal<bool> = use_signal(|| false);
|
let mut is_streaming: Signal<bool> = use_signal(|| false);
|
||||||
let mut streaming_content: Signal<String> = use_signal(String::new);
|
let mut streaming_content: Signal<String> = use_signal(String::new);
|
||||||
let mut selected_model: Signal<String> = use_signal(String::new);
|
let mut selected_model: Signal<String> = 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::<Vec<_>>()
|
||||||
|
.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
|
// Scroll to bottom when messages or streaming content changes
|
||||||
let msg_count = messages.read().len();
|
let msg_count = messages.read().len();
|
||||||
let stream_len = streaming_content.read().len();
|
let stream_len = streaming_content.read().len();
|
||||||
@@ -244,6 +317,14 @@ pub fn ChatPage() -> Element {
|
|||||||
streaming_content: streaming_content.read().clone(),
|
streaming_content: streaming_content.read().clone(),
|
||||||
is_streaming: *is_streaming.read(),
|
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 {
|
ChatInputBar {
|
||||||
input_text: input_text,
|
input_text: input_text,
|
||||||
on_send: on_send,
|
on_send: on_send,
|
||||||
|
|||||||
Reference in New Issue
Block a user