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