Skip to main content

perspective_viewer/components/
chat_panel.rs

1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
5// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13//! The chat sidebar tab (feature `llm-agent`) — a pure VIEW over
14//! [`AgentSlot`]: all conversation state lives on the slot so this component
15//! can unmount (tab switch, settings close) without losing the transcript or
16//! interrupting a running turn.
17
18use 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/// A reasoning-model's "thinking" text: plain-text collapsible block,
30/// expanded while it is streaming (the only progress signal during a long
31/// think) and collapsed once real content exists.
32///
33/// The body is capped and scrolls (`viewer.css`), and follows its own
34/// tail as deltas append — but only while the reader is AT that tail.
35/// Scrolling up to read something is a deliberate act, so it wins until
36/// the reader returns to the bottom, exactly like the transcript.
37#[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/// The prompt input, over the shared [`MirroredTextarea`] — so it grows
80/// with its text instead of scrolling. This consumer wraps
81/// (`white-space: pre-wrap` is the shared default), which confines growth
82/// to the vertical axis; the expression editor's `pre` instead lets it
83/// grow sideways too.
84///
85/// Split from [`ChatPanel`] because the mirror re-renders per keystroke:
86/// the transcript re-parses markdown for every message it renders, which
87/// must not happen per character.
88#[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    // Mirrors the textarea's value. The textarea is deliberately NOT
99    // value-bound — binding it fights the caret — so this is a shadow of
100    // the DOM value, written on `input` and cleared with it on submit.
101    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                    // Failures land in the transcript; nothing to propagate.
122                    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    // `input` fires after the value settles and covers paste, cut and
139    // Shift+Enter newlines, none of which `keydown` sees correctly.
140    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    // Autoscroll only while the user is pinned at the bottom — reading
200    // scrolled-back transcript must survive incoming streaming deltas.
201    // `onscroll` fires for programmatic scrolls too, so scrolling to the
202    // bottom re-pins consistently.
203    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    // The in-flight turn's streaming tail: reasoning renders auto-OPEN
262    // while it is the only signal, collapsing once answer text starts;
263    // no deltas yet (or between tool rounds) shows the typing dots.
264    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}