//! 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(" ")
}