Vectrum
Sign In
128 lines · 5.1 KB · Rust
//! The turn rail, like T3 Code's: a short dash per prompt down the
//! conversation's left edge. Hovering one shows the prompt and how its
//! answer begins; a click scrolls to it.

use gpui_kit::component::v_flex;
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;

use super::ChatView;
use crate::session::ChatItem;
use crate::ui::metal;

/// Each dash's share of the rail; many prompts squeeze them closer.
const SLOT: f32 = 8.;

impl ChatView {
    /// The rail, floating over the conversation's left edge, once the
    /// thread has more than one prompt.
    pub(super) fn render_turn_rail(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
        let p = metal::palette(cx);
        let items = &self.session.read(cx).items;
        let prompts: Vec<(usize, usize)> = items
            .iter()
            .enumerate()
            .filter_map(|(ix, item)| match item {
                ChatItem::User { text, .. } if !text.starts_with(crate::orchestrator::RESULT_PREFIX) => Some((ix, text.chars().count())),
                _ => None,
            })
            .collect();
        if prompts.len() < 2 {
            return None;
        }
        let hovered = self.rail_hover.filter(|ix| prompts.iter().any(|(prompt, _)| prompt == ix));
        let mut card = hovered.map(|ix| self.render_turn_card(ix, cx));
        let dashes = prompts.into_iter().map(|(ix, chars)| {
            let on = hovered == Some(ix);
            // Longer prompts draw longer dashes, within the rows' margin.
            let width = 8. + (chars as f32 / 40.).min(10.);
            div()
                .id(("turn-dash", ix))
                .relative()
                .flex_basis(px(SLOT))
                .min_h(px(2.))
                .w_full()
                .pl(px(4.))
                .flex()
                .items_center()
                .cursor_pointer()
                .on_hover(cx.listener(move |this, hovered: &bool, _, cx| {
                    if *hovered {
                        this.rail_hover = Some(ix);
                    } else if this.rail_hover == Some(ix) {
                        this.rail_hover = None;
                    }
                    cx.notify();
                }))
                .on_click(cx.listener(move |this, _, _, cx| {
                    this.scroller.update(cx, |scroller, cx| {
                        scroller.scroll_to_item(ix, cx);
                    });
                }))
                .child(
                    div()
                        .h(px(2.))
                        .w(px(if on { width + 4. } else { width }))
                        .rounded_full()
                        .bg(if on { p.ink2 } else { p.ink3.opacity(0.45) }),
                )
                .when_some(on.then(|| card.take()).flatten(), |this, card| {
                    // Centered on its dash, whatever the card's height.
                    this.child(
                        deferred(div().absolute().left(px(36.)).top_0().bottom_0().flex().items_center().child(card)).with_priority(1),
                    )
                })
        });
        Some(
            v_flex()
                .absolute()
                .left(px(4.))
                .top(px(48.))
                .bottom(px(48.))
                .w(px(28.))
                .justify_center()
                .children(dashes)
                .into_any_element(),
        )
    }

    /// A prompt's first line over the start of its answer.
    fn render_turn_card(&self, ix: usize, cx: &mut Context<Self>) -> AnyElement {
        let p = metal::palette(cx);
        let items = &self.session.read(cx).items;
        let prompt = match items.get(ix) {
            Some(ChatItem::User { text, .. }) => text.lines().map(str::trim).find(|line| !line.is_empty()).unwrap_or_default().to_string(),
            _ => String::new(),
        };
        let next = items[ix + 1..].iter().position(|item| matches!(item, ChatItem::User { .. })).map_or(items.len(), |n| ix + 1 + n);
        let answer = items[ix + 1..next].iter().rev().find_map(|item| match item {
            ChatItem::Assistant(answer) if !answer.text.trim().is_empty() => Some(plain(&answer.text)),
            _ => None,
        });
        v_flex()
            .w(px(420.))
            .px(px(16.))
            .py(px(12.))
            .gap(px(4.))
            .rounded(px(14.))
            .bg(p.menu_bg)
            .shadow(p.raise.clone())
            .child(div().truncate().text_size(px(14.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child(prompt))
            .when_some(answer, |this, answer| {
                this.child(div().text_size(px(13.)).line_height(px(20.)).text_color(p.ink3).line_clamp(3).text_ellipsis().child(answer))
            })
            .into_any_element()
    }
}

/// An answer's opening as plain running text: no Markdown markers, one
/// line, cut short.
fn plain(text: &str) -> String {
    let words: Vec<&str> = text
        .split_whitespace()
        .map(|word| word.trim_start_matches(['#', '>']).trim_matches(['*', '`', '_']))
        .filter(|word| !word.is_empty() && *word != "-")
        .take(80)
        .collect();
    words.join(" ")
}