Skip to main content

nightshade_api/web/
chat.rs

1//! A chat transcript with a compose box, a connection indicator, and a busy spinner.
2
3use leptos::html;
4use leptos::prelude::*;
5
6/// Who or what produced a `ChatMessage`, used to pick the bubble's styling.
7#[derive(Clone, Copy, PartialEq, Eq)]
8pub enum ChatRole {
9    /// A message from the user.
10    User,
11    /// A message from the assistant.
12    Assistant,
13    /// Assistant reasoning shown as a thinking bubble.
14    Thinking,
15    /// Output from a tool invocation.
16    Tool,
17    /// An informational system message.
18    Info,
19    /// An error message.
20    Error,
21}
22
23impl ChatRole {
24    fn class(self) -> &'static str {
25        match self {
26            ChatRole::User => "user",
27            ChatRole::Assistant => "assistant",
28            ChatRole::Thinking => "thinking",
29            ChatRole::Tool => "tool",
30            ChatRole::Info => "info",
31            ChatRole::Error => "error",
32        }
33    }
34}
35
36/// A single message in the transcript: a stable `id` for keyed rendering, its
37/// `role`, and the message `text`.
38#[derive(Clone)]
39pub struct ChatMessage {
40    /// Stable identifier used as the render key.
41    pub id: usize,
42    /// The author role controlling the bubble styling.
43    pub role: ChatRole,
44    /// The message body.
45    pub text: String,
46}
47
48impl ChatMessage {
49    /// Creates a message with the given id, role, and text.
50    pub fn new(id: usize, role: ChatRole, text: impl Into<String>) -> Self {
51        Self {
52            id,
53            role,
54            text: text.into(),
55        }
56    }
57}
58
59/// A chat panel that renders the `messages` transcript, auto-scrolling to the
60/// latest, and a compose box that fires `on_send` with the trimmed text on click
61/// or Enter (Shift+Enter inserts a newline). The header shows a `connected` dot,
62/// a `busy` spinner appears while working, and an optional `on_reset` renders a
63/// "New" button. `placeholder` sets the input hint.
64#[component]
65pub fn Chat(
66    #[prop(into)] messages: Signal<Vec<ChatMessage>>,
67    on_send: Callback<String>,
68    #[prop(into, optional)] busy: Signal<bool>,
69    #[prop(into, optional)] connected: Signal<bool>,
70    #[prop(optional)] on_reset: Option<Callback<()>>,
71    #[prop(into, optional)] placeholder: String,
72) -> impl IntoView {
73    let draft = RwSignal::new(String::new());
74    let body_ref = NodeRef::<html::Div>::new();
75    let placeholder = if placeholder.is_empty() {
76        "Message…".to_string()
77    } else {
78        placeholder
79    };
80
81    Effect::new(move |_| {
82        let _ = messages.get();
83        if let Some(body) = body_ref.get() {
84            body.set_scroll_top(body.scroll_height());
85        }
86    });
87
88    let submit = move || {
89        let text = draft.get_untracked().trim().to_string();
90        if !text.is_empty() {
91            on_send.run(text);
92            draft.set(String::new());
93        }
94    };
95
96    view! {
97        <div class="nightshade-chat">
98            <div class="nightshade-chat-head">
99                <span class="nightshade-chat-status" class:online=move || connected.get()></span>
100                <span class="nightshade-chat-status-text">
101                    {move || if connected.get() { "Connected" } else { "Offline" }}
102                </span>
103                <span class="nightshade-chat-spacer"></span>
104                {on_reset
105                    .map(|callback| {
106                        view! {
107                            <button class="nightshade-chat-reset" on:click=move |_| callback.run(())>
108                                "New"
109                            </button>
110                        }
111                    })}
112            </div>
113            <div class="nightshade-chat-body" node_ref=body_ref>
114                <For each=move || messages.get() key=|message| message.id let:message>
115                    <div class=format!("nightshade-chat-message {}", message.role.class())>
116                        {message.text.clone()}
117                    </div>
118                </For>
119                <Show when=move || busy.get() fallback=|| ()>
120                    <div class="nightshade-chat-busy">
121                        <span class="nightshade-spinner"></span>
122                        "Working…"
123                    </div>
124                </Show>
125            </div>
126            <div class="nightshade-chat-compose">
127                <textarea
128                    class="nightshade-chat-input"
129                    placeholder=placeholder
130                    prop:value=move || draft.get()
131                    on:input=move |event| draft.set(event_target_value(&event))
132                    on:keydown=move |event| {
133                        if event.key() == "Enter" && !event.shift_key() {
134                            event.prevent_default();
135                            submit();
136                        }
137                    }
138                ></textarea>
139                <button
140                    class="nightshade-button primary nightshade-chat-send"
141                    disabled=move || draft.get().trim().is_empty()
142                    on:click=move |_| submit()
143                >
144                    "Send"
145                </button>
146            </div>
147        </div>
148    }
149}