use crate::models::{ChatMessage, ChatRole}; use dioxus::prelude::*; /// Render markdown content to HTML using `pulldown-cmark`. /// /// # Arguments /// /// * `md` - Raw markdown string /// /// # Returns /// /// HTML string suitable for `dangerous_inner_html` fn markdown_to_html(md: &str) -> String { use pulldown_cmark::{Options, Parser}; let mut opts = Options::empty(); opts.insert(Options::ENABLE_TABLES); opts.insert(Options::ENABLE_STRIKETHROUGH); opts.insert(Options::ENABLE_TASKLISTS); let parser = Parser::new_ext(md, opts); let mut html = String::with_capacity(md.len() * 2); pulldown_cmark::html::push_html(&mut html, parser); html } /// Renders a single chat message bubble with role-based styling. /// /// User messages are displayed as plain text, right-aligned. /// Assistant messages are rendered as markdown with `pulldown-cmark`. /// System messages are hidden from the UI. /// /// # Arguments /// /// * `message` - The chat message to render #[component] pub fn ChatBubble(message: ChatMessage) -> Element { // System messages are not rendered in the UI if message.role == ChatRole::System { return rsx! {}; } let bubble_class = match message.role { ChatRole::User => "chat-bubble chat-bubble--user", ChatRole::Assistant => "chat-bubble chat-bubble--assistant", ChatRole::System => unreachable!(), }; let role_label = match message.role { ChatRole::User => "You", ChatRole::Assistant => "Assistant", ChatRole::System => unreachable!(), }; // Format timestamp for display (show time only if today) let display_time = if message.timestamp.len() >= 16 { // Extract HH:MM from ISO 8601 message.timestamp[11..16].to_string() } else { message.timestamp.clone() }; let is_assistant = message.role == ChatRole::Assistant; rsx! { div { class: "{bubble_class}", div { class: "chat-bubble-header", span { class: "chat-bubble-role", "{role_label}" } span { class: "chat-bubble-time", "{display_time}" } } if is_assistant { // Render markdown for assistant messages div { class: "chat-bubble-content chat-prose", dangerous_inner_html: "{markdown_to_html(&message.content)}", } } else { div { class: "chat-bubble-content", "{message.content}" } } if !message.attachments.is_empty() { div { class: "chat-bubble-attachments", for att in &message.attachments { span { class: "chat-attachment", "{att.name}" } } } } } } } /// Renders a streaming assistant message bubble. /// /// While waiting for tokens, shows a "Thinking..." indicator with /// a pulsing dot animation. Once tokens arrive, renders them as /// markdown with a blinking cursor. /// /// # Arguments /// /// * `content` - The accumulated streaming content so far #[component] pub fn StreamingBubble(content: String) -> Element { if content.is_empty() { // Thinking state -- no tokens yet rsx! { div { class: "chat-bubble chat-bubble--assistant chat-bubble--thinking", div { class: "chat-thinking", span { class: "chat-thinking-dots", span { class: "chat-dot" } span { class: "chat-dot" } span { class: "chat-dot" } } span { class: "chat-thinking-text", "Thinking..." } } } } } else { let html = markdown_to_html(&content); rsx! { div { class: "chat-bubble chat-bubble--assistant chat-bubble--streaming", div { class: "chat-bubble-header", span { class: "chat-bubble-role", "Assistant" } } div { class: "chat-bubble-content chat-prose", dangerous_inner_html: "{html}", } span { class: "chat-streaming-cursor" } } } } }