| @@ -0,0 +1,128 @@ |
| 1 | + //! The turn rail, like T3 Code's: a short dash per prompt down the |
| 2 | + //! conversation's left edge. Hovering one shows the prompt and how its |
| 3 | + //! answer begins; a click scrolls to it. |
| 4 | + |
| 5 | + use gpui_kit::component::v_flex; |
| 6 | + use gpui_kit::prelude::FluentBuilder as _; |
| 7 | + use gpui_kit::*; |
| 8 | + |
| 9 | + use super::ChatView; |
| 10 | + use crate::session::ChatItem; |
| 11 | + use crate::ui::metal; |
| 12 | + |
| 13 | + /// Each dash's share of the rail; many prompts squeeze them closer. |
| 14 | + const SLOT: f32 = 8.; |
| 15 | + |
| 16 | + impl ChatView { |
| 17 | + /// The rail, floating over the conversation's left edge, once the |
| 18 | + /// thread has more than one prompt. |
| 19 | + pub(super) fn render_turn_rail(&self, cx: &mut Context<Self>) -> Option<AnyElement> { |
| 20 | + let p = metal::palette(cx); |
| 21 | + let items = &self.session.read(cx).items; |
| 22 | + let prompts: Vec<(usize, usize)> = items |
| 23 | + .iter() |
| 24 | + .enumerate() |
| 25 | + .filter_map(|(ix, item)| match item { |
| 26 | + ChatItem::User { text, .. } if !text.starts_with(crate::orchestrator::RESULT_PREFIX) => Some((ix, text.chars().count())), |
| 27 | + _ => None, |
| 28 | + }) |
| 29 | + .collect(); |
| 30 | + if prompts.len() < 2 { |
| 31 | + return None; |
| 32 | + } |
| 33 | + let hovered = self.rail_hover.filter(|ix| prompts.iter().any(|(prompt, _)| prompt == ix)); |
| 34 | + let mut card = hovered.map(|ix| self.render_turn_card(ix, cx)); |
| 35 | + let dashes = prompts.into_iter().map(|(ix, chars)| { |
| 36 | + let on = hovered == Some(ix); |
| 37 | + // Longer prompts draw longer dashes, within the rows' margin. |
| 38 | + let width = 8. + (chars as f32 / 40.).min(10.); |
| 39 | + div() |
| 40 | + .id(("turn-dash", ix)) |
| 41 | + .relative() |
| 42 | + .flex_basis(px(SLOT)) |
| 43 | + .min_h(px(2.)) |
| 44 | + .w_full() |
| 45 | + .pl(px(4.)) |
| 46 | + .flex() |
| 47 | + .items_center() |
| 48 | + .cursor_pointer() |
| 49 | + .on_hover(cx.listener(move |this, hovered: &bool, _, cx| { |
| 50 | + if *hovered { |
| 51 | + this.rail_hover = Some(ix); |
| 52 | + } else if this.rail_hover == Some(ix) { |
| 53 | + this.rail_hover = None; |
| 54 | + } |
| 55 | + cx.notify(); |
| 56 | + })) |
| 57 | + .on_click(cx.listener(move |this, _, _, cx| { |
| 58 | + this.scroller.update(cx, |scroller, cx| { |
| 59 | + scroller.scroll_to_item(ix, cx); |
| 60 | + }); |
| 61 | + })) |
| 62 | + .child( |
| 63 | + div() |
| 64 | + .h(px(2.)) |
| 65 | + .w(px(if on { width + 4. } else { width })) |
| 66 | + .rounded_full() |
| 67 | + .bg(if on { p.ink2 } else { p.ink3.opacity(0.45) }), |
| 68 | + ) |
| 69 | + .when_some(on.then(|| card.take()).flatten(), |this, card| { |
| 70 | + // Centered on its dash, whatever the card's height. |
| 71 | + this.child( |
| 72 | + deferred(div().absolute().left(px(36.)).top_0().bottom_0().flex().items_center().child(card)).with_priority(1), |
| 73 | + ) |
| 74 | + }) |
| 75 | + }); |
| 76 | + Some( |
| 77 | + v_flex() |
| 78 | + .absolute() |
| 79 | + .left(px(4.)) |
| 80 | + .top(px(48.)) |
| 81 | + .bottom(px(48.)) |
| 82 | + .w(px(28.)) |
| 83 | + .justify_center() |
| 84 | + .children(dashes) |
| 85 | + .into_any_element(), |
| 86 | + ) |
| 87 | + } |
| 88 | + |
| 89 | + /// A prompt's first line over the start of its answer. |
| 90 | + fn render_turn_card(&self, ix: usize, cx: &mut Context<Self>) -> AnyElement { |
| 91 | + let p = metal::palette(cx); |
| 92 | + let items = &self.session.read(cx).items; |
| 93 | + let prompt = match items.get(ix) { |
| 94 | + Some(ChatItem::User { text, .. }) => text.lines().map(str::trim).find(|line| !line.is_empty()).unwrap_or_default().to_string(), |
| 95 | + _ => String::new(), |
| 96 | + }; |
| 97 | + let next = items[ix + 1..].iter().position(|item| matches!(item, ChatItem::User { .. })).map_or(items.len(), |n| ix + 1 + n); |
| 98 | + let answer = items[ix + 1..next].iter().rev().find_map(|item| match item { |
| 99 | + ChatItem::Assistant(answer) if !answer.text.trim().is_empty() => Some(plain(&answer.text)), |
| 100 | + _ => None, |
| 101 | + }); |
| 102 | + v_flex() |
| 103 | + .w(px(420.)) |
| 104 | + .px(px(16.)) |
| 105 | + .py(px(12.)) |
| 106 | + .gap(px(4.)) |
| 107 | + .rounded(px(14.)) |
| 108 | + .bg(p.menu_bg) |
| 109 | + .shadow(p.raise.clone()) |
| 110 | + .child(div().truncate().text_size(px(14.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child(prompt)) |
| 111 | + .when_some(answer, |this, answer| { |
| 112 | + this.child(div().text_size(px(13.)).line_height(px(20.)).text_color(p.ink3).line_clamp(3).text_ellipsis().child(answer)) |
| 113 | + }) |
| 114 | + .into_any_element() |
| 115 | + } |
| 116 | + } |
| 117 | + |
| 118 | + /// An answer's opening as plain running text: no Markdown markers, one |
| 119 | + /// line, cut short. |
| 120 | + fn plain(text: &str) -> String { |
| 121 | + let words: Vec<&str> = text |
| 122 | + .split_whitespace() |
| 123 | + .map(|word| word.trim_start_matches(['#', '>']).trim_matches(['*', '`', '_'])) |
| 124 | + .filter(|word| !word.is_empty() && *word != "-") |
| 125 | + .take(80) |
| 126 | + .collect(); |
| 127 | + words.join(" ") |
| 128 | + } |