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