perspective_viewer/components/
chat_panel.rs1use yew::prelude::*;
19
20use crate::agent::{AgentSlot, ChatEntry, render_markdown};
21use crate::components::form::mirrored_textarea::MirroredTextarea;
22
23#[derive(Properties, PartialEq)]
24struct ChatReasoningProps {
25 reasoning: String,
26 open: bool,
27}
28
29#[function_component]
38fn ChatReasoning(props: &ChatReasoningProps) -> Html {
39 let body_ref = use_node_ref();
40 let scroll_pinned = use_mut_ref(|| true);
41 let seen_len = use_mut_ref(|| None::<usize>);
42 let onscroll = {
43 let body_ref = body_ref.clone();
44 let scroll_pinned = scroll_pinned.clone();
45 Callback::from(move |_: Event| {
46 if let Some(elem) = body_ref.cast::<web_sys::Element>() {
47 let gap = elem.scroll_height() - elem.scroll_top() - elem.client_height();
48 *scroll_pinned.borrow_mut() = gap < 24;
49 }
50 })
51 };
52
53 {
54 let body_ref = body_ref.clone();
55 let scroll_pinned = scroll_pinned.clone();
56 let seen_len = seen_len.clone();
57 let len = props.reasoning.len();
58 use_effect(move || {
59 let grew = seen_len.replace(Some(len)).is_some_and(|seen| len > seen);
60 if grew
61 && *scroll_pinned.borrow()
62 && let Some(elem) = body_ref.cast::<web_sys::Element>()
63 {
64 elem.set_scroll_top(elem.scroll_height());
65 }
66 });
67 }
68
69 html! {
70 <details class="chat-reasoning" open={props.open}>
71 <summary />
72 <div class="chat-reasoning-body scrollable" ref={body_ref} {onscroll}>
73 { &props.reasoning }
74 </div>
75 </details>
76 }
77}
78
79#[derive(Properties, PartialEq)]
89struct ChatInputProps {
90 agent: AgentSlot,
91 busy: bool,
92}
93
94#[function_component]
95fn ChatInput(props: &ChatInputProps) -> Html {
96 let input_ref = use_node_ref();
97
98 let text = use_state_eq(String::new);
102 let submit = {
103 let agent = props.agent.clone();
104 let input_ref = input_ref.clone();
105 let text = text.clone();
106 Callback::from(move |()| {
107 if agent.is_busy() {
108 return;
109 }
110
111 if let Some(elem) = input_ref.cast::<web_sys::HtmlTextAreaElement>() {
112 let prompt = elem.value().trim().to_owned();
113 if prompt.is_empty() {
114 return;
115 }
116
117 elem.set_value("");
118 text.set(String::new());
119 let agent = agent.clone();
120 wasm_bindgen_futures::spawn_local(async move {
121 let _ = agent.run_prompt(prompt).await;
123 });
124 }
125 })
126 };
127
128 let onkeydown = {
129 let submit = submit.clone();
130 Callback::from(move |event: KeyboardEvent| {
131 if event.key() == "Enter" && !event.shift_key() {
132 event.prevent_default();
133 submit.emit(());
134 }
135 })
136 };
137
138 let oninput = {
141 let input_ref = input_ref.clone();
142 let text = text.clone();
143 Callback::from(move |_: InputEvent| {
144 if let Some(elem) = input_ref.cast::<web_sys::HtmlTextAreaElement>() {
145 text.set(elem.value());
146 }
147 })
148 };
149
150 let onsend = submit.reform(|_: MouseEvent| ());
151 let onstop = {
152 let agent = props.agent.clone();
153 Callback::from(move |_: MouseEvent| agent.stop())
154 };
155
156 html! {
157 <div id="chat_input_row">
158 <div id="chat_input_scroll" class="scrollable">
159 <MirroredTextarea
160 id="chat_input"
161 class="chat-input-box"
162 mirror={html! { (*text).clone() }}
163 is_empty={text.is_empty()}
164 textarea_ref={input_ref}
165 placeholder="Ask about your data\u{2026}"
166 disabled={props.busy}
167 {onkeydown}
168 {oninput}
169 />
170 </div>
171 if props.busy {
172 <button id="chat_stop_button" onclick={onstop} />
173 } else {
174 <button id="chat_send_button" onclick={onsend} />
175 }
176 </div>
177 }
178}
179
180#[derive(Properties, PartialEq)]
181pub struct ChatPanelProps {
182 pub agent: AgentSlot,
183}
184
185#[function_component]
186pub fn ChatPanel(props: &ChatPanelProps) -> Html {
187 let update = use_force_update();
188 {
189 let agent = props.agent.clone();
190 use_effect_with((), move |_| {
191 let sub = agent
192 .on_update
193 .add_notify_listener(&Callback::from(move |_| update.force_update()));
194
195 move || drop(sub)
196 });
197 }
198
199 let log_ref = use_node_ref();
204 let scroll_pinned = use_mut_ref(|| true);
205 let onscroll = {
206 let log_ref = log_ref.clone();
207 let scroll_pinned = scroll_pinned.clone();
208 Callback::from(move |_: Event| {
209 if let Some(elem) = log_ref.cast::<web_sys::Element>() {
210 let gap = elem.scroll_height() - elem.scroll_top() - elem.client_height();
211 *scroll_pinned.borrow_mut() = gap < 30;
212 }
213 })
214 };
215
216 {
217 let log_ref = log_ref.clone();
218 let scroll_pinned = scroll_pinned.clone();
219 use_effect(move || {
220 if let Some(elem) = log_ref.cast::<web_sys::Element>()
221 && *scroll_pinned.borrow()
222 {
223 elem.set_scroll_top(elem.scroll_height());
224 }
225 });
226 }
227
228 let busy = props.agent.is_busy();
229 let entries = props
230 .agent
231 .transcript()
232 .into_iter()
233 .map(|entry| match entry {
234 ChatEntry::User(text) => html! {
235 <div class="chat-message chat-user">{ text.trim() }</div>
236 },
237 ChatEntry::Assistant { text, reasoning } => html! {
238 <div class="chat-message chat-assistant">
239 if let Some(reasoning) = reasoning { <ChatReasoning {reasoning} open=false /> }
240 { render_markdown(&text) }
241 </div>
242 },
243 ChatEntry::Tool { name, args, error } => {
244 let class = if error.is_some() {
245 "chat-tool-chip chat-tool-chip-error"
246 } else {
247 "chat-tool-chip"
248 };
249
250 let title = match &error {
251 Some(err) => format!("{err}\n\n{args}"),
252 None => args.clone(),
253 };
254
255 html! { <div {class} {title}>{ name }</div> }
256 },
257 ChatEntry::Error(text) => html! { <div class="chat-message chat-error">{ text }</div> },
258 })
259 .collect::<Html>();
260
261 let pending = props.agent.pending();
265 let tail = match &pending {
266 Some((text, reasoning)) if !text.is_empty() || !reasoning.is_empty() => html! {
267 <div class="chat-message chat-assistant chat-streaming">
268 if !reasoning.is_empty() {
269 <ChatReasoning reasoning={reasoning.clone()} open={text.is_empty()} />
270 }
271 if !text.is_empty() {
272 { render_markdown(text) }
273 }
274 </div>
275 },
276 _ if busy => html! {
277 <div class="chat-message chat-assistant chat-pending"><span /><span /><span /></div>
278 },
279 _ => html! {},
280 };
281
282 html! {
283 <div id="chat_panel">
284 <div id="chat_log" class="scrollable" ref={log_ref} {onscroll}>{ entries }{ tail }</div>
285 <ChatInput agent={props.agent.clone()} {busy} />
286 <div id="chat_badge">{ props.agent.label().unwrap_or_default() }</div>
287 </div>
288 }
289}